feature: download set

This commit is contained in:
Mikaël Cluseau
2025-07-22 18:54:48 +02:00
parent cef4441208
commit 5fa367949b
6 changed files with 317 additions and 10 deletions

View File

@ -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/

View File

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

View File

@ -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(`<!doctype html>
<html>
<head>
<title>` + err.Error() + `</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
</head>
<body><h1>` + err.Error() + `</h1></body>
</html>`))
return
}
buf := new(bytes.Buffer)
buf.WriteString(`<!doctype html>
<html>
<head>
<title>Download set</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
</head>
<body><h1>Download set</h1>
`)
for _, item := range set.Items {
fmt.Fprintf(buf, "<h2>%s %s</h2>", strings.Title(item.Kind), item.Name)
fmt.Fprintf(buf, "<p class=\"download-links\">\n")
for _, asset := range item.Assets {
fmt.Fprintf(buf, " <a href=\"/public/download-set/%s/%s/%s?set=%s\" download>%s</a>\n", item.Kind, item.Name, asset, setStr, asset)
}
fmt.Fprintf(buf, `</p>`)
}
buf.WriteString("</body></html>")
buf.WriteTo(resp)
}

View File

@ -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

View File

@ -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).

View File

@ -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 {