Compare commits

...

12 Commits

Author SHA1 Message Date
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
cd69d9234e cosmetic changes 2025-07-23 10:45:31 +02:00
5fa367949b feature: download set 2025-07-22 18:54:48 +02:00
cef4441208 download token: also render JSON 2025-07-22 11:58:18 +02:00
d4087d3534 download token: make a token page
- token page helps communicating a single link to multiple assets
- provide an extra layer in case of "miss click"
- ui: just link the page, not every asset of each download token.
2025-07-22 11:47:41 +02:00
ab6f0b6358 remove dep on udev
- remove the need to map host's /dev
- remove race issues or need to have a working `udevadm settle`
2025-07-22 11:00:33 +02:00
af2758dead compress initrds with zstd 2025-07-21 17:37:29 +02:00
899a0a9dab pull-through dist server 2025-07-08 22:20:26 +02:00
08cbccc756 allow download of .../dist assets 2025-07-08 21:45:33 +02:00
62882e78d8 darken inputs in dark mode 2025-07-08 14:50:14 +02:00
23 changed files with 636 additions and 67 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

@ -8,6 +8,7 @@ import (
"math/rand"
"path"
"reflect"
"strconv"
"strings"
"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) {
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 = make([]any, 0)

View File

@ -109,7 +109,7 @@ func qemuImgBootImg(format string) func(out io.Writer, ctx *renderContext) (err
var grubSupportVersion = flag.String("grub-support", "1.1.0", "GRUB support version")
func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
path, err := ctx.distFetch("grub-support", *grubSupportVersion)
path, err := distFetch("grub-support", *grubSupportVersion)
if err != nil {
return
}
@ -148,6 +148,7 @@ func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
}()
log.Print("device: ", dev)
syncSysToDev()
tempDir := bootImg.Name() + ".p1.mount"
@ -161,9 +162,10 @@ func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
os.RemoveAll(tempDir)
}()
err = syscall.Mount(dev+"p1", tempDir, "vfat", 0, "")
devp1 := dev + "p1"
err = syscall.Mount(devp1, tempDir, "vfat", 0, "")
if err != nil {
return fmt.Errorf("failed to mount %s to %s: %v", dev+"p1", tempDir, err)
return fmt.Errorf("failed to mount %s to %s: %v", devp1, tempDir, err)
}
defer func() {

View File

@ -31,7 +31,7 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
}
// kernel
kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
kernelPath, err := distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return
}
@ -92,7 +92,7 @@ func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) {
}
// kernel
kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
kernelPath, err := distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return
}

View File

@ -11,6 +11,7 @@ import (
"net/http"
"os"
"github.com/klauspost/compress/zstd"
yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/cpiocat"
@ -39,10 +40,15 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
return
}
cat := cpiocat.New(out)
zout, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(12)))
if err != nil {
return fmt.Errorf("zstd writer setup failed: %w", err)
}
cat := cpiocat.New(zout)
// initrd
initrdPath, err := ctx.distFetch("initrd", ctx.Host.Initrd)
initrdPath, err := distFetch("initrd", ctx.Host.Initrd)
if err != nil {
return
}
@ -54,7 +60,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
case "modules":
layerVersion := ctx.Host.Versions[layer]
modulesPath, err := ctx.distFetch("layers", layer, layerVersion)
modulesPath, err := distFetch("layers", layer, layerVersion)
if err != nil {
return err
}
@ -88,7 +94,15 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
cat.AppendBytes(userCA, "user_ca.pub", 0600)
return cat.Close()
if err = cat.Close(); err != nil {
return fmt.Errorf("cpio close failed: %w", err)
}
if err = zout.Close(); err != nil {
return fmt.Errorf("zstd close failed: %w", err)
}
return
}
func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
@ -165,7 +179,7 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
return fmt.Errorf("layer %q not mapped to a version", layer)
}
outPath, err := ctx.distFetch("layers", layer, layerVersion)
outPath, err := distFetch("layers", layer, layerVersion)
if err != nil {
return err
}

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

@ -8,7 +8,7 @@ import (
)
func renderKernel(w http.ResponseWriter, r *http.Request, ctx *renderContext) error {
path, err := ctx.distFetch("kernels", ctx.Host.Kernel)
path, err := distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return err
}
@ -19,7 +19,7 @@ func renderKernel(w http.ResponseWriter, r *http.Request, ctx *renderContext) er
}
func fetchKernel(out io.Writer, ctx *renderContext) (err error) {
path, err := ctx.distFetch("kernels", ctx.Host.Kernel)
path, err := distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return err
}

View File

@ -72,6 +72,7 @@ func main() {
staticHandler := http.FileServer(http.FS(dlshtml.FS))
http.Handle("/favicon.ico", staticHandler)
http.Handle("/ui/", staticHandler)
http.Handle("/dist/", http.StripPrefix("/dist/", upstreamServer{}))
http.Handle("/public-state", streamsse.StreamHandler(wPublicState))
http.Handle("/state", requireAdmin(streamsse.StreamHandler(wState)))

View File

@ -14,6 +14,7 @@ import (
"path"
"path/filepath"
"text/template"
"time"
cfsslconfig "github.com/cloudflare/cfssl/config"
restful "github.com/emicklei/go-restful"
@ -138,7 +139,7 @@ func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
return
}
func (ctx *renderContext) distFilePath(path ...string) string {
func distFilePath(path ...string) string {
return filepath.Join(append([]string{*dataDir, "dist"}, path...)...)
}
@ -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
},
} {

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,63 @@
package main
import (
"bytes"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
// Simulate a udev run for our needs
func syncSysToDev() {
// loop devices
sysPaths, _ := filepath.Glob("/sys/devices/virtual/block/loop*/**/dev")
for _, sysPath := range sysPaths {
mknodBlk(sysPath)
}
}
func mknodBlk(sysPath string) {
devPath := "/dev/" + filepath.Base(filepath.Dir(sysPath))
if _, err := os.Stat(devPath); os.IsNotExist(err) {
// ok
} else if err != nil {
log.Printf("stat %s failed: %v", devPath, err)
return
} else {
return // exists
}
devBytes, err := os.ReadFile(sysPath)
if err != nil {
log.Printf("read %s failed: %v", sysPath, err)
return
}
devBytes = bytes.TrimSpace(devBytes)
// rust: let Some(dev) = devBytes.split_once(':').filter_map(|a,b| Some(mkdev(a.parse().ok()?, b.parse().ok()?)));
majorStr, minorStr, ok := strings.Cut(string(devBytes), ":")
if !ok {
log.Printf("%s: invalid dev string: %s", sysPath, string(devBytes))
return
}
major, err := strconv.ParseUint(majorStr, 10, 32)
if err != nil {
log.Printf("%s: invalid major: %q", sysPath, majorStr)
return
}
minor, err := strconv.ParseUint(minorStr, 10, 32)
if err != nil {
log.Printf("%s: invalid minor: %q", sysPath, minorStr)
return
}
devMajMin := int(unix.Mkdev(uint32(major), uint32(minor)))
log.Printf("mknod %s b %d %d", devPath, major, minor)
unix.Mknod(devPath, syscall.S_IFBLK|0o0600, devMajMin)
}

View File

@ -9,9 +9,11 @@ import (
"log"
"net/http"
"os"
"path"
gopath "path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/dustin/go-humanize"
@ -22,8 +24,22 @@ var (
upstreamURL = flag.String("upstream", "https://dkl.novit.io/dist", "Upstream server for dist elements")
)
func (ctx *renderContext) distFetch(path ...string) (outPath string, err error) {
outPath = ctx.distFilePath(path...)
type upstreamServer struct{}
func (_ upstreamServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := path.Clean(req.URL.Path)
outPath, err := distFetch(strings.Split(path, "/")...)
if err != nil {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte(err.Error() + "\n"))
return
}
http.ServeFile(w, req, outPath)
}
func distFetch(path ...string) (outPath string, err error) {
outPath = distFilePath(path...)
if _, err = os.Stat(outPath); err == nil {
return

View File

@ -0,0 +1,292 @@
package main
import (
"bytes"
"crypto/ed25519"
"encoding/base32"
"fmt"
"io"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
restful "github.com/emicklei/go-restful"
"github.com/pierrec/lz4"
"m.cluseau.fr/go/httperr"
)
func globMatch(pattern, value string) bool {
ok, _ := filepath.Match(pattern, value)
return ok
}
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 && globMatch(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
}
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 {
Kind string
Name string
Assets []string
}
func (i DownloadSetItem) EncodeTo(buf *strings.Builder) {
kind := i.Kind
switch kind {
case "host":
kind = "h"
case "cluster":
kind = "c"
}
buf.WriteString(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, ":")
switch i.Kind {
case "h":
i.Kind = "host"
case "c":
i.Kind = "cluster"
}
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,
}
privKey, _ := dlsSigningKeys()
resp.WriteEntity(set.Signed(privKey))
}
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(htmlHeader(err.Error())))
resp.Write([]byte(htmlFooter))
return
}
buf := new(bytes.Buffer)
buf.WriteString(htmlHeader("Download set"))
cfg, err2 := readConfig()
if err2 != nil {
wsError(resp, err2)
return
}
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)
}

View File

@ -1,11 +1,14 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/base32"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
restful "github.com/emicklei/go-restful"
@ -53,7 +56,7 @@ func wsAuthorizeDownload(req *restful.Request, resp *restful.Response) {
resp.WriteAsJson(token)
}
func wsDownload(req *restful.Request, resp *restful.Response) {
func wsDownloadAsset(req *restful.Request, resp *restful.Response) {
token := req.PathParameter("token")
asset := req.PathParameter("asset")
@ -102,6 +105,10 @@ func wsDownload(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)
@ -109,12 +116,12 @@ func wsDownload(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
@ -130,7 +137,7 @@ func wsDownload(req *restful.Request, resp *restful.Response) {
}
case "host":
host := hostOrTemplate(cfg, spec.Name)
host := hostOrTemplate(cfg, name)
if host == nil {
wsNotFound(resp)
return
@ -149,3 +156,44 @@ func wsDownload(req *restful.Request, resp *restful.Response) {
wsNotFound(resp)
}
}
func wsDownload(req *restful.Request, resp *restful.Response) {
if strings.HasSuffix(req.Request.URL.Path, "/") {
wsDownloadPage(req, resp)
return
}
token := req.PathParameter("token")
spec, ok := wState.Get().Downloads[token]
if !ok {
wsNotFound(resp)
return
}
resp.WriteEntity(spec)
}
func wsDownloadPage(req *restful.Request, resp *restful.Response) {
token := req.PathParameter("token")
spec, ok := wState.Get().Downloads[token]
if !ok {
resp.WriteHeader(http.StatusNotFound)
resp.Write([]byte(htmlHeader("Token not found")))
resp.Write([]byte(htmlFooter))
return
}
buf := new(bytes.Buffer)
buf.WriteString(htmlHeader(fmt.Sprintf("Token assets: %s %s", spec.Kind, spec.Name)))
buf.WriteString("<ul>")
for _, asset := range spec.Assets {
fmt.Fprintf(buf, "<li><a href=\"%s\" download>%s</a></li>\n", asset, asset)
}
buf.WriteString("</ul>")
buf.WriteString(htmlFooter)
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) {
host = cfg.Host(name)
if host != nil {
log.Print("no host named ", name)
return
}
@ -39,13 +38,13 @@ func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Hos
}
if !found {
log.Print("no host from template named ", name)
log.Print("no host named ", name)
return
}
ht := cfg.HostTemplate(hft.Template)
if ht == nil {
log.Print("no host template named ", name)
log.Print("host ", name, " found but no template named ", hft.Template)
return
}

View File

@ -37,10 +37,13 @@ func registerWS(rest *restful.Container) {
Route(ws.POST("/store.tar").To(wsStoreUpload).
Consumes(mime.TAR).
Doc("Upload an existing store")).
Route(ws.GET("/downloads/{token}/{asset}").To(wsDownload).
Route(ws.GET("/downloads/{token}").To(wsDownload)).
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)
}
@ -65,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).

1
go.mod
View File

@ -55,6 +55,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kisielk/sqlstruct v0.0.0-20210630145711-dae28ed37023 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect

4
go.sum
View File

@ -3,6 +3,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@ -128,6 +130,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/sqlstruct v0.0.0-20210630145711-dae28ed37023 h1:/pb3UJ+3ZtSEUKWnufwsoVF7f0AX5ytPULbTwHMgbq4=
github.com/kisielk/sqlstruct v0.0.0-20210630145711-dae28ed37023/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=

View File

@ -10,7 +10,7 @@
cursor: pointer;
}
.downloads {
.downloads, .download-links {
& > * {
display: inline-block;
margin-right: 1ex;
@ -20,18 +20,10 @@
border-radius: 1ex;
cursor: pointer;
}
}
.downloads, .view-links {
& > .selected {
color: blue;
}
}
.download-links a {
margin-right: 1ex;
}
@media (prefers-color-scheme: dark) {
.downloads > .selected,
.view-links > .selected {
color: #31b0fa;
color: var(--link);
}
}

View File

@ -74,7 +74,7 @@
</template>
<template v-else>
<div style="float:right;"><input type="text" placeholder="Filter" v-model="viewFilter"/></div>
<div style="float:right;"><input type="search" placeholder="Filter" v-model="viewFilter"/></div>
<p class="view-links"><span v-for="v in views" @click="view = v" :class="{selected: view.type==v.type && view.name==v.name}">{{v.title}}</span></p>
<h2 v-if="view">{{view.title}}</h2>
@ -112,9 +112,9 @@
<template v-for="k,i in state.Store.KeyNames">{{i?", ":""}}<code @click="forms.delKey.name=k">{{k}}</code></template>.</p>
</form>
<template v-if="state.HostTemplates && state.HostTemplates.length">
<template v-if="any(state.HostTemplates) || any(hostsFromTemplate)">
<h3>Hosts from template</h3>
<form @submit="hostFromTemplateAdd" action="">
<form @submit="hostFromTemplateAdd" action="" v-if="any(state.HostTemplates)">
<p>Add a host from template instance:</p>
<input type="text" v-model="forms.hostFromTemplate.name" required placeholder="Name" />
<select v-model="forms.hostFromTemplate.Template" required>
@ -123,7 +123,7 @@
<input type="text" v-model="forms.hostFromTemplate.IP" required placeholder="IP" />
<input type="submit" value="add instance" />
</form>
<form @submit="hostFromTemplateDel" action="">
<form @submit="hostFromTemplateDel" action="" v-if="any(hostsFromTemplate)">
<p>Remove a host from template instance:</p>
<select v-model="forms.hostFromTemplateDel" required>
<option v-for="h in hostsFromTemplate" :value="h.Name">{{h.Name}}</option>

View File

@ -27,16 +27,15 @@ export default {
}[this.kind]
},
downloads() {
let ret = []
Object.entries(this.state.Downloads)
return Object.entries(this.state.Downloads)
.filter(e => { let d=e[1]; return d.Kind == this.kind && d.Name == this.name })
.forEach(e => {
let token= e[0], d = e[1]
d.Assets.forEach(asset => {
ret.push({name: asset, url: '/public/downloads/'+token+'/'+asset})
})
.map(e => {
const token= e[0];
return {
text: token.substring(0, 5) + '...',
url: '/public/downloads/'+token+"/",
}
})
return ret
},
assets() {
return this.availableAssets.filter(a => this.selectedAssets[a])
@ -64,9 +63,9 @@ export default {
{{" "}}
</template>
</p>
<p><button :disabled="createDisabled || assets.length==0" @click="createToken">Create links</button></p>
<p><button :disabled="createDisabled || assets.length==0" @click="createToken">Create link</button></p>
<template v-if="downloads.length">
<h4>Active links</h4>
<p class="download-links"><template v-for="d in downloads"><a :href="d.url" download>{{ d.name }}</a>{{" "}}</template></p>
<p class="download-links"><template v-for="d in downloads"><a :href="d.url" target="_blank">{{ d.text }}</a>{{" "}}</template></p>
</template>`
}

View File

@ -79,11 +79,14 @@ createApp({
return undefined;
},
hostsFromTemplate() {
return (this.state.Hosts||[]).filter((h) => h.Template)
return (this.state.Hosts||[]).filter((h) => h.Template);
},
},
methods: {
any(array) {
return array && array.length != 0;
},
copyText(text) {
event.preventDefault()
window.navigator.clipboard.writeText(text)

View File

@ -1,5 +1,30 @@
:root {
--bg: #eee;
--color: black;
--bevel-dark: darkgray;
--bevel-light: lightgray;
--link: blue;
--input-bg: #ddd;
--input-text: white;
--btn-bg: #eee;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: black;
--color: orange;
--bevel-dark: #402900;
--bevel-light: #805300;
--link: #31b0fa;
--input-bg: #111;
--input-text: #ddd;
--btn-bg: #222;
}
}
body {
background: white;
background: var(--bg);
color: var(--color);
}
button[disabled] {
@ -8,7 +33,7 @@ button[disabled] {
a[href], a[href]:visited, button.link {
border: none;
color: blue;
color: var(--link);
background: none;
cursor: pointer;
text-decoration: none;
@ -37,20 +62,38 @@ th, tr:last-child > td {
.red { color: red; }
@media (prefers-color-scheme: dark) {
body {
background: black;
color: orange;
}
button, input[type=submit] {
background: #333;
color: #eee;
}
a[href], a[href]:visited, button.link {
border: none;
color: #31b0fa;
.red { color: #c00; }
}
textarea, select, input {
background: var(--input-bg);
color: var(--input-text);
border: solid 1pt;
border-color: var(--bevel-light);
border-top-color: var(--bevel-dark);
border-left-color: var(--bevel-dark);
margin: 1pt;
&:focus {
outline: solid 1pt var(--color);
}
}
.red { color: #c00; }
button, input[type=button], input[type=submit], ::file-selector-button {
background: var(--btn-bg);
color: var(--color);
border: solid 2pt;
border-color: var(--bevel-dark);
border-top-color: var(--bevel-light);
border-left-color: var(--bevel-light);
&:hover {
background: var(--bevel-dark);
}
&:active {
background: var(--bevel-dark);
border-color: var(--bevel-light);
}
}
header {