1package userbase
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "time"
11
12 "filippo.io/age"
13 "github.com/go-git/go-git/v5"
14 "github.com/go-git/go-git/v5/plumbing"
15 "golang.org/x/crypto/ssh"
16
17 "tug/internal/platform/replica"
18 "tug/internal/service/invite"
19 "tug/internal/storage"
20 "tug/internal/storage/dexstore"
21)
22
23const (
24 StageFetch = "fetch"
25 StageVerify = "verify"
26 StageDecrypt = "decrypt"
27 StageDecode = "decode"
28 StageImport = "import"
29)
30
31type StageError struct {
32 Stage string
33 Err error
34}
35
36func (e *StageError) Error() string {
37 return e.Stage + ": " + e.Err.Error()
38}
39
40func (e *StageError) Unwrap() error {
41 return e.Err
42}
43
44type RestoreConfig struct {
45 Store *storage.Store
46 StateDir string
47 Remote replica.Remote
48 Identity *age.X25519Identity
49 AllowedKeys []ssh.PublicKey
50 Commit plumbing.Hash
51 DryRun bool
52 WithoutDexKeys bool
53 DexValidFor time.Duration
54 DexRotation time.Duration
55 Now func() time.Time
56}
57
58type RestoreSummary struct {
59 Head plumbing.Hash
60 Commit plumbing.Hash
61 ExportedAt time.Time
62 Users int
63 Groups int
64 Memberships int
65 GoogleLinks int
66 Invitations int
67 DexKeys bool
68}
69
70var verifyErrors = []error{
71 replica.ErrChainUnverified,
72 replica.ErrMergeCommit,
73 replica.ErrNoAllowedKeys,
74 replica.ErrNotOnBranch,
75 replica.ErrNotExport,
76}
77
78func staged(stage string, err error) error {
79 var se *StageError
80 if errors.As(err, &se) {
81 return err
82 }
83 return &StageError{Stage: stage, Err: err}
84}
85
86func replicaStage(err error) error {
87 for _, v := range verifyErrors {
88 if errors.Is(err, v) {
89 return staged(StageVerify, err)
90 }
91 }
92 return staged(StageFetch, err)
93}
94
95func openDocument(blob []byte, identity *age.X25519Identity) (Document, error) {
96 plaintext, err := Open(blob, identity)
97 if err != nil {
98 return Document{}, staged(StageDecrypt, err)
99 }
100 doc, err := Decode(plaintext)
101 if err != nil {
102 return Document{}, staged(StageDecode, err)
103 }
104 return doc, nil
105}
106
107func summarize(doc Document) RestoreSummary {
108 s := doc.Snapshot
109 return RestoreSummary{
110 ExportedAt: doc.ExportedAt,
111 Users: len(s.Users),
112 Groups: len(s.Groups),
113 Memberships: len(s.Memberships),
114 GoogleLinks: len(s.GoogleLinks),
115 Invitations: len(s.Invitations),
116 DexKeys: len(doc.DexKeys) > 0,
117 }
118}
119
120func (cfg RestoreConfig) now() time.Time {
121 if cfg.Now != nil {
122 return cfg.Now()
123 }
124 return time.Now()
125}
126
127func (cfg RestoreConfig) importDocument(ctx context.Context, doc Document, restored, checkpoint plumbing.Hash) error {
128 if len(doc.DexKeys) > 0 && !cfg.WithoutDexKeys {
129 dex, err := dexstore.Attach(ctx, cfg.Store)
130 if err != nil {
131 return staged(StageImport, err)
132 }
133 now := cfg.now()
134 if err := dexstore.ImportPublicKeys(ctx, dex, doc.DexKeys, now, cfg.DexValidFor, now.Add(cfg.DexRotation)); err != nil {
135 return staged(StageImport, err)
136 }
137 }
138 if err := cfg.Store.ImportUserbase(ctx, doc.Snapshot, restored.String(), checkpoint.String()); err != nil {
139 return staged(StageImport, err)
140 }
141 if err := os.Remove(filepath.Join(cfg.StateDir, invite.FileName)); err != nil && !errors.Is(err, fs.ErrNotExist) {
142 return staged(StageImport, err)
143 }
144 return nil
145}
146
147const headCountersFormat = "the head %s, whose uid and gid counters a restore of an older commit keeps: %w"
148
149func openHead(blob []byte, head plumbing.Hash, identity *age.X25519Identity) (Document, error) {
150 plaintext, err := Open(blob, identity)
151 if err != nil {
152 return Document{}, staged(StageDecrypt, fmt.Errorf(headCountersFormat, head, err))
153 }
154 current, err := Decode(plaintext)
155 if err != nil {
156 return Document{}, staged(StageDecode, fmt.Errorf(headCountersFormat, head, err))
157 }
158 return current, nil
159}
160
161func keepLargerCounters(repo *git.Repository, head plumbing.Hash, identity *age.X25519Identity, s storage.UserbaseSnapshot) (storage.UserbaseSnapshot, error) {
162 blob, err := replica.ReadFile(repo.Storer, head)
163 if err != nil {
164 return s, staged(StageImport, err)
165 }
166 current, err := openHead(blob, head, identity)
167 if err != nil {
168 return s, err
169 }
170 s.NextUIDNumber = max(s.NextUIDNumber, current.Snapshot.NextUIDNumber)
171 s.NextGIDNumber = max(s.NextGIDNumber, current.Snapshot.NextGIDNumber)
172 return s, nil
173}
174
175func Restore(ctx context.Context, cfg RestoreConfig) (RestoreSummary, error) {
176 if err := cfg.Store.UserbaseImportable(ctx); err != nil {
177 return RestoreSummary{}, staged(StageImport, err)
178 }
179 in := replica.RestoreInput{
180 Dir: cfg.StateDir,
181 Remote: cfg.Remote,
182 Commit: cfg.Commit,
183 Keys: cfg.AllowedKeys,
184 DryRun: cfg.DryRun,
185 }
186 var summary RestoreSummary
187 if !cfg.DryRun {
188 repo, err := OpenLocalRepo(cfg.StateDir)
189 if err != nil {
190 return RestoreSummary{}, staged(StageImport, err)
191 }
192 in.Repo = repo
193 in.Import = func(ctx context.Context, blob []byte, restored, checkpoint plumbing.Hash) error {
194 doc, err := openDocument(blob, cfg.Identity)
195 if err != nil {
196 return err
197 }
198 if restored != checkpoint {
199 if doc.Snapshot, err = keepLargerCounters(repo, checkpoint, cfg.Identity, doc.Snapshot); err != nil {
200 return err
201 }
202 }
203 if err := cfg.importDocument(ctx, doc, restored, checkpoint); err != nil {
204 return err
205 }
206 summary = summarize(doc)
207 return nil
208 }
209 }
210 res, err := replica.Restore(ctx, in)
211 if err != nil {
212 return RestoreSummary{}, replicaStage(err)
213 }
214 if cfg.DryRun {
215 doc, err := openDocument(res.Blob, cfg.Identity)
216 if err != nil {
217 return RestoreSummary{}, err
218 }
219 if res.Commit != res.Head {
220 if _, err := openHead(res.HeadBlob, res.Head, cfg.Identity); err != nil {
221 return RestoreSummary{}, err
222 }
223 }
224 summary = summarize(doc)
225 }
226 summary.Head, summary.Commit = res.Head, res.Commit
227 return summary, nil
228}
229
230type FirstBootOutcome int
231
232const (
233 FirstBootNotEmpty FirstBootOutcome = iota + 1
234 FirstBootEmptyRemote
235 FirstBootImported
236)
237
238func FirstBoot(ctx context.Context, cfg RestoreConfig) (FirstBootOutcome, RestoreSummary, error) {
239 err := cfg.Store.UserbaseImportable(ctx)
240 if errors.Is(err, storage.ErrUserbaseNotEmpty) {
241 return FirstBootNotEmpty, RestoreSummary{}, nil
242 }
243 if err != nil {
244 return 0, RestoreSummary{}, staged(StageImport, err)
245 }
246 cfg.Commit = plumbing.ZeroHash
247 cfg.DryRun = false
248 summary, err := Restore(ctx, cfg)
249 if errors.Is(err, replica.ErrNothingToRestore) {
250 return FirstBootEmptyRemote, RestoreSummary{}, nil
251 }
252 if err != nil {
253 return 0, RestoreSummary{}, err
254 }
255 return FirstBootImported, summary, nil
256}