Compare commits

...

3 Commits

Author SHA1 Message Date
2b27cc57e3 render context: add asset_download_token 2025-07-27 12:40:01 +02:00
7f1193cdda dlset: allow globs in name, short kind 2025-07-27 12:08:45 +02:00
cd69d9234e cosmetic changes 2025-07-23 10:45:31 +02:00
7 changed files with 173 additions and 83 deletions

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

@ -6,6 +6,7 @@ import (
"encoding/base32"
"fmt"
"io"
"path/filepath"
"slices"
"strconv"
"strings"
@ -69,6 +70,28 @@ func (s *DownloadSet) Decode(encoded string) (err error) {
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
@ -76,7 +99,15 @@ type DownloadSetItem struct {
}
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.WriteString(i.Name)
@ -89,6 +120,14 @@ func (i DownloadSetItem) EncodeTo(buf *strings.Builder) {
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 == "" {
@ -121,32 +160,8 @@ func wsSignDownloadSet(req *restful.Request, resp *restful.Response) {
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()))
privKey, _ := dlsSigningKeys()
resp.WriteEntity(set.Signed(privKey))
}
func getDlSet(req *restful.Request) (*DownloadSet, *httperr.Error) {
@ -230,10 +245,8 @@ func wsDownloadSet(req *restful.Request, resp *restful.Response) {
<html>
<head>
<title>` + err.Error() + `</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
<style src="/ui/style.css"/>
<style src="/ui/app.css"/>
</head>
<body><h1>` + err.Error() + `</h1></body>
</html>`))
@ -245,22 +258,44 @@ func wsDownloadSet(req *restful.Request, resp *restful.Response) {
<html>
<head>
<title>Download set</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
<style src="/ui/style.css"/>
<style src="/ui/app.css"/>
</head>
<body><h1>Download set</h1>
`)
cfg, err2 := readConfig()
if err2 != nil {
wsError(resp, err2)
return
}
for _, item := range set.Items {
fmt.Fprintf(buf, "<h2>%s %s</h2>", strings.Title(item.Kind), item.Name)
names := make([]string, 0)
switch item.Kind {
case "cluster":
for _, c := range cfg.Clusters {
if ok, _ := filepath.Match(item.Name, c.Name); ok {
names = append(names, c.Name)
}
}
case "host":
for _, h := range cfg.Hosts {
if ok, _ := filepath.Match(item.Name, h.Name); ok {
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, item.Name, asset, setStr, asset)
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("</body></html>")
buf.WriteTo(resp)

View File

@ -184,10 +184,8 @@ func wsDownloadPage(req *restful.Request, resp *restful.Response) {
<html>
<head>
<title>Token not found</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
<style src="/ui/style.css"/>
<style src="/ui/app.css"/>
</head>
<body><h1>Token not found</h1></body>
</html>`))
@ -199,10 +197,8 @@ func wsDownloadPage(req *restful.Request, resp *restful.Response) {
<html>
<head>
<title>Token assets: %s %s</title>
<style>
@import url('/ui/style.css');
@import url('/ui/app.css');
</style>
<style src="/ui/style.css"/>
<style src="/ui/app.css"/>
</head>
<body><h1>Token assets: %s %s</h1>
<ul>

View File

@ -20,14 +20,10 @@
border-radius: 1ex;
cursor: pointer;
}
& > .selected {
color: blue;
}
}
@media (prefers-color-scheme: dark) {
.downloads > .selected,
.view-links > .selected {
color: #31b0fa;
.downloads, .view-links {
& > .selected {
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

@ -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,29 +62,37 @@ 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, input[type=text] {
background: #111;
color: #ddd;
border: dotted 1pt;
border-top-color: #805300;
border-left-color: #805300;
border-bottom-color: orange;
border-right-color: orange;
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);
}
}
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);
}
}