From 5fa367949bb98b2a54a823908adb52b550bf62f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Cluseau?= Date: Tue, 22 Jul 2025 18:54:48 +0200 Subject: [PATCH] feature: download set --- Dockerfile | 2 + cmd/dkl-local-server/secrets.go | 32 +++ cmd/dkl-local-server/ws-download-set.go | 267 ++++++++++++++++++++++++ cmd/dkl-local-server/ws-downloads.go | 12 +- cmd/dkl-local-server/ws.go | 8 +- html/ui/app.css | 6 +- 6 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 cmd/dkl-local-server/ws-download-set.go diff --git a/Dockerfile b/Dockerfile index 90a10b9..78bfd02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +from novit.tech/direktil/dkl:bbea9b9 as dkl # ------------------------------------------------------------------------ from golang:1.24.4-bookworm as build @@ -30,4 +31,5 @@ run apt-get update \ grub2 grub-pc-bin grub-efi-amd64-bin ca-certificates curl openssh-client qemu-utils \ && apt-get clean +copy --from=dkl /bin/dkl /bin/dls /bin/ copy --from=build /src/dist/ /bin/ diff --git a/cmd/dkl-local-server/secrets.go b/cmd/dkl-local-server/secrets.go index cff4af3..c868a21 100644 --- a/cmd/dkl-local-server/secrets.go +++ b/cmd/dkl-local-server/secrets.go @@ -1,6 +1,7 @@ package main import ( + "crypto/ed25519" "encoding/json" "errors" "os" @@ -9,6 +10,7 @@ import ( "github.com/cloudflare/cfssl/certinfo" "github.com/cloudflare/cfssl/config" + "github.com/cloudflare/cfssl/helpers/derhelpers" "github.com/cloudflare/cfssl/log" ) @@ -73,3 +75,33 @@ func checkCertUsable(certPEM []byte) error { return nil } + +func dlsSigningKeys() (ed25519.PrivateKey, ed25519.PublicKey) { + var signerDER []byte + + if err := readSecret("signer", &signerDER); os.IsNotExist(err) { + _, key, err := ed25519.GenerateKey(nil) + if err != nil { + panic(err) + } + + signerDER, err = derhelpers.MarshalEd25519PrivateKey(key) + if err != nil { + panic(err) + } + + writeSecret("signer", signerDER) + } else if err != nil { + panic(err) + } + + pkeyGeneric, err := derhelpers.ParseEd25519PrivateKey(signerDER) + if err != nil { + panic(err) + } + + pkey := pkeyGeneric.(ed25519.PrivateKey) + pubkey := pkey.Public().(ed25519.PublicKey) + + return pkey, pubkey +} diff --git a/cmd/dkl-local-server/ws-download-set.go b/cmd/dkl-local-server/ws-download-set.go new file mode 100644 index 0000000..3cd60c6 --- /dev/null +++ b/cmd/dkl-local-server/ws-download-set.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/base32" + "fmt" + "io" + "slices" + "strconv" + "strings" + "time" + + restful "github.com/emicklei/go-restful" + "github.com/pierrec/lz4" + "m.cluseau.fr/go/httperr" +) + +type DownloadSet struct { + Expiry time.Time + Items []DownloadSetItem +} + +func (s DownloadSet) Contains(kind, name, asset string) bool { + for _, item := range s.Items { + if item.Kind == kind && item.Name == name && + slices.Contains(item.Assets, asset) { + return true + } + } + return false +} + +func (s DownloadSet) Encode() string { + buf := new(strings.Builder) + s.EncodeTo(buf) + return buf.String() +} + +func (s DownloadSet) EncodeTo(buf *strings.Builder) { + buf.WriteString(strconv.FormatInt(s.Expiry.Unix(), 16)) + + for _, item := range s.Items { + buf.WriteByte('|') + item.EncodeTo(buf) + } +} + +func (s *DownloadSet) Decode(encoded string) (err error) { + exp, rem, _ := strings.Cut(encoded, "|") + + expUnix, err := strconv.ParseInt(exp, 16, 64) + if err != nil { + return + } + + s.Expiry = time.Unix(expUnix, 0) + + if rem == "" { + s.Items = nil + } else { + itemStrs := strings.Split(rem, "|") + s.Items = make([]DownloadSetItem, len(itemStrs)) + for i, itemStr := range itemStrs { + s.Items[i].Decode(itemStr) + } + } + + return +} + +type DownloadSetItem struct { + Kind string + Name string + Assets []string +} + +func (i DownloadSetItem) EncodeTo(buf *strings.Builder) { + buf.WriteString(i.Kind) + buf.WriteByte(':') + buf.WriteString(i.Name) + + for _, asset := range i.Assets { + buf.WriteByte(':') + buf.WriteString(asset) + } +} + +func (i *DownloadSetItem) Decode(encoded string) { + rem := encoded + i.Kind, rem, _ = strings.Cut(rem, ":") + i.Name, rem, _ = strings.Cut(rem, ":") + + if rem == "" { + i.Assets = nil + } else { + i.Assets = strings.Split(rem, ":") + } +} + +type DownloadSetReq struct { + Expiry string + Items []DownloadSetItem +} + +func wsSignDownloadSet(req *restful.Request, resp *restful.Response) { + setReq := DownloadSetReq{} + if err := req.ReadEntity(&setReq); err != nil { + wsError(resp, err) + return + } + + exp, err := parseCertDuration(setReq.Expiry, time.Now()) + if err != nil { + wsError(resp, err) + return + } + + set := DownloadSet{ + Expiry: exp, + Items: setReq.Items, + } + + buf := new(bytes.Buffer) + { + setBytes := []byte(set.Encode()) + + w := lz4.NewWriter(buf) + w.Write(setBytes) + w.Close() + } + + setBytes := buf.Bytes() + + privkey, pubkey := dlsSigningKeys() + sig := ed25519.Sign(privkey, setBytes) + + if !ed25519.Verify(pubkey, setBytes, sig) { + wsError(resp, fmt.Errorf("signature self-check failed")) + return + } + + buf = bytes.NewBuffer(make([]byte, 0, 1+len(sig)+len(setBytes))) + buf.WriteByte(byte(len(sig))) + buf.Write(sig) + buf.Write(setBytes) + + enc := base32.StdEncoding.WithPadding(base32.NoPadding) + resp.WriteEntity(enc.EncodeToString(buf.Bytes())) +} + +func getDlSet(req *restful.Request) (*DownloadSet, *httperr.Error) { + setStr := req.QueryParameter("set") + + setBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(setStr) + if err != nil { + err := httperr.BadRequest("invalid set") + return nil, &err + } + + if len(setBytes) == 0 { + err := httperr.BadRequest("invalid set") + return nil, &err + } + + sigLen := int(setBytes[0]) + setBytes = setBytes[1:] + + if len(setBytes) < sigLen { + err := httperr.BadRequest("invalid set") + return nil, &err + } + + sig := setBytes[:sigLen] + setBytes = setBytes[sigLen:] + + _, pubkey := dlsSigningKeys() + if !ed25519.Verify(pubkey, setBytes, sig) { + err := httperr.BadRequest("invalid signature") + return nil, &err + } + + setBytes, err = io.ReadAll(lz4.NewReader(bytes.NewBuffer(setBytes))) + if err != nil { + err := httperr.BadRequest("invalid data") + return nil, &err + } + + fmt.Println(string(setBytes)) + + set := DownloadSet{} + if err := set.Decode(string(setBytes)); err != nil { + err := httperr.BadRequest("invalid set: " + err.Error()) + return nil, &err + } + + if time.Now().After(set.Expiry) { + err := httperr.BadRequest("set expired") + return nil, &err + } + + return &set, nil +} + +func wsDownloadSetAsset(req *restful.Request, resp *restful.Response) { + set, err := getDlSet(req) + if err != nil { + wsError(resp, *err) + return + } + + kind := req.PathParameter("kind") + name := req.PathParameter("name") + asset := req.PathParameter("asset") + + if !set.Contains(kind, name, asset) { + wsNotFound(resp) + return + } + + downloadAsset(req, resp, kind, name, asset) +} + +func wsDownloadSet(req *restful.Request, resp *restful.Response) { + setStr := req.QueryParameter("set") + set, err := getDlSet(req) + if err != nil { + resp.WriteHeader(err.Status) + resp.Write([]byte(` + + + ` + err.Error() + ` + + +

` + err.Error() + `

+`)) + return + } + + buf := new(bytes.Buffer) + buf.WriteString(` + + + Download set + + +

Download set

+`) + + for _, item := range set.Items { + fmt.Fprintf(buf, "

%s %s

", strings.Title(item.Kind), item.Name) + fmt.Fprintf(buf, "

\n") + for _, asset := range item.Assets { + fmt.Fprintf(buf, " %s\n", item.Kind, item.Name, asset, setStr, asset) + } + fmt.Fprintf(buf, `

`) + } + + buf.WriteString("") + buf.WriteTo(resp) +} diff --git a/cmd/dkl-local-server/ws-downloads.go b/cmd/dkl-local-server/ws-downloads.go index 842d473..67868e7 100644 --- a/cmd/dkl-local-server/ws-downloads.go +++ b/cmd/dkl-local-server/ws-downloads.go @@ -105,6 +105,10 @@ func wsDownloadAsset(req *restful.Request, resp *restful.Response) { log.Printf("download via token: %s %q asset %q", spec.Kind, spec.Name, asset) + downloadAsset(req, resp, spec.Kind, spec.Name, asset) +} + +func downloadAsset(req *restful.Request, resp *restful.Response, kind, name, asset string) { cfg, err := readConfig() if err != nil { wsError(resp, err) @@ -112,12 +116,12 @@ func wsDownloadAsset(req *restful.Request, resp *restful.Response) { } setHeader := func(ext string) { - resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(spec.Kind+"_"+spec.Name+"_"+asset+ext)) + resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(kind+"_"+name+"_"+asset+ext)) } - switch spec.Kind { + switch kind { case "cluster": - cluster := cfg.ClusterByName(spec.Name) + cluster := cfg.ClusterByName(name) if cluster == nil { wsNotFound(resp) return @@ -133,7 +137,7 @@ func wsDownloadAsset(req *restful.Request, resp *restful.Response) { } case "host": - host := hostOrTemplate(cfg, spec.Name) + host := hostOrTemplate(cfg, name) if host == nil { wsNotFound(resp) return diff --git a/cmd/dkl-local-server/ws.go b/cmd/dkl-local-server/ws.go index 667fc19..9816494 100644 --- a/cmd/dkl-local-server/ws.go +++ b/cmd/dkl-local-server/ws.go @@ -41,7 +41,9 @@ func registerWS(rest *restful.Container) { Route(ws.GET("/downloads/{token}/{asset}").To(wsDownloadAsset). Param(ws.PathParameter("token", "the download token")). Param(ws.PathParameter("asset", "the requested asset")). - Doc("Fetch an asset via a download token")) + Doc("Fetch an asset via a download token")). + Route(ws.GET("/download-set").To(wsDownloadSet)). + Route(ws.GET("/download-set/{kind}/{name}/{asset}").To(wsDownloadSetAsset)) rest.Add(ws) } @@ -66,6 +68,10 @@ func registerWS(rest *restful.Container) { Consumes(mime.JSON).Reads(DownloadSpec{}). Produces(mime.JSON). Doc("Create a download token for the given download")) + ws.Route(ws.POST("/sign-download-set").To(wsSignDownloadSet). + Consumes(mime.JSON).Reads(DownloadSetReq{}). + Produces(mime.JSON). + Doc("Sign a download set")) // - configs API ws.Route(ws.POST("/configs").To(wsUploadConfig). diff --git a/html/ui/app.css b/html/ui/app.css index 45d593c..e34718a 100644 --- a/html/ui/app.css +++ b/html/ui/app.css @@ -10,7 +10,7 @@ cursor: pointer; } -.downloads { +.downloads, .download-links { & > * { display: inline-block; margin-right: 1ex; @@ -25,10 +25,6 @@ } } -.download-links a { - margin-right: 1ex; -} - @media (prefers-color-scheme: dark) { .downloads > .selected, .view-links > .selected {