21 Commits

Author SHA1 Message Date
Mikaël Cluseau c255f1f196 ui rework 2026-06-18 00:19:26 +02:00
Mikaël Cluseau f6b301c9a0 download set with c= to match every host of a cluster 2026-06-17 22:20:55 +02:00
Mikaël Cluseau d975836adf ui: remove cluster downloads (no use anymore) 2026-06-17 18:35:49 +02:00
Mikaël Cluseau 99592d6efb add download sets and regroup access granting parts 2026-06-17 14:02:37 +02:00
Mikaël Cluseau 6b34628bea grub is not needed here 2026-06-17 12:20:45 +02:00
Mikaël Cluseau d522ab994f add downloadset generation 2026-06-17 12:20:37 +02:00
Mikaël Cluseau 99d8c7598e format ESP using mformat 2026-06-17 11:37:52 +02:00
Mikaël Cluseau 66f391de19 increate boot disk size 2026-06-13 17:22:11 +02:00
Mikaël Cluseau 07a9df2952 in serial boot mode, keep tty0 active 2026-06-08 12:27:55 +02:00
Mikaël Cluseau c8ad4c20a0 <host>/known_hosts 2026-05-30 10:07:44 +02:00
Mikaël Cluseau 6d26fd9fa6 <host>/ssh.pub 2026-05-30 09:36:35 +02:00
Mikaël Cluseau 1a4d908bef dist & cache cleaning + preseeding 2026-05-15 16:29:23 +02:00
Mikaël Cluseau 78aa9ba20d temporarily source udev rules from system config 2026-05-12 10:38:21 +02:00
Mikaël Cluseau b0e84f6aa8 adapt ui to new assets 2026-05-08 15:01:54 +02:00
Mikaël Cluseau 7a6310c93e support UKI 2026-05-07 23:41:29 +02:00
Mikaël Cluseau e89b164581 merged system layer support 2026-04-25 12:52:18 +02:00
Mikaël Cluseau 1ad9785d07 allow adding a raw key 2026-03-16 11:10:13 +01:00
Mikaël Cluseau 06a87a6d07 fix ws config as yaml 2026-02-21 14:34:53 +01:00
Mikaël Cluseau d37c4c2f13 feat: ca extra certs 2026-02-21 08:43:43 +01:00
Mikaël Cluseau 629bb21f12 base64 k8s (yaml) decoder expects padding 2026-02-10 21:08:45 +01:00
Mikaël Cluseau 6d9499ebb1 bump pkg dep 2026-02-10 15:20:51 +01:00
45 changed files with 2313 additions and 882 deletions
+8 -6
View File
@@ -1,6 +1,7 @@
from novit.tech/direktil/dkl:bbea9b9 as dkl from novit.tech/direktil/dkl:v1.2.0 as dkl
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
from golang:1.25.5-trixie as build from golang:1.26.4-trixie as build
run apt-get update && apt-get install -y git run apt-get update && apt-get install -y git
@@ -26,10 +27,11 @@ from debian:trixie
entrypoint ["/bin/dkl-local-server"] entrypoint ["/bin/dkl-local-server"]
env _uncache=1 env _uncache=1
run apt-get update \ run --mount=type=cache,id=debian-trixie-apt,target=/var/cache/apt \
&& yes |apt-get install -y genisoimage gdisk dosfstools util-linux udev binutils systemd \ apt update \
grub2 grub-pc-bin grub-efi-amd64-bin ca-certificates curl openssh-client qemu-utils wireguard-tools \ && yes |apt install -y genisoimage gdisk dosfstools util-linux udev binutils systemd \
&& apt-get clean ca-certificates curl openssh-client qemu-utils wireguard-tools \
erofs-utils erofsfuse cryptsetup systemd-boot-efi mtools
copy --from=dkl /bin/dkl /bin/dls /bin/ copy --from=dkl /bin/dkl /bin/dls /bin/
copy --from=build /src/dist/ /bin/ copy --from=build /src/dist/ /bin/
+87 -47
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"archive/tar" "archive/tar"
"bytes"
"compress/gzip" "compress/gzip"
"flag" "flag"
"fmt" "fmt"
@@ -12,18 +13,16 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"github.com/pierrec/lz4"
) )
func buildBootImg(out io.Writer, ctx *renderContext) (err error) { func buildBootImg(out io.Writer, ctx *renderContext, uki bool, cmdline string) (err error) {
bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-") bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-")
if err != nil { if err != nil {
return return
} }
defer rmTempFile(bootImg) defer rmTempFile(bootImg)
err = setupBootImage(bootImg, ctx) err = setupBootImage(bootImg, ctx, uki, cmdline)
if err != nil { if err != nil {
return return
} }
@@ -34,21 +33,10 @@ func buildBootImg(out io.Writer, ctx *renderContext) (err error) {
return return
} }
func buildBootImgLZ4(out io.Writer, ctx *renderContext) (err error) { func buildBootImgGZ(out io.Writer, ctx *renderContext, uki bool, cmdline string) (err error) {
lz4Out := lz4.NewWriter(out)
if err = buildBootImg(lz4Out, ctx); err != nil {
return
}
lz4Out.Close()
return
}
func buildBootImgGZ(out io.Writer, ctx *renderContext) (err error) {
gzOut := gzip.NewWriter(out) gzOut := gzip.NewWriter(out)
if err = buildBootImg(gzOut, ctx); err != nil { if err = buildBootImg(gzOut, ctx, uki, cmdline); err != nil {
return return
} }
@@ -56,7 +44,7 @@ func buildBootImgGZ(out io.Writer, ctx *renderContext) (err error) {
return return
} }
func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string) (err error) { func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string, uki bool, cmdline string) (err error) {
imgPath, err := func() (imgPath string, err error) { imgPath, err := func() (imgPath string, err error) {
bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-") bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-")
if err != nil { if err != nil {
@@ -64,7 +52,7 @@ func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string) (
} }
defer rmTempFile(bootImg) defer rmTempFile(bootImg)
err = setupBootImage(bootImg, ctx) err = setupBootImage(bootImg, ctx, uki, cmdline)
if err != nil { if err != nil {
return return
} }
@@ -100,37 +88,20 @@ func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string) (
io.Copy(out, img) io.Copy(out, img)
return return
} }
func qemuImgBootImg(format string) func(out io.Writer, ctx *renderContext) (err error) { func qemuImgBootImg(format string, uki bool, cmdline string) func(out io.Writer, ctx *renderContext) (err error) {
return func(out io.Writer, ctx *renderContext) (err error) { return func(out io.Writer, ctx *renderContext) (err error) {
return buildBootImgQemuConvert(out, ctx, format) return buildBootImgQemuConvert(out, ctx, format, uki, cmdline)
} }
} }
var grubSupportVersion = flag.String("grub-support", "1.1.0", "GRUB support version") func setupBootImage(bootImg *os.File, ctx *renderContext, uki bool, cmdline string) (err error) {
if uki {
func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) { err = baseDiskUki(bootImg, 192)
path, err := distFetch("grub-support", *grubSupportVersion) } else {
if err != nil { err = baseDiskGrub(bootImg)
return
} }
baseImage, err := os.Open(path)
if err != nil { if err != nil {
return return err
}
defer baseImage.Close()
baseImageGz, err := gzip.NewReader(baseImage)
if err != nil {
return
}
defer baseImageGz.Close()
_, err = io.Copy(bootImg, baseImageGz)
if err != nil {
return
} }
log.Print("running losetup...") log.Print("running losetup...")
@@ -163,6 +134,7 @@ func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
}() }()
devp1 := dev + "p1" devp1 := dev + "p1"
err = syscall.Mount(devp1, tempDir, "vfat", 0, "") err = syscall.Mount(devp1, tempDir, "vfat", 0, "")
if err != nil { if err != nil {
return fmt.Errorf("failed to mount %s to %s: %v", devp1, tempDir, err) return fmt.Errorf("failed to mount %s to %s: %v", devp1, tempDir, err)
@@ -176,10 +148,10 @@ func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
// add system elements // add system elements
tarOut, tarIn := io.Pipe() tarOut, tarIn := io.Pipe()
go func() { go func() {
err2 := buildBootTar(tarIn, ctx) e := buildBootTar(tarIn, ctx, uki, cmdline)
tarIn.Close() tarIn.Close()
if err2 != nil { if e != nil {
err = err2 err = e
} }
}() }()
@@ -217,6 +189,74 @@ func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
return return
} }
func baseDiskUki(image *os.File, sizeMB int16) (err error) {
err = image.Truncate(0)
if err != nil {
return
}
size := int64(sizeMB) << 20
err = image.Truncate(size)
if err != nil {
return
}
err = image.Sync()
if err != nil {
return
}
err = run("sgdisk", "--zap", "--new=1:2048:", "--typecode=1:EF00", "--change-name=1:ESP", image.Name())
if err != nil {
return
}
const (
espOffset = 2048 * 512
gptBackupSectors = 34 // real world data
)
espSectors := (size-espOffset)/512 - gptBackupSectors
cfg := fmt.Sprintf("drive d: file=%q offset=%d\n", image.Name(), espOffset)
err = mtool(cfg, "mformat", "-F", "-T", fmt.Sprint(espSectors), "d:")
return
}
var grubSupportVersion = flag.String("grub-support", "1.1.1", "GRUB support version")
func baseDiskGrub(image *os.File) (err error) {
path, err := distFetch("grub-support", *grubSupportVersion)
if err != nil {
return
}
baseImage, err := os.Open(path)
if err != nil {
return
}
defer baseImage.Close()
baseImageGz, err := gzip.NewReader(baseImage)
if err != nil {
return
}
defer baseImageGz.Close()
_, err = io.Copy(image, baseImageGz)
return
}
func mtool(config string, program string, args ...string) (err error) {
cmd := exec.Command(program, args...)
cmd.Env = append(os.Environ(), "MTOOLSRC=/dev/stdin")
cmd.Stdin = bytes.NewBufferString(config)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func run(program string, args ...string) (err error) { func run(program string, args ...string) (err error) {
cmd := exec.Command(program, args...) cmd := exec.Command(program, args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
+1 -1
View File
@@ -51,7 +51,7 @@ func buildBootISO(out io.Writer, ctx *renderContext) (err error) {
} }
// create a tag file // create a tag file
bootstrapBytes, _, err := ctx.BootstrapConfig() bootstrapBytes, err := ctx.BootstrapConfig()
if err != nil { if err != nil {
return return
} }
+127 -40
View File
@@ -3,11 +3,13 @@ package main
import ( import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"debug/pe"
"fmt"
"io" "io"
"log" "log"
"os" "os"
"path/filepath"
"novit.tech/direktil/local-server/pkg/utf16" "time"
) )
func rmTempFile(f *os.File) { func rmTempFile(f *os.File) {
@@ -17,7 +19,124 @@ func rmTempFile(f *os.File) {
} }
} }
func buildBootTar(out io.Writer, ctx *renderContext) (err error) { func buildUki(out io.Writer, ctx *renderContext, uki bool, cmdline string) error {
if !uki {
return fmt.Errorf("buildUki only builds UKI")
}
const efiStubPath = "/usr/lib/systemd/boot/efi/linuxx64.efi.stub"
stub, err := pe.Open(efiStubPath)
if err != nil {
return fmt.Errorf("efi stub open: %w", err)
}
hdr := stub.OptionalHeader.(*pe.OptionalHeader64)
align := uint64(hdr.SectionAlignment)
var offset uint64
for _, s := range stub.Sections {
end := uint64(s.VirtualAddress) + uint64(s.VirtualSize)
if end > offset {
offset = end
}
}
offset += hdr.ImageBase
stub.Close()
// kernel
kernelPath, err := distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return fmt.Errorf("fetch kernel: %w", err)
}
// temp dir
tempDir, err := os.MkdirTemp(os.TempDir(), "uki-")
if err != nil {
return fmt.Errorf("mkdir temp: %w", err)
}
defer os.RemoveAll(tempDir)
// osrel
osrelPath := filepath.Join(tempDir, "osrel")
if err := os.WriteFile(osrelPath, fmt.Appendf(nil, "ID=direktil\nPRETTY_NAME='Direktil %s %s+0'\n", ctx.Host.Name,
time.Now().UTC().Format(time.DateTime)), 0o600); err != nil {
return fmt.Errorf("create osrel: %w", err)
}
// cmdline
cmdlinePath := filepath.Join(tempDir, "cmdline")
if err := os.WriteFile(cmdlinePath, append([]byte(cmdline), 0), 0o600); err != nil {
return fmt.Errorf("create cmdline: %w", err)
}
// initrd
initrdPath := filepath.Join(tempDir, "initrd")
if err := func() (err error) {
initrd, err := os.Create(initrdPath)
if err != nil {
return
}
defer initrd.Close()
return buildInitrd(initrd, ctx)
}(); err != nil {
return fmt.Errorf("create initrd: %w", err)
}
// assemble
args := make([]string, 0)
for _, i := range []struct {
section string
path string
}{
{"osrel", osrelPath},
{"cmdline", cmdlinePath},
{"initrd", initrdPath},
{"linux", kernelPath},
} {
offset += align - offset%align
args = append(args,
"--add-section", "."+i.section+"="+i.path, "--change-section-vma", fmt.Sprintf(".%s=0x%x", i.section, offset))
stat, err := os.Stat(i.path)
if err != nil {
return fmt.Errorf("stat %s: %w", i.section, err)
}
offset += uint64(stat.Size())
}
ukiPath := filepath.Join(tempDir, "uki")
args = append(args, efiStubPath, ukiPath)
if err := run("objcopy", args...); err != nil {
return fmt.Errorf("objcopy: %w", err)
}
// read
ukiBytes, err := os.ReadFile(ukiPath)
if err != nil {
return fmt.Errorf("read uki: %w", err)
}
io.Copy(out, bytes.NewBuffer(ukiBytes))
// done
return nil
}
func buildBootTar(out io.Writer, ctx *renderContext, uki bool, cmdline string) (err error) {
if uki {
return buildUkiBootTar(out, ctx, cmdline)
} else {
return buildGrubBootTar(out, ctx)
}
}
func buildGrubBootTar(out io.Writer, ctx *renderContext) (err error) {
arch := tar.NewWriter(out) arch := tar.NewWriter(out)
defer arch.Close() defer arch.Close()
@@ -62,7 +181,7 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
return nil return nil
} }
func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) { func buildUkiBootTar(out io.Writer, ctx *renderContext, cmdline string) (err error) {
arch := tar.NewWriter(out) arch := tar.NewWriter(out)
defer arch.Close() defer arch.Close()
@@ -75,46 +194,14 @@ func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) {
return return
} }
const ( // UKI
prefix = "EFI/dkl/" uki := new(bytes.Buffer)
efiPrefix = "\\EFI\\dkl\\" err = buildUki(uki, ctx, true, cmdline)
)
// boot.csv
// -> annoyingly it's UTF-16...
bootCsvBytes := utf16.FromUTF8([]byte("" +
"current_kernel.efi,dkl current,initrd=" + efiPrefix + "current_initrd.img,Direktil current\n" +
"previous_kernel.efi,dkl previous,initrd=" + efiPrefix + "previous_initrd.img,Direktil previous\n"))
err = archAdd(prefix+"BOOT.CSV", []byte(bootCsvBytes))
if err != nil { if err != nil {
return return
} }
// kernel err = archAdd("EFI/BOOT/BOOTX64.EFI", uki.Bytes())
kernelPath, err := distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return
}
kernelBytes, err := os.ReadFile(kernelPath)
if err != nil {
return
}
err = archAdd(prefix+"current_kernel.efi", kernelBytes)
if err != nil {
return
}
// initrd
initrd := new(bytes.Buffer)
err = buildInitrd(initrd, ctx)
if err != nil {
return
}
err = archAdd(prefix+"current_initrd.img", initrd.Bytes())
if err != nil { if err != nil {
return return
} }
+309 -42
View File
@@ -4,40 +4,43 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"crypto" "crypto"
"encoding/json" "encoding/binary"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/cavaliergopher/cpio"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/base64"
"novit.tech/direktil/pkg/config"
"novit.tech/direktil/pkg/cpiocat" "novit.tech/direktil/pkg/cpiocat"
"novit.tech/direktil/pkg/localconfig"
) )
func renderBootstrapConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) { func renderBootstrapConfig(w http.ResponseWriter, ctx *renderContext) (err error) {
log.Printf("sending bootstrap config for %q", ctx.Host.Name) log.Printf("sending bootstrap config for %q", ctx.Host.Name)
_, cfg, err := ctx.BootstrapConfig() ba, err := ctx.BootstrapConfig()
if err != nil { if err != nil {
return err return err
} }
if asJson { _, err = w.Write(ba)
err = json.NewEncoder(w).Encode(cfg) return
} else {
err = yaml.NewEncoder(w).Encode(cfg)
}
return nil
} }
func buildInitrd(out io.Writer, ctx *renderContext) (err error) { func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
_, cfg, err := ctx.Config() _, cfg, err := ctx.Config()
if err != nil { if err != nil {
return return fmt.Errorf("render config failed: %w", err)
} }
zout, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(12))) zout, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(12)))
@@ -50,10 +53,32 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
// initrd // initrd
initrdPath, err := distFetch("initrd", ctx.Host.Initrd) initrdPath, err := distFetch("initrd", ctx.Host.Initrd)
if err != nil { if err != nil {
return return fmt.Errorf("fetch initrd failed: %w", err)
} }
cat.AppendArchFile(initrdPath) cat.AppendArchFile(initrdPath)
// copy config's udev rules (FIXME: too much logic for dls)
for _, file := range cfg.Files {
if !strings.HasPrefix(file.Path, "/etc/udev/rules.d/") {
continue
}
content := []byte(file.Content)
if c := file.Content64; c != "" {
content, err = base64.Decode(c)
if err != nil {
return fmt.Errorf("decode %s failed: %w", file.Path, err)
}
}
mode := file.Mode
if mode == 0 {
mode = 0o644
}
cat.AppendBytes(content, file.Path, cpio.FileMode(mode))
}
// embedded layers (modules) // embedded layers (modules)
for _, layer := range cfg.Layers { for _, layer := range cfg.Layers {
switch layer { switch layer {
@@ -64,7 +89,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
} }
modulesPath, err := distFetch("layers", layer, layerVersion) modulesPath, err := distFetch("layers", layer, layerVersion)
if err != nil { if err != nil {
return err return fmt.Errorf("fetch layer %s failed: %w", layer, err)
} }
cat.AppendFile(modulesPath, "modules.sqfs") cat.AppendFile(modulesPath, "modules.sqfs")
@@ -72,9 +97,9 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
} }
// config // config
cfgBytes, _, err := ctx.BootstrapConfig() cfgBytes, err := ctx.BootstrapConfig()
if err != nil { if err != nil {
return return fmt.Errorf("render bootstrap config failed: %w", err)
} }
cat.AppendBytes(cfgBytes, "config.yaml", 0o600) cat.AppendBytes(cfgBytes, "config.yaml", 0o600)
@@ -83,7 +108,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
cat.AppendDir("/etc/ssh", 0o700) cat.AppendDir("/etc/ssh", 0o700)
// XXX do we want bootstrap-stage keys instead of the real host key? // XXX do we want bootstrap-stage keys instead of the real host key?
for _, format := range []string{"rsa", "ecdsa", "ed25519"} { for _, format := range sshKeyTypes {
keyPath := "/etc/ssh/ssh_host_" + format + "_key" keyPath := "/etc/ssh/ssh_host_" + format + "_key"
cat.AppendBytes(cfg.FileContent(keyPath), keyPath, 0o600) cat.AppendBytes(cfg.FileContent(keyPath), keyPath, 0o600)
} }
@@ -107,15 +132,19 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
return return
} }
func getSigner(clusterName string) (signer crypto.Signer, err error) {
ca, err := getUsableClusterCA(clusterName, "boot-signer")
if err != nil {
return
}
return ca.ParseKey()
}
func buildBootstrap(out io.Writer, ctx *renderContext) (err error) { func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
arch := tar.NewWriter(out) arch := tar.NewWriter(out)
defer arch.Close() defer arch.Close()
ca, err := getUsableClusterCA(ctx.Host.ClusterName, "boot-signer") signer, err := getSigner(ctx.Host.ClusterName)
if err != nil {
return
}
signer, err := ca.ParseKey()
if err != nil { if err != nil {
return return
} }
@@ -171,22 +200,8 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
} }
// layers // layers
for _, layer := range cfg.Layers { appendSignedLayer := func(layer, layerPath string) (err error) {
if layer == "modules" { f, err := os.Open(layerPath)
continue // modules are in the initrd with boot v2
}
layerVersion := ctx.Host.Versions[layer]
if layerVersion == "" {
return fmt.Errorf("layer %q not mapped to a version", layer)
}
outPath, err := distFetch("layers", layer, layerVersion)
if err != nil {
return err
}
f, err := os.Open(outPath)
if err != nil { if err != nil {
return err return err
} }
@@ -202,7 +217,7 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
reader := io.TeeReader(f, h) reader := io.TeeReader(f, h)
if err = arch.WriteHeader(&tar.Header{ if err = arch.WriteHeader(&tar.Header{
Name: layer + ".fs", Name: layer,
Size: stat.Size(), Size: stat.Size(),
Mode: 0o600, Mode: 0o600,
}); err != nil { }); err != nil {
@@ -215,11 +230,263 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
} }
digest := h.Sum(nil) digest := h.Sum(nil)
err = sign(layer+".fs.sig", digest) err = sign(layer+".sig", digest)
if err != nil {
return err return
}
allErofs := true
for _, layer := range cfg.Layers {
if layer == "modules" {
continue // modules are in the initrd with boot v2
}
if !strings.HasSuffix(ctx.Host.Versions[layer], ".erofs") {
allErofs = false
break
}
}
if allErofs {
layerPath, e := layersCombo(ctx.Host, cfg, signer)
if e != nil {
err = e
return
}
if err = appendSignedLayer("merged", layerPath); err != nil {
return
}
} else {
for _, layer := range cfg.Layers {
if layer == "modules" {
continue // modules are in the initrd with boot v2
}
layerPath, e := fetchHostLayer(ctx.Host, layer)
if e != nil {
err = e
return
}
if err = appendSignedLayer(layer+".fs", layerPath); err != nil {
return
}
} }
} }
return nil return nil
} }
func layersCombo(host *localconfig.Host, cfg *config.Config, signer crypto.Signer) (path string, err error) {
key := layersComboKey(host, cfg)
return opMutex(key, func() (path string, err error) {
return buildLayersCombo(host, cfg, signer)
})
}
func layersComboKey(host *localconfig.Host, cfg *config.Config) string {
key := new(strings.Builder)
key.WriteString("layers")
for _, layer := range cfg.Layers {
if layer == "modules" {
continue
}
key.WriteByte(':')
key.WriteString(layer)
if v, ok := host.Versions[layer]; ok {
key.WriteByte('@')
key.WriteString(v)
}
}
return key.String()
}
func buildLayersCombo(host *localconfig.Host, cfg *config.Config, signer crypto.Signer) (path string, err error) {
path = filepath.Join(*dataDir, "cache")
if err = os.MkdirAll(path, 0o700); err != nil {
return
}
key := layersComboKey(host, cfg)
path = filepath.Join(path, key) + ".fs"
if _, statErr := os.Stat(path); statErr == nil {
return // exists -> already done
}
workdir, err := os.MkdirTemp("/tmp", "layers")
if err != nil {
return
}
defer os.RemoveAll(workdir)
tmpTar := filepath.Join(workdir, "output.tar")
layers := slices.Clone(cfg.Layers)
slices.Reverse(layers)
cmdOut := new(bytes.Buffer)
run := func(prog string, arg ...string) bool {
cmdOut.Reset()
cmd := exec.Command(prog, arg...)
cmd.Stdout = cmdOut // os.Stdout
cmd.Stderr = os.Stderr
if e := cmd.Run(); e != nil {
err = fmt.Errorf("%s %q failed: %w", cmd.Path, cmd.Args, e)
return false
}
return true
}
for i, layer := range layers {
if layer == "modules" {
continue // modules are in the initrd with boot v2
}
layerFile, e := fetchHostLayer(host, layer)
if e != nil {
err = e
return
}
mountPoint := filepath.Join(workdir, layer)
os.MkdirAll(mountPoint, 0700)
if e := exec.Command("erofsfuse", layerFile, mountPoint).Run(); e != nil {
err = fmt.Errorf("erofsfuse %s %s failed: %w", layerFile, mountPoint, e)
return
}
defer func() {
if err := exec.Command("umount", mountPoint).Run(); err != nil {
log.Printf("umount %s failed: %v", mountPoint, err)
}
}()
mode := "--append"
if i == 0 {
mode = "--create"
}
if !run("tar", mode, "-p", "-f", tmpTar, "-C", mountPoint, ".") {
return
}
}
fsOut := filepath.Join(workdir, "output.fs")
if !run("mkfs.erofs", "-z", "lzma", "-C131072", "-Efragments,ztailpacking",
"-T0", "--all-time", "--ignore-mtime", "--tar=f", fsOut, tmpTar) {
return
}
hashOut := filepath.Join(workdir, "output.hash")
if !run("veritysetup", "format", fsOut, hashOut) {
return
}
var rootHash []byte
for line := range strings.SplitSeq(cmdOut.String(), "\n") {
v, ok := strings.CutPrefix(line, "Root hash:")
if !ok {
continue
}
v = strings.TrimSpace(v)
b, e := hex.DecodeString(v)
if e != nil {
err = fmt.Errorf("invalid root hash: %w", e)
return
}
rootHash = b
break
}
if len(rootHash) == 0 {
err = fmt.Errorf("root hash not found in output")
return
}
sigBytes, err := signer.Sign(nil, rootHash, crypto.SHA256)
if err != nil {
err = fmt.Errorf("root hash signature failed: %w", err)
return
}
outPath := path + ".tmp"
err = func() (err error) {
fsRd, e := os.Open(fsOut)
if e != nil {
return e
}
defer fsRd.Close()
hashRd, e := os.Open(hashOut)
if e != nil {
return e
}
defer hashRd.Close()
fsStat, e := fsRd.Stat()
if e != nil {
return e
}
hashStat, e := hashRd.Stat()
if e != nil {
return e
}
out, err := os.Create(outPath)
if err != nil {
return
}
defer out.Close()
append := func(sz uint64, rd io.Reader) {
if err != nil {
return
}
szB := make([]byte, 8)
binary.BigEndian.PutUint64(szB, sz)
_, err = out.Write(szB)
if err != nil {
return
}
_, err = io.Copy(out, rd)
}
append(uint64(len(sigBytes)), bytes.NewBuffer(sigBytes))
append(uint64(len(rootHash)), bytes.NewBuffer(rootHash))
append(uint64(fsStat.Size()), fsRd)
append(uint64(hashStat.Size()), hashRd)
if err != nil {
return
}
err = out.Close()
return
}()
if err != nil {
err = fmt.Errorf("assembly failed: %w", err)
return
}
err = os.Rename(outPath, path)
return
}
func fetchHostLayer(host *localconfig.Host, layer string) (path string, err error) {
layerVersion := host.Versions[layer]
if layerVersion == "" {
return "", fmt.Errorf("layer %q not mapped to a version", layer)
}
return distFetch("layers", layer, layerVersion)
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"io/fs"
"log"
"os"
"path/filepath"
"strings"
)
func updateCache() {
log.Print("updating cache")
cfg, err := readConfig()
if err != nil {
log.Printf("update cache failed: read config failed: %v", err)
return
}
errs := 0
inUse := map[string]bool{}
fetch := func(path ...string) {
assetPath, err := distFetch(path...)
if err != nil {
log.Printf("update cache: dist fetch failed: %v", err)
errs += 1
}
inUse[assetPath] = true
}
fetch("grub-support", *grubSupportVersion)
for _, host := range cfg.Hosts {
ctx, err := newRenderContext(host, cfg)
if err != nil {
log.Printf("update cache: %s: build context failed: %v", host.Name, err)
errs += 1
continue
}
_, cfg, err := ctx.Config()
if err != nil {
log.Printf("update cache: %s: render config failed: %v", host.Name, err)
errs += 1
continue
}
signer, err := getSigner(host.ClusterName)
if err != nil {
log.Printf("update cache: %s: get signer failed: %v", host.Name, err)
errs += 1
continue
}
fetch("kernels", host.Kernel)
fetch("layers", "modules", host.Kernel)
fetch("initrd", host.Initrd)
allErofs := true
for layer, version := range host.Versions {
fetch("layers", layer, version)
if layer != "modules" && !strings.HasSuffix(version, ".erofs") {
allErofs = false
}
}
if allErofs {
path, err := layersCombo(host, cfg, signer)
if err != nil {
log.Printf("update cache: layer combo failed: %v", err)
continue
}
inUse[path] = true
}
}
if errs != 0 {
log.Printf("update cache: %d errors, not cleaning", errs)
return
}
distDir := filepath.Join(*dataDir, "dist")
cacheDir := filepath.Join(*dataDir, "cache")
existing := []string{}
for _, dir := range []string{distDir, cacheDir} {
fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
log.Printf("update cache: walking %s failed: %v", dir, err)
return nil
}
if !d.IsDir() {
existing = append(existing, filepath.Join(dir, path))
}
return nil
})
}
log.Printf("update cache: %d/%d assets in use", len(inUse), len(existing))
for _, path := range existing {
if inUse[path] {
continue
}
log.Print("update cache: removing ", path)
if err := os.Remove(path); err != nil {
log.Print("update cache: failed to remove: ", err)
}
}
}
+12 -2
View File
@@ -117,7 +117,12 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
return return
} }
s = string(ca.Cert) extra, err := caExtraCerts(cluster, name)
if err != nil {
return
}
s = string(ca.Cert) + extra
return return
}, },
@@ -127,13 +132,18 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
return return
} }
extra, err := caExtraCerts(cluster, name)
if err != nil {
return
}
dir := "/etc/tls-ca/" + name dir := "/etc/tls-ca/" + name
return asYaml([]config.FileDef{ return asYaml([]config.FileDef{
{ {
Path: path.Join(dir, "ca.crt"), Path: path.Join(dir, "ca.crt"),
Mode: 0644, Mode: 0644,
Content: string(ca.Cert), Content: string(ca.Cert) + extra,
}, },
{ {
Path: path.Join(dir, "ca.key"), Path: path.Join(dir, "ca.key"),
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"encoding/json" "encoding/json"
) )
func hash(values ...interface{}) string { func hash(values ...any) string {
ba, err := json.Marshal(values) ba, err := json.Marshal(values)
if err != nil { if err != nil {
panic(err) // should not happen panic(err) // should not happen
+3 -5
View File
@@ -4,14 +4,12 @@ import (
"encoding/json" "encoding/json"
"log" "log"
"net/http" "net/http"
yaml "gopkg.in/yaml.v2"
) )
func renderConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) { func renderConfig(w http.ResponseWriter, _ *http.Request, ctx *renderContext, asJson bool) (err error) {
log.Printf("sending config for %q", ctx.Host.Name) log.Printf("sending config for %q", ctx.Host.Name)
_, cfg, err := ctx.Config() cfgBytes, cfg, err := ctx.Config()
if err != nil { if err != nil {
return err return err
} }
@@ -19,7 +17,7 @@ func renderConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, as
if asJson { if asJson {
err = json.NewEncoder(w).Encode(cfg) err = json.NewEncoder(w).Encode(cfg)
} else { } else {
err = yaml.NewEncoder(w).Encode(cfg) _, err = w.Write(cfgBytes)
} }
return nil return nil
+5 -2
View File
@@ -18,7 +18,10 @@ const (
etcDir = "/etc/direktil" etcDir = "/etc/direktil"
) )
var Version = "dev" var (
Version = "dev"
Date = "now"
)
var ( var (
address = flag.String("address", ":7606", "HTTP listen address") address = flag.String("address", ":7606", "HTTP listen address")
@@ -38,7 +41,7 @@ func main() {
log.Fatal("no listen address given") log.Fatal("no listen address given")
} }
log.Print("Direktil local-server version ", Version) log.Print("Direktil local-server version ", Version, " (", Date, ")")
wPublicState.Change(func(s *PublicState) { s.ServerVersion = Version }) wPublicState.Change(func(s *PublicState) { s.ServerVersion = Version })
computeUIHash() computeUIHash()
+28
View File
@@ -0,0 +1,28 @@
package main
import "sync"
var (
opMutexes = map[string]*sync.Mutex{}
opsLock sync.Mutex
)
func opMutex[T any](key string, op func() (T, error)) (T, error) {
lock := func() *sync.Mutex {
opsLock.Lock()
defer opsLock.Unlock()
lock, ok := opMutexes[key]
if !ok {
lock = new(sync.Mutex)
opMutexes[key] = lock
}
return lock
}()
lock.Lock()
defer lock.Unlock()
return op()
}
+3 -18
View File
@@ -26,14 +26,10 @@ import (
"novit.tech/direktil/pkg/config" "novit.tech/direktil/pkg/config"
"novit.tech/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
bsconfig "novit.tech/direktil/pkg/bootstrapconfig"
) )
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition") var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
var b64 = base64.StdEncoding.WithPadding(base64.NoPadding)
type renderContext struct { type renderContext struct {
Host *localconfig.Host Host *localconfig.Host
SSLConfig *cfsslconfig.Config SSLConfig *cfsslconfig.Config
@@ -114,19 +110,8 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
return return
} }
func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, err error) { func (ctx *renderContext) BootstrapConfig() (ba []byte, err error) {
ba, err = ctx.render(ctx.Host.BootstrapConfig) return ctx.render(ctx.Host.BootstrapConfig)
if err != nil {
return
}
cfg = &bsconfig.Config{}
if err = yaml.Unmarshal(ba, cfg); err != nil {
log.Print("invalid bootstrap config yaml:\n", string(ba))
return
}
return
} }
func (ctx *renderContext) render(templateText string) (ba []byte, err error) { func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
@@ -190,7 +175,7 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
for name, method := range map[string]any{ for name, method := range map[string]any{
"base64": func(input string) string { "base64": func(input string) string {
return b64.EncodeToString([]byte(input)) return base64.StdEncoding.EncodeToString([]byte(input))
}, },
"host_ip": func() (s string) { "host_ip": func() (s string) {
+3 -1
View File
@@ -137,7 +137,9 @@ func unlockSecretStore(name string, passphrase []byte) (err httperr.Error) {
}) })
go updateState() go updateState()
go migrateSecrets()
migrateSecrets() // we can probably remove it now
go updateCache()
return return
} }
+2
View File
@@ -24,6 +24,7 @@ type State struct {
Store struct { Store struct {
DownloadToken string DownloadToken string
KeyNames []string KeyNames []string
Salt []byte
} }
Clusters []ClusterState Clusters []ClusterState
@@ -157,6 +158,7 @@ func updateState() {
wState.Change(func(v *State) { wState.Change(func(v *State) {
v.HasConfig = true v.HasConfig = true
v.Store.KeyNames = keyNames v.Store.KeyNames = keyNames
v.Store.Salt = secStore.Salt[:]
v.Clusters = clusters v.Clusters = clusters
v.Hosts = hosts v.Hosts = hosts
v.HostTemplates = hostTemplates v.HostTemplates = hostTemplates
+11
View File
@@ -79,6 +79,17 @@ func getUsableClusterCA(cluster, name string) (ca CA, err error) {
return return
} }
func caExtraCerts(cluster, name string) (extra string, err error) {
cfg, err := readConfig()
if err != nil {
return
}
if cfg.ExtraCaCerts != nil {
extra = cfg.ExtraCaCerts[cluster+"/"+name]
}
return
}
var clusterCASignedKeys = newClusterSecretKV[KeyCert]("CA-signed-keys") var clusterCASignedKeys = newClusterSecretKV[KeyCert]("CA-signed-keys")
func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) { func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) {
+2
View File
@@ -68,6 +68,8 @@ func writeNewConfig(cfgBytes []byte) (err error) {
err = os.Rename(out.Name(), cfgPath) err = os.Rename(out.Name(), cfgPath)
updateState() updateState()
go updateCache()
return return
} }
+53 -11
View File
@@ -15,6 +15,8 @@ import (
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"github.com/pierrec/lz4" "github.com/pierrec/lz4"
"m.cluseau.fr/go/httperr" "m.cluseau.fr/go/httperr"
"novit.tech/direktil/pkg/localconfig"
) )
func globMatch(pattern, value string) bool { func globMatch(pattern, value string) bool {
@@ -27,10 +29,22 @@ type DownloadSet struct {
Items []DownloadSetItem Items []DownloadSetItem
} }
func (s DownloadSet) Contains(kind, name, asset string) bool { func (s DownloadSet) Contains(cfg *localconfig.Config, kind, name, asset string) bool {
for _, item := range s.Items { for _, item := range s.Items {
if item.Kind == kind && globMatch(item.Name, name) && if item.Kind != kind || !slices.Contains(item.Assets, asset) {
slices.Contains(item.Assets, asset) { continue
}
// c=<cluster> matches any host of the given cluster
if kind == "host" && strings.HasPrefix(item.Name, "c=") {
host := hostOrTemplate(cfg, name)
if host != nil && host.ClusterName == item.Name[2:] {
return true
}
continue
}
if globMatch(item.Name, name) {
return true return true
} }
} }
@@ -233,7 +247,13 @@ func wsDownloadSetAsset(req *restful.Request, resp *restful.Response) {
name := req.PathParameter("name") name := req.PathParameter("name")
asset := req.PathParameter("asset") asset := req.PathParameter("asset")
if !set.Contains(kind, name, asset) { cfg, err2 := readConfig()
if err2 != nil {
wsError(resp, err2)
return
}
if !set.Contains(cfg, kind, name, asset) {
wsNotFound(resp) wsNotFound(resp)
return return
} }
@@ -262,28 +282,50 @@ func wsDownloadSet(req *restful.Request, resp *restful.Response) {
for _, item := range set.Items { for _, item := range set.Items {
names := make([]string, 0) names := make([]string, 0)
section := ""
switch item.Kind { switch item.Kind {
case "cluster": case "cluster":
section = "Cluster"
for _, c := range cfg.Clusters { for _, c := range cfg.Clusters {
if globMatch(item.Name, c.Name) { if globMatch(item.Name, c.Name) {
names = append(names, c.Name) names = append(names, c.Name)
} }
} }
case "host": case "host":
for _, h := range cfg.Hosts { section = "Host"
if globMatch(item.Name, h.Name) { if strings.HasPrefix(item.Name, "c=") {
names = append(names, h.Name) clusterName := item.Name[2:]
for _, h := range cfg.Hosts {
if h.ClusterName == clusterName {
names = append(names, h.Name)
}
}
} else {
for _, h := range cfg.Hosts {
if globMatch(item.Name, h.Name) {
names = append(names, h.Name)
}
} }
} }
} }
for _, name := range names { for _, name := range names {
fmt.Fprintf(buf, "<h2>%s %s</h2>", strings.Title(item.Kind), name) buf.WriteString("<p>")
fmt.Fprintf(buf, "<p class=\"download-links\">\n") fmt.Fprintf(buf, "<strong>%s %s:</strong>", section, name)
buf.WriteString(" <span class=\"download-links\">")
for _, asset := range item.Assets { 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) for _, v := range hostAssetVariants(asset) {
url := fmt.Sprintf("/public/download-set/%s/%s/%s", item.Kind, name, v.url)
if strings.Contains(url, "?") {
url += "&"
} else {
url += "?"
}
url += "set=" + setStr
fmt.Fprintf(buf, " <a href=\"%s\" download>%s</a>\n", url, v.name)
}
} }
fmt.Fprintf(buf, `</p>`) buf.WriteString("</span></p>\n")
} }
} }
+7 -3
View File
@@ -188,11 +188,15 @@ func wsDownloadPage(req *restful.Request, resp *restful.Response) {
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
buf.WriteString(htmlHeader(fmt.Sprintf("Token assets: %s %s", spec.Kind, spec.Name))) buf.WriteString(htmlHeader(fmt.Sprintf("Token assets: %s %s", spec.Kind, spec.Name)))
buf.WriteString("<ul>") buf.WriteString("<table class=\"download-table\">")
for _, asset := range spec.Assets { for _, asset := range spec.Assets {
fmt.Fprintf(buf, "<li><a href=\"%s\" download>%s</a></li>\n", asset, asset) buf.WriteString("<tr>")
for _, v := range hostAssetVariants(asset) {
fmt.Fprintf(buf, "<td><a href=\"%s\" download>%s</a></td>", v.url, v.name)
}
buf.WriteString("</tr>\n")
} }
buf.WriteString("</ul>") buf.WriteString("</table>")
buf.WriteString(htmlFooter) buf.WriteString(htmlFooter)
buf.WriteTo(resp) buf.WriteTo(resp)
+144 -50
View File
@@ -1,10 +1,12 @@
package main package main
import ( import (
"flag" "bytes"
"io"
"log" "log"
"net/http" "net/http"
"path" "path"
"strings"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
@@ -13,10 +15,38 @@ import (
"novit.tech/direktil/local-server/pkg/mime" "novit.tech/direktil/local-server/pkg/mime"
) )
var ( var sshKeyTypes = []string{"rsa", "ecdsa", "ed25519"}
allowDetectedHost = flag.Bool("allow-detected-host", false, "Allow access to host assets from its IP (insecure but enables unattended netboot)")
trustXFF = flag.Bool("trust-xff", false, "Trust the X-Forwarded-For header") type AssetVariant struct {
) name string
url string
}
func hostAssetVariants(asset string) []AssetVariant {
switch asset {
case "uki":
return []AssetVariant{
{asset, asset},
{asset + " (serial)", asset + "?serial=0"},
}
case "boot.img",
"boot.img.gz",
"boot.qcow2",
"boot.qed",
"boot.vdi",
"boot.vmdk",
"boot.vpc",
"boot.iso",
"boot.tar":
return []AssetVariant{
{asset, asset},
{asset + " (UKI)", asset + "?uki"},
{asset + " (UKI+serial)", asset + "?uki&serial=0"},
}
default:
return []AssetVariant{{asset, asset}}
}
}
type wsHost struct { type wsHost struct {
hostDoc string hostDoc string
@@ -51,9 +81,6 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
b("boot.img.gz"). b("boot.img.gz").
Produces(mime.DISK + "+gzip"). Produces(mime.DISK + "+gzip").
Doc("Get the " + ws.hostDoc + "'s boot disk image, gzip compressed"), Doc("Get the " + ws.hostDoc + "'s boot disk image, gzip compressed"),
b("boot.img.lz4").
Produces(mime.DISK + "+lz4").
Doc("Get the " + ws.hostDoc + "'s boot disk image, lz4 compressed"),
// - other formats // - other formats
b("boot.qcow2"). b("boot.qcow2").
@@ -62,10 +89,10 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
b("boot.qed"). b("boot.qed").
Produces(mime.DISK + "+qed"). Produces(mime.DISK + "+qed").
Doc("Get the " + ws.hostDoc + "'s boot disk image, QED (KVM)"), Doc("Get the " + ws.hostDoc + "'s boot disk image, QED (KVM)"),
b("boot.vmdk"). b("boot.vdi").
Produces(mime.DISK + "+vdi"). Produces(mime.DISK + "+vdi").
Doc("Get the " + ws.hostDoc + "'s boot disk image, VDI (VirtualBox)"), Doc("Get the " + ws.hostDoc + "'s boot disk image, VDI (VirtualBox)"),
b("boot.qcow2"). b("boot.vpc").
Produces(mime.DISK + "+vpc"). Produces(mime.DISK + "+vpc").
Doc("Get the " + ws.hostDoc + "'s boot disk image, VHD (Hyper-V)"), Doc("Get the " + ws.hostDoc + "'s boot disk image, VHD (Hyper-V)"),
b("boot.vmdk"). b("boot.vmdk").
@@ -75,10 +102,7 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
// metal/local HDD upgrades // metal/local HDD upgrades
b("boot.tar"). b("boot.tar").
Produces(mime.TAR). Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"), Doc("Get the " + ws.hostDoc + "'s /boot archive"),
b("boot-efi.tar").
Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
// read-only ISO support // read-only ISO support
b("boot.iso"). b("boot.iso").
@@ -98,6 +122,9 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
b("initrd"). b("initrd").
Produces(mime.OCTET). Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s initial RAM disk (ie: for netboot)"), Doc("Get the " + ws.hostDoc + "'s initial RAM disk (ie: for netboot)"),
b("uki").
Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s Unified Kernel Image"),
// - bootstrap config // - bootstrap config
b("bootstrap-config"). b("bootstrap-config").
@@ -109,6 +136,10 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
b("bootstrap.tar"). b("bootstrap.tar").
Produces(mime.TAR). Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s bootstrap seed archive"), Doc("Get the " + ws.hostDoc + "'s bootstrap seed archive"),
// ssh
b("ssh.pub").Produces("text/plain").Doc("Get the " + ws.hostDoc + "'s ssh public keys"),
b("known_hosts").Produces("text/plain").Doc("Get the " + ws.hostDoc + "'s ssh known_hosts fragment"),
} { } {
alterRB(rb) alterRB(rb)
rws.Route(rb) rws.Route(rb)
@@ -169,7 +200,59 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
return return
} }
cmdline := r.FormValue("cmdline")
if s := r.FormValue("serial"); s != "" {
if cmdline != "" {
cmdline += " "
}
cmdline += "console=tty0 console=ttyS" + s + ",115200"
}
_, uki := r.Form["uki"]
withUki := func(callback func(out io.Writer, ctx *renderContext, uki bool, cmdline string) (err error)) func(io.Writer, *renderContext) error {
return func(out io.Writer, ctx *renderContext) error {
return callback(out, ctx, uki, cmdline)
}
}
switch what { switch what {
case "kernel":
err = renderKernel(w, r, ctx)
case "initrd":
err = renderCtx(w, r, ctx, what, buildInitrd)
case "uki":
uki = true
err = renderCtx(w, r, ctx, what, withUki(buildUki))
case "bootstrap.tar":
err = renderCtx(w, r, ctx, what, buildBootstrap)
case "boot.img":
err = renderCtx(w, r, ctx, what, withUki(buildBootImg))
case "boot.img.gz":
err = renderCtx(w, r, ctx, what, withUki(buildBootImgGZ))
case "boot.qcow2":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("qcow2", uki, cmdline))
case "boot.qed":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("qed", uki, cmdline))
case "boot.vdi":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("vdi", uki, cmdline))
case "boot.vmdk":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("vmdk", uki, cmdline))
case "boot.vpc":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("vpc", uki, cmdline))
case "boot.iso":
err = renderCtx(w, r, ctx, what, buildBootISO)
case "boot.tar":
err = renderCtx(w, r, ctx, what, withUki(buildBootTar))
// boot v2
case "bootstrap-config":
err = renderBootstrapConfig(w, ctx)
case "config": case "config":
err = renderConfig(w, r, ctx, false) err = renderConfig(w, r, ctx, false)
case "config.json": case "config.json":
@@ -178,42 +261,10 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "ipxe": case "ipxe":
err = renderIPXE(w, ctx) err = renderIPXE(w, ctx)
case "kernel": case "ssh.pub":
err = renderKernel(w, r, ctx) err = renderSshPub(w, ctx)
case "initrd": case "known_hosts":
err = renderCtx(w, r, ctx, what, buildInitrd) err = renderKnownHosts(w, ctx)
case "bootstrap.tar":
err = renderCtx(w, r, ctx, what, buildBootstrap)
case "boot.img":
err = renderCtx(w, r, ctx, what, buildBootImg)
case "boot.img.gz":
err = renderCtx(w, r, ctx, what, buildBootImgGZ)
case "boot.img.lz4":
err = renderCtx(w, r, ctx, what, buildBootImgLZ4)
case "boot.qcow2":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("qcow2"))
case "boot.qed":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("qed"))
case "boot.vdi":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("vdi"))
case "boot.vmdk":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("vmdk"))
case "boot.vpc":
err = renderCtx(w, r, ctx, what, qemuImgBootImg("vpc"))
case "boot.iso":
err = renderCtx(w, r, ctx, what, buildBootISO)
case "boot.tar":
err = renderCtx(w, r, ctx, what, buildBootTar)
case "boot-efi.tar":
err = renderCtx(w, r, ctx, what, buildBootEFITar)
// boot v2
case "bootstrap-config":
err = renderBootstrapConfig(w, r, ctx, false)
case "bootstrap-config.json":
err = renderBootstrapConfig(w, r, ctx, true)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
@@ -224,3 +275,46 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
http.Error(w, "", http.StatusServiceUnavailable) http.Error(w, "", http.StatusServiceUnavailable)
} }
} }
func renderSshPub(w http.ResponseWriter, ctx *renderContext) (err error) {
buf := new(bytes.Buffer)
for _, t := range sshKeyTypes {
k, err := ctx.sshHostKeyPair(t)
if err != nil {
return err
}
buf.WriteString(k.Public)
}
_, err = w.Write(buf.Bytes())
return
}
func renderKnownHosts(w http.ResponseWriter, ctx *renderContext) (err error) {
buf := new(bytes.Buffer)
names := append([]string{ctx.Host.Name},
ctx.Host.IPs...)
for _, name := range names {
for _, t := range sshKeyTypes {
k, err := ctx.sshHostKeyPair(t)
if err != nil {
return err
}
fields := strings.Fields(k.Public)
buf.WriteString(name)
buf.WriteByte(' ')
buf.WriteString(fields[0])
buf.WriteByte(' ')
buf.WriteString(fields[1])
buf.WriteByte('\n')
}
}
_, err = w.Write(buf.Bytes())
return
}
+21 -4
View File
@@ -8,8 +8,13 @@ import (
"novit.tech/direktil/local-server/secretstore" "novit.tech/direktil/local-server/secretstore"
) )
type AddKeyReq struct {
NamedPassphrase `json:",inline"`
Hash []byte `json:",omitempty"`
}
func wsStoreAddKey(req *restful.Request, resp *restful.Response) { func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
np := NamedPassphrase{} np := AddKeyReq{}
err := req.ReadEntity(&np) err := req.ReadEntity(&np)
if err != nil { if err != nil {
@@ -24,8 +29,13 @@ func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
return return
} }
if len(np.Passphrase) == 0 { if len(np.Hash) == 0 && len(np.Passphrase) == 0 {
wsBadRequest(resp, "no passphrase given") wsBadRequest(resp, "no hash or passphrase given")
return
}
if len(np.Hash) != 0 && len(np.Hash) != 32 {
wsBadRequest(resp, "hash of a wrong length")
return return
} }
@@ -36,7 +46,14 @@ func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
} }
} }
secStore.AddKey(np.Name, np.Passphrase) if len(np.Hash) != 0 {
hash := [32]byte{}
copy(hash[:], np.Hash[:32])
secStore.AddRawKey(np.Name, hash)
} else {
secStore.AddKey(np.Name, np.Passphrase)
}
defer updateState() defer updateState()
err = secStore.SaveTo(secKeysStorePath()) err = secStore.SaveTo(secKeysStorePath())
+1 -1
View File
@@ -241,7 +241,7 @@ func wsError(resp *restful.Response, err error) {
} }
} }
func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) { func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value any) {
tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr) tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr)
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
+1 -1
View File
@@ -25,7 +25,7 @@ require (
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
k8s.io/apimachinery v0.33.2 k8s.io/apimachinery v0.33.2
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766 m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766
novit.tech/direktil/pkg v0.0.0-20260125193049-56f78e083a84 novit.tech/direktil/pkg v0.0.0-20260508111456-5a75785cfbbf
) )
replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1 replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1
+6 -2
View File
@@ -346,5 +346,9 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766 h1:JRzMBDbUwrTTGDJaJSH0ap4vRL0Q9CN1bG8a6n49eaQ= m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766 h1:JRzMBDbUwrTTGDJaJSH0ap4vRL0Q9CN1bG8a6n49eaQ=
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766/go.mod h1:BMv3aOSYpupuiiG3Ch3ND88aB5CfAks3YZuRLE8j1ls= m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766/go.mod h1:BMv3aOSYpupuiiG3Ch3ND88aB5CfAks3YZuRLE8j1ls=
novit.tech/direktil/pkg v0.0.0-20260125193049-56f78e083a84 h1:eqLPaRpVth1WgdvprKKtc4CVF13dkxuKbo7bLzlYG6s= novit.tech/direktil/pkg v0.0.0-20260210141740-4d5661fa8ecd h1:proGf8Cid9tzJzoRbqQHGGpZZKTpUDFwOREbjYrCbkM=
novit.tech/direktil/pkg v0.0.0-20260125193049-56f78e083a84/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10= novit.tech/direktil/pkg v0.0.0-20260210141740-4d5661fa8ecd/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
novit.tech/direktil/pkg v0.0.0-20260221072850-b72bed72bb51 h1:NBcpvWcTBMzFos0pkuLsbVCQ+mHf8KqNOdVywMX6FFk=
novit.tech/direktil/pkg v0.0.0-20260221072850-b72bed72bb51/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
novit.tech/direktil/pkg v0.0.0-20260508111456-5a75785cfbbf h1:Qt4aaB1R/BjQGEAhhNoIhzQwzaycxPgxHiA9M7aEujk=
novit.tech/direktil/pkg v0.0.0-20260508111456-5a75785cfbbf/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
+1 -1
View File
@@ -1,3 +1,3 @@
#! /bin/sh #! /bin/sh
set -ex set -ex
go build -o dist/ -trimpath -ldflags "-X main.Version=${GIT_TAG:-$(git describe --always --dirty)}" $* go build -o dist/ -trimpath -ldflags "-X main.Version=${GIT_TAG:-$(git describe --always --dirty)} -X main.Date=$(date --utc +%Y%m%d_%H%M%S)" $*
+1 -1
View File
@@ -8,4 +8,4 @@ commit) tag=$GIT_TAG ;;
"") tag=latest ;; "") tag=latest ;;
*) tag=$1 ;; *) tag=$1 ;;
esac esac
docker build -t novit.tech/direktil/local-server:$tag . --build-arg GIT_TAG=$GIT_TAG docker build --network=host -t novit.tech/direktil/local-server:$tag . --build-arg GIT_TAG=$GIT_TAG
+27
View File
@@ -0,0 +1,27 @@
const Cluster = {
components: { ClusterAccess, ClusterCAs, GetCopy },
props: [ 'cluster', 'token', 'state' ],
template: `
<details open>
<summary>Access</summary>
<ClusterAccess :cluster="cluster" :token="token" :state="state" />
</details>
<details open>
<summary>CAs</summary>
<ClusterCAs :cluster="cluster" :token="token" />
</details>
<details open>
<summary>Secrets</summary>
<h4>Tokens</h4>
<section class="links">
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
</section>
<h4>Passwords</h4>
<section class="links">
<GetCopy v-for="n in cluster.Passwords" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/passwords/'+n" />
</section>
</details>
`
}
-131
View File
@@ -1,131 +0,0 @@
const Cluster = {
components: { Downloads, GetCopy },
props: [ 'cluster', 'token', 'state' ],
data() {
return {
signReqValidity: "1d",
sshSignReq: {
PubKey: "",
Principal: "root",
},
sshUserCert: null,
kubeSignReq: {
CSR: "",
User: "",
Group: "system:masters",
},
kubeUserCert: null,
};
},
methods: {
sshCASign() {
event.preventDefault();
fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.sshUserCert = URL.createObjectURL(cert) })
} else {
resp.json().then((resp) => alert('failed to sign: '+resp.message))
}
})
.catch((e) => { alert('failed to sign: '+e); })
},
kubeCASign() {
event.preventDefault();
fetch(`/clusters/${this.cluster.Name}/kube/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.kubeSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.kubeUserCert = URL.createObjectURL(cert) })
} else {
resp.json().then((resp) => alert('failed to sign: '+resp.message))
}
})
.catch((e) => { alert('failed to sign: '+e); })
},
readFile(e, onload) {
const file = e.target.files[0];
if (!file) { return; }
const reader = new FileReader();
reader.onload = () => { onload(reader.result) };
reader.onerror = () => { alert("error reading file"); };
reader.readAsText(file);
},
loadPubKey(e) {
this.readFile(e, (v) => {
this.sshSignReq.PubKey = v;
});
},
loadCSR(e) {
this.readFile(e, (v) => {
this.kubeSignReq.CSR = v;
});
},
},
template: `
<h3>Access</h3>
<p>Allow cluster access from a public key</p>
<h4>Grant SSH access</h4>
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
<p>Public key (OpenSSH format):<br/>
<span class="text-and-file"><textarea v-model="sshSignReq.PubKey" style="height:3lh"></textarea>
<input type="file" accept=".pub" @change="loadPubKey" /></span>
</p>
<p><button @click="sshCASign">Sign SSH access</button>
<template v-if="sshUserCert">
=&gt; <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
</template>
</p>
<h4>Grant Kubernetes API access</h4>
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
<p>User: <input type="text" v-model="kubeSignReq.User"/> (by default, from the CSR)</p>
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
<p>Certificate signing request (PEM format):<br/>
<span class="text-and-file"><textarea v-model="kubeSignReq.CSR" style="height:7lh;"></textarea>
<input type="file" accept=".csr" @change="loadCSR" /></span>
</p>
<p><button @click="kubeCASign">Sign Kubernetes API access</button>
<template v-if="kubeUserCert">
=&gt; <a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
</template>
</p>
<h3>Tokens</h3>
<section class="links">
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
</section>
<h3>Passwords</h3>
<section class="links">
<GetCopy v-for="n in cluster.Passwords" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/passwords/'+n" />
</section>
<h3>Downloads</h3>
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
<h3>CAs</h3>
<table><tr><th>Name</th><th>Certificate</th><th>Signed certificates</th></tr>
<tr v-for="ca in cluster.CAs">
<td>{{ ca.Name }}</td>
<td><GetCopy :token="token" name="cert" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/certificate'" /></td>
<td><template v-for="signed in ca.Signed">
{{" "}}
<GetCopy :token="token" :name="signed" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/signed?name='+signed" />
</template></td>
</tr></table>
`
}
+190
View File
@@ -0,0 +1,190 @@
const ClusterAccess = {
props: [ 'cluster', 'token', 'state' ],
data() {
return {
accessType: "ssh",
signReqValidity: "1d",
sshSignReq: {
PubKey: "",
Principal: "root",
},
sshUserCert: null,
kubeSignReq: {
CSR: "",
User: "",
Group: "system:masters",
},
kubeUserCert: null,
downloadSet: null,
selectedAssets: {},
loading: false,
msg: null,
};
},
computed: {
availableAssets() {
return [
"kernel",
"initrd",
"uki",
"bootstrap.tar",
"boot.img.gz",
"boot.img",
"boot.qcow2",
"boot.vmdk",
"boot.iso",
"boot.tar",
"bootstrap-config",
"config",
"config.json",
"ipxe",
]
},
selectedAssetList() {
return this.availableAssets.filter(a => this.selectedAssets[a])
},
hostCount() {
return (this.state.Hosts||[]).filter(h => h.Cluster == this.cluster.Name).length
},
},
methods: {
sshCASign() {
event.preventDefault();
this.loading = true; this.msg = null;
fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.sshUserCert = URL.createObjectURL(cert); this.loading = false })
} else {
resp.json().then((resp) => { this.msg = 'failed to sign: '+resp.message; this.loading = false })
}
})
.catch((e) => { this.msg = 'failed to sign: '+e; this.loading = false })
},
kubeCASign() {
event.preventDefault();
this.loading = true; this.msg = null;
fetch(`/clusters/${this.cluster.Name}/kube/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.kubeSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.kubeUserCert = URL.createObjectURL(cert); this.loading = false })
} else {
resp.json().then((resp) => { this.msg = 'failed to sign: '+resp.message; this.loading = false })
}
})
.catch((e) => { this.msg = 'failed to sign: '+e; this.loading = false })
},
generateDownloadSet() {
event.preventDefault()
this.loading = true; this.msg = null;
const items = [{
Kind: "host",
Name: "c=" + this.cluster.Name,
Assets: this.selectedAssetList,
}]
fetch('/sign-download-set', {
method: 'POST',
body: JSON.stringify({Expiry: this.signReqValidity, Items: items}),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.json().then((set) => { this.downloadSet = set; this.loading = false })
} else {
resp.json().then((resp) => { this.msg = 'failed to generate: '+resp.message; this.loading = false })
}
}).catch((e) => { this.msg = 'failed to generate: '+e; this.loading = false })
},
readFile(e, onload) {
const file = e.target.files[0];
if (!file) { return; }
const reader = new FileReader();
reader.onload = () => { onload(reader.result) };
reader.onerror = () => { this.msg = "error reading file" };
reader.readAsText(file);
},
loadPubKey(e) {
this.readFile(e, (v) => {
this.sshSignReq.PubKey = v;
});
},
copyDownloadSetUrl() {
try {
navigator.clipboard.writeText(window.location.origin+'/public/download-set?set='+this.downloadSet)
this.$root.toast('copied!', 'info')
} catch (e) {
this.$root.toast('copy failed', 'error')
}
},
loadCSR(e) {
this.readFile(e, (v) => {
this.kubeSignReq.CSR = v;
});
},
},
template: `
<p>Grant access to:
<label class="radio"><input type="radio" v-model="accessType" value="ssh" /> SSH</label>
<label class="radio"><input type="radio" v-model="accessType" value="kube" /> Kubernetes</label>
<label class="radio"><input type="radio" v-model="accessType" value="download-set" /> Download set</label>
</p>
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
<p class="error" v-if="msg">{{ msg }} <button class="btn-close" @click="msg=null">&times;</button></p>
<div v-if="accessType == 'ssh'">
<h4>Grant SSH access</h4>
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
<p>Public key (OpenSSH format):<br/>
<span class="text-and-file"><textarea v-model="sshSignReq.PubKey" style="height:3lh"></textarea>
<input type="file" accept=".pub" @change="loadPubKey" /></span>
</p>
<p><button :disabled="loading" @click="sshCASign">{{ loading ? 'signing...' : 'Sign SSH access' }}</button>
<template v-if="sshUserCert">
=&gt; <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
</template>
</p>
</div>
<div v-if="accessType == 'kube'">
<h4>Grant Kubernetes API access</h4>
<p>User: <input type="text" v-model="kubeSignReq.User"/> (by default, from the CSR)</p>
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
<p>Certificate signing request (PEM format):<br/>
<span class="text-and-file"><textarea v-model="kubeSignReq.CSR" style="height:7lh;"></textarea>
<input type="file" accept=".csr" @change="loadCSR" /></span>
</p>
<p><button :disabled="loading" @click="kubeCASign">{{ loading ? 'signing...' : 'Sign Kubernetes API access' }}</button>
<template v-if="kubeUserCert">
=&gt; <a :href="kubeUserCert" download="tls.crt">Get certificate</a>
</template>
</p>
</div>
<div v-if="accessType == 'download-set'">
<h4>Grant download access</h4>
<p>Generates a signed download set granting access to the selected assets for all {{ hostCount }} hosts in this cluster.</p>
<h4>Available assets</h4>
<p class="downloads">
<template v-for="asset in availableAssets">
<label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label>
{{" "}}
</template>
</p>
<p><button :disabled="loading || selectedAssetList.length==0" @click="generateDownloadSet">{{ loading ? 'generating...' : 'Generate download set' }}</button></p>
<div v-if="downloadSet" class="term">
<a :href="'/public/download-set?set='+downloadSet" target="_blank">/public/download-set?set={{ downloadSet }}</a>
<br/>
<button @click="copyDownloadSetUrl">Copy URL</button>
</div>
</div>
`
}
+15
View File
@@ -0,0 +1,15 @@
const ClusterCAs = {
components: { GetCopy },
props: [ 'cluster', 'token' ],
template: `
<table><tr><th>Name</th><th>Certificate</th><th>Signed certificates</th></tr>
<tr v-for="ca in cluster.CAs">
<td>{{ ca.Name }}</td>
<td><GetCopy :token="token" name="cert" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/certificate'" /></td>
<td><template v-for="signed in ca.Signed">
{{" "}}
<GetCopy :token="token" :name="signed" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/signed?name='+signed" />
</template></td>
</tr></table>
`
}
@@ -1,29 +1,28 @@
const Downloads = { const Downloads = {
props: [ 'kind', 'name', 'token', 'state' ], props: [ 'kind', 'name', 'token', 'state' ],
data() { data() {
return { createDisabled: false, selectedAssets: {} } return { createDisabled: false, selectedAssets: {}, msg: null }
}, },
computed: { computed: {
availableAssets() { availableAssets() {
return { return ({
cluster: ['addons'],
host: [ host: [
"kernel", "kernel",
"initrd", "initrd",
"uki",
"bootstrap.tar", "bootstrap.tar",
"boot.img.lz4",
"boot.img.gz", "boot.img.gz",
"boot.img",
"boot.qcow2", "boot.qcow2",
"boot.vmdk", "boot.vmdk",
"boot.img",
"boot.iso", "boot.iso",
"boot.tar", "boot.tar",
"boot-efi.tar",
"config",
"bootstrap-config", "bootstrap-config",
"config",
"config.json",
"ipxe", "ipxe",
], ],
}[this.kind] }[this.kind] || [])
}, },
downloads() { downloads() {
return Object.entries(this.state.Downloads) return Object.entries(this.state.Downloads)
@@ -51,11 +50,12 @@ const Downloads = {
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => resp.json()) }).then((resp) => resp.json())
.then((token) => { this.selectedAssets = {}; this.createDisabled = false }) .then((token) => { this.selectedAssets = {}; this.createDisabled = false })
.catch((e) => { alert('failed to create link'); this.createDisabled = false }) .catch((e) => { this.msg = 'failed to create link'; this.createDisabled = false })
}, },
}, },
template: ` template: `
<h4>Available assets</h4> <h4>Available assets</h4>
<p class="error" v-if="msg">{{ msg }} <button class="btn-close" @click="msg=null">&times;</button></p>
<p class="downloads"> <p class="downloads">
<template v-for="asset in availableAssets"> <template v-for="asset in availableAssets">
<label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label> <label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label>
@@ -1,7 +1,7 @@
const GetCopy = { const GetCopy = {
props: [ 'name', 'href', 'token' ], props: [ 'name', 'href', 'token' ],
data() { return {showCopied: false} }, data() { return {showCopied: false} },
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a>&nbsp;<a href="#" class="copy" @click="fetchAndCopy()">&#x1F5D0;</a></span>`, template: `<span><a :href="href" @click="fetchAndSave()">{{name}}</a>&nbsp;<a href="#" class="copy" @click="fetchAndCopy()">&#x1F5D0;</a></span>`,
methods: { methods: {
fetch() { fetch() {
event.preventDefault() event.preventDefault()
@@ -12,7 +12,7 @@ const GetCopy = {
}, },
handleFetchError(e) { handleFetchError(e) {
console.log("failed to get value:", e) console.log("failed to get value:", e)
alert('failed to get value') this.$root.toast('failed to get value', 'error')
}, },
fetchAndSave() { fetchAndSave() {
this.fetch().then(resp => resp.blob()).then((value) => { this.fetch().then(resp => resp.blob()).then((value) => {
@@ -23,9 +23,12 @@ const GetCopy = {
this.fetch() this.fetch()
.then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text()) .then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
.then((value) => { .then((value) => {
window.navigator.clipboard.writeText(value) try {
this.showCopied = true window.navigator.clipboard.writeText(value)
setTimeout(() => { this.showCopied = false }, 1000) this.$root.toast('copied!', 'info')
} catch (e) {
this.$root.toast('copy failed', 'error')
}
}).catch(this.handleFetchError) }).catch(this.handleFetchError)
}, },
}, },
@@ -21,11 +21,17 @@ createApp({
uiHash: null, uiHash: null,
watchingState: false, watchingState: false,
state: null, state: null,
toasts: [],
toastId: 0,
} }
}, },
mounted() { mounted() {
this.session = JSON.parse(sessionStorage.state || "{}") this.session = JSON.parse(sessionStorage.state || "{}")
this.watchPublicState() this.watchPublicState()
document.addEventListener('keydown', (e) => this.onKeydown(e))
},
unmounted() {
document.removeEventListener('keydown', (e) => this.onKeydown(e))
}, },
watch: { watch: {
session: { session: {
@@ -56,13 +62,16 @@ createApp({
}, },
computed: { computed: {
views() { adminViews() {
var views = [{type: "actions", name: "admin", title: "Admin actions"}]; return [{type: "actions", name: "admin", title: "Admin actions"}];
},
(this.state.Clusters||[]).forEach((c) => views.push({type: "cluster", name: c.Name, title: `Cluster ${c.Name}`})); clusterViews() {
(this.state.Hosts ||[]).forEach((c) => views.push({type: "host", name: c.Name, title: `Host ${c.Name}`})); return (this.state.Clusters||[]).map((c) => ({type: "cluster", name: c.Name, title: c.Name}));
},
return views.filter((v) => v.type != "host" || v.name.includes(this.viewFilter)); hostViews() {
return (this.state.Hosts||[])
.filter((h) => h.Name.includes(this.viewFilter))
.map((h) => ({type: "host", name: h.Name, title: h.Name}));
}, },
viewObj() { viewObj() {
if (this.view) { if (this.view) {
@@ -84,9 +93,32 @@ createApp({
any(array) { any(array) {
return array && array.length != 0; return array && array.length != 0;
}, },
isActive(v) {
return this.view && this.view.type == v.type && this.view.name == v.name
},
onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key == 'k') {
e.preventDefault()
this.$nextTick(() => {
const el = document.querySelector('.sidebar input[type="search"]')
if (el) el.focus()
})
return
}
if (e.key == 'Escape') {
this.viewFilter = ''
return
}
},
copyText(text) { copyText(text) {
event.preventDefault() event.preventDefault()
window.navigator.clipboard.writeText(text) try {
window.navigator.clipboard.writeText(text)
this.toast('copied!', 'info')
} catch (e) {
this.toast('copy failed: ' + e, 'error')
}
}, },
setToken() { setToken() {
event.preventDefault() event.preventDefault()
@@ -103,7 +135,12 @@ createApp({
return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)} return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)}
}, },
storeAddKey() { storeAddKey() {
this.apiPost('/store/add-key', this.namedPassphrase(), (v) => { const params = this.namedPassphrase();
if (this.forms.store.byHash) {
params.Hash = this.forms.store.hash;
}
this.apiPost('/store/add-key', params, (v) => {
this.forms.store = {} this.forms.store = {}
}) })
}, },
@@ -172,16 +209,12 @@ createApp({
var xhr = new XMLHttpRequest() var xhr = new XMLHttpRequest()
xhr.responseType = 'json' xhr.responseType = 'json'
// TODO spinner, pending action notification, or something xhr.onerror = () => {}
xhr.onerror = () => {
// this.actionResults.splice(idx, 1, {...item, done: true, failed: true })
}
xhr.onload = (r) => { xhr.onload = (r) => {
if (xhr.status != 200) { if (xhr.status != 200) {
this.error = xhr.response this.toast((xhr.response && xhr.response.message) || 'request failed', 'error')
return return
} }
// this.actionResults.splice(idx, 1, {...item, done: true, resp: xhr.responseText})
this.error = null this.error = null
if (onload) { if (onload) {
onload(xhr.response) onload(xhr.response)
@@ -230,6 +263,14 @@ createApp({
// TODO once-shot download link // TODO once-shot download link
return url + '?token=' + this.session.token return url + '?token=' + this.session.token
}, },
toast(message, kind) {
const id = ++this.toastId
this.toasts.push({id, message, kind: kind || 'info'})
setTimeout(() => this.dismissToast(id), 4000)
},
dismissToast(id) {
this.toasts = this.toasts.filter(t => t.id != id)
},
watchPublicState() { watchPublicState() {
this.watchStream('publicState', '/public-state') this.watchStream('publicState', '/public-state')
}, },
+7 -10
View File
@@ -1,27 +1,24 @@
.view-links > span { .view-links > span {
display: inline-block; display: block;
white-space: nowrap; white-space: nowrap;
margin-right: 1ex;
margin-bottom: 1ex;
padding: 0.5ex;
border: 1pt solid;
border-radius: 1ex;
cursor: pointer; cursor: pointer;
} }
.downloads, .download-links { .downloads, .download-links, .download-table td {
& > * { & > * {
display: inline-block; display: inline-block;
margin-right: 1ex; margin-right: 1ex;
margin-bottom: 1ex; margin-bottom: 1ex;
padding: 0.5ex;
border: 1px solid;
border-radius: 1ex;
cursor: pointer; cursor: pointer;
} }
} }
.download-table th,
.download-table td {
border: none !important;
}
.downloads, .view-links { .downloads, .view-links {
& > .selected { & > .selected {
color: var(--link); color: var(--link);
+29 -9
View File
@@ -10,10 +10,12 @@
<style>@import url('/ui/style.css');@import url('/ui/app.css');</style> <style>@import url('/ui/style.css');@import url('/ui/app.css');</style>
<script src="/ui/jsonpatch.min-942279a1c4209cc2.js" integrity="sha384-GcPrkRS12jtrElEkbJcrZ8asvvb9s3mc+CUq9UW/8bL4L0bpkmh9M+oFnWN6qLq2"></script> <script src="/ui/jsonpatch.min-942279a1c4209cc2.js" integrity="sha384-GcPrkRS12jtrElEkbJcrZ8asvvb9s3mc+CUq9UW/8bL4L0bpkmh9M+oFnWN6qLq2"></script>
<script src="/ui/app-7f4645e84eaae7ce.js" defer integrity="sha384-31uiQnXVSPLDq61o+chfyKRkSdkmzv023M7KafOo+0xRw5AM70BUFyYO6yo0kPiZ" type="module"></script> <script src="/ui/app-4f0c6935250753e9.js" defer integrity="sha384-SrAfGs5pnGcgL5E78t2MszNYmdkGME9yPgtPsDg61Rljw+dfiP+rabjPnrMkWDUH" type="module"></script>
<script src="/ui/Downloads-c9374f19f52c46d.js" integrity="sha384-WQIkCSxlUkvu4jFIWwS3bESMaazGwwnBn9dyvB7nItz3L8BmusRMsJACzfJa7sC4"></script> <script src="/ui/Downloads-3c8cba0572aebfae.js" integrity="sha384-Pof/sPwhdqKGp7NUmYkDc0pSSyACabwLjkRRylZKHxRmiHBV3SGomPUMAWG9y7Ix"></script>
<script src="/ui/GetCopy-7e3c9678f9647d40.js" integrity="sha384-LzxUXylxE/t25HyTch8y2qvKcehvP2nqCo37swIBGEKZZUzHVJVQrS5UJDWNskTA"></script> <script src="/ui/GetCopy-2e04b7b63750e25a.js" integrity="sha384-sNvTYXhjog3BkYL20RzrUWIG9VGyHNO8oEiP5oxL5f2WKmLj/03fdR79FgHfzlj+"></script>
<script src="/ui/Cluster-703dcdca97841304.js" integrity="sha384-ifGpq/GB1nDfqczm5clTRtcfnc9UMkT+SptMyS3UG9Di3xoKORtOGGjmK5RnJx+v"></script> <script src="/ui/ClusterAccess-8b4165435ba4fcac.js" integrity="sha384-C1mVnhsaxmn3Dpp1vtcBuctxxXI1Rlh+qmxicCIpNv7ukyTbB524pg0ZwhvAnfH7"></script>
<script src="/ui/ClusterCAs-4f0a381bfb0c1b88.js" integrity="sha384-QcSHKzEHNSCqyjFELXfYFAdfmIZZwJHDzPqI2prH47jHrkeomJD60rcEMLkVAO3o"></script>
<script src="/ui/Cluster-34a012ee0910827c.js" integrity="sha384-Q0sXP0WFJu4gHOI/8NMOGwpJvvFDNBR3T/vDCYxCWL2szAu4+sQuwHm81Jcad/ve"></script>
<script src="/ui/Host-61916516a854adff.js" integrity="sha384-/wh3KrC0sb4MT7ekO2U84rswxI42WSH/0jB4dbDaaGaGhX60xTEZHFsdQAf7UgTG"></script> <script src="/ui/Host-61916516a854adff.js" integrity="sha384-/wh3KrC0sb4MT7ekO2U84rswxI42WSH/0jB4dbDaaGaGhX60xTEZHFsdQAf7UgTG"></script>
<body> <body>
@@ -42,6 +44,8 @@
<div class="message">{{ error.message }}</div> <div class="message">{{ error.message }}</div>
</div> </div>
<div class="toasts"><div v-for="t in toasts" :class="'toast '+t.kind" @click="dismissToast(t.id)">{{ t.message }}</div></div>
<template v-if="!publicState"> <template v-if="!publicState">
<p>Not connected.</p> <p>Not connected.</p>
</template> </template>
@@ -81,10 +85,18 @@
</template> </template>
<template v-else> <template v-else>
<div style="float:right;"><input type="search" placeholder="Filter" v-model="viewFilter"/></div> <div class="layout">
<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> <nav class="sidebar">
<input type="search" placeholder="Filter" v-model="viewFilter"/>
<h2 v-if="view">{{view.title}}</h2> <p class="nav-section">Admin</p>
<p class="view-links"><span v-for="v in adminViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="clusterViews.length">Clusters</p>
<p class="view-links" v-if="clusterViews.length"><span v-for="v in clusterViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="hostViews.length">Hosts</p>
<p class="view-links" v-if="hostViews.length"><span v-for="v in hostViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
</nav>
<main class="content">
<h2 v-if="view">{{ view.type == 'cluster' ? 'Cluster ' : view.type == 'host' ? 'Host ' : '' }}{{view.title}}</h2>
<div v-if="view.type == 'cluster'" id="clusters"> <div v-if="view.type == 'cluster'" id="clusters">
<Cluster :cluster="viewObj" :token="session.token" :state="state" /> <Cluster :cluster="viewObj" :token="session.token" :state="state" />
@@ -106,9 +118,15 @@
<form @submit="storeAddKey" action="/store/add-key"> <form @submit="storeAddKey" action="/store/add-key">
<p>Add an unlock phrase:</p> <p>Add an unlock phrase:</p>
<input type="text" v-model="forms.store.name" name="name" required placeholder="Name" /><br/> <input type="text" v-model="forms.store.name" name="name" required placeholder="Name" /><br/>
<label><input type="checkbox" v-model="forms.store.byHash"> {{ forms.store.byHash ? "hash (base64)" : "passphrase"}}</label>
<div v-if="forms.store.byHash">
<input type="hash" v-model="forms.store.hash" name="passphrase" required placeholder="Hash" />
{{" "}}generate with <code>dls hash '{{state.Store.Salt}}'</code>
</div><div v-else>
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" /> <input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" />
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" /> <input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" /> </div>
<input type="submit" value="add unlock phrase" :disabled="!((forms.store.pass1 && forms.store.pass1 == forms.store.pass2) || forms.store.hash)" />
</form> </form>
<form @submit="storeDelKey" action="/store/delete-key"> <form @submit="storeDelKey" action="/store/delete-key">
<p>Remove an unlock phrase:</p> <p>Remove an unlock phrase:</p>
@@ -139,6 +157,8 @@
</form> </form>
</template> </template>
</div> </div>
</main>
</div>
</template> </template>
</div> </div>
</body> </body>
+343 -147
View File
@@ -1,35 +1,214 @@
:root { :root {
--bg: #eee; --bg: #000;
--color: black; --color: #ddd;
--bevel-dark: darkgray; --accent: #d4a017;
--bevel-light: lightgray; --dim: #555;
--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; --link: #31b0fa;
--input-bg: #111; --input-bg: #1a1a1a;
--input-text: #ddd; --border: #333;
--btn-bg: #222; --hover: #222;
}
} }
body { body {
background: var(--bg); background: var(--bg);
color: var(--color); color: var(--color);
font-family: "Courier New", Courier, monospace;
font-size: 14px;
line-height: 1.5;
margin: 0;
padding: 0;
} }
button[disabled] { /* Layout */
opacity: 0.5;
header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding: 0.5em 1em;
justify-content: space-between;
} }
#logo > img {
vertical-align: middle;
}
header .utils > * {
margin-left: 1em;
}
.layout {
display: flex;
min-height: calc(100vh - 3em);
}
.sidebar {
width: 18em;
border-right: 1px solid var(--border);
padding: 0.5em;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar input[type="search"] {
width: 100%;
box-sizing: border-box;
margin-bottom: 0.5em;
background: var(--input-bg);
color: var(--color);
border: 1px solid var(--border);
padding: 0.3em 0.5em;
font-family: inherit;
}
.content {
flex: 1;
padding: 1em;
overflow-y: auto;
}
/* Sidebar nav sections */
.nav-section {
margin: 0.5em 0 0.2em;
padding: 0.2em 0.5em;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.85em;
border-bottom: 1px solid var(--border);
}
/* Sidebar nav items */
.view-links {
margin: 0;
padding: 0;
}
.view-links > span {
display: block;
padding: 0.2em 0.5em;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--dim);
border-bottom: 1px solid var(--border);
}
.view-links > span:last-child {
border-bottom: none;
}
.view-links > span:hover {
background: var(--hover);
color: var(--color);
}
.view-links > span.selected {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Headings */
h2 {
color: var(--accent);
border-bottom: 1px solid var(--border);
padding-bottom: 0.3em;
margin-top: 0;
}
h3 {
color: var(--accent);
margin: 1em 0 0.5em;
}
h3::before {
content: "── ";
}
h3::after {
content: " ──";
}
h4 {
color: var(--color);
margin: 0.5em 0 0.3em;
font-weight: bold;
}
/* Details / summary (collapsible sections) */
details {
margin: 0.5em 0;
border: 1px solid var(--border);
}
details[open] {
border-color: var(--accent);
}
summary {
cursor: pointer;
padding: 0.3em 0.5em;
background: var(--hover);
color: var(--accent);
user-select: none;
}
summary:hover {
background: #2a2a2a;
}
details[open] summary {
border-bottom: 1px solid var(--border);
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
}
th, td {
border: 1px solid var(--border);
padding: 0.3em 0.5em;
text-align: left;
}
th {
color: var(--accent);
font-weight: bold;
}
/* Form elements */
textarea, select, input {
background: var(--input-bg);
color: var(--color);
border: 1px solid var(--border);
padding: 0.3em 0.5em;
font-family: inherit;
font-size: inherit;
}
textarea:focus, select:focus, input:focus {
outline: none;
border-color: var(--accent);
}
button, input[type=button], input[type=submit], ::file-selector-button {
background: var(--input-bg);
color: var(--color);
border: 1px solid var(--border);
padding: 0.3em 0.7em;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
button:hover, input[type=button]:hover, input[type=submit]:hover {
border-color: var(--accent);
color: var(--accent);
}
button:active, input[type=button]:active, input[type=submit]:active {
background: var(--accent);
color: var(--bg);
}
button[disabled] {
opacity: 0.4;
cursor: default;
}
button[disabled]:hover {
border-color: var(--border);
color: var(--color);
}
/* Links */
a[href], a[href]:visited, button.link { a[href], a[href]:visited, button.link {
border: none; border: none;
@@ -37,138 +216,153 @@ a[href], a[href]:visited, button.link {
background: none; background: none;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
font-family: inherit;
font-size: inherit;
}
a[href]:hover {
text-decoration: underline;
} }
table { /* Radio pills */
border-collapse: collapse;
label.radio {
display: inline-block;
margin-right: 0.5em;
margin-bottom: 0.5em;
padding: 0.3em 0.7em;
border: 1px solid var(--border);
cursor: pointer;
user-select: none;
} }
th, td { label.radio:has(input:checked) {
border-left: dotted 1pt; color: var(--accent);
border-right: dotted 1pt; border-color: var(--accent);
border-bottom: dotted 1pt;
padding: 2pt 4pt;
} }
tr:first-child > th { label.radio input {
border-top: dotted 1pt; position: absolute;
opacity: 0;
width: 0;
height: 0;
} }
th, tr:last-child > td { label.radio:focus-within {
border-bottom: solid 1pt; color: var(--accent);
border-color: var(--accent);
} }
/* Asset checkboxes → [x] / [ ] style */
.downloads label {
display: inline-block;
margin-right: 0.5em;
margin-bottom: 0.5em;
padding: 0.2em 0;
cursor: pointer;
user-select: none;
font-family: inherit;
border-bottom: 1px solid transparent;
}
.downloads label.selected {
color: var(--accent);
border-bottom-color: var(--accent);
}
.downloads label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.downloads label:focus-within {
color: var(--accent);
border-bottom-color: var(--accent);
}
.downloads label::before {
content: "[ ] ";
color: var(--dim);
}
.downloads label.selected::before {
content: "[x] ";
color: var(--accent);
}
/* Toast notifications */
.toasts {
position: fixed;
bottom: 1em;
right: 1em;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.3em;
}
.toast {
background: #1a1a1a;
border: 1px solid var(--border);
padding: 0.5em 1em;
max-width: 30em;
animation: fadein 0.2s;
font-family: inherit;
opacity: 1;
}
.toast.info {
border-color: var(--accent);
color: var(--accent);
}
.toast.error {
background: #100;
border-color: #c00;
color: #c00;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(0.5em); }
to { opacity: 1; transform: translateY(0); }
}
/* Terminal output block */
.term {
background: #000;
border: 1px solid var(--border);
padding: 0.5em;
word-break: break-all;
font-family: inherit;
white-space: pre-wrap;
}
.term a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--link);
}
/* Misc */
.green { color: #0a0; }
.red { color: #c00; }
.copy { font-size: small; }
.links > * { margin-left: 1ex; }
.links > *:first-child { margin-left: 0; }
.flat > * { margin-left: 1ex; } .flat > * { margin-left: 1ex; }
.flat > *:first-child { margin-left: 0; } .flat > *:first-child { margin-left: 0; }
.green { color: green; }
.red { color: red; }
@media (prefers-color-scheme: dark) {
.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);
}
}
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 {
display: flex;
align-items: center;
border-bottom: 2pt solid;
margin: 0 0 1em 0;
padding: 1ex;
justify-content: space-between;
}
#logo > img {
vertical-align: middle;
}
header .utils > * {
margin-left: 1ex;
}
.error { .error {
display: flex; display: flex;
position: relative; background: rgba(200,0,0,0.15);
background: rgba(255,0,0,0.2); border: 1px solid #c00;
border: 1pt solid red; padding: 0.5em;
margin: 0.5em 0;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.error .btn-close, .error .btn-close {
.error .code {
background: #600; background: #600;
color: white; color: white;
font-weight: bold;
border: none; border: none;
align-self: stretch; padding: 0.3em 0.7em;
padding: 1ex 1em; cursor: pointer;
}
.error .code {
order: 1;
display: flex;
align-items: center;
text-align: center;
}
.error .message {
order: 2;
padding: 1ex 2em;
}
.error .btn-close {
order: 3;
}
.sheets {
display: flex;
align-items: stretch;
}
.sheets > div {
margin: 0 1ex;
border: 1pt solid;
border-radius: 6pt;
}
.sheets .title {
text-align: center;
font-weight: bold;
font-size: large;
padding: 2pt 6pt;
background: rgba(127,127,127,0.5);
}
.sheets .section {
padding: 2pt 6pt 2pt 6pt;
font-weight: bold;
border-top: 1px dotted;
}
.sheets section {
margin: 2pt 6pt 6pt 6pt;
}
.sheets > *:last-child > table:last-child > tr:last-child > td {
border-bottom: none;
} }
.notif { .notif {
@@ -178,18 +372,20 @@ header .utils > * {
.notif > div:first-child { .notif > div:first-child {
position: absolute; position: absolute;
min-width: 100%; height: 100%; min-width: 100%; height: 100%;
background: white; background: var(--bg);
opacity: 75%; opacity: 85%;
text-align: center; text-align: center;
} }
.links > * { margin-left: 1ex; } .text-and-file {
.links > *:first-child { margin-left: 0; } position: relative;
}
@media (prefers-color-scheme: dark) { .text-and-file textarea {
.notif > div:first-child { width: 100%;
background: black; box-sizing: border-box;
} }
.text-and-file input[type="file"] {
position: absolute;
bottom: 0;
right: 0;
} }
.copy { font-size: small; }
+6 -2
View File
@@ -211,10 +211,13 @@ func (s *Store) Unlock(passphrase []byte) (ok bool) {
} }
func (s *Store) AddKey(name string, passphrase []byte) { func (s *Store) AddKey(name string, passphrase []byte) {
key, hash := s.keyPairFromPassword(passphrase) key, _ := s.keyPairFromPassword(passphrase)
memzero(passphrase) memzero(passphrase)
s.AddRawKey(name, key)
}
defer memzero(key[:]) func (s *Store) AddRawKey(name string, key [32]byte) {
hash := sha512.Sum512(key[:])
k := KeyEntry{Name: name, Hash: hash} k := KeyEntry{Name: name, Hash: hash}
@@ -222,6 +225,7 @@ func (s *Store) AddKey(name string, passphrase []byte) {
copy(k.EncKey[:], encKey) copy(k.EncKey[:], encKey)
s.Keys = append(s.Keys, k) s.Keys = append(s.Keys, k)
memzero(key[:])
} }
func (s *Store) keyPairFromPassword(password []byte) (key [32]byte, hash [64]byte) { func (s *Store) keyPairFromPassword(password []byte) (key [32]byte, hash [64]byte) {
+7 -10
View File
@@ -1,27 +1,24 @@
.view-links > span { .view-links > span {
display: inline-block; display: block;
white-space: nowrap; white-space: nowrap;
margin-right: 1ex;
margin-bottom: 1ex;
padding: 0.5ex;
border: 1pt solid;
border-radius: 1ex;
cursor: pointer; cursor: pointer;
} }
.downloads, .download-links { .downloads, .download-links, .download-table td {
& > * { & > * {
display: inline-block; display: inline-block;
margin-right: 1ex; margin-right: 1ex;
margin-bottom: 1ex; margin-bottom: 1ex;
padding: 0.5ex;
border: 1px solid;
border-radius: 1ex;
cursor: pointer; cursor: pointer;
} }
} }
.download-table th,
.download-table td {
border: none !important;
}
.downloads, .view-links { .downloads, .view-links {
& > .selected { & > .selected {
color: var(--link); color: var(--link);
+25 -5
View File
@@ -13,6 +13,8 @@
<script data-trunk src="js/app.js" type="module" defer></script> <script data-trunk src="js/app.js" type="module" defer></script>
<script data-trunk src="js/Downloads.js"></script> <script data-trunk src="js/Downloads.js"></script>
<script data-trunk src="js/GetCopy.js"></script> <script data-trunk src="js/GetCopy.js"></script>
<script data-trunk src="js/ClusterAccess.js"></script>
<script data-trunk src="js/ClusterCAs.js"></script>
<script data-trunk src="js/Cluster.js"></script> <script data-trunk src="js/Cluster.js"></script>
<script data-trunk src="js/Host.js"></script> <script data-trunk src="js/Host.js"></script>
<body> <body>
@@ -42,6 +44,8 @@
<div class="message">{{ error.message }}</div> <div class="message">{{ error.message }}</div>
</div> </div>
<div class="toasts"><div v-for="t in toasts" :class="'toast '+t.kind" @click="dismissToast(t.id)">{{ t.message }}</div></div>
<template v-if="!publicState"> <template v-if="!publicState">
<p>Not connected.</p> <p>Not connected.</p>
</template> </template>
@@ -81,10 +85,18 @@
</template> </template>
<template v-else> <template v-else>
<div style="float:right;"><input type="search" placeholder="Filter" v-model="viewFilter"/></div> <div class="layout">
<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> <nav class="sidebar">
<input type="search" placeholder="Filter" v-model="viewFilter"/>
<h2 v-if="view">{{view.title}}</h2> <p class="nav-section">Admin</p>
<p class="view-links"><span v-for="v in adminViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="clusterViews.length">Clusters</p>
<p class="view-links" v-if="clusterViews.length"><span v-for="v in clusterViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="hostViews.length">Hosts</p>
<p class="view-links" v-if="hostViews.length"><span v-for="v in hostViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
</nav>
<main class="content">
<h2 v-if="view">{{ view.type == 'cluster' ? 'Cluster ' : view.type == 'host' ? 'Host ' : '' }}{{view.title}}</h2>
<div v-if="view.type == 'cluster'" id="clusters"> <div v-if="view.type == 'cluster'" id="clusters">
<Cluster :cluster="viewObj" :token="session.token" :state="state" /> <Cluster :cluster="viewObj" :token="session.token" :state="state" />
@@ -106,9 +118,15 @@
<form @submit="storeAddKey" action="/store/add-key"> <form @submit="storeAddKey" action="/store/add-key">
<p>Add an unlock phrase:</p> <p>Add an unlock phrase:</p>
<input type="text" v-model="forms.store.name" name="name" required placeholder="Name" /><br/> <input type="text" v-model="forms.store.name" name="name" required placeholder="Name" /><br/>
<label><input type="checkbox" v-model="forms.store.byHash"> {{ forms.store.byHash ? "hash (base64)" : "passphrase"}}</label>
<div v-if="forms.store.byHash">
<input type="hash" v-model="forms.store.hash" name="passphrase" required placeholder="Hash" />
{{" "}}generate with <code>dls hash '{{state.Store.Salt}}'</code>
</div><div v-else>
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" /> <input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" />
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" /> <input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" /> </div>
<input type="submit" value="add unlock phrase" :disabled="!((forms.store.pass1 && forms.store.pass1 == forms.store.pass2) || forms.store.hash)" />
</form> </form>
<form @submit="storeDelKey" action="/store/delete-key"> <form @submit="storeDelKey" action="/store/delete-key">
<p>Remove an unlock phrase:</p> <p>Remove an unlock phrase:</p>
@@ -139,6 +157,8 @@
</form> </form>
</template> </template>
</div> </div>
</main>
</div>
</template> </template>
</div> </div>
</body> </body>
+20 -124
View File
@@ -1,131 +1,27 @@
const Cluster = { const Cluster = {
components: { Downloads, GetCopy }, components: { ClusterAccess, ClusterCAs, GetCopy },
props: [ 'cluster', 'token', 'state' ], props: [ 'cluster', 'token', 'state' ],
data() {
return {
signReqValidity: "1d",
sshSignReq: {
PubKey: "",
Principal: "root",
},
sshUserCert: null,
kubeSignReq: {
CSR: "",
User: "",
Group: "system:masters",
},
kubeUserCert: null,
};
},
methods: {
sshCASign() {
event.preventDefault();
fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.sshUserCert = URL.createObjectURL(cert) })
} else {
resp.json().then((resp) => alert('failed to sign: '+resp.message))
}
})
.catch((e) => { alert('failed to sign: '+e); })
},
kubeCASign() {
event.preventDefault();
fetch(`/clusters/${this.cluster.Name}/kube/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.kubeSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.kubeUserCert = URL.createObjectURL(cert) })
} else {
resp.json().then((resp) => alert('failed to sign: '+resp.message))
}
})
.catch((e) => { alert('failed to sign: '+e); })
},
readFile(e, onload) {
const file = e.target.files[0];
if (!file) { return; }
const reader = new FileReader();
reader.onload = () => { onload(reader.result) };
reader.onerror = () => { alert("error reading file"); };
reader.readAsText(file);
},
loadPubKey(e) {
this.readFile(e, (v) => {
this.sshSignReq.PubKey = v;
});
},
loadCSR(e) {
this.readFile(e, (v) => {
this.kubeSignReq.CSR = v;
});
},
},
template: ` template: `
<h3>Access</h3> <details open>
<summary>Access</summary>
<ClusterAccess :cluster="cluster" :token="token" :state="state" />
</details>
<p>Allow cluster access from a public key</p> <details open>
<summary>CAs</summary>
<h4>Grant SSH access</h4> <ClusterCAs :cluster="cluster" :token="token" />
</details>
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
<p>Public key (OpenSSH format):<br/>
<span class="text-and-file"><textarea v-model="sshSignReq.PubKey" style="height:3lh"></textarea>
<input type="file" accept=".pub" @change="loadPubKey" /></span>
</p>
<p><button @click="sshCASign">Sign SSH access</button>
<template v-if="sshUserCert">
=&gt; <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
</template>
</p>
<h4>Grant Kubernetes API access</h4>
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
<p>User: <input type="text" v-model="kubeSignReq.User"/> (by default, from the CSR)</p>
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
<p>Certificate signing request (PEM format):<br/>
<span class="text-and-file"><textarea v-model="kubeSignReq.CSR" style="height:7lh;"></textarea>
<input type="file" accept=".csr" @change="loadCSR" /></span>
</p>
<p><button @click="kubeCASign">Sign Kubernetes API access</button>
<template v-if="kubeUserCert">
=&gt; <a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
</template>
</p>
<h3>Tokens</h3>
<section class="links">
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
</section>
<h3>Passwords</h3>
<section class="links">
<GetCopy v-for="n in cluster.Passwords" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/passwords/'+n" />
</section>
<h3>Downloads</h3>
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
<h3>CAs</h3>
<table><tr><th>Name</th><th>Certificate</th><th>Signed certificates</th></tr>
<tr v-for="ca in cluster.CAs">
<td>{{ ca.Name }}</td>
<td><GetCopy :token="token" name="cert" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/certificate'" /></td>
<td><template v-for="signed in ca.Signed">
{{" "}}
<GetCopy :token="token" :name="signed" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/signed?name='+signed" />
</template></td>
</tr></table>
<details open>
<summary>Secrets</summary>
<h4>Tokens</h4>
<section class="links">
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
</section>
<h4>Passwords</h4>
<section class="links">
<GetCopy v-for="n in cluster.Passwords" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/passwords/'+n" />
</section>
</details>
` `
} }
+190
View File
@@ -0,0 +1,190 @@
const ClusterAccess = {
props: [ 'cluster', 'token', 'state' ],
data() {
return {
accessType: "ssh",
signReqValidity: "1d",
sshSignReq: {
PubKey: "",
Principal: "root",
},
sshUserCert: null,
kubeSignReq: {
CSR: "",
User: "",
Group: "system:masters",
},
kubeUserCert: null,
downloadSet: null,
selectedAssets: {},
loading: false,
msg: null,
};
},
computed: {
availableAssets() {
return [
"kernel",
"initrd",
"uki",
"bootstrap.tar",
"boot.img.gz",
"boot.img",
"boot.qcow2",
"boot.vmdk",
"boot.iso",
"boot.tar",
"bootstrap-config",
"config",
"config.json",
"ipxe",
]
},
selectedAssetList() {
return this.availableAssets.filter(a => this.selectedAssets[a])
},
hostCount() {
return (this.state.Hosts||[]).filter(h => h.Cluster == this.cluster.Name).length
},
},
methods: {
sshCASign() {
event.preventDefault();
this.loading = true; this.msg = null;
fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.sshUserCert = URL.createObjectURL(cert); this.loading = false })
} else {
resp.json().then((resp) => { this.msg = 'failed to sign: '+resp.message; this.loading = false })
}
})
.catch((e) => { this.msg = 'failed to sign: '+e; this.loading = false })
},
kubeCASign() {
event.preventDefault();
this.loading = true; this.msg = null;
fetch(`/clusters/${this.cluster.Name}/kube/sign`, {
method: 'POST',
body: JSON.stringify({ ...this.kubeSignReq, Validity: this.signReqValidity }),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.blob().then((cert) => { this.kubeUserCert = URL.createObjectURL(cert); this.loading = false })
} else {
resp.json().then((resp) => { this.msg = 'failed to sign: '+resp.message; this.loading = false })
}
})
.catch((e) => { this.msg = 'failed to sign: '+e; this.loading = false })
},
generateDownloadSet() {
event.preventDefault()
this.loading = true; this.msg = null;
const items = [{
Kind: "host",
Name: "c=" + this.cluster.Name,
Assets: this.selectedAssetList,
}]
fetch('/sign-download-set', {
method: 'POST',
body: JSON.stringify({Expiry: this.signReqValidity, Items: items}),
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => {
if (resp.ok) {
resp.json().then((set) => { this.downloadSet = set; this.loading = false })
} else {
resp.json().then((resp) => { this.msg = 'failed to generate: '+resp.message; this.loading = false })
}
}).catch((e) => { this.msg = 'failed to generate: '+e; this.loading = false })
},
readFile(e, onload) {
const file = e.target.files[0];
if (!file) { return; }
const reader = new FileReader();
reader.onload = () => { onload(reader.result) };
reader.onerror = () => { this.msg = "error reading file" };
reader.readAsText(file);
},
loadPubKey(e) {
this.readFile(e, (v) => {
this.sshSignReq.PubKey = v;
});
},
copyDownloadSetUrl() {
try {
navigator.clipboard.writeText(window.location.origin+'/public/download-set?set='+this.downloadSet)
this.$root.toast('copied!', 'info')
} catch (e) {
this.$root.toast('copy failed', 'error')
}
},
loadCSR(e) {
this.readFile(e, (v) => {
this.kubeSignReq.CSR = v;
});
},
},
template: `
<p>Grant access to:
<label class="radio"><input type="radio" v-model="accessType" value="ssh" /> SSH</label>
<label class="radio"><input type="radio" v-model="accessType" value="kube" /> Kubernetes</label>
<label class="radio"><input type="radio" v-model="accessType" value="download-set" /> Download set</label>
</p>
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
<p class="error" v-if="msg">{{ msg }} <button class="btn-close" @click="msg=null">&times;</button></p>
<div v-if="accessType == 'ssh'">
<h4>Grant SSH access</h4>
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
<p>Public key (OpenSSH format):<br/>
<span class="text-and-file"><textarea v-model="sshSignReq.PubKey" style="height:3lh"></textarea>
<input type="file" accept=".pub" @change="loadPubKey" /></span>
</p>
<p><button :disabled="loading" @click="sshCASign">{{ loading ? 'signing...' : 'Sign SSH access' }}</button>
<template v-if="sshUserCert">
=&gt; <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
</template>
</p>
</div>
<div v-if="accessType == 'kube'">
<h4>Grant Kubernetes API access</h4>
<p>User: <input type="text" v-model="kubeSignReq.User"/> (by default, from the CSR)</p>
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
<p>Certificate signing request (PEM format):<br/>
<span class="text-and-file"><textarea v-model="kubeSignReq.CSR" style="height:7lh;"></textarea>
<input type="file" accept=".csr" @change="loadCSR" /></span>
</p>
<p><button :disabled="loading" @click="kubeCASign">{{ loading ? 'signing...' : 'Sign Kubernetes API access' }}</button>
<template v-if="kubeUserCert">
=&gt; <a :href="kubeUserCert" download="tls.crt">Get certificate</a>
</template>
</p>
</div>
<div v-if="accessType == 'download-set'">
<h4>Grant download access</h4>
<p>Generates a signed download set granting access to the selected assets for all {{ hostCount }} hosts in this cluster.</p>
<h4>Available assets</h4>
<p class="downloads">
<template v-for="asset in availableAssets">
<label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label>
{{" "}}
</template>
</p>
<p><button :disabled="loading || selectedAssetList.length==0" @click="generateDownloadSet">{{ loading ? 'generating...' : 'Generate download set' }}</button></p>
<div v-if="downloadSet" class="term">
<a :href="'/public/download-set?set='+downloadSet" target="_blank">/public/download-set?set={{ downloadSet }}</a>
<br/>
<button @click="copyDownloadSetUrl">Copy URL</button>
</div>
</div>
`
}
+15
View File
@@ -0,0 +1,15 @@
const ClusterCAs = {
components: { GetCopy },
props: [ 'cluster', 'token' ],
template: `
<table><tr><th>Name</th><th>Certificate</th><th>Signed certificates</th></tr>
<tr v-for="ca in cluster.CAs">
<td>{{ ca.Name }}</td>
<td><GetCopy :token="token" name="cert" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/certificate'" /></td>
<td><template v-for="signed in ca.Signed">
{{" "}}
<GetCopy :token="token" :name="signed" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/signed?name='+signed" />
</template></td>
</tr></table>
`
}
+9 -9
View File
@@ -1,29 +1,28 @@
const Downloads = { const Downloads = {
props: [ 'kind', 'name', 'token', 'state' ], props: [ 'kind', 'name', 'token', 'state' ],
data() { data() {
return { createDisabled: false, selectedAssets: {} } return { createDisabled: false, selectedAssets: {}, msg: null }
}, },
computed: { computed: {
availableAssets() { availableAssets() {
return { return ({
cluster: ['addons'],
host: [ host: [
"kernel", "kernel",
"initrd", "initrd",
"uki",
"bootstrap.tar", "bootstrap.tar",
"boot.img.lz4",
"boot.img.gz", "boot.img.gz",
"boot.img",
"boot.qcow2", "boot.qcow2",
"boot.vmdk", "boot.vmdk",
"boot.img",
"boot.iso", "boot.iso",
"boot.tar", "boot.tar",
"boot-efi.tar",
"config",
"bootstrap-config", "bootstrap-config",
"config",
"config.json",
"ipxe", "ipxe",
], ],
}[this.kind] }[this.kind] || [])
}, },
downloads() { downloads() {
return Object.entries(this.state.Downloads) return Object.entries(this.state.Downloads)
@@ -51,11 +50,12 @@ const Downloads = {
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }, headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
}).then((resp) => resp.json()) }).then((resp) => resp.json())
.then((token) => { this.selectedAssets = {}; this.createDisabled = false }) .then((token) => { this.selectedAssets = {}; this.createDisabled = false })
.catch((e) => { alert('failed to create link'); this.createDisabled = false }) .catch((e) => { this.msg = 'failed to create link'; this.createDisabled = false })
}, },
}, },
template: ` template: `
<h4>Available assets</h4> <h4>Available assets</h4>
<p class="error" v-if="msg">{{ msg }} <button class="btn-close" @click="msg=null">&times;</button></p>
<p class="downloads"> <p class="downloads">
<template v-for="asset in availableAssets"> <template v-for="asset in availableAssets">
<label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label> <label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label>
+8 -5
View File
@@ -1,7 +1,7 @@
const GetCopy = { const GetCopy = {
props: [ 'name', 'href', 'token' ], props: [ 'name', 'href', 'token' ],
data() { return {showCopied: false} }, data() { return {showCopied: false} },
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a>&nbsp;<a href="#" class="copy" @click="fetchAndCopy()">&#x1F5D0;</a></span>`, template: `<span><a :href="href" @click="fetchAndSave()">{{name}}</a>&nbsp;<a href="#" class="copy" @click="fetchAndCopy()">&#x1F5D0;</a></span>`,
methods: { methods: {
fetch() { fetch() {
event.preventDefault() event.preventDefault()
@@ -12,7 +12,7 @@ const GetCopy = {
}, },
handleFetchError(e) { handleFetchError(e) {
console.log("failed to get value:", e) console.log("failed to get value:", e)
alert('failed to get value') this.$root.toast('failed to get value', 'error')
}, },
fetchAndSave() { fetchAndSave() {
this.fetch().then(resp => resp.blob()).then((value) => { this.fetch().then(resp => resp.blob()).then((value) => {
@@ -23,9 +23,12 @@ const GetCopy = {
this.fetch() this.fetch()
.then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text()) .then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
.then((value) => { .then((value) => {
window.navigator.clipboard.writeText(value) try {
this.showCopied = true window.navigator.clipboard.writeText(value)
setTimeout(() => { this.showCopied = false }, 1000) this.$root.toast('copied!', 'info')
} catch (e) {
this.$root.toast('copy failed', 'error')
}
}).catch(this.handleFetchError) }).catch(this.handleFetchError)
}, },
}, },
+56 -15
View File
@@ -21,11 +21,17 @@ createApp({
uiHash: null, uiHash: null,
watchingState: false, watchingState: false,
state: null, state: null,
toasts: [],
toastId: 0,
} }
}, },
mounted() { mounted() {
this.session = JSON.parse(sessionStorage.state || "{}") this.session = JSON.parse(sessionStorage.state || "{}")
this.watchPublicState() this.watchPublicState()
document.addEventListener('keydown', (e) => this.onKeydown(e))
},
unmounted() {
document.removeEventListener('keydown', (e) => this.onKeydown(e))
}, },
watch: { watch: {
session: { session: {
@@ -56,13 +62,16 @@ createApp({
}, },
computed: { computed: {
views() { adminViews() {
var views = [{type: "actions", name: "admin", title: "Admin actions"}]; return [{type: "actions", name: "admin", title: "Admin actions"}];
},
(this.state.Clusters||[]).forEach((c) => views.push({type: "cluster", name: c.Name, title: `Cluster ${c.Name}`})); clusterViews() {
(this.state.Hosts ||[]).forEach((c) => views.push({type: "host", name: c.Name, title: `Host ${c.Name}`})); return (this.state.Clusters||[]).map((c) => ({type: "cluster", name: c.Name, title: c.Name}));
},
return views.filter((v) => v.type != "host" || v.name.includes(this.viewFilter)); hostViews() {
return (this.state.Hosts||[])
.filter((h) => h.Name.includes(this.viewFilter))
.map((h) => ({type: "host", name: h.Name, title: h.Name}));
}, },
viewObj() { viewObj() {
if (this.view) { if (this.view) {
@@ -84,9 +93,32 @@ createApp({
any(array) { any(array) {
return array && array.length != 0; return array && array.length != 0;
}, },
isActive(v) {
return this.view && this.view.type == v.type && this.view.name == v.name
},
onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key == 'k') {
e.preventDefault()
this.$nextTick(() => {
const el = document.querySelector('.sidebar input[type="search"]')
if (el) el.focus()
})
return
}
if (e.key == 'Escape') {
this.viewFilter = ''
return
}
},
copyText(text) { copyText(text) {
event.preventDefault() event.preventDefault()
window.navigator.clipboard.writeText(text) try {
window.navigator.clipboard.writeText(text)
this.toast('copied!', 'info')
} catch (e) {
this.toast('copy failed: ' + e, 'error')
}
}, },
setToken() { setToken() {
event.preventDefault() event.preventDefault()
@@ -103,7 +135,12 @@ createApp({
return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)} return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)}
}, },
storeAddKey() { storeAddKey() {
this.apiPost('/store/add-key', this.namedPassphrase(), (v) => { const params = this.namedPassphrase();
if (this.forms.store.byHash) {
params.Hash = this.forms.store.hash;
}
this.apiPost('/store/add-key', params, (v) => {
this.forms.store = {} this.forms.store = {}
}) })
}, },
@@ -172,16 +209,12 @@ createApp({
var xhr = new XMLHttpRequest() var xhr = new XMLHttpRequest()
xhr.responseType = 'json' xhr.responseType = 'json'
// TODO spinner, pending action notification, or something xhr.onerror = () => {}
xhr.onerror = () => {
// this.actionResults.splice(idx, 1, {...item, done: true, failed: true })
}
xhr.onload = (r) => { xhr.onload = (r) => {
if (xhr.status != 200) { if (xhr.status != 200) {
this.error = xhr.response this.toast((xhr.response && xhr.response.message) || 'request failed', 'error')
return return
} }
// this.actionResults.splice(idx, 1, {...item, done: true, resp: xhr.responseText})
this.error = null this.error = null
if (onload) { if (onload) {
onload(xhr.response) onload(xhr.response)
@@ -230,6 +263,14 @@ createApp({
// TODO once-shot download link // TODO once-shot download link
return url + '?token=' + this.session.token return url + '?token=' + this.session.token
}, },
toast(message, kind) {
const id = ++this.toastId
this.toasts.push({id, message, kind: kind || 'info'})
setTimeout(() => this.dismissToast(id), 4000)
},
dismissToast(id) {
this.toasts = this.toasts.filter(t => t.id != id)
},
watchPublicState() { watchPublicState() {
this.watchStream('publicState', '/public-state') this.watchStream('publicState', '/public-state')
}, },
+343 -147
View File
@@ -1,35 +1,214 @@
:root { :root {
--bg: #eee; --bg: #000;
--color: black; --color: #ddd;
--bevel-dark: darkgray; --accent: #d4a017;
--bevel-light: lightgray; --dim: #555;
--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; --link: #31b0fa;
--input-bg: #111; --input-bg: #1a1a1a;
--input-text: #ddd; --border: #333;
--btn-bg: #222; --hover: #222;
}
} }
body { body {
background: var(--bg); background: var(--bg);
color: var(--color); color: var(--color);
font-family: "Courier New", Courier, monospace;
font-size: 14px;
line-height: 1.5;
margin: 0;
padding: 0;
} }
button[disabled] { /* Layout */
opacity: 0.5;
header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding: 0.5em 1em;
justify-content: space-between;
} }
#logo > img {
vertical-align: middle;
}
header .utils > * {
margin-left: 1em;
}
.layout {
display: flex;
min-height: calc(100vh - 3em);
}
.sidebar {
width: 18em;
border-right: 1px solid var(--border);
padding: 0.5em;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar input[type="search"] {
width: 100%;
box-sizing: border-box;
margin-bottom: 0.5em;
background: var(--input-bg);
color: var(--color);
border: 1px solid var(--border);
padding: 0.3em 0.5em;
font-family: inherit;
}
.content {
flex: 1;
padding: 1em;
overflow-y: auto;
}
/* Sidebar nav sections */
.nav-section {
margin: 0.5em 0 0.2em;
padding: 0.2em 0.5em;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.85em;
border-bottom: 1px solid var(--border);
}
/* Sidebar nav items */
.view-links {
margin: 0;
padding: 0;
}
.view-links > span {
display: block;
padding: 0.2em 0.5em;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--dim);
border-bottom: 1px solid var(--border);
}
.view-links > span:last-child {
border-bottom: none;
}
.view-links > span:hover {
background: var(--hover);
color: var(--color);
}
.view-links > span.selected {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Headings */
h2 {
color: var(--accent);
border-bottom: 1px solid var(--border);
padding-bottom: 0.3em;
margin-top: 0;
}
h3 {
color: var(--accent);
margin: 1em 0 0.5em;
}
h3::before {
content: "── ";
}
h3::after {
content: " ──";
}
h4 {
color: var(--color);
margin: 0.5em 0 0.3em;
font-weight: bold;
}
/* Details / summary (collapsible sections) */
details {
margin: 0.5em 0;
border: 1px solid var(--border);
}
details[open] {
border-color: var(--accent);
}
summary {
cursor: pointer;
padding: 0.3em 0.5em;
background: var(--hover);
color: var(--accent);
user-select: none;
}
summary:hover {
background: #2a2a2a;
}
details[open] summary {
border-bottom: 1px solid var(--border);
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
}
th, td {
border: 1px solid var(--border);
padding: 0.3em 0.5em;
text-align: left;
}
th {
color: var(--accent);
font-weight: bold;
}
/* Form elements */
textarea, select, input {
background: var(--input-bg);
color: var(--color);
border: 1px solid var(--border);
padding: 0.3em 0.5em;
font-family: inherit;
font-size: inherit;
}
textarea:focus, select:focus, input:focus {
outline: none;
border-color: var(--accent);
}
button, input[type=button], input[type=submit], ::file-selector-button {
background: var(--input-bg);
color: var(--color);
border: 1px solid var(--border);
padding: 0.3em 0.7em;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
button:hover, input[type=button]:hover, input[type=submit]:hover {
border-color: var(--accent);
color: var(--accent);
}
button:active, input[type=button]:active, input[type=submit]:active {
background: var(--accent);
color: var(--bg);
}
button[disabled] {
opacity: 0.4;
cursor: default;
}
button[disabled]:hover {
border-color: var(--border);
color: var(--color);
}
/* Links */
a[href], a[href]:visited, button.link { a[href], a[href]:visited, button.link {
border: none; border: none;
@@ -37,138 +216,153 @@ a[href], a[href]:visited, button.link {
background: none; background: none;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
font-family: inherit;
font-size: inherit;
}
a[href]:hover {
text-decoration: underline;
} }
table { /* Radio pills */
border-collapse: collapse;
label.radio {
display: inline-block;
margin-right: 0.5em;
margin-bottom: 0.5em;
padding: 0.3em 0.7em;
border: 1px solid var(--border);
cursor: pointer;
user-select: none;
} }
th, td { label.radio:has(input:checked) {
border-left: dotted 1pt; color: var(--accent);
border-right: dotted 1pt; border-color: var(--accent);
border-bottom: dotted 1pt;
padding: 2pt 4pt;
} }
tr:first-child > th { label.radio input {
border-top: dotted 1pt; position: absolute;
opacity: 0;
width: 0;
height: 0;
} }
th, tr:last-child > td { label.radio:focus-within {
border-bottom: solid 1pt; color: var(--accent);
border-color: var(--accent);
} }
/* Asset checkboxes → [x] / [ ] style */
.downloads label {
display: inline-block;
margin-right: 0.5em;
margin-bottom: 0.5em;
padding: 0.2em 0;
cursor: pointer;
user-select: none;
font-family: inherit;
border-bottom: 1px solid transparent;
}
.downloads label.selected {
color: var(--accent);
border-bottom-color: var(--accent);
}
.downloads label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.downloads label:focus-within {
color: var(--accent);
border-bottom-color: var(--accent);
}
.downloads label::before {
content: "[ ] ";
color: var(--dim);
}
.downloads label.selected::before {
content: "[x] ";
color: var(--accent);
}
/* Toast notifications */
.toasts {
position: fixed;
bottom: 1em;
right: 1em;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.3em;
}
.toast {
background: #1a1a1a;
border: 1px solid var(--border);
padding: 0.5em 1em;
max-width: 30em;
animation: fadein 0.2s;
font-family: inherit;
opacity: 1;
}
.toast.info {
border-color: var(--accent);
color: var(--accent);
}
.toast.error {
background: #100;
border-color: #c00;
color: #c00;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(0.5em); }
to { opacity: 1; transform: translateY(0); }
}
/* Terminal output block */
.term {
background: #000;
border: 1px solid var(--border);
padding: 0.5em;
word-break: break-all;
font-family: inherit;
white-space: pre-wrap;
}
.term a {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--link);
}
/* Misc */
.green { color: #0a0; }
.red { color: #c00; }
.copy { font-size: small; }
.links > * { margin-left: 1ex; }
.links > *:first-child { margin-left: 0; }
.flat > * { margin-left: 1ex; } .flat > * { margin-left: 1ex; }
.flat > *:first-child { margin-left: 0; } .flat > *:first-child { margin-left: 0; }
.green { color: green; }
.red { color: red; }
@media (prefers-color-scheme: dark) {
.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);
}
}
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 {
display: flex;
align-items: center;
border-bottom: 2pt solid;
margin: 0 0 1em 0;
padding: 1ex;
justify-content: space-between;
}
#logo > img {
vertical-align: middle;
}
header .utils > * {
margin-left: 1ex;
}
.error { .error {
display: flex; display: flex;
position: relative; background: rgba(200,0,0,0.15);
background: rgba(255,0,0,0.2); border: 1px solid #c00;
border: 1pt solid red; padding: 0.5em;
margin: 0.5em 0;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.error .btn-close, .error .btn-close {
.error .code {
background: #600; background: #600;
color: white; color: white;
font-weight: bold;
border: none; border: none;
align-self: stretch; padding: 0.3em 0.7em;
padding: 1ex 1em; cursor: pointer;
}
.error .code {
order: 1;
display: flex;
align-items: center;
text-align: center;
}
.error .message {
order: 2;
padding: 1ex 2em;
}
.error .btn-close {
order: 3;
}
.sheets {
display: flex;
align-items: stretch;
}
.sheets > div {
margin: 0 1ex;
border: 1pt solid;
border-radius: 6pt;
}
.sheets .title {
text-align: center;
font-weight: bold;
font-size: large;
padding: 2pt 6pt;
background: rgba(127,127,127,0.5);
}
.sheets .section {
padding: 2pt 6pt 2pt 6pt;
font-weight: bold;
border-top: 1px dotted;
}
.sheets section {
margin: 2pt 6pt 6pt 6pt;
}
.sheets > *:last-child > table:last-child > tr:last-child > td {
border-bottom: none;
} }
.notif { .notif {
@@ -178,18 +372,20 @@ header .utils > * {
.notif > div:first-child { .notif > div:first-child {
position: absolute; position: absolute;
min-width: 100%; height: 100%; min-width: 100%; height: 100%;
background: white; background: var(--bg);
opacity: 75%; opacity: 85%;
text-align: center; text-align: center;
} }
.links > * { margin-left: 1ex; } .text-and-file {
.links > *:first-child { margin-left: 0; } position: relative;
}
@media (prefers-color-scheme: dark) { .text-and-file textarea {
.notif > div:first-child { width: 100%;
background: black; box-sizing: border-box;
} }
.text-and-file input[type="file"] {
position: absolute;
bottom: 0;
right: 0;
} }
.copy { font-size: small; }