Compare commits

4 Commits

Author SHA1 Message Date
98eb601fd3 hack/docker-build: add GIT_TAG 2025-08-08 10:46:34 +02:00
8e87d406e4 log oops 2025-07-28 08:48:10 +02:00
f83b1eab23 render context: add asset_download_token 2025-07-27 13:08:59 +02:00
d03a7ab4ec dlset: allow globs in name, short kind 2025-07-27 12:49:28 +02:00
7 changed files with 147 additions and 87 deletions

View File

@ -8,6 +8,7 @@ import (
"math/rand" "math/rand"
"path" "path"
"reflect" "reflect"
"strconv"
"strings" "strings"
"github.com/cespare/xxhash" "github.com/cespare/xxhash"
@ -290,6 +291,14 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
"host_download_token": func() (s string) { "host_download_token": func() (s string) {
return "{{ host_download_token }}" return "{{ host_download_token }}"
}, },
"asset_download_token": func(args ...string) (s string) {
argsStr := new(strings.Builder)
for _, arg := range args {
argsStr.WriteByte(' ')
argsStr.WriteString(strconv.Quote(arg))
}
return "{{ asset_download_token" + argsStr.String() + " }}"
},
"hosts_of_group": func() (hosts []any) { "hosts_of_group": func() (hosts []any) {
hosts = make([]any, 0) hosts = make([]any, 0)

View File

@ -0,0 +1,15 @@
package main
func htmlHeader(title string) string {
return `<!doctype html>
<html>
<head>
<title>` + title + `</title>
<style>@import url('/ui/style.css');@import url('/ui/app.css');</style>
</head>
<body><h1>` + title + `</h1>
`
}
var htmlFooter = `</body>
</html>`

View File

@ -14,6 +14,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"text/template" "text/template"
"time"
cfsslconfig "github.com/cloudflare/cfssl/config" cfsslconfig "github.com/cloudflare/cfssl/config"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
@ -237,6 +238,32 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
} }
} }
return
},
"asset_download_token": func(asset string, params ...string) (token string, err error) {
now := time.Now()
exp := now.Add(24 * time.Hour) // expire in 24h by default
if len(params) != 0 {
exp, err = parseCertDuration(params[0], now)
if err != nil {
return
}
}
set := DownloadSet{
Expiry: exp,
Items: []DownloadSetItem{
{
Kind: "host",
Name: ctx.Host.Name,
Assets: []string{asset},
},
},
}
privKey, _ := dlsSigningKeys()
token = set.Signed(privKey)
return return
}, },
} { } {

View File

@ -6,6 +6,7 @@ import (
"encoding/base32" "encoding/base32"
"fmt" "fmt"
"io" "io"
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@ -16,6 +17,11 @@ import (
"m.cluseau.fr/go/httperr" "m.cluseau.fr/go/httperr"
) )
func globMatch(pattern, value string) bool {
ok, _ := filepath.Match(pattern, value)
return ok
}
type DownloadSet struct { type DownloadSet struct {
Expiry time.Time Expiry time.Time
Items []DownloadSetItem Items []DownloadSetItem
@ -23,7 +29,7 @@ type DownloadSet struct {
func (s DownloadSet) Contains(kind, name, asset string) bool { func (s DownloadSet) Contains(kind, name, asset string) bool {
for _, item := range s.Items { for _, item := range s.Items {
if item.Kind == kind && item.Name == name && if item.Kind == kind && globMatch(item.Name, name) &&
slices.Contains(item.Assets, asset) { slices.Contains(item.Assets, asset) {
return true return true
} }
@ -69,6 +75,28 @@ func (s *DownloadSet) Decode(encoded string) (err error) {
return return
} }
func (s DownloadSet) Signed(privKey ed25519.PrivateKey) string {
buf := new(bytes.Buffer)
{
setBytes := []byte(s.Encode())
w := lz4.NewWriter(buf)
w.Write(setBytes)
w.Close()
}
setBytes := buf.Bytes()
sig := ed25519.Sign(privKey, setBytes)
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)
return enc.EncodeToString(buf.Bytes())
}
type DownloadSetItem struct { type DownloadSetItem struct {
Kind string Kind string
Name string Name string
@ -76,7 +104,15 @@ type DownloadSetItem struct {
} }
func (i DownloadSetItem) EncodeTo(buf *strings.Builder) { func (i DownloadSetItem) EncodeTo(buf *strings.Builder) {
buf.WriteString(i.Kind) kind := i.Kind
switch kind {
case "host":
kind = "h"
case "cluster":
kind = "c"
}
buf.WriteString(kind)
buf.WriteByte(':') buf.WriteByte(':')
buf.WriteString(i.Name) buf.WriteString(i.Name)
@ -89,6 +125,14 @@ func (i DownloadSetItem) EncodeTo(buf *strings.Builder) {
func (i *DownloadSetItem) Decode(encoded string) { func (i *DownloadSetItem) Decode(encoded string) {
rem := encoded rem := encoded
i.Kind, rem, _ = strings.Cut(rem, ":") i.Kind, rem, _ = strings.Cut(rem, ":")
switch i.Kind {
case "h":
i.Kind = "host"
case "c":
i.Kind = "cluster"
}
i.Name, rem, _ = strings.Cut(rem, ":") i.Name, rem, _ = strings.Cut(rem, ":")
if rem == "" { if rem == "" {
@ -121,32 +165,8 @@ func wsSignDownloadSet(req *restful.Request, resp *restful.Response) {
Items: setReq.Items, Items: setReq.Items,
} }
buf := new(bytes.Buffer) privKey, _ := dlsSigningKeys()
{ resp.WriteEntity(set.Signed(privKey))
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) { func getDlSet(req *restful.Request) (*DownloadSet, *httperr.Error) {
@ -226,42 +246,47 @@ func wsDownloadSet(req *restful.Request, resp *restful.Response) {
set, err := getDlSet(req) set, err := getDlSet(req)
if err != nil { if err != nil {
resp.WriteHeader(err.Status) resp.WriteHeader(err.Status)
resp.Write([]byte(`<!doctype html> resp.Write([]byte(htmlHeader(err.Error())))
<html> resp.Write([]byte(htmlFooter))
<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 return
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
buf.WriteString(`<!doctype html> buf.WriteString(htmlHeader("Download set"))
<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 { cfg, err2 := readConfig()
fmt.Fprintf(buf, "<h2>%s %s</h2>", strings.Title(item.Kind), item.Name) if err2 != nil {
fmt.Fprintf(buf, "<p class=\"download-links\">\n") wsError(resp, err2)
for _, asset := range item.Assets { return
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>") for _, item := range set.Items {
names := make([]string, 0)
switch item.Kind {
case "cluster":
for _, c := range cfg.Clusters {
if globMatch(item.Name, c.Name) {
names = append(names, c.Name)
}
}
case "host":
for _, h := range cfg.Hosts {
if globMatch(item.Name, h.Name) {
names = append(names, h.Name)
}
}
}
for _, name := range names {
fmt.Fprintf(buf, "<h2>%s %s</h2>", strings.Title(item.Kind), 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, name, asset, setStr, asset)
}
fmt.Fprintf(buf, `</p>`)
}
}
buf.WriteString(htmlFooter)
buf.WriteTo(resp) buf.WriteTo(resp)
} }

View File

@ -180,38 +180,20 @@ func wsDownloadPage(req *restful.Request, resp *restful.Response) {
spec, ok := wState.Get().Downloads[token] spec, ok := wState.Get().Downloads[token]
if !ok { if !ok {
resp.WriteHeader(http.StatusNotFound) resp.WriteHeader(http.StatusNotFound)
resp.Write([]byte(`<!doctype html> resp.Write([]byte(htmlHeader("Token not found")))
<html> resp.Write([]byte(htmlFooter))
<head>
<title>Token not found</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
</head>
<body><h1>Token not found</h1></body>
</html>`))
return return
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
fmt.Fprintf(buf, `<!doctype html> buf.WriteString(htmlHeader(fmt.Sprintf("Token assets: %s %s", spec.Kind, spec.Name)))
<html>
<head>
<title>Token assets: %s %s</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
</head>
<body><h1>Token assets: %s %s</h1>
<ul>
`, spec.Kind, spec.Name, spec.Kind, spec.Name)
buf.WriteString("<ul>")
for _, asset := range spec.Assets { for _, asset := range spec.Assets {
fmt.Fprintf(buf, "<li><a href=\"%s\" download>%s</a></li>\n", asset, asset) fmt.Fprintf(buf, "<li><a href=\"%s\" download>%s</a></li>\n", asset, asset)
} }
buf.WriteString("</ul>")
buf.WriteString("</ul></body></html>") buf.WriteString(htmlFooter)
buf.WriteTo(resp) buf.WriteTo(resp)
} }

View File

@ -28,7 +28,6 @@ func (hft HostFromTemplate) ClusterName(cfg *localconfig.Config) string {
func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Host) { func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Host) {
host = cfg.Host(name) host = cfg.Host(name)
if host != nil { if host != nil {
log.Print("no host named ", name)
return return
} }
@ -39,13 +38,13 @@ func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Hos
} }
if !found { if !found {
log.Print("no host from template named ", name) log.Print("no host named ", name)
return return
} }
ht := cfg.HostTemplate(hft.Template) ht := cfg.HostTemplate(hft.Template)
if ht == nil { if ht == nil {
log.Print("no host template named ", name) log.Print("host ", name, " found but no template named ", hft.Template)
return return
} }

View File

@ -1,8 +1,11 @@
#! /bin/bash #! /bin/bash
set -ex set -ex
GIT_TAG=$(git describe --always --dirty)
case "$1" in case "$1" in
commit) tag=$(git describe --always --dirty) ;; commit) tag=$GIT_TAG ;;
"") tag=latest ;; "") tag=latest ;;
*) tag=$1 ;; *) tag=$1 ;;
esac esac
docker build -t novit.tech/direktil/local-server:$tag . docker build -t novit.tech/direktil/local-server:$tag . --build-arg GIT_TAG=$GIT_TAG