From e8bb5a622d0c1895b1768a90d0b422ec084db73f Mon Sep 17 00:00:00 2001 From: Ophestra Date: Sun, 7 Jun 2026 17:15:27 +0900 Subject: [PATCH] internal/rosa: mirror service via external cache This provides an authenticated implementation of the external cache. Signed-off-by: Ophestra --- cmd/mbf/cache.go | 19 ++- cmd/mbf/main.go | 82 +++++++++ internal/rosa/mirror.go | 361 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 internal/rosa/mirror.go diff --git a/cmd/mbf/cache.go b/cmd/mbf/cache.go index 0452a3e1..b683326f 100644 --- a/cmd/mbf/cache.go +++ b/cmd/mbf/cache.go @@ -2,6 +2,7 @@ package main import ( "context" + "net/http" "os" "path/filepath" "testing" @@ -9,6 +10,7 @@ import ( "hakurei.app/check" "hakurei.app/container" "hakurei.app/internal/pkg" + "hakurei.app/internal/rosa" "hakurei.app/message" ) @@ -30,7 +32,7 @@ type cache struct { // Loaded artifact of [rosa.QEMU]. qemu pkg.Artifact - base string + base, mirror string } // open opens the underlying [pkg.Cache]. @@ -86,6 +88,21 @@ func (cache *cache) open() (err error) { } done <- struct{}{} + if cache.mirror != "" { + var pub []byte + pub, err = os.ReadFile(base.Append("ed25519.pub").String()) + if err != nil { + cache.c.Close() + return + } + var r rosa.Remote + if r, err = rosa.NewRemote(cache.mirror, pub, http.DefaultClient); err != nil { + cache.c.Close() + return err + } + cache.c.SetExternal(r) + } + if cache.qemu != nil { var pathname *check.Absolute pathname, _, err = cache.c.Cure(cache.qemu) diff --git a/cmd/mbf/main.go b/cmd/mbf/main.go index 86496f98..ae835500 100644 --- a/cmd/mbf/main.go +++ b/cmd/mbf/main.go @@ -14,6 +14,7 @@ package main import ( "context" + "crypto/ed25519" "crypto/sha512" "errors" "fmt" @@ -47,6 +48,19 @@ import ( "hakurei.app/cmd/mbf/internal/pkgserver/ui" ) +// writeFileExcl is like [os.WriteFile], but sets [os.O_EXCL] instead. +func writeFileExcl(name string, data []byte, perm os.FileMode) error { + f, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if err != nil { + return err + } + _, err = f.Write(data) + if err1 := f.Close(); err1 != nil && err == nil { + err = err1 + } + return err +} + func main() { container.TryArgv0(nil) @@ -186,6 +200,10 @@ func main() { &cm.base, "d", command.StringFlag("$MBF_CACHE_DIR"), "Directory to store cured artifacts", + ).Flag( + &cm.mirror, + "r", command.StringFlag("$MBF_REMOTE"), + "URL of mirror service", ).Flag( &cm.idle, "sched-idle", command.BoolFlag(false), @@ -462,6 +480,70 @@ func main() { }, ) + c.NewCommand( + "keygen", + "Create keypair for local cache", + func([]string) error { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + return err + } + + return errors.Join(writeFileExcl(filepath.Join( + cm.base, + "ed25519.pub", + ), pub, 0444), writeFileExcl(filepath.Join( + cm.base, + "ed25519", + ), priv, 0400)) + }, + ) + + c.NewCommand( + "serve", + "Export local cache as mirror", + func(args []string) error { + const shutdownTimeout = 15 * time.Second + + if len(args) != 1 { + return errors.New("serve requires 1 argument") + } + + var key ed25519.PrivateKey + if p, err := os.ReadFile(filepath.Join(cm.base, "ed25519")); err != nil { + return err + } else if len(p) != ed25519.PrivateKeySize { + return errors.New("invalid private key") + } else { + key = p + } + + var h http.Handler + if base, err := os.OpenRoot(cm.base); err != nil { + return err + } else { + h = rosa.NewMirror(msg, base, key) + } + + server := http.Server{Addr: args[0], Handler: h} + go func() { + <-ctx.Done() + cc, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(cc); err != nil { + log.Fatal(err) + } + }() + + msg.Verbosef("listening on %q", args[0]) + err := server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + return err + }, + ) + { var ( flagGentoo string diff --git a/internal/rosa/mirror.go b/internal/rosa/mirror.go new file mode 100644 index 00000000..3f7822e5 --- /dev/null +++ b/internal/rosa/mirror.go @@ -0,0 +1,361 @@ +package rosa + +import ( + "compress/gzip" + "context" + "crypto/ed25519" + "crypto/sha512" + "errors" + "io" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "unique" + + "hakurei.app/internal/pkg" + "hakurei.app/message" +) + +// Remote is an authenticated cache mirror. +type Remote struct { + // Mirror URL. + url *url.URL + // Trusted public key. + pub ed25519.PublicKey + // For requests to the mirror. + c *http.Client +} + +// NewRemote returns a populated [Remote] +func NewRemote(base string, pub ed25519.PublicKey, c *http.Client) (Remote, error) { + u, err := url.Parse(base) + return Remote{u, pub, c}, err +} + +// get makes a [http.MethodGet] request and returns the response, or nil if +// the response StatusCode is [http.StatusNotFound]. +func (r Remote) get(ctx context.Context, elem ...string) (*http.Response, error) { + if r.url == nil || len(r.pub) != ed25519.PublicKeySize || r.c == nil { + return nil, os.ErrInvalid + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + r.url.JoinPath(elem...).String(), + nil, + ) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Rosa/1.1") + + var resp *http.Response + if resp, err = r.c.Do(req); err != nil { + return nil, err + } + + switch resp.StatusCode { + case http.StatusOK: + return resp, nil + + case http.StatusNotFound: + return nil, resp.Body.Close() + + default: + _ = resp.Body.Close() + return nil, pkg.ResponseStatusError(resp.StatusCode) + } +} + +const ( + // dirArtifact holds signed artifact outcome checksums. + dirArtifact = "artifact" + // dirOutcome holds outcome archives by their checksum. + dirOutcome = "outcome" + // dirStatus holds signed status files. + dirStatus = "status" +) + +// An OutcomeBadSizeError describes a mirror outcome with unexpected size. +type OutcomeBadSizeError struct { + Ident unique.Handle[pkg.ID] + Size int64 +} + +func (e OutcomeBadSizeError) Error() string { + if e.Size < 0 { + return "remote did not return outcome size for " + + pkg.Encode(e.Ident.Value()) + } + return "outcome size " + strconv.FormatInt(e.Size, 10) + + " invalid for " + pkg.Encode(e.Ident.Value()) +} + +// An OutcomeAuthError describes a mirror outcome with invalid signature. +type OutcomeAuthError unique.Handle[pkg.ID] + +func (e OutcomeAuthError) Error() string { + return "invalid outcome signature for " + + pkg.Encode(unique.Handle[pkg.ID](e).Value()) +} + +// Artifact fetches and authenticates an outcome. +func (r Remote) Artifact( + ctx context.Context, + id unique.Handle[pkg.ID], +) (*pkg.Checksum, error) { + if len(r.pub) != ed25519.PublicKeySize || r.c == nil { + return nil, os.ErrInvalid + } + + resp, err := r.get(ctx, dirArtifact, pkg.Encode(id.Value())) + if err != nil || resp == nil { + return nil, err + } + + var buf [ed25519.SignatureSize + 2*len(pkg.Checksum{})]byte + if resp.ContentLength != int64(len(buf)) { + _ = resp.Body.Close() + return nil, OutcomeBadSizeError{id, resp.ContentLength} + } + if _, err = io.ReadFull(resp.Body, buf[:]); err != nil { + return nil, errors.Join(err, resp.Body.Close()) + } else if err = resp.Body.Close(); err != nil { + return nil, err + } + + if !ed25519.Verify( + r.pub, + buf[ed25519.SignatureSize:], + buf[:ed25519.SignatureSize], + ) { + return nil, OutcomeAuthError(id) + } else if unique.Make((pkg.ID)(buf[ed25519.SignatureSize:])) != id { + return nil, OutcomeAuthError(id) + } + return (*pkg.Checksum)(buf[ed25519.SignatureSize+len(pkg.Checksum{}):]), nil +} + +// Checksum returns an artifact satisfying checksum. +func (r Remote) Checksum(checksum unique.Handle[pkg.Checksum]) pkg.Artifact { + return pkg.NewArchive(pkg.NewHTTPGet( + r.c, + r.url.JoinPath(dirOutcome, pkg.Encode(checksum.Value())).String(), + checksum.Value(), + )) +} + +// A StatusBadSizeError describes a mirror status with unexpected size. +type StatusBadSizeError unique.Handle[pkg.ID] + +func (e StatusBadSizeError) Error() string { + return "status payload too short for " + + pkg.Encode(unique.Handle[pkg.ID](e).Value()) +} + +// A StatusAuthError describes a mirror status with invalid signature. +type StatusAuthError unique.Handle[pkg.ID] + +func (e StatusAuthError) Error() string { + return "invalid status signature for " + + pkg.Encode(unique.Handle[pkg.ID](e).Value()) +} + +// Status authenticates the checksum of a status file and returns its +// corresponding measured reader. +func (r Remote) Status( + ctx *pkg.RContext, + id unique.Handle[pkg.ID], +) (io.ReadCloser, error) { + resp, err := r.get(ctx.Unwrap(), dirStatus, pkg.Encode(id.Value())) + if err != nil || resp == nil { + return nil, err + } + + var buf [ed25519.SignatureSize + 2*len(pkg.Checksum{})]byte + if _, err = io.ReadFull(resp.Body, buf[:]); err != nil { + if errors.Is(err, io.ErrUnexpectedEOF) { + err = StatusBadSizeError(id) + } + return nil, err + } + + if !ed25519.Verify( + r.pub, + buf[ed25519.SignatureSize:], + buf[:ed25519.SignatureSize], + ) { + _ = resp.Body.Close() + return nil, StatusAuthError(id) + } else if unique.Make((pkg.ID)(buf[ed25519.SignatureSize:])) != id { + return nil, StatusAuthError(id) + } + return ctx.NewMeasuredReader( + resp.Body, + unique.Make((pkg.Checksum)(buf[ed25519.SignatureSize+len(pkg.Checksum{}):])), + ), nil +} + +// NewMirror returns an [http.Handler] for servicing mirror requests. +func NewMirror( + msg message.Msg, + base *os.Root, + key ed25519.PrivateKey, +) http.Handler { + const identName = "ident" + var mux http.ServeMux + + mux.HandleFunc("/"+dirArtifact+"/{"+identName+"}", func( + w http.ResponseWriter, + req *http.Request, + ) { + var buf [2 * len(pkg.Checksum{})]byte + if err := pkg.Decode( + (*pkg.Checksum)(buf[:len(pkg.Checksum{})]), + req.PathValue(identName), + ); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ids := pkg.Encode((pkg.Checksum)(buf[:len(pkg.Checksum{})])) + if linkname, err := base.Readlink(filepath.Join( + "identifier", + ids, + )); err != nil { + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(http.StatusNotFound) + return + } + msg.GetLogger().Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } else if err = pkg.Decode( + (*pkg.Checksum)(buf[len(pkg.Checksum{}):]), + filepath.Base(linkname), + ); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + msg.Verbosef("serving artifact %s", ids) + + w.Header().Set( + "Content-Length", + strconv.Itoa(ed25519.SignatureSize+len(buf)), + ) + if _, err := w.Write(append( + ed25519.Sign(key, buf[:]), + buf[:]..., + )); err != nil { + msg.Verbose(err) + } + }) + + mux.HandleFunc("/"+dirOutcome+"/{"+identName+"}", func( + w http.ResponseWriter, + req *http.Request, + ) { + if !strings.Contains(req.Header.Get("Accept-Encoding"), "gzip") { + w.WriteHeader(http.StatusNotAcceptable) + return + } + + var buf pkg.Checksum + if err := pkg.Decode( + &buf, + req.PathValue(identName), + ); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + checksums := pkg.Encode(buf) + rel := filepath.Join("checksum", checksums) + if _, err := base.Lstat(rel); err != nil { + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(http.StatusNotFound) + return + } + msg.GetLogger().Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + msg.Verbosef("serving outcome %s", pkg.Encode(buf)) + + fsys, err := fs.Sub(base.FS(), rel) + if err != nil { + msg.GetLogger().Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + var gw *gzip.Writer + if gw, err = gzip.NewWriterLevel(w, gzip.BestCompression); err != nil { + msg.GetLogger().Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Encoding", "gzip") + if err = pkg.Write(fsys, ".", gw); err != nil { + msg.Verbose(err) + } + if err = gw.Close(); err != nil { + msg.GetLogger().Println(err) + } + }) + + mux.HandleFunc("/"+dirStatus+"/{"+identName+"}", func( + w http.ResponseWriter, + req *http.Request, + ) { + var buf [2 * len(pkg.Checksum{})]byte + if err := pkg.Decode( + (*pkg.Checksum)(buf[:len(pkg.Checksum{})]), + req.PathValue(identName), + ); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + ids := pkg.Encode((pkg.Checksum)(buf[:len(pkg.Checksum{})])) + f, err := base.Open(filepath.Join( + "status", + ids, + )) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + w.WriteHeader(http.StatusNotFound) + return + } + msg.GetLogger().Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + msg.Verbosef("serving status %s", ids) + + h := sha512.New384() + if _, err = io.Copy(h, f); err != nil { + _ = f.Close() + msg.Verbose(err) + w.WriteHeader(http.StatusInternalServerError) + } + h.Sum(buf[len(pkg.Checksum{}):len(pkg.Checksum{})]) + if _, err = w.Write(append(ed25519.Sign(key, buf[:]), buf[:]...)); err != nil { + msg.Verbose(err) + return + } else if _, err = f.Seek(0, io.SeekStart); err != nil { + msg.GetLogger().Println(err) + return + } else if _, err = io.Copy(w, f); err != nil { + msg.Verbose(err) + return + } + }) + + return &mux +}