aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOphestra <cat@gensokyo.uk>2026-06-07 17:15:27 +0900
committerOphestra <cat@gensokyo.uk>2026-06-07 19:25:11 +0900
commite8bb5a622d0c1895b1768a90d0b422ec084db73f (patch)
tree46011917ee77bc70c2f6d473b85cee24dfe3e152
parent22e508fe17cdf6be6240e6b73ac5b90bc6bfd77e (diff)
internal/rosa: mirror service via external cache
This provides an authenticated implementation of the external cache. Signed-off-by: Ophestra <cat@gensokyo.uk>
-rw-r--r--cmd/mbf/cache.go19
-rw-r--r--cmd/mbf/main.go82
-rw-r--r--internal/rosa/mirror.go361
3 files changed, 461 insertions, 1 deletions
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)
@@ -187,6 +201,10 @@ func main() {
"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),
"Set SCHED_IDLE scheduling policy",
@@ -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
+}