Compare commits
2 Commits
main
..
2b27cc57e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b27cc57e3 | |||
| 7f1193cdda |
+7
-9
@@ -1,7 +1,6 @@
|
||||
from novit.tech/direktil/dkl:v1.2.0 as dkl
|
||||
|
||||
from novit.tech/direktil/dkl:bbea9b9 as dkl
|
||||
# ------------------------------------------------------------------------
|
||||
from golang:1.26.1-trixie as build
|
||||
from golang:1.24.4-bookworm as build
|
||||
|
||||
run apt-get update && apt-get install -y git
|
||||
|
||||
@@ -23,15 +22,14 @@ run \
|
||||
hack/build ./...
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
from debian:trixie
|
||||
from debian:bookworm
|
||||
entrypoint ["/bin/dkl-local-server"]
|
||||
|
||||
env _uncache=1
|
||||
run apt update \
|
||||
&& yes |apt install -y genisoimage gdisk dosfstools util-linux udev binutils systemd \
|
||||
grub2 grub-pc-bin grub-efi-amd64-bin ca-certificates curl openssh-client qemu-utils wireguard-tools \
|
||||
erofs-utils erofsfuse cryptsetup systemd-boot-efi \
|
||||
&& apt clean
|
||||
run apt-get update \
|
||||
&& yes |apt-get install -y genisoimage gdisk dosfstools util-linux udev binutils systemd \
|
||||
grub2 grub-pc-bin grub-efi-amd64-bin ca-certificates curl openssh-client qemu-utils \
|
||||
&& apt-get clean
|
||||
|
||||
copy --from=dkl /bin/dkl /bin/dls /bin/
|
||||
copy --from=build /src/dist/ /bin/
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
@@ -129,22 +127,12 @@ func main() {
|
||||
|
||||
defer out.Close()
|
||||
|
||||
switch ext := path.Ext(*outPath); ext {
|
||||
case ".yaml":
|
||||
out.Write([]byte("# dkl-dir2config " + Version + "\n"))
|
||||
|
||||
if err = yaml.NewEncoder(out).Encode(dst); err != nil {
|
||||
log.Fatal("failed to render output: ", err)
|
||||
}
|
||||
|
||||
case ".json":
|
||||
if err = json.NewEncoder(out).Encode(dst); err != nil {
|
||||
log.Fatal("failed to render output: ", err)
|
||||
}
|
||||
|
||||
default:
|
||||
log.Fatal("unknown output file extension: ", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func cfgPath(subPath string) string { return filepath.Join(*dir, subPath) }
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"math/rand"
|
||||
"path"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash"
|
||||
@@ -224,7 +223,6 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reqJson := cleanJsonObject(buf.String())
|
||||
|
||||
key := name
|
||||
if req.PerHost {
|
||||
@@ -237,11 +235,11 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
|
||||
dir := "/etc/tls/" + name
|
||||
|
||||
s = fmt.Sprintf("{{ %s %q %q %q %q %q %q %q }}", funcName,
|
||||
dir, cluster, req.CA, key, req.Profile, req.Label, reqJson)
|
||||
dir, cluster, req.CA, key, req.Profile, req.Label, buf.String())
|
||||
|
||||
default:
|
||||
s = fmt.Sprintf("{{ %s %q %q %q %q %q %q }}", funcName,
|
||||
cluster, req.CA, key, req.Profile, req.Label, reqJson)
|
||||
cluster, req.CA, key, req.Profile, req.Label, buf.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -292,14 +290,6 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
|
||||
"host_download_token": func() (s string) {
|
||||
return "{{ host_download_token }}"
|
||||
},
|
||||
"asset_download_token": func(args ...string) (s string) {
|
||||
argsStr := new(strings.Builder)
|
||||
for _, arg := range args {
|
||||
argsStr.WriteByte(' ')
|
||||
argsStr.WriteString(strconv.Quote(arg))
|
||||
}
|
||||
return "{{ asset_download_token" + argsStr.String() + " }}"
|
||||
},
|
||||
|
||||
"hosts_of_group": func() (hosts []any) {
|
||||
hosts = make([]any, 0)
|
||||
|
||||
@@ -29,7 +29,7 @@ func (ctx *renderContext) renderStaticPods() (pods []namePod) {
|
||||
for n := 0; ; n++ {
|
||||
str := buf.String()
|
||||
|
||||
podMap := map[string]any{}
|
||||
podMap := map[string]interface{}{}
|
||||
err := dec.Decode(podMap)
|
||||
|
||||
if err == io.EOF {
|
||||
@@ -46,7 +46,7 @@ func (ctx *renderContext) renderStaticPods() (pods []namePod) {
|
||||
log.Fatalf("static pod %d: no metadata\n%s", n, buf.String())
|
||||
}
|
||||
|
||||
md := podMap["metadata"].(map[any]any)
|
||||
md := podMap["metadata"].(map[interface{}]interface{})
|
||||
|
||||
namespace := md["namespace"].(string)
|
||||
name := md["name"].(string)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func cleanJsonObject(raw string) string {
|
||||
v := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
panic(fmt.Errorf("invalid json: %w\n%s", err, raw))
|
||||
}
|
||||
clean, _ := json.Marshal(v)
|
||||
return string(clean)
|
||||
}
|
||||
@@ -12,16 +12,18 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/pierrec/lz4"
|
||||
)
|
||||
|
||||
func buildBootImg(out io.Writer, ctx *renderContext, uki bool, cmdline string) (err error) {
|
||||
func buildBootImg(out io.Writer, ctx *renderContext) (err error) {
|
||||
bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rmTempFile(bootImg)
|
||||
|
||||
err = setupBootImage(bootImg, ctx, uki, cmdline)
|
||||
err = setupBootImage(bootImg, ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -32,10 +34,21 @@ func buildBootImg(out io.Writer, ctx *renderContext, uki bool, cmdline string) (
|
||||
return
|
||||
}
|
||||
|
||||
func buildBootImgGZ(out io.Writer, ctx *renderContext, uki bool, cmdline string) (err error) {
|
||||
func buildBootImgLZ4(out io.Writer, ctx *renderContext) (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)
|
||||
|
||||
if err = buildBootImg(gzOut, ctx, uki, cmdline); err != nil {
|
||||
if err = buildBootImg(gzOut, ctx); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,7 +56,7 @@ func buildBootImgGZ(out io.Writer, ctx *renderContext, uki bool, cmdline string)
|
||||
return
|
||||
}
|
||||
|
||||
func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string, uki bool, cmdline string) (err error) {
|
||||
func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string) (err error) {
|
||||
imgPath, err := func() (imgPath string, err error) {
|
||||
bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-")
|
||||
if err != nil {
|
||||
@@ -51,7 +64,7 @@ func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string, u
|
||||
}
|
||||
defer rmTempFile(bootImg)
|
||||
|
||||
err = setupBootImage(bootImg, ctx, uki, cmdline)
|
||||
err = setupBootImage(bootImg, ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -87,32 +100,15 @@ func buildBootImgQemuConvert(out io.Writer, ctx *renderContext, format string, u
|
||||
io.Copy(out, img)
|
||||
return
|
||||
}
|
||||
func qemuImgBootImg(format string, uki bool, cmdline string) func(out io.Writer, ctx *renderContext) (err error) {
|
||||
func qemuImgBootImg(format string) func(out io.Writer, ctx *renderContext) (err error) {
|
||||
return func(out io.Writer, ctx *renderContext) (err error) {
|
||||
return buildBootImgQemuConvert(out, ctx, format, uki, cmdline)
|
||||
return buildBootImgQemuConvert(out, ctx, format)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
err = bootImg.Truncate(128 << 20)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = bootImg.Sync()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = run("sgdisk", bootImg.Name(), "--clear", "--largest-new=1", "--typecode=1:EF00", "--change-name=1:ESP")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
} else {
|
||||
err = func() (err error) {
|
||||
func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
|
||||
path, err := distFetch("grub-support", *grubSupportVersion)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -132,11 +128,9 @@ func setupBootImage(bootImg *os.File, ctx *renderContext, uki bool, cmdline stri
|
||||
|
||||
defer baseImageGz.Close()
|
||||
_, err = io.Copy(bootImg, baseImageGz)
|
||||
return
|
||||
}()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
log.Print("running losetup...")
|
||||
@@ -169,14 +163,6 @@ func setupBootImage(bootImg *os.File, ctx *renderContext, uki bool, cmdline stri
|
||||
}()
|
||||
|
||||
devp1 := dev + "p1"
|
||||
|
||||
if uki {
|
||||
err = run("mkfs.vfat", "-F", "32", devp1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mkfs.vfat: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = syscall.Mount(devp1, tempDir, "vfat", 0, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mount %s to %s: %v", devp1, tempDir, err)
|
||||
@@ -190,10 +176,10 @@ func setupBootImage(bootImg *os.File, ctx *renderContext, uki bool, cmdline stri
|
||||
// add system elements
|
||||
tarOut, tarIn := io.Pipe()
|
||||
go func() {
|
||||
e := buildBootTar(tarIn, ctx, uki, cmdline)
|
||||
err2 := buildBootTar(tarIn, ctx)
|
||||
tarIn.Close()
|
||||
if e != nil {
|
||||
err = e
|
||||
if err2 != nil {
|
||||
err = err2
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func buildBootISO(out io.Writer, ctx *renderContext) (err error) {
|
||||
}
|
||||
|
||||
// create a tag file
|
||||
bootstrapBytes, err := ctx.BootstrapConfig()
|
||||
bootstrapBytes, _, err := ctx.BootstrapConfig()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ package main
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"debug/pe"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"novit.tech/direktil/local-server/pkg/utf16"
|
||||
)
|
||||
|
||||
func rmTempFile(f *os.File) {
|
||||
@@ -19,124 +17,7 @@ func rmTempFile(f *os.File) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
|
||||
arch := tar.NewWriter(out)
|
||||
defer arch.Close()
|
||||
|
||||
@@ -181,7 +62,7 @@ func buildGrubBootTar(out io.Writer, ctx *renderContext) (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildUkiBootTar(out io.Writer, ctx *renderContext, cmdline string) (err error) {
|
||||
func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) {
|
||||
arch := tar.NewWriter(out)
|
||||
defer arch.Close()
|
||||
|
||||
@@ -194,14 +75,46 @@ func buildUkiBootTar(out io.Writer, ctx *renderContext, cmdline string) (err err
|
||||
return
|
||||
}
|
||||
|
||||
// UKI
|
||||
uki := new(bytes.Buffer)
|
||||
err = buildUki(uki, ctx, true, cmdline)
|
||||
const (
|
||||
prefix = "EFI/dkl/"
|
||||
efiPrefix = "\\EFI\\dkl\\"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
|
||||
err = archAdd("EFI/BOOT/BOOTX64.EFI", uki.Bytes())
|
||||
// kernel
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
+43
-312
@@ -4,43 +4,40 @@ import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"crypto"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/cavaliergopher/cpio"
|
||||
"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/localconfig"
|
||||
)
|
||||
|
||||
func renderBootstrapConfig(w http.ResponseWriter, ctx *renderContext) (err error) {
|
||||
func renderBootstrapConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) {
|
||||
log.Printf("sending bootstrap config for %q", ctx.Host.Name)
|
||||
|
||||
ba, err := ctx.BootstrapConfig()
|
||||
_, cfg, err := ctx.BootstrapConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(ba)
|
||||
return
|
||||
if asJson {
|
||||
err = json.NewEncoder(w).Encode(cfg)
|
||||
} else {
|
||||
err = yaml.NewEncoder(w).Encode(cfg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
||||
_, cfg, err := ctx.Config()
|
||||
if err != nil {
|
||||
return fmt.Errorf("render config failed: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
zout, err := zstd.NewWriter(out, zstd.WithEncoderLevel(zstd.EncoderLevelFromZstd(12)))
|
||||
@@ -53,43 +50,19 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
||||
// initrd
|
||||
initrdPath, err := distFetch("initrd", ctx.Host.Initrd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch initrd failed: %w", err)
|
||||
return
|
||||
}
|
||||
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)
|
||||
for _, layer := range cfg.Layers {
|
||||
switch layer {
|
||||
case "modules":
|
||||
|
||||
layerVersion := ctx.Host.Versions[layer]
|
||||
if layerVersion == "" {
|
||||
layerVersion = ctx.Host.Kernel
|
||||
}
|
||||
modulesPath, err := distFetch("layers", layer, layerVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch layer %s failed: %w", layer, err)
|
||||
return err
|
||||
}
|
||||
|
||||
cat.AppendFile(modulesPath, "modules.sqfs")
|
||||
@@ -97,9 +70,9 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
||||
}
|
||||
|
||||
// config
|
||||
cfgBytes, err := ctx.BootstrapConfig()
|
||||
cfgBytes, _, err := ctx.BootstrapConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("render bootstrap config failed: %w", err)
|
||||
return
|
||||
}
|
||||
cat.AppendBytes(cfgBytes, "config.yaml", 0o600)
|
||||
|
||||
@@ -108,7 +81,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
||||
cat.AppendDir("/etc/ssh", 0o700)
|
||||
|
||||
// XXX do we want bootstrap-stage keys instead of the real host key?
|
||||
for _, format := range []string{"rsa", "ecdsa", "ed25519"} {
|
||||
for _, format := range []string{"rsa", "dsa", "ecdsa", "ed25519"} {
|
||||
keyPath := "/etc/ssh/ssh_host_" + format + "_key"
|
||||
cat.AppendBytes(cfg.FileContent(keyPath), keyPath, 0o600)
|
||||
}
|
||||
@@ -132,19 +105,15 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
||||
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) {
|
||||
arch := tar.NewWriter(out)
|
||||
defer arch.Close()
|
||||
|
||||
signer, err := getSigner(ctx.Host.ClusterName)
|
||||
ca, err := getUsableClusterCA(ctx.Host.ClusterName, "boot-signer")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
signer, err := ca.ParseKey()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -200,8 +169,22 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
|
||||
}
|
||||
|
||||
// layers
|
||||
appendSignedLayer := func(layer, layerPath string) (err error) {
|
||||
f, err := os.Open(layerPath)
|
||||
for _, layer := range cfg.Layers {
|
||||
if layer == "modules" {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -217,7 +200,7 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
|
||||
reader := io.TeeReader(f, h)
|
||||
|
||||
if err = arch.WriteHeader(&tar.Header{
|
||||
Name: layer,
|
||||
Name: layer + ".fs",
|
||||
Size: stat.Size(),
|
||||
Mode: 0o600,
|
||||
}); err != nil {
|
||||
@@ -230,263 +213,11 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
|
||||
}
|
||||
|
||||
digest := h.Sum(nil)
|
||||
err = sign(layer+".sig", digest)
|
||||
|
||||
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
|
||||
}
|
||||
err = sign(layer+".fs.sig", digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,14 +60,6 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
|
||||
|
||||
return map[string]any{
|
||||
"quote": strconv.Quote,
|
||||
"yaml": asYaml,
|
||||
"indent": func(s, indent string) string {
|
||||
buf := new(strings.Builder)
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
buf.WriteString(indent + line + "\n")
|
||||
}
|
||||
return buf.String()
|
||||
},
|
||||
|
||||
"password": func(cluster, name, hashAlg string) (password string, err error) {
|
||||
key := cluster + "/" + name
|
||||
@@ -117,12 +109,7 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
|
||||
return
|
||||
}
|
||||
|
||||
extra, err := caExtraCerts(cluster, name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s = string(ca.Cert) + extra
|
||||
s = string(ca.Cert)
|
||||
return
|
||||
},
|
||||
|
||||
@@ -132,18 +119,13 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
|
||||
return
|
||||
}
|
||||
|
||||
extra, err := caExtraCerts(cluster, name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dir := "/etc/tls-ca/" + name
|
||||
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(ca.Cert) + extra,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "ca.key"),
|
||||
@@ -188,10 +170,40 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
|
||||
s = string(kc.Cert)
|
||||
return
|
||||
},
|
||||
|
||||
"tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
ca, err := getUsableClusterCA(cluster, caName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(kc.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.key"),
|
||||
Mode: 0600,
|
||||
Content: string(kc.Key),
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func asYaml(v any) (string, error) {
|
||||
func asYaml(v interface{}) (string, error) {
|
||||
ba, err := yaml.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func hash(values ...any) string {
|
||||
func hash(values ...interface{}) string {
|
||||
ba, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
panic(err) // should not happen
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
func htmlHeader(title string) string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>` + title + `</title>
|
||||
<style>@import url('/ui/style.css');@import url('/ui/app.css');</style>
|
||||
</head>
|
||||
<body><h1>` + title + `</h1>
|
||||
`
|
||||
}
|
||||
|
||||
var htmlFooter = `</body>
|
||||
</html>`
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func renderConfig(w http.ResponseWriter, _ *http.Request, ctx *renderContext, asJson bool) (err error) {
|
||||
func renderConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) {
|
||||
log.Printf("sending config for %q", ctx.Host.Name)
|
||||
|
||||
cfgBytes, cfg, err := ctx.Config()
|
||||
_, cfg, err := ctx.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -17,7 +19,7 @@ func renderConfig(w http.ResponseWriter, _ *http.Request, ctx *renderContext, as
|
||||
if asJson {
|
||||
err = json.NewEncoder(w).Encode(cfg)
|
||||
} else {
|
||||
_, err = w.Write(cfgBytes)
|
||||
err = yaml.NewEncoder(w).Encode(cfg)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -18,10 +18,7 @@ const (
|
||||
etcDir = "/etc/direktil"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Date = "now"
|
||||
)
|
||||
var Version = "dev"
|
||||
|
||||
var (
|
||||
address = flag.String("address", ":7606", "HTTP listen address")
|
||||
@@ -41,7 +38,7 @@ func main() {
|
||||
log.Fatal("no listen address given")
|
||||
}
|
||||
|
||||
log.Print("Direktil local-server version ", Version, " (", Date, ")")
|
||||
log.Print("Direktil local-server version ", Version)
|
||||
wPublicState.Change(func(s *PublicState) { s.ServerVersion = Version })
|
||||
|
||||
computeUIHash()
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -15,17 +13,17 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
cfsslconfig "github.com/cloudflare/cfssl/config"
|
||||
"github.com/cloudflare/cfssl/csr"
|
||||
restful "github.com/emicklei/go-restful"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
|
||||
"novit.tech/direktil/pkg/config"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
|
||||
bsconfig "novit.tech/direktil/pkg/bootstrapconfig"
|
||||
)
|
||||
|
||||
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
|
||||
@@ -100,8 +98,6 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// log.Print("rendered config:\n", string(ba))
|
||||
|
||||
cfg = &config.Config{}
|
||||
if err = yaml.Unmarshal(ba, cfg); err != nil {
|
||||
return
|
||||
@@ -110,8 +106,19 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (ctx *renderContext) BootstrapConfig() (ba []byte, err error) {
|
||||
return ctx.render(ctx.Host.BootstrapConfig)
|
||||
func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, err error) {
|
||||
ba, err = 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) {
|
||||
@@ -158,26 +165,7 @@ func (ctx *renderContext) Tag() (string, error) {
|
||||
func (ctx *renderContext) TemplateFuncs() map[string]any {
|
||||
funcs := templateFuncs(ctx.SSLConfig)
|
||||
|
||||
// FIXME duplicate from cluster-render-context
|
||||
getKeyCert := func(cluster, caName, name, profile, label, reqJson string) (kc KeyCert, err error) {
|
||||
certReq := &csr.CertificateRequest{
|
||||
KeyRequest: csr.NewKeyRequest(),
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(reqJson), certReq)
|
||||
if err != nil {
|
||||
log.Print("CSR unmarshal failed on: ", reqJson)
|
||||
return
|
||||
}
|
||||
|
||||
return getUsableKeyCert(cluster, caName, name, profile, label, certReq, ctx.SSLConfig)
|
||||
}
|
||||
|
||||
for name, method := range map[string]any{
|
||||
"base64": func(input string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(input))
|
||||
},
|
||||
|
||||
"host_ip": func() (s string) {
|
||||
return ctx.Host.IPs[0]
|
||||
},
|
||||
@@ -189,39 +177,6 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
||||
return hex.EncodeToString(ba[:])
|
||||
},
|
||||
|
||||
"tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
ca, err := getUsableClusterCA(cluster, caName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
reqJson = strings.ReplaceAll(reqJson, "${host_ip}", ctx.Host.IPs[0])
|
||||
reqJson = strings.ReplaceAll(reqJson, "${host_name}", ctx.Host.Name)
|
||||
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(kc.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.key"),
|
||||
Mode: 0600,
|
||||
Content: string(kc.Key),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
"ssh_user_ca": func(path, cluster string) (s string, err error) {
|
||||
userCA, err := sshCAPubKey(cluster)
|
||||
return asYaml([]config.FileDef{{
|
||||
@@ -230,10 +185,6 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
||||
Content: string(userCA),
|
||||
}})
|
||||
},
|
||||
"ssh_user_ca_pub": func(cluster string) (s string, err error) {
|
||||
userCA, err := sshCAPubKey(cluster)
|
||||
return string(userCA), err
|
||||
},
|
||||
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
|
||||
if host == "" {
|
||||
host = ctx.Host.Name
|
||||
@@ -268,20 +219,6 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
||||
|
||||
return asYaml(files)
|
||||
},
|
||||
"ssh_host_key": func(type_ string) (s string, err error) {
|
||||
pair, err := ctx.sshHostKeyPair(type_)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return pair.Private, nil
|
||||
},
|
||||
"ssh_host_pubkey": func(type_ string) (s string, err error) {
|
||||
pair, err := ctx.sshHostKeyPair(type_)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return pair.Public, nil
|
||||
},
|
||||
"host_download_token": func() (token string, err error) {
|
||||
key := ctx.Host.Name
|
||||
token, found, err := hostDownloadTokens.Get(key)
|
||||
@@ -329,39 +266,9 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
||||
|
||||
return
|
||||
},
|
||||
"wg_key": func(name string) (key string, err error) {
|
||||
return wgKey(name + "/hosts/" + ctx.Host.Name)
|
||||
},
|
||||
"wg_psk": func(name, peerName string) (key string, err error) {
|
||||
a := ctx.Host.Name
|
||||
b := peerName
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return wgKey(name + "/psks/" + a + " " + b)
|
||||
},
|
||||
"wg_pubkey": func(name, host string) (key string, err error) {
|
||||
return wgKey(name + "/hosts/" + host)
|
||||
},
|
||||
} {
|
||||
funcs[name] = method
|
||||
}
|
||||
|
||||
return funcs
|
||||
}
|
||||
|
||||
func (ctx *renderContext) sshHostKeyPair(type_ string) (kp SSHKeyPair, err error) {
|
||||
pairs, err := getSSHKeyPairs(ctx.Host.Name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, pair := range pairs {
|
||||
if pair.Type == type_ {
|
||||
return pair, nil
|
||||
}
|
||||
}
|
||||
|
||||
err = fmt.Errorf("no key pair with type %q", type_)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,9 +137,7 @@ func unlockSecretStore(name string, passphrase []byte) (err httperr.Error) {
|
||||
})
|
||||
|
||||
go updateState()
|
||||
|
||||
migrateSecrets() // we can probably remove it now
|
||||
go updateCache()
|
||||
go migrateSecrets()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func getSSHKeyPairs(host string) (pairs []SSHKeyPair, err error) {
|
||||
genLoop:
|
||||
for _, keyType := range []string{
|
||||
"rsa",
|
||||
"dsa",
|
||||
"ecdsa",
|
||||
"ed25519",
|
||||
} {
|
||||
@@ -156,25 +157,28 @@ func sshCASign(cluster string, userPubKey []byte, principal, validity string, op
|
||||
return
|
||||
}
|
||||
|
||||
pubkey, identity, _, _, err := ssh.ParseAuthorizedKey(bytes.TrimSpace(userPubKey))
|
||||
_, identity, _, _, err := ssh.ParseAuthorizedKey(userPubKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ak := ssh.MarshalAuthorizedKey(pubkey)
|
||||
|
||||
userPubKeyFile, err := os.CreateTemp("/tmp", "user.pub")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer os.Remove(userPubKeyFile.Name())
|
||||
|
||||
_, err = io.Copy(userPubKeyFile, bytes.NewBuffer(ak))
|
||||
_, err = io.Copy(userPubKeyFile, bytes.NewBuffer(userPubKey))
|
||||
userPubKeyFile.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = os.WriteFile(userPubKeyFile.Name(), userPubKey, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
serial := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
cmd := exec.Command("ssh-keygen", "-q", "-s", "/dev/stdin", "-I", identity, "-z", serial, "-n", principal)
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ type State struct {
|
||||
Store struct {
|
||||
DownloadToken string
|
||||
KeyNames []string
|
||||
Salt []byte
|
||||
}
|
||||
|
||||
Clusters []ClusterState
|
||||
@@ -158,7 +157,6 @@ func updateState() {
|
||||
wState.Change(func(v *State) {
|
||||
v.HasConfig = true
|
||||
v.Store.KeyNames = keyNames
|
||||
v.Store.Salt = secStore.Salt[:]
|
||||
v.Clusters = clusters
|
||||
v.Hosts = hosts
|
||||
v.HostTemplates = hostTemplates
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
var wgKeys = KVSecrets[string]{"wireguard"}
|
||||
|
||||
func wgKey(path string) (key string, err error) {
|
||||
return wgKeys.GetOrCreate(path, func() (key string, err error) {
|
||||
k, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
key = k.String()
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func wgPSKey(path string) (key string, err error) {
|
||||
return wgKeys.GetOrCreate(path, func() (key string, err error) {
|
||||
k, err := wgtypes.GenerateKey()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
key = k.String()
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
func wgPubKey(path string) (pubkey string, err error) {
|
||||
key, err := wgKey(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
k, err := wgtypes.ParseKey(key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pubkey = k.PublicKey().String()
|
||||
return
|
||||
}
|
||||
@@ -79,17 +79,6 @@ func getUsableClusterCA(cluster, name string) (ca CA, err error) {
|
||||
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")
|
||||
|
||||
func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) {
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
"gopkg.in/yaml.v2"
|
||||
"m.cluseau.fr/go/httperr"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
)
|
||||
|
||||
func wsUploadConfig(req *restful.Request, resp *restful.Response) {
|
||||
cfg := &localconfig.Config{}
|
||||
if err := req.ReadEntity(cfg); err != nil {
|
||||
wsError(resp, httperr.BadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
body := req.Request.Body
|
||||
|
||||
cfgBytes, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
err := writeNewConfig(body)
|
||||
body.Close()
|
||||
|
||||
err = writeNewConfig(cfgBytes)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
@@ -35,7 +23,7 @@ func wsUploadConfig(req *restful.Request, resp *restful.Response) {
|
||||
resp.WriteEntity(true)
|
||||
}
|
||||
|
||||
func writeNewConfig(cfgBytes []byte) (err error) {
|
||||
func writeNewConfig(reader io.Reader) (err error) {
|
||||
out, err := os.CreateTemp(*dataDir, ".config-upload")
|
||||
if err != nil {
|
||||
return
|
||||
@@ -43,7 +31,7 @@ func writeNewConfig(cfgBytes []byte) (err error) {
|
||||
|
||||
defer os.Remove(out.Name())
|
||||
|
||||
_, err = io.Copy(out, bytes.NewReader(cfgBytes))
|
||||
_, err = io.Copy(out, reader)
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -68,8 +56,6 @@ func writeNewConfig(cfgBytes []byte) (err error) {
|
||||
err = os.Rename(out.Name(), cfgPath)
|
||||
|
||||
updateState()
|
||||
go updateCache()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,6 @@ import (
|
||||
"m.cluseau.fr/go/httperr"
|
||||
)
|
||||
|
||||
func globMatch(pattern, value string) bool {
|
||||
ok, _ := filepath.Match(pattern, value)
|
||||
return ok
|
||||
}
|
||||
|
||||
type DownloadSet struct {
|
||||
Expiry time.Time
|
||||
Items []DownloadSetItem
|
||||
@@ -29,7 +24,7 @@ type DownloadSet struct {
|
||||
|
||||
func (s DownloadSet) Contains(kind, name, asset string) bool {
|
||||
for _, item := range s.Items {
|
||||
if item.Kind == kind && globMatch(item.Name, name) &&
|
||||
if item.Kind == kind && item.Name == name &&
|
||||
slices.Contains(item.Assets, asset) {
|
||||
return true
|
||||
}
|
||||
@@ -246,13 +241,28 @@ func wsDownloadSet(req *restful.Request, resp *restful.Response) {
|
||||
set, err := getDlSet(req)
|
||||
if err != nil {
|
||||
resp.WriteHeader(err.Status)
|
||||
resp.Write([]byte(htmlHeader(err.Error())))
|
||||
resp.Write([]byte(htmlFooter))
|
||||
resp.Write([]byte(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>` + err.Error() + `</title>
|
||||
<style src="/ui/style.css"/>
|
||||
<style src="/ui/app.css"/>
|
||||
</head>
|
||||
<body><h1>` + err.Error() + `</h1></body>
|
||||
</html>`))
|
||||
return
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
buf.WriteString(htmlHeader("Download set"))
|
||||
buf.WriteString(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Download set</title>
|
||||
<style src="/ui/style.css"/>
|
||||
<style src="/ui/app.css"/>
|
||||
</head>
|
||||
<body><h1>Download set</h1>
|
||||
`)
|
||||
|
||||
cfg, err2 := readConfig()
|
||||
if err2 != nil {
|
||||
@@ -262,44 +272,31 @@ func wsDownloadSet(req *restful.Request, resp *restful.Response) {
|
||||
|
||||
for _, item := range set.Items {
|
||||
names := make([]string, 0)
|
||||
section := ""
|
||||
switch item.Kind {
|
||||
case "cluster":
|
||||
section = "Cluster"
|
||||
for _, c := range cfg.Clusters {
|
||||
if globMatch(item.Name, c.Name) {
|
||||
if ok, _ := filepath.Match(item.Name, c.Name); ok {
|
||||
names = append(names, c.Name)
|
||||
}
|
||||
}
|
||||
case "host":
|
||||
section = "Host"
|
||||
for _, h := range cfg.Hosts {
|
||||
if globMatch(item.Name, h.Name) {
|
||||
if ok, _ := filepath.Match(item.Name, h.Name); ok {
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
buf.WriteString("<p>")
|
||||
fmt.Fprintf(buf, "<strong>%s %s:</strong>", section, name)
|
||||
buf.WriteString(" <span class=\"download-links\">")
|
||||
fmt.Fprintf(buf, "<h2>%s %s</h2>", strings.Title(item.Kind), name)
|
||||
fmt.Fprintf(buf, "<p class=\"download-links\">\n")
|
||||
for _, asset := range item.Assets {
|
||||
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 += "?"
|
||||
fmt.Fprintf(buf, " <a href=\"/public/download-set/%s/%s/%s?set=%s\" download>%s</a>\n", item.Kind, name, asset, setStr, asset)
|
||||
}
|
||||
url += "set=" + setStr
|
||||
fmt.Fprintf(buf, " <a href=\"%s\" download>%s</a>\n", url, v.name)
|
||||
}
|
||||
}
|
||||
buf.WriteString("</span></p>\n")
|
||||
fmt.Fprintf(buf, `</p>`)
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(htmlFooter)
|
||||
buf.WriteString("</body></html>")
|
||||
buf.WriteTo(resp)
|
||||
}
|
||||
|
||||
@@ -180,24 +180,34 @@ func wsDownloadPage(req *restful.Request, resp *restful.Response) {
|
||||
spec, ok := wState.Get().Downloads[token]
|
||||
if !ok {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
resp.Write([]byte(htmlHeader("Token not found")))
|
||||
resp.Write([]byte(htmlFooter))
|
||||
resp.Write([]byte(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Token not found</title>
|
||||
<style src="/ui/style.css"/>
|
||||
<style src="/ui/app.css"/>
|
||||
</head>
|
||||
<body><h1>Token not found</h1></body>
|
||||
</html>`))
|
||||
return
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
buf.WriteString(htmlHeader(fmt.Sprintf("Token assets: %s %s", spec.Kind, spec.Name)))
|
||||
fmt.Fprintf(buf, `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Token assets: %s %s</title>
|
||||
<style src="/ui/style.css"/>
|
||||
<style src="/ui/app.css"/>
|
||||
</head>
|
||||
<body><h1>Token assets: %s %s</h1>
|
||||
<ul>
|
||||
`, spec.Kind, spec.Name, spec.Kind, spec.Name)
|
||||
|
||||
buf.WriteString("<table class=\"download-table\">")
|
||||
for _, asset := range spec.Assets {
|
||||
buf.WriteString("<tr>")
|
||||
for _, v := range hostAssetVariants(asset) {
|
||||
fmt.Fprintf(buf, "<td><a href=\"%s\" download>%s</a></td>", v.url, v.name)
|
||||
fmt.Fprintf(buf, "<li><a href=\"%s\" download>%s</a></li>\n", asset, asset)
|
||||
}
|
||||
buf.WriteString("</tr>\n")
|
||||
}
|
||||
buf.WriteString("</table>")
|
||||
|
||||
buf.WriteString(htmlFooter)
|
||||
buf.WriteString("</ul></body></html>")
|
||||
buf.WriteTo(resp)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
@@ -13,36 +13,10 @@ import (
|
||||
"novit.tech/direktil/local-server/pkg/mime"
|
||||
)
|
||||
|
||||
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}}
|
||||
}
|
||||
}
|
||||
var (
|
||||
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 wsHost struct {
|
||||
hostDoc string
|
||||
@@ -77,6 +51,9 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
|
||||
b("boot.img.gz").
|
||||
Produces(mime.DISK + "+gzip").
|
||||
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
|
||||
b("boot.qcow2").
|
||||
@@ -85,10 +62,10 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
|
||||
b("boot.qed").
|
||||
Produces(mime.DISK + "+qed").
|
||||
Doc("Get the " + ws.hostDoc + "'s boot disk image, QED (KVM)"),
|
||||
b("boot.vdi").
|
||||
b("boot.vmdk").
|
||||
Produces(mime.DISK + "+vdi").
|
||||
Doc("Get the " + ws.hostDoc + "'s boot disk image, VDI (VirtualBox)"),
|
||||
b("boot.vpc").
|
||||
b("boot.qcow2").
|
||||
Produces(mime.DISK + "+vpc").
|
||||
Doc("Get the " + ws.hostDoc + "'s boot disk image, VHD (Hyper-V)"),
|
||||
b("boot.vmdk").
|
||||
@@ -98,7 +75,10 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
|
||||
// metal/local HDD upgrades
|
||||
b("boot.tar").
|
||||
Produces(mime.TAR).
|
||||
Doc("Get the " + ws.hostDoc + "'s /boot archive"),
|
||||
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
|
||||
b("boot-efi.tar").
|
||||
Produces(mime.TAR).
|
||||
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
|
||||
|
||||
// read-only ISO support
|
||||
b("boot.iso").
|
||||
@@ -118,9 +98,6 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
|
||||
b("initrd").
|
||||
Produces(mime.OCTET).
|
||||
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
|
||||
b("bootstrap-config").
|
||||
@@ -192,59 +169,7 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
|
||||
return
|
||||
}
|
||||
|
||||
cmdline := r.FormValue("cmdline")
|
||||
|
||||
if s := r.FormValue("serial"); s != "" {
|
||||
if cmdline != "" {
|
||||
cmdline += " "
|
||||
}
|
||||
cmdline += "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 {
|
||||
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":
|
||||
err = renderConfig(w, r, ctx, false)
|
||||
case "config.json":
|
||||
@@ -253,6 +178,43 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
|
||||
case "ipxe":
|
||||
err = renderIPXE(w, ctx)
|
||||
|
||||
case "kernel":
|
||||
err = renderKernel(w, r, ctx)
|
||||
case "initrd":
|
||||
err = renderCtx(w, r, ctx, what, buildInitrd)
|
||||
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:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func (hft HostFromTemplate) ClusterName(cfg *localconfig.Config) string {
|
||||
func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Host) {
|
||||
host = cfg.Host(name)
|
||||
if host != nil {
|
||||
log.Print("no host named ", name)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -38,13 +39,13 @@ func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Hos
|
||||
}
|
||||
|
||||
if !found {
|
||||
log.Print("no host named ", name)
|
||||
log.Print("no host from template named ", name)
|
||||
return
|
||||
}
|
||||
|
||||
ht := cfg.HostTemplate(hft.Template)
|
||||
if ht == nil {
|
||||
log.Print("host ", name, " found but no template named ", hft.Template)
|
||||
log.Print("no host template named ", name)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type SSH_ACL struct {
|
||||
Keys []string
|
||||
Clusters []string
|
||||
Groups []string
|
||||
Hosts []string
|
||||
}
|
||||
|
||||
func loadSSH_ACLs() (acls []SSH_ACL, err error) {
|
||||
f, err := os.Open(filepath.Join(*dataDir, "ssh-acls.yaml"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
err = yaml.NewDecoder(f).Decode(&acls)
|
||||
return
|
||||
}
|
||||
|
||||
func wsSSH_ACL_List(req *restful.Request, resp *restful.Response) {
|
||||
// TODO
|
||||
http.NotFound(resp.ResponseWriter, req.Request)
|
||||
}
|
||||
|
||||
func wsSSH_ACL_Get(req *restful.Request, resp *restful.Response) {
|
||||
// TODO
|
||||
http.NotFound(resp.ResponseWriter, req.Request)
|
||||
}
|
||||
|
||||
func wsSSH_ACL_Set(req *restful.Request, resp *restful.Response) {
|
||||
// TODO
|
||||
http.NotFound(resp.ResponseWriter, req.Request)
|
||||
}
|
||||
@@ -8,13 +8,8 @@ import (
|
||||
"novit.tech/direktil/local-server/secretstore"
|
||||
)
|
||||
|
||||
type AddKeyReq struct {
|
||||
NamedPassphrase `json:",inline"`
|
||||
Hash []byte `json:",omitempty"`
|
||||
}
|
||||
|
||||
func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
|
||||
np := AddKeyReq{}
|
||||
np := NamedPassphrase{}
|
||||
|
||||
err := req.ReadEntity(&np)
|
||||
if err != nil {
|
||||
@@ -29,13 +24,8 @@ func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(np.Hash) == 0 && len(np.Passphrase) == 0 {
|
||||
wsBadRequest(resp, "no hash or passphrase given")
|
||||
return
|
||||
}
|
||||
|
||||
if len(np.Hash) != 0 && len(np.Hash) != 32 {
|
||||
wsBadRequest(resp, "hash of a wrong length")
|
||||
if len(np.Passphrase) == 0 {
|
||||
wsBadRequest(resp, "no passphrase given")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,14 +36,7 @@ func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
err = secStore.SaveTo(secKeysStorePath())
|
||||
|
||||
+56
-26
@@ -4,12 +4,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
cfsslconfig "github.com/cloudflare/cfssl/config"
|
||||
"github.com/emicklei/go-restful"
|
||||
"gopkg.in/yaml.v2"
|
||||
"m.cluseau.fr/go/httperr"
|
||||
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
@@ -74,7 +75,7 @@ func registerWS(rest *restful.Container) {
|
||||
|
||||
// - configs API
|
||||
ws.Route(ws.POST("/configs").To(wsUploadConfig).
|
||||
Consumes(mime.YAML, mime.JSON).Param(ws.BodyParameter("config", "The new full configuration")).
|
||||
Consumes(mime.YAML).Param(ws.BodyParameter("config", "The new full configuration")).
|
||||
Produces(mime.JSON).Writes(true).
|
||||
Doc("Upload a new current configuration, archiving the previous one"))
|
||||
|
||||
@@ -150,6 +151,10 @@ func registerWS(rest *restful.Container) {
|
||||
ws.Route(ws.GET("/hosts").To(wsListHosts).
|
||||
Doc("List hosts"))
|
||||
|
||||
ws.Route(ws.GET("/ssh-acls").To(wsSSH_ACL_List))
|
||||
ws.Route(ws.GET("/ssh-acls/{acl-name}").To(wsSSH_ACL_Get))
|
||||
ws.Route(ws.PUT("/ssh-acls/{acl-name}").To(wsSSH_ACL_Set))
|
||||
|
||||
rest.Add(ws)
|
||||
|
||||
// Hosts API
|
||||
@@ -170,6 +175,19 @@ func registerWS(rest *restful.Container) {
|
||||
|
||||
rest.Add(ws)
|
||||
|
||||
// Detected host API
|
||||
ws = (&restful.WebService{}).
|
||||
Filter(requireSecStore).
|
||||
Path("/me").
|
||||
Param(ws.HeaderParameter("Authorization", "Host or admin bearer token"))
|
||||
|
||||
(&wsHost{
|
||||
hostDoc: "detected host",
|
||||
getHost: detectHost,
|
||||
}).register(ws, func(rb *restful.RouteBuilder) {
|
||||
rb.Notes("In this case, the host is detected from the remote IP")
|
||||
})
|
||||
|
||||
// Hosts by token API
|
||||
ws = (&restful.WebService{}).
|
||||
Filter(requireSecStore).
|
||||
@@ -210,6 +228,41 @@ func requireSecStore(req *restful.Request, resp *restful.Response, chain *restfu
|
||||
chain.ProcessFilter(req, resp)
|
||||
}
|
||||
|
||||
func detectHost(req *restful.Request) (hostName string, err error) {
|
||||
if !*allowDetectedHost {
|
||||
return
|
||||
}
|
||||
|
||||
r := req.Request
|
||||
remoteAddr := r.RemoteAddr
|
||||
|
||||
if *trustXFF {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
remoteAddr = strings.Split(xff, ",")[0]
|
||||
}
|
||||
}
|
||||
|
||||
hostIP, _, err := net.SplitHostPort(remoteAddr)
|
||||
|
||||
if err != nil {
|
||||
hostIP = remoteAddr
|
||||
}
|
||||
|
||||
cfg, err := readConfig()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
host := cfg.HostByIP(hostIP)
|
||||
|
||||
if host == nil {
|
||||
log.Print("no host found for IP ", hostIP)
|
||||
return
|
||||
}
|
||||
|
||||
return host.Name, nil
|
||||
}
|
||||
|
||||
func wsReadConfig(resp *restful.Response) *localconfig.Config {
|
||||
cfg, err := readConfig()
|
||||
if err != nil {
|
||||
@@ -241,7 +294,7 @@ func wsError(resp *restful.Response, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value any) {
|
||||
func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) {
|
||||
tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
@@ -254,26 +307,3 @@ func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
restful.RegisterEntityAccessor(mime.YAML, yamlEntityAccessor{})
|
||||
}
|
||||
|
||||
type yamlEntityAccessor struct{}
|
||||
|
||||
var _ restful.EntityReaderWriter = yamlEntityAccessor{}
|
||||
|
||||
func (_ yamlEntityAccessor) Read(req *restful.Request, v any) error {
|
||||
decoder := yaml.NewDecoder(req.Request.Body)
|
||||
return decoder.Decode(v)
|
||||
}
|
||||
func (_ yamlEntityAccessor) Write(resp *restful.Response, status int, v any) error {
|
||||
if v == nil {
|
||||
resp.WriteHeader(status)
|
||||
// do not write a nil representation
|
||||
return nil
|
||||
}
|
||||
resp.Header().Set("Content-Type", mime.YAML)
|
||||
resp.WriteHeader(status)
|
||||
return yaml.NewEncoder(resp.ResponseWriter).Encode(v)
|
||||
}
|
||||
|
||||
@@ -11,21 +11,18 @@ require (
|
||||
github.com/emicklei/go-restful v2.16.0+incompatible
|
||||
github.com/emicklei/go-restful-openapi v1.4.1
|
||||
github.com/go-git/go-git/v5 v5.16.2
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/mcluseau/go-swagger-ui v0.0.0-20191019002626-fd9128c24a34
|
||||
github.com/miolini/datacounter v1.0.3
|
||||
github.com/oklog/ulid v1.3.1
|
||||
github.com/pierrec/lz4 v2.6.1+incompatible
|
||||
github.com/sergeymakinen/go-crypt v1.0.1
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
k8s.io/apimachinery v0.33.2
|
||||
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766
|
||||
novit.tech/direktil/pkg v0.0.0-20260508111456-5a75785cfbbf
|
||||
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1
|
||||
)
|
||||
|
||||
replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1
|
||||
@@ -58,6 +55,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/kisielk/sqlstruct v0.0.0-20210630145711-dae28ed37023 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -73,6 +71,7 @@ require (
|
||||
github.com/zmap/zlint/v3 v3.5.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
|
||||
@@ -3,6 +3,8 @@ dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
|
||||
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
@@ -311,8 +313,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||
gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
|
||||
gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
@@ -346,9 +346,5 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8
|
||||
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/go.mod h1:BMv3aOSYpupuiiG3Ch3ND88aB5CfAks3YZuRLE8j1ls=
|
||||
novit.tech/direktil/pkg v0.0.0-20260210141740-4d5661fa8ecd h1:proGf8Cid9tzJzoRbqQHGGpZZKTpUDFwOREbjYrCbkM=
|
||||
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=
|
||||
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1 h1:hKj9qhbTAoTxYIj6KaMLJp9I+bvZfkSM/QwK8Bd496o=
|
||||
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#! /bin/sh
|
||||
set -ex
|
||||
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)" $*
|
||||
go build -o dist/ -trimpath -ldflags "-X main.Version=${GIT_TAG:-$(git describe --always --dirty)}" $*
|
||||
|
||||
+2
-5
@@ -1,11 +1,8 @@
|
||||
#! /bin/bash
|
||||
set -ex
|
||||
|
||||
GIT_TAG=$(git describe --always --dirty)
|
||||
|
||||
case "$1" in
|
||||
commit) tag=$GIT_TAG ;;
|
||||
commit) tag=$(git describe --always --dirty) ;;
|
||||
"") tag=latest ;;
|
||||
*) tag=$1 ;;
|
||||
esac
|
||||
docker build --network=host -t novit.tech/direktil/local-server:$tag . --build-arg GIT_TAG=$GIT_TAG
|
||||
docker build -t novit.tech/direktil/local-server:$tag .
|
||||
|
||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
+1
-1
@@ -2,5 +2,5 @@ package dlshtml
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed ui
|
||||
//go:embed favicon.ico ui
|
||||
var FS embed.FS
|
||||
|
||||
@@ -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">
|
||||
=> <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">
|
||||
=> <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>
|
||||
|
||||
`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
const Downloads = {
|
||||
props: [ 'kind', 'name', 'token', 'state' ],
|
||||
data() {
|
||||
return { createDisabled: false, selectedAssets: {} }
|
||||
},
|
||||
computed: {
|
||||
availableAssets() {
|
||||
return {
|
||||
cluster: ['addons'],
|
||||
host: [
|
||||
"kernel",
|
||||
"initrd",
|
||||
"uki",
|
||||
"bootstrap.tar",
|
||||
"boot.img.gz",
|
||||
"boot.img",
|
||||
"boot.qcow2",
|
||||
"boot.vmdk",
|
||||
"boot.iso",
|
||||
"boot.tar",
|
||||
"bootstrap-config",
|
||||
"config",
|
||||
"config.json",
|
||||
"ipxe",
|
||||
],
|
||||
}[this.kind]
|
||||
},
|
||||
downloads() {
|
||||
return Object.entries(this.state.Downloads)
|
||||
.filter(e => { let d=e[1]; return d.Kind == this.kind && d.Name == this.name })
|
||||
.map(e => {
|
||||
const token= e[0];
|
||||
return {
|
||||
text: token.substring(0, 5) + '...',
|
||||
url: '/public/downloads/'+token+"/",
|
||||
}
|
||||
})
|
||||
},
|
||||
assets() {
|
||||
return this.availableAssets.filter(a => this.selectedAssets[a])
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
createToken() {
|
||||
event.preventDefault()
|
||||
this.createDisabled = true
|
||||
|
||||
fetch('/authorize-download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({Kind: this.kind, Name: this.name, Assets: this.assets}),
|
||||
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
|
||||
}).then((resp) => resp.json())
|
||||
.then((token) => { this.selectedAssets = {}; this.createDisabled = false })
|
||||
.catch((e) => { alert('failed to create link'); this.createDisabled = false })
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<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]" /> {{ asset }}</label>
|
||||
{{" "}}
|
||||
</template>
|
||||
</p>
|
||||
<p><button :disabled="createDisabled || assets.length==0" @click="createToken">Create link</button></p>
|
||||
<template v-if="downloads.length">
|
||||
<h4>Active links</h4>
|
||||
<p class="download-links"><template v-for="d in downloads"><a :href="d.url" target="_blank">{{ d.text }}</a>{{" "}}</template></p>
|
||||
</template>`
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
const GetCopy = {
|
||||
props: [ 'name', 'href', 'token' ],
|
||||
data() { return {showCopied: false} },
|
||||
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a> <a href="#" class="copy" @click="fetchAndCopy()">🗐</a></span>`,
|
||||
methods: {
|
||||
fetch() {
|
||||
event.preventDefault()
|
||||
return fetch(this.href, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer ' + this.token },
|
||||
})
|
||||
},
|
||||
handleFetchError(e) {
|
||||
console.log("failed to get value:", e)
|
||||
alert('failed to get value')
|
||||
},
|
||||
fetchAndSave() {
|
||||
this.fetch().then(resp => resp.blob()).then((value) => {
|
||||
window.open(URL.createObjectURL(value), "_blank")
|
||||
}).catch(this.handleFetchError)
|
||||
},
|
||||
fetchAndCopy() {
|
||||
this.fetch()
|
||||
.then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
|
||||
.then((value) => {
|
||||
window.navigator.clipboard.writeText(value)
|
||||
this.showCopied = true
|
||||
setTimeout(() => { this.showCopied = false }, 1000)
|
||||
}).catch(this.handleFetchError)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
const Host = {
|
||||
components: { Downloads },
|
||||
props: [ 'host', 'token', 'state' ],
|
||||
template: `
|
||||
<p>Cluster: {{ host.Cluster }}<template v-if="host.Template"> ({{ host.Template }})</template></p>
|
||||
<p>IPs:
|
||||
<code v-for="ip in host.IPs">
|
||||
{{ ip }}{{" "}}
|
||||
</code>
|
||||
</p>
|
||||
<h3>Downloads</h3>
|
||||
<Downloads :token="token" :state="state" kind="host" :name="host.Name" />
|
||||
`
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
|
||||
import { createApp } from './vue.esm-browser.js';
|
||||
|
||||
createApp({
|
||||
components: { Cluster, Host },
|
||||
data() {
|
||||
return {
|
||||
forms: {
|
||||
store: {},
|
||||
storeUpload: {},
|
||||
delKey: {},
|
||||
hostFromTemplate: {},
|
||||
hostFromTemplateDel: "",
|
||||
},
|
||||
view: "",
|
||||
viewFilter: "",
|
||||
session: {},
|
||||
error: null,
|
||||
publicState: null,
|
||||
serverVersion: null,
|
||||
uiHash: null,
|
||||
watchingState: false,
|
||||
state: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.session = JSON.parse(sessionStorage.state || "{}")
|
||||
this.watchPublicState()
|
||||
},
|
||||
watch: {
|
||||
session: {
|
||||
deep: true,
|
||||
handler(v) {
|
||||
sessionStorage.state = JSON.stringify(v)
|
||||
|
||||
if (v.token && !this.watchingState) {
|
||||
this.watchState()
|
||||
this.watchingState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
publicState: {
|
||||
deep: true,
|
||||
handler(v) {
|
||||
if (v) {
|
||||
this.serverVersion = v.ServerVersion
|
||||
if (this.uiHash && v.UIHash != this.uiHash) {
|
||||
console.log("reloading")
|
||||
location.reload()
|
||||
} else {
|
||||
this.uiHash = v.UIHash
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
views() {
|
||||
var views = [{type: "actions", name: "admin", title: "Admin actions"}];
|
||||
|
||||
(this.state.Clusters||[]).forEach((c) => views.push({type: "cluster", name: c.Name, title: `Cluster ${c.Name}`}));
|
||||
(this.state.Hosts ||[]).forEach((c) => views.push({type: "host", name: c.Name, title: `Host ${c.Name}`}));
|
||||
|
||||
return views.filter((v) => v.type != "host" || v.name.includes(this.viewFilter));
|
||||
},
|
||||
viewObj() {
|
||||
if (this.view) {
|
||||
if (this.view.type == "cluster") {
|
||||
return this.state.Clusters.find((c) => c.Name == this.view.name);
|
||||
}
|
||||
if (this.view.type == "host") {
|
||||
return this.state.Hosts.find((h) => h.Name == this.view.name);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
hostsFromTemplate() {
|
||||
return (this.state.Hosts||[]).filter((h) => h.Template);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
any(array) {
|
||||
return array && array.length != 0;
|
||||
},
|
||||
copyText(text) {
|
||||
event.preventDefault()
|
||||
window.navigator.clipboard.writeText(text)
|
||||
},
|
||||
setToken() {
|
||||
event.preventDefault()
|
||||
this.session.token = this.forms.setToken
|
||||
this.forms.setToken = null
|
||||
},
|
||||
uploadStore() {
|
||||
event.preventDefault()
|
||||
this.apiPost('/public/store.tar', this.$refs.storeUpload.files[0], (v) => {
|
||||
this.forms.store = {}
|
||||
}, "application/tar")
|
||||
},
|
||||
namedPassphrase(name, passphrase) {
|
||||
return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)}
|
||||
},
|
||||
storeAddKey() {
|
||||
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 = {}
|
||||
})
|
||||
},
|
||||
storeDelKey() {
|
||||
event.preventDefault()
|
||||
|
||||
let name = this.forms.delKey.name
|
||||
|
||||
if (!confirm("Remove key named "+JSON.stringify(name)+"?")) {
|
||||
return
|
||||
}
|
||||
this.apiPost('/store/delete-key', name , (v) => {
|
||||
this.forms.delKey = {}
|
||||
})
|
||||
},
|
||||
unlockStore() {
|
||||
this.apiPost('/public/unlock-store', this.namedPassphrase(), (v) => {
|
||||
this.forms.store = {}
|
||||
|
||||
if (v) {
|
||||
this.session.token = v
|
||||
if (!this.watchingState) {
|
||||
this.watchState()
|
||||
this.watchingState = true
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
uploadConfig() {
|
||||
const file = this.$refs.configUpload.files[0];
|
||||
let contentType = "text/vnd.yaml";
|
||||
if (file.name.endsWith(".json")) {
|
||||
contentType = "application/json";
|
||||
}
|
||||
this.apiPost('/configs', file, (v) => {}, contentType, true)
|
||||
},
|
||||
hostFromTemplateAdd() {
|
||||
let v = this.forms.hostFromTemplate;
|
||||
this.apiPost('/hosts-from-template/'+v.name, v, (v) => { this.forms.hostFromTemplate = {} });
|
||||
},
|
||||
hostFromTemplateDel() {
|
||||
event.preventDefault()
|
||||
|
||||
let v = this.forms.hostFromTemplateDel;
|
||||
if (!confirm("delete host template instance "+v+"?")) {
|
||||
return
|
||||
}
|
||||
this.apiDelete('/hosts-from-template/'+v, (v) => { this.forms.hostFromTemplateDel = "" });
|
||||
},
|
||||
apiPost(action, data, onload, contentType = 'application/json', raw = false) {
|
||||
event.preventDefault()
|
||||
|
||||
if (data === undefined) {
|
||||
throw("action " + action + ": no data")
|
||||
}
|
||||
|
||||
/* TODO
|
||||
fetch(action, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((result) => onload)
|
||||
// */
|
||||
|
||||
var xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.responseType = 'json'
|
||||
// TODO spinner, pending action notification, or something
|
||||
xhr.onerror = () => {
|
||||
// this.actionResults.splice(idx, 1, {...item, done: true, failed: true })
|
||||
}
|
||||
xhr.onload = (r) => {
|
||||
if (xhr.status != 200) {
|
||||
this.error = xhr.response
|
||||
return
|
||||
}
|
||||
// this.actionResults.splice(idx, 1, {...item, done: true, resp: xhr.responseText})
|
||||
this.error = null
|
||||
if (onload) {
|
||||
onload(xhr.response)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.open("POST", action)
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
xhr.setRequestHeader('Content-Type', contentType)
|
||||
if (this.session.token) {
|
||||
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||
}
|
||||
|
||||
if (!raw && contentType == "application/json") {
|
||||
xhr.send(JSON.stringify(data))
|
||||
} else {
|
||||
xhr.send(data)
|
||||
}
|
||||
},
|
||||
apiDelete(action, data, onload) {
|
||||
event.preventDefault()
|
||||
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.onload = (r) => {
|
||||
if (xhr.status != 200) {
|
||||
this.error = xhr.response
|
||||
return
|
||||
}
|
||||
this.error = null
|
||||
if (onload) {
|
||||
onload(xhr.response)
|
||||
}
|
||||
}
|
||||
xhr.open("DELETE", action)
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
if (this.session.token) {
|
||||
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||
}
|
||||
xhr.send()
|
||||
},
|
||||
download(url) {
|
||||
event.target.target = '_blank'
|
||||
event.target.href = this.downloadLink(url)
|
||||
},
|
||||
downloadLink(url) {
|
||||
// TODO once-shot download link
|
||||
return url + '?token=' + this.session.token
|
||||
},
|
||||
watchPublicState() {
|
||||
this.watchStream('publicState', '/public-state')
|
||||
},
|
||||
watchState() {
|
||||
this.watchStream('state', '/state', true)
|
||||
},
|
||||
watchStream(field, path, withToken) {
|
||||
let evtSrc = new EventSource(path + (withToken ? '?token='+this.session.token : ''));
|
||||
evtSrc.onmessage = (e) => {
|
||||
let update = JSON.parse(e.data)
|
||||
|
||||
console.log("watch "+path+":", update)
|
||||
|
||||
if (update.err) {
|
||||
console.log("watch error from server:", err)
|
||||
}
|
||||
if (update.set) {
|
||||
this[field] = update.set
|
||||
}
|
||||
if (update.p) { // patch
|
||||
new jsonpatch.JSONPatch(update.p, true).apply(this[field])
|
||||
}
|
||||
}
|
||||
evtSrc.onerror = (e) => {
|
||||
// console.log("event source " + path + " error:", e)
|
||||
if (evtSrc) evtSrc.close()
|
||||
|
||||
this[field] = null
|
||||
|
||||
window.setTimeout(() => { this.watchStream(field, path, withToken) }, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
}).mount('#app');
|
||||
+2
-20
@@ -10,38 +10,20 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.downloads, .download-links, .download-table td {
|
||||
.downloads, .download-links {
|
||||
& > * {
|
||||
display: inline-block;
|
||||
margin-right: 1ex;
|
||||
margin-bottom: 1ex;
|
||||
padding: 0.5ex;
|
||||
border: 1px solid !important;
|
||||
border: 1px solid;
|
||||
border-radius: 1ex;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.download-table th,
|
||||
.download-table td {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.downloads, .view-links {
|
||||
& > .selected {
|
||||
color: var(--link);
|
||||
}
|
||||
}
|
||||
|
||||
.text-and-file {
|
||||
position:relative;
|
||||
|
||||
textarea {
|
||||
width: 64em;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
position:absolute;
|
||||
bottom:0;right:0;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-21
@@ -2,25 +2,18 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Direktil Local Server</title>
|
||||
<base href="/ui/" />
|
||||
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
|
||||
|
||||
|
||||
<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/app-e7fb26679b9aa0f2.js" defer integrity="sha384-4oRQalb7IIBcqQzfDkeCj53qYOP6dLsTwqcjnm3EiBa92oNDD3chUw38W2gEC+3p" type="module"></script>
|
||||
<script src="/ui/Downloads-29497c61f1fe9bf0.js" integrity="sha384-xwn49JflUBaZlQCHCn55Q9qSGqsw01Job+TXk53HLhvNhKAALN18+gNCdF0bUJW0"></script>
|
||||
<script src="/ui/GetCopy-7e3c9678f9647d40.js" integrity="sha384-LzxUXylxE/t25HyTch8y2qvKcehvP2nqCo37swIBGEKZZUzHVJVQrS5UJDWNskTA"></script>
|
||||
<script src="/ui/Cluster-703dcdca97841304.js" integrity="sha384-ifGpq/GB1nDfqczm5clTRtcfnc9UMkT+SptMyS3UG9Di3xoKORtOGGjmK5RnJx+v"></script>
|
||||
<script src="/ui/Host-61916516a854adff.js" integrity="sha384-/wh3KrC0sb4MT7ekO2U84rswxI42WSH/0jB4dbDaaGaGhX60xTEZHFsdQAf7UgTG"></script>
|
||||
<style>
|
||||
@import url('./style.css');
|
||||
@import url('./app.css');
|
||||
</style>
|
||||
<script src="js/jsonpatch.min.js" crossorigin="anonymous"></script>
|
||||
<script src="js/app.js" type="module" defer></script>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<header>
|
||||
<div id="logo">
|
||||
<img src="favicon.ico" />
|
||||
<img src="/favicon.ico" />
|
||||
<span>Direktil Local Server</span>
|
||||
</div>
|
||||
<div class="utils">
|
||||
@@ -106,15 +99,9 @@
|
||||
<form @submit="storeAddKey" action="/store/add-key">
|
||||
<p>Add an unlock phrase:</p>
|
||||
<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.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
|
||||
</div>
|
||||
<input type="submit" value="add unlock phrase" :disabled="!((forms.store.pass1 && forms.store.pass1 == forms.store.pass2) || forms.store.hash)" />
|
||||
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
|
||||
</form>
|
||||
<form @submit="storeDelKey" action="/store/delete-key">
|
||||
<p>Remove an unlock phrase:</p>
|
||||
@@ -147,5 +134,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const Cluster = {
|
||||
|
||||
import Downloads from './Downloads.js';
|
||||
import GetCopy from './GetCopy.js';
|
||||
|
||||
export default {
|
||||
components: { Downloads, GetCopy },
|
||||
props: [ 'cluster', 'token', 'state' ],
|
||||
data() {
|
||||
@@ -11,8 +15,8 @@ const Cluster = {
|
||||
sshUserCert: null,
|
||||
kubeSignReq: {
|
||||
CSR: "",
|
||||
User: "",
|
||||
Group: "system:masters",
|
||||
User: "anonymous",
|
||||
Group: "",
|
||||
},
|
||||
kubeUserCert: null,
|
||||
};
|
||||
@@ -24,13 +28,8 @@ const Cluster = {
|
||||
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))
|
||||
}
|
||||
})
|
||||
}).then((resp) => resp.blob())
|
||||
.then((cert) => { this.sshUserCert = URL.createObjectURL(cert) })
|
||||
.catch((e) => { alert('failed to sign: '+e); })
|
||||
},
|
||||
kubeCASign() {
|
||||
@@ -39,70 +38,12 @@ const Cluster = {
|
||||
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))
|
||||
}
|
||||
})
|
||||
}).then((resp) => resp.blob())
|
||||
.then((cert) => { this.kubeUserCert = URL.createObjectURL(cert) })
|
||||
.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">
|
||||
=> <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">
|
||||
=> <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" />
|
||||
@@ -127,5 +68,34 @@ const Cluster = {
|
||||
</template></td>
|
||||
</tr></table>
|
||||
|
||||
<h3>Access</h3>
|
||||
|
||||
<p>Allow cluster access from a public key</p>
|
||||
<p>Certificate time validity: <input type="text" v-model="signReqValidity"/> <small>ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</p>
|
||||
|
||||
<h4>Grant SSH access</h4>
|
||||
|
||||
<p>Public key (OpenSSH format):<br/>
|
||||
<textarea v-model="sshSignReq.PubKey" style="width:64em;height:2lh"></textarea>
|
||||
</p>
|
||||
<p>Principal: <input type="text" v-model="sshSignReq.Principal"/></p>
|
||||
|
||||
<p><button @click="sshCASign">Sign SSH access request</button></p>
|
||||
<p v-if="sshUserCert">
|
||||
<a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
|
||||
</p>
|
||||
|
||||
<h4>Grant Kubernetes API access</h4>
|
||||
|
||||
<p>Certificate signing request (PEM format):<br/>
|
||||
<textarea v-model="kubeSignReq.CSR" style="width:64em;height:7lh;"></textarea>
|
||||
</p>
|
||||
<p>User: <input type="text" v-model="kubeSignReq.User"/></p>
|
||||
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
|
||||
|
||||
<p><button @click="kubeCASign">Sign Kubernetes API access request</button></p>
|
||||
<p v-if="kubeUserCert">
|
||||
<a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
|
||||
</p>
|
||||
`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const Downloads = {
|
||||
|
||||
export default {
|
||||
props: [ 'kind', 'name', 'token', 'state' ],
|
||||
data() {
|
||||
return { createDisabled: false, selectedAssets: {} }
|
||||
@@ -10,17 +11,17 @@ const Downloads = {
|
||||
host: [
|
||||
"kernel",
|
||||
"initrd",
|
||||
"uki",
|
||||
"bootstrap.tar",
|
||||
"boot.img.lz4",
|
||||
"boot.img.gz",
|
||||
"boot.img",
|
||||
"boot.qcow2",
|
||||
"boot.vmdk",
|
||||
"boot.img",
|
||||
"boot.iso",
|
||||
"boot.tar",
|
||||
"bootstrap-config",
|
||||
"boot-efi.tar",
|
||||
"config",
|
||||
"config.json",
|
||||
"bootstrap-config",
|
||||
"ipxe",
|
||||
],
|
||||
}[this.kind]
|
||||
@@ -1,4 +1,4 @@
|
||||
const GetCopy = {
|
||||
export default {
|
||||
props: [ 'name', 'href', 'token' ],
|
||||
data() { return {showCopied: false} },
|
||||
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a> <a href="#" class="copy" @click="fetchAndCopy()">🗐</a></span>`,
|
||||
@@ -1,4 +1,7 @@
|
||||
const Host = {
|
||||
|
||||
import Downloads from './Downloads.js';
|
||||
|
||||
export default {
|
||||
components: { Downloads },
|
||||
props: [ 'host', 'token', 'state' ],
|
||||
template: `
|
||||
@@ -1,6 +1,9 @@
|
||||
|
||||
import { createApp } from './vue.esm-browser.js';
|
||||
|
||||
import Cluster from './Cluster.js';
|
||||
import Host from './Host.js';
|
||||
|
||||
createApp({
|
||||
components: { Cluster, Host },
|
||||
data() {
|
||||
@@ -62,7 +65,7 @@ createApp({
|
||||
(this.state.Clusters||[]).forEach((c) => views.push({type: "cluster", name: c.Name, title: `Cluster ${c.Name}`}));
|
||||
(this.state.Hosts ||[]).forEach((c) => views.push({type: "host", name: c.Name, title: `Host ${c.Name}`}));
|
||||
|
||||
return views.filter((v) => v.type != "host" || v.name.includes(this.viewFilter));
|
||||
return views.filter((v) => v.name.includes(this.viewFilter));
|
||||
},
|
||||
viewObj() {
|
||||
if (this.view) {
|
||||
@@ -103,12 +106,7 @@ createApp({
|
||||
return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)}
|
||||
},
|
||||
storeAddKey() {
|
||||
const params = this.namedPassphrase();
|
||||
if (this.forms.store.byHash) {
|
||||
params.Hash = this.forms.store.hash;
|
||||
}
|
||||
|
||||
this.apiPost('/store/add-key', params, (v) => {
|
||||
this.apiPost('/store/add-key', this.namedPassphrase(), (v) => {
|
||||
this.forms.store = {}
|
||||
})
|
||||
},
|
||||
@@ -138,12 +136,7 @@ createApp({
|
||||
})
|
||||
},
|
||||
uploadConfig() {
|
||||
const file = this.$refs.configUpload.files[0];
|
||||
let contentType = "text/vnd.yaml";
|
||||
if (file.name.endsWith(".json")) {
|
||||
contentType = "application/json";
|
||||
}
|
||||
this.apiPost('/configs', file, (v) => {}, contentType, true)
|
||||
this.apiPost('/configs', this.$refs.configUpload.files[0], (v) => {}, "text/vnd.yaml")
|
||||
},
|
||||
hostFromTemplateAdd() {
|
||||
let v = this.forms.hostFromTemplate;
|
||||
@@ -158,7 +151,7 @@ createApp({
|
||||
}
|
||||
this.apiDelete('/hosts-from-template/'+v, (v) => { this.forms.hostFromTemplateDel = "" });
|
||||
},
|
||||
apiPost(action, data, onload, contentType = 'application/json', raw = false) {
|
||||
apiPost(action, data, onload, contentType = 'application/json') {
|
||||
event.preventDefault()
|
||||
|
||||
if (data === undefined) {
|
||||
@@ -200,7 +193,7 @@ createApp({
|
||||
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||
}
|
||||
|
||||
if (!raw && contentType == "application/json") {
|
||||
if (contentType == "application/json") {
|
||||
xhr.send(JSON.stringify(data))
|
||||
} else {
|
||||
xhr.send(data)
|
||||
@@ -269,4 +262,5 @@ createApp({
|
||||
},
|
||||
}
|
||||
|
||||
}).mount('#app');
|
||||
}).mount('#app')
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-3
@@ -48,11 +48,9 @@ th, td {
|
||||
border-bottom: dotted 1pt;
|
||||
padding: 2pt 4pt;
|
||||
}
|
||||
tr:first-child {
|
||||
th, td {
|
||||
tr:first-child > th {
|
||||
border-top: dotted 1pt;
|
||||
}
|
||||
}
|
||||
th, tr:last-child > td {
|
||||
border-bottom: solid 1pt;
|
||||
}
|
||||
|
||||
+18
-4
@@ -1,23 +1,37 @@
|
||||
#! /bin/sh
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "USAGE: $0 <device>"
|
||||
echo "USAGE: $0 <device> <base url>"
|
||||
fi
|
||||
|
||||
dev=$1
|
||||
base_url=$2
|
||||
|
||||
: ${MP:=/mnt}
|
||||
|
||||
set -ex
|
||||
|
||||
zcat boot.img.gz | dd of=$dev
|
||||
|
||||
apk add sgdisk
|
||||
mkdir -p $MP
|
||||
|
||||
[[ $dev =~ nvme ]] &&
|
||||
devp=${dev}p ||
|
||||
devp=${dev}
|
||||
|
||||
if vgdisplay storage; then
|
||||
# the system is already installed, just upgrade
|
||||
mount -t vfat ${devp}1 $MP
|
||||
curl ${base_url}/boot.tar |tar xv -C $MP
|
||||
umount $MP
|
||||
|
||||
else
|
||||
sgdisk --clear $dev
|
||||
|
||||
curl ${base_url}/boot.img.lz4 |lz4cat >$dev
|
||||
|
||||
sgdisk --move-second-header --new=3:0:0 $dev
|
||||
|
||||
pvcreate ${devp}3
|
||||
vgcreate storage ${devp}3
|
||||
fi
|
||||
|
||||
while umount $MP; do true; done
|
||||
|
||||
@@ -2,6 +2,7 @@ package clustersconfig
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -177,7 +178,7 @@ type dirStore struct {
|
||||
|
||||
// listDir
|
||||
func (b *dirStore) listDir(prefix string) (subDirs []string, err error) {
|
||||
entries, err := os.ReadDir(filepath.Join(b.path, prefix))
|
||||
entries, err := ioutil.ReadDir(filepath.Join(b.path, prefix))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -225,7 +226,7 @@ func (b *dirStore) List(prefix string) ([]string, error) {
|
||||
|
||||
// Load is part of the DataBackend interface
|
||||
func (b *dirStore) Get(key string) (ba []byte, err error) {
|
||||
ba, err = os.ReadFile(filepath.Join(b.path, filepath.Join(path.Split(key))+".yaml"))
|
||||
ba, err = ioutil.ReadFile(filepath.Join(b.path, filepath.Join(path.Split(key))+".yaml"))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -211,13 +211,10 @@ func (s *Store) Unlock(passphrase []byte) (ok bool) {
|
||||
}
|
||||
|
||||
func (s *Store) AddKey(name string, passphrase []byte) {
|
||||
key, _ := s.keyPairFromPassword(passphrase)
|
||||
key, hash := s.keyPairFromPassword(passphrase)
|
||||
memzero(passphrase)
|
||||
s.AddRawKey(name, key)
|
||||
}
|
||||
|
||||
func (s *Store) AddRawKey(name string, key [32]byte) {
|
||||
hash := sha512.Sum512(key[:])
|
||||
defer memzero(key[:])
|
||||
|
||||
k := KeyEntry{Name: name, Hash: hash}
|
||||
|
||||
@@ -225,7 +222,6 @@ func (s *Store) AddRawKey(name string, key [32]byte) {
|
||||
copy(k.EncKey[:], encKey)
|
||||
|
||||
s.Keys = append(s.Keys, k)
|
||||
memzero(key[:])
|
||||
}
|
||||
|
||||
func (s *Store) keyPairFromPassword(password []byte) (key [32]byte, hash [64]byte) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
[build]
|
||||
public_url = "/ui"
|
||||
dist = "../html/ui"
|
||||
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/public-state"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/state"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/public"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/store"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/authorize-download"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/sign-download-set"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/configs"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/clusters"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/hosts-from-template"
|
||||
[[proxy]]
|
||||
backend = "http://localhost:7606/hosts"
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
|
||||
.view-links > span {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
margin-right: 1ex;
|
||||
margin-bottom: 1ex;
|
||||
padding: 0.5ex;
|
||||
border: 1pt solid;
|
||||
border-radius: 1ex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.downloads, .download-links, .download-table td {
|
||||
& > * {
|
||||
display: inline-block;
|
||||
margin-right: 1ex;
|
||||
margin-bottom: 1ex;
|
||||
padding: 0.5ex;
|
||||
border: 1px solid !important;
|
||||
border-radius: 1ex;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.download-table th,
|
||||
.download-table td {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.downloads, .view-links {
|
||||
& > .selected {
|
||||
color: var(--link);
|
||||
}
|
||||
}
|
||||
|
||||
.text-and-file {
|
||||
position:relative;
|
||||
|
||||
textarea {
|
||||
width: 64em;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
position:absolute;
|
||||
bottom:0;right:0;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
-151
@@ -1,151 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Direktil Local Server</title>
|
||||
<base data-trunk-public-url />
|
||||
<link data-trunk rel="copy-file" href="favicon.ico" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<link data-trunk rel="copy-file" href="js/vue.esm-browser.js" />
|
||||
<link data-trunk rel="copy-file" href="style.css" />
|
||||
<link data-trunk rel="copy-file" href="app.css" />
|
||||
<style>@import url('/ui/style.css');@import url('/ui/app.css');</style>
|
||||
<script data-trunk src="js/jsonpatch.min.js"></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/GetCopy.js"></script>
|
||||
<script data-trunk src="js/Cluster.js"></script>
|
||||
<script data-trunk src="js/Host.js"></script>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<header>
|
||||
<div id="logo">
|
||||
<img src="favicon.ico" />
|
||||
<span>Direktil Local Server</span>
|
||||
</div>
|
||||
<div class="utils">
|
||||
<span id="login-hdr" v-if="session.token">
|
||||
Logged in
|
||||
<button class="link" @click="copyText(session.token)">🗐</button>
|
||||
</span>
|
||||
|
||||
<span>server <code>{{ serverVersion || '-----' }}</code></span>
|
||||
<span>ui <code>{{ uiHash || '-----' }}</code></span>
|
||||
|
||||
<span :class="publicState ? 'green' : 'red'">🗲</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
<button class="btn-close" @click="error=null">×</button>
|
||||
<div class="code" v-if="error.code">{{ error.code }}</div>
|
||||
<div class="message">{{ error.message }}</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!publicState">
|
||||
<p>Not connected.</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="publicState.Store.New">
|
||||
<p>Store is new.</p>
|
||||
<p>Option 1: initialize a new store</p>
|
||||
<form @submit="unlockStore">
|
||||
<input type="text" v-model="forms.store.name" name="name" placeholder="Name" /><br/>
|
||||
<input type="password" v-model="forms.store.pass1" name="passphrase" required placeholder="Passphrase" />
|
||||
<input type="password" v-model="forms.store.pass2" required placeholder="Passphrase confirmation" />
|
||||
<input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
|
||||
</form>
|
||||
<p>Option 2: upload a previously downloaded store</p>
|
||||
<form @submit="uploadStore">
|
||||
<input type="file" ref="storeUpload" />
|
||||
<input type="submit" value="upload" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!publicState.Store.Open">
|
||||
<p>Store is not open.</p>
|
||||
<form @submit="unlockStore">
|
||||
<input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" />
|
||||
<input type="submit" value="unlock" :disabled="!forms.store.pass1" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!state">
|
||||
<p v-if="!session.token">Not logged in.</p>
|
||||
<p v-else>Invalid token</p>
|
||||
|
||||
<form @submit="unlockStore">
|
||||
<input type="password" v-model="forms.store.pass1" required placeholder="Passphrase" />
|
||||
<input type="submit" value="log in"/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div style="float:right;"><input type="search" placeholder="Filter" v-model="viewFilter"/></div>
|
||||
<p class="view-links"><span v-for="v in views" @click="view = v" :class="{selected: view.type==v.type && view.name==v.name}">{{v.title}}</span></p>
|
||||
|
||||
<h2 v-if="view">{{view.title}}</h2>
|
||||
|
||||
<div v-if="view.type == 'cluster'" id="clusters">
|
||||
<Cluster :cluster="viewObj" :token="session.token" :state="state" />
|
||||
</div>
|
||||
|
||||
<div v-if="view.type == 'host'" id="hosts">
|
||||
<Host :host="viewObj" :token="session.token" :state="state" />
|
||||
</div>
|
||||
|
||||
<div v-if="view.type == 'actions' && view.name == 'admin'">
|
||||
<h3>Config</h3>
|
||||
<form @submit="uploadConfig">
|
||||
<input type="file" ref="configUpload" required />
|
||||
<input type="submit" value="upload config" />
|
||||
</form>
|
||||
|
||||
<h3>Store</h3>
|
||||
<p><a :href="'/public/store.tar?token='+state.Store.DownloadToken" target="_blank">Download</a></p>
|
||||
<form @submit="storeAddKey" action="/store/add-key">
|
||||
<p>Add an unlock phrase:</p>
|
||||
<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.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
|
||||
</div>
|
||||
<input type="submit" value="add unlock phrase" :disabled="!((forms.store.pass1 && forms.store.pass1 == forms.store.pass2) || forms.store.hash)" />
|
||||
</form>
|
||||
<form @submit="storeDelKey" action="/store/delete-key">
|
||||
<p>Remove an unlock phrase:</p>
|
||||
<input type="text" v-model="forms.delKey.name" name="name" required placeholder="Name" />
|
||||
<input type="submit" value="remove unlock phrase" />
|
||||
|
||||
<p v-if="state.Store.KeyNames">Available names:
|
||||
<template v-for="k,i in state.Store.KeyNames">{{i?", ":""}}<code @click="forms.delKey.name=k">{{k}}</code></template>.</p>
|
||||
</form>
|
||||
|
||||
<template v-if="any(state.HostTemplates) || any(hostsFromTemplate)">
|
||||
<h3>Hosts from template</h3>
|
||||
<form @submit="hostFromTemplateAdd" action="" v-if="any(state.HostTemplates)">
|
||||
<p>Add a host from template instance:</p>
|
||||
<input type="text" v-model="forms.hostFromTemplate.name" required placeholder="Name" />
|
||||
<select v-model="forms.hostFromTemplate.Template" required>
|
||||
<option v-for="name in state.HostTemplates" :value="name">{{name}}</option>
|
||||
</select>
|
||||
<input type="text" v-model="forms.hostFromTemplate.IP" required placeholder="IP" />
|
||||
<input type="submit" value="add instance" />
|
||||
</form>
|
||||
<form @submit="hostFromTemplateDel" action="" v-if="any(hostsFromTemplate)">
|
||||
<p>Remove a host from template instance:</p>
|
||||
<select v-model="forms.hostFromTemplateDel" required>
|
||||
<option v-for="h in hostsFromTemplate" :value="h.Name">{{h.Name}}</option>
|
||||
</select>
|
||||
<input type="submit" value="delete instance" :disabled="!forms.hostFromTemplateDel" />
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
-197
@@ -1,197 +0,0 @@
|
||||
:root {
|
||||
--bg: #eee;
|
||||
--color: black;
|
||||
--bevel-dark: darkgray;
|
||||
--bevel-light: lightgray;
|
||||
--link: blue;
|
||||
--input-bg: #ddd;
|
||||
--input-text: white;
|
||||
--btn-bg: #eee;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: black;
|
||||
--color: orange;
|
||||
--bevel-dark: #402900;
|
||||
--bevel-light: #805300;
|
||||
--link: #31b0fa;
|
||||
--input-bg: #111;
|
||||
--input-text: #ddd;
|
||||
--btn-bg: #222;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
a[href], a[href]:visited, button.link {
|
||||
border: none;
|
||||
color: var(--link);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border-left: dotted 1pt;
|
||||
border-right: dotted 1pt;
|
||||
border-bottom: dotted 1pt;
|
||||
padding: 2pt 4pt;
|
||||
}
|
||||
tr:first-child {
|
||||
th, td {
|
||||
border-top: dotted 1pt;
|
||||
}
|
||||
}
|
||||
th, tr:last-child > td {
|
||||
border-bottom: solid 1pt;
|
||||
}
|
||||
|
||||
.flat > * { margin-left: 1ex; }
|
||||
.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 {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: rgba(255,0,0,0.2);
|
||||
border: 1pt solid red;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.error .btn-close,
|
||||
.error .code {
|
||||
background: #600;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
align-self: stretch;
|
||||
padding: 1ex 1em;
|
||||
}
|
||||
.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 {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
.notif > div:first-child {
|
||||
position: absolute;
|
||||
min-width: 100%; height: 100%;
|
||||
background: white;
|
||||
opacity: 75%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.links > * { margin-left: 1ex; }
|
||||
.links > *:first-child { margin-left: 0; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.notif > div:first-child {
|
||||
background: black;
|
||||
}
|
||||
}
|
||||
|
||||
.copy { font-size: small; }
|
||||
Reference in New Issue
Block a user