Skip to content

Commit a747183

Browse files
committed
Track internal backup package for CI builds
1 parent 9ae885d commit a747183

File tree

3 files changed

+278
-1
lines changed

3 files changed

+278
-1
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ go.work.sum
2727
# Credentials (never commit)
2828
*.enc
2929
credentials.enc
30-
backup/
30+
/backup/
3131

3232
# OS
3333
.DS_Store

internal/backup/transfer.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package backup
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"time"
10+
11+
"github.com/managedssh/managedssh/internal/vault"
12+
)
13+
14+
const (
15+
bundleVersion = 1
16+
bundleName = "managedssh-export.json"
17+
)
18+
19+
type bundle struct {
20+
Version int `json:"version"`
21+
CreatedAt string `json:"created_at"`
22+
VaultJSON json.RawMessage `json:"vault_json"`
23+
HostsJSON json.RawMessage `json:"hosts_json"`
24+
}
25+
26+
func DefaultPath() (string, error) {
27+
home, err := os.UserHomeDir()
28+
if err != nil {
29+
return "", err
30+
}
31+
return filepath.Join(home, bundleName), nil
32+
}
33+
34+
func ExportPathForDir(dir string) string {
35+
return filepath.Join(dir, bundleName)
36+
}
37+
38+
func Export(path string) error {
39+
dir, err := vault.Dir()
40+
if err != nil {
41+
return err
42+
}
43+
44+
vaultPath := filepath.Join(dir, "vault.json")
45+
hostsPath := filepath.Join(dir, "hosts.json")
46+
47+
vaultData, err := os.ReadFile(vaultPath)
48+
if err != nil {
49+
return fmt.Errorf("reading vault metadata: %w", err)
50+
}
51+
52+
hostsData, err := os.ReadFile(hostsPath)
53+
if err != nil {
54+
return fmt.Errorf("reading hosts data: %w", err)
55+
}
56+
57+
b := bundle{
58+
Version: bundleVersion,
59+
CreatedAt: time.Now().UTC().Format(time.RFC3339),
60+
VaultJSON: json.RawMessage(vaultData),
61+
HostsJSON: json.RawMessage(hostsData),
62+
}
63+
64+
out, err := json.MarshalIndent(b, "", " ")
65+
if err != nil {
66+
return fmt.Errorf("encoding export bundle: %w", err)
67+
}
68+
69+
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
70+
return fmt.Errorf("creating export directory: %w", err)
71+
}
72+
73+
tmp := path + ".tmp"
74+
if err := os.WriteFile(tmp, out, 0600); err != nil {
75+
return fmt.Errorf("writing export bundle: %w", err)
76+
}
77+
if err := os.Rename(tmp, path); err != nil {
78+
return fmt.Errorf("finalizing export bundle: %w", err)
79+
}
80+
return nil
81+
}
82+
83+
func Import(path string) error {
84+
b, err := loadBundle(path)
85+
if err != nil {
86+
return err
87+
}
88+
89+
dir, err := vault.Dir()
90+
if err != nil {
91+
return err
92+
}
93+
if err := os.MkdirAll(dir, 0700); err != nil {
94+
return fmt.Errorf("creating config directory: %w", err)
95+
}
96+
97+
if err := atomicWrite(filepath.Join(dir, "vault.json"), b.VaultJSON, 0600); err != nil {
98+
return fmt.Errorf("writing vault metadata: %w", err)
99+
}
100+
if err := atomicWrite(filepath.Join(dir, "hosts.json"), b.HostsJSON, 0600); err != nil {
101+
return fmt.Errorf("writing hosts data: %w", err)
102+
}
103+
104+
return nil
105+
}
106+
107+
func VerifyMasterPassword(path, password string) error {
108+
b, err := loadBundle(path)
109+
if err != nil {
110+
return err
111+
}
112+
113+
key, err := vault.UnlockWithMetaJSON(password, b.VaultJSON)
114+
if err != nil {
115+
if errors.Is(err, vault.ErrWrongPassword) {
116+
return vault.ErrWrongPassword
117+
}
118+
return fmt.Errorf("verifying backup master key: %w", err)
119+
}
120+
vault.ZeroKey(key)
121+
return nil
122+
}
123+
124+
func loadBundle(path string) (*bundle, error) {
125+
data, err := os.ReadFile(path)
126+
if err != nil {
127+
return nil, fmt.Errorf("reading import bundle: %w", err)
128+
}
129+
130+
var b bundle
131+
if err := json.Unmarshal(data, &b); err != nil {
132+
return nil, fmt.Errorf("decoding import bundle: %w", err)
133+
}
134+
if b.Version != bundleVersion {
135+
return nil, fmt.Errorf("unsupported bundle version: %d", b.Version)
136+
}
137+
if len(b.VaultJSON) == 0 {
138+
return nil, fmt.Errorf("import bundle is missing vault data")
139+
}
140+
if len(b.HostsJSON) == 0 {
141+
return nil, fmt.Errorf("import bundle is missing hosts data")
142+
}
143+
144+
var vaultDoc map[string]any
145+
if err := json.Unmarshal(b.VaultJSON, &vaultDoc); err != nil {
146+
return nil, fmt.Errorf("invalid vault data in import bundle: %w", err)
147+
}
148+
var hostsDoc map[string]any
149+
if err := json.Unmarshal(b.HostsJSON, &hostsDoc); err != nil {
150+
return nil, fmt.Errorf("invalid hosts data in import bundle: %w", err)
151+
}
152+
153+
return &b, nil
154+
}
155+
156+
func atomicWrite(path string, data []byte, perm os.FileMode) error {
157+
tmp := path + ".tmp"
158+
if err := os.WriteFile(tmp, data, perm); err != nil {
159+
return err
160+
}
161+
return os.Rename(tmp, path)
162+
}

internal/backup/transfer_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package backup
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"reflect"
8+
"testing"
9+
10+
"github.com/managedssh/managedssh/internal/vault"
11+
)
12+
13+
func TestExportImportRoundTrip(t *testing.T) {
14+
home := t.TempDir()
15+
t.Setenv("HOME", home)
16+
17+
configDir := filepath.Join(home, ".config", "managedssh")
18+
if err := os.MkdirAll(configDir, 0700); err != nil {
19+
t.Fatalf("mkdir config dir: %v", err)
20+
}
21+
22+
originalVault := []byte(`{"salt":"abc","nonce":"def","verifier":"ghi"}`)
23+
originalHosts := []byte(`{"hosts":[{"id":"1","alias":"demo"}]}`)
24+
if err := os.WriteFile(filepath.Join(configDir, "vault.json"), originalVault, 0600); err != nil {
25+
t.Fatalf("write vault: %v", err)
26+
}
27+
if err := os.WriteFile(filepath.Join(configDir, "hosts.json"), originalHosts, 0600); err != nil {
28+
t.Fatalf("write hosts: %v", err)
29+
}
30+
31+
bundlePath, err := DefaultPath()
32+
if err != nil {
33+
t.Fatalf("default path: %v", err)
34+
}
35+
36+
if err := Export(bundlePath); err != nil {
37+
t.Fatalf("export failed: %v", err)
38+
}
39+
40+
if err := os.WriteFile(filepath.Join(configDir, "vault.json"), []byte(`{"corrupt":true}`), 0600); err != nil {
41+
t.Fatalf("overwrite vault: %v", err)
42+
}
43+
if err := os.WriteFile(filepath.Join(configDir, "hosts.json"), []byte(`{"hosts":[]}`), 0600); err != nil {
44+
t.Fatalf("overwrite hosts: %v", err)
45+
}
46+
47+
if err := Import(bundlePath); err != nil {
48+
t.Fatalf("import failed: %v", err)
49+
}
50+
51+
gotVault, err := os.ReadFile(filepath.Join(configDir, "vault.json"))
52+
if err != nil {
53+
t.Fatalf("read vault: %v", err)
54+
}
55+
if !jsonEqual(originalVault, gotVault) {
56+
t.Fatalf("vault mismatch\nwant: %s\ngot: %s", originalVault, gotVault)
57+
}
58+
59+
gotHosts, err := os.ReadFile(filepath.Join(configDir, "hosts.json"))
60+
if err != nil {
61+
t.Fatalf("read hosts: %v", err)
62+
}
63+
if !jsonEqual(originalHosts, gotHosts) {
64+
t.Fatalf("hosts mismatch\nwant: %s\ngot: %s", originalHosts, gotHosts)
65+
}
66+
67+
if err := VerifyMasterPassword(bundlePath, "backup-master-key"); err == nil {
68+
t.Fatalf("expected verification to fail with wrong password")
69+
}
70+
}
71+
72+
func TestVerifyMasterPassword(t *testing.T) {
73+
home := t.TempDir()
74+
t.Setenv("HOME", home)
75+
76+
configDir := filepath.Join(home, ".config", "managedssh")
77+
if err := os.MkdirAll(configDir, 0700); err != nil {
78+
t.Fatalf("mkdir config dir: %v", err)
79+
}
80+
81+
master := "correct horse battery staple"
82+
if _, err := vault.Create(master); err != nil {
83+
t.Fatalf("create vault: %v", err)
84+
}
85+
if err := os.WriteFile(filepath.Join(configDir, "hosts.json"), []byte(`{"hosts":[]}`), 0600); err != nil {
86+
t.Fatalf("write hosts: %v", err)
87+
}
88+
89+
bundlePath, err := DefaultPath()
90+
if err != nil {
91+
t.Fatalf("default path: %v", err)
92+
}
93+
if err := Export(bundlePath); err != nil {
94+
t.Fatalf("export failed: %v", err)
95+
}
96+
97+
if err := VerifyMasterPassword(bundlePath, master); err != nil {
98+
t.Fatalf("expected verification success: %v", err)
99+
}
100+
if err := VerifyMasterPassword(bundlePath, "wrong-password"); err == nil {
101+
t.Fatalf("expected wrong password failure")
102+
}
103+
}
104+
105+
func jsonEqual(a, b []byte) bool {
106+
var va any
107+
if err := json.Unmarshal(a, &va); err != nil {
108+
return false
109+
}
110+
var vb any
111+
if err := json.Unmarshal(b, &vb); err != nil {
112+
return false
113+
}
114+
return reflect.DeepEqual(va, vb)
115+
}

0 commit comments

Comments
 (0)