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 }