Raw
  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}