Compare commits

...

16 Commits

52 changed files with 19645 additions and 393 deletions

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
*.sw[po] *.sw[po]
modd-local.conf modd-local.conf
/tmp
/go.work
/dist

View File

@ -1,5 +1,5 @@
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
from mcluseau/golang-builder:1.17.3 as build from mcluseau/golang-builder:1.19.4 as build
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
from debian:stretch from debian:stretch
@ -7,7 +7,7 @@ entrypoint ["/bin/dkl-local-server"]
env _uncache 1 env _uncache 1
run apt-get update \ run apt-get update \
&& apt-get install -y genisoimage gdisk dosfstools util-linux udev \ && apt-get install -y genisoimage gdisk dosfstools util-linux udev binutils systemd \
&& apt-get clean && apt-get clean
run yes |apt-get install -y grub2 grub-pc-bin grub-efi-amd64-bin \ run yes |apt-get install -y grub2 grub-pc-bin grub-efi-amd64-bin \

View File

@ -6,9 +6,10 @@ import (
"os" "os"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.nc/direktil/pkg/localconfig"
"novit.nc/direktil/local-server/pkg/clustersconfig" "novit.tech/direktil/pkg/localconfig"
"novit.tech/direktil/local-server/pkg/clustersconfig"
) )
var ( var (
@ -44,7 +45,6 @@ func main() {
dst.Clusters = append(dst.Clusters, &localconfig.Cluster{ dst.Clusters = append(dst.Clusters, &localconfig.Cluster{
Name: cluster.Name, Name: cluster.Name,
Addons: renderAddons(cluster), Addons: renderAddons(cluster),
BootstrapPods: renderBootstrapPodsDS(cluster),
}) })
} }
@ -92,6 +92,7 @@ func main() {
Initrd: ctx.Group.Initrd, Initrd: ctx.Group.Initrd,
Versions: ctx.Group.Versions, Versions: ctx.Group.Versions,
BootstrapConfig: ctx.BootstrapConfig(),
Config: ctx.Config(), Config: ctx.Config(),
}) })
} }

View File

@ -3,13 +3,10 @@ package main
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io"
"log" "log"
"path" "path"
yaml "gopkg.in/yaml.v2" "novit.tech/direktil/local-server/pkg/clustersconfig"
"novit.nc/direktil/local-server/pkg/clustersconfig"
) )
func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]interface{} { func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]interface{} {
@ -117,106 +114,3 @@ type namePod struct {
Name string Name string
Pod map[string]interface{} Pod map[string]interface{}
} }
func renderBootstrapPods(cluster *clustersconfig.Cluster) (pods []namePod) {
if cluster.BootstrapPods == "" {
return nil
}
bootstrapPods := src.BootstrapPods[cluster.BootstrapPods]
if bootstrapPods == nil {
log.Fatalf("no bootstrap pods template named %q", cluster.BootstrapPods)
}
// render bootstrap pods
parts := bytes.Split(renderClusterTemplates(cluster, "bootstrap-pods", bootstrapPods), []byte("\n---\n"))
for _, part := range parts {
buf := bytes.NewBuffer(part)
dec := yaml.NewDecoder(buf)
for n := 0; ; n++ {
str := buf.String()
podMap := map[string]interface{}{}
err := dec.Decode(podMap)
if err == io.EOF {
break
} else if err != nil {
log.Fatalf("bootstrap pod %d: failed to parse: %v\n%s", n, err, str)
}
if len(podMap) == 0 {
continue
}
if podMap["metadata"] == nil {
log.Fatalf("bootstrap pod %d: no metadata\n%s", n, buf.String())
}
md := podMap["metadata"].(map[interface{}]interface{})
namespace := md["namespace"].(string)
name := md["name"].(string)
pods = append(pods, namePod{namespace, name, podMap})
}
}
return
}
func renderBootstrapPodsDS(cluster *clustersconfig.Cluster) string {
buf := &bytes.Buffer{}
enc := yaml.NewEncoder(buf)
for _, namePod := range renderBootstrapPods(cluster) {
pod := namePod.Pod
md := pod["metadata"].(map[interface{}]interface{})
labels := md["labels"]
if labels == nil {
labels = map[string]interface{}{
"app": namePod.Name,
}
md["labels"] = labels
}
ann := md["annotations"]
annotations := map[interface{}]interface{}{}
if ann != nil {
annotations = ann.(map[interface{}]interface{})
}
annotations["node.kubernetes.io/bootstrap-checkpoint"] = "true"
md["annotations"] = annotations
delete(md, "name")
delete(md, "namespace")
err := enc.Encode(map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "DaemonSet",
"metadata": map[string]interface{}{
"namespace": namePod.Namespace,
"name": namePod.Name,
"labels": labels,
},
"spec": map[string]interface{}{
"minReadySeconds": 60,
"selector": map[string]interface{}{
"matchLabels": labels,
},
"template": map[string]interface{}{
"metadata": pod["metadata"],
"spec": pod["spec"],
},
},
})
if err != nil {
panic(err)
}
}
return buf.String()
}

View File

@ -5,14 +5,17 @@ import (
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io"
"log" "log"
"path" "path"
"reflect" "reflect"
"strings"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.nc/direktil/local-server/pkg/clustersconfig" "novit.tech/direktil/pkg/config"
"novit.nc/direktil/pkg/config"
"novit.tech/direktil/local-server/pkg/clustersconfig"
) )
type renderContext struct { type renderContext struct {
@ -23,6 +26,8 @@ type renderContext struct {
Group *clustersconfig.Group Group *clustersconfig.Group
Cluster *clustersconfig.Cluster Cluster *clustersconfig.Cluster
Vars map[string]interface{} Vars map[string]interface{}
BootstrapConfigTemplate *clustersconfig.Template
ConfigTemplate *clustersconfig.Template ConfigTemplate *clustersconfig.Template
StaticPodsTemplate *clustersconfig.Template StaticPodsTemplate *clustersconfig.Template
@ -60,6 +65,8 @@ func newRenderContext(host *clustersconfig.Host, cfg *clustersconfig.Config) (ct
Group: group, Group: group,
Cluster: cluster, Cluster: cluster,
Vars: vars, Vars: vars,
BootstrapConfigTemplate: cfg.ConfigTemplate(group.BootstrapConfig),
ConfigTemplate: cfg.ConfigTemplate(group.Config), ConfigTemplate: cfg.ConfigTemplate(group.Config),
StaticPodsTemplate: cfg.StaticPodsTemplate(group.StaticPods), StaticPodsTemplate: cfg.StaticPodsTemplate(group.StaticPods),
@ -134,11 +141,27 @@ func (ctx *renderContext) Name() string {
} }
} }
func (ctx *renderContext) BootstrapConfig() string {
if ctx.BootstrapConfigTemplate == nil {
log.Fatalf("no such (bootstrap) config: %q", ctx.Group.BootstrapConfig)
}
return ctx.renderConfig(ctx.BootstrapConfigTemplate)
}
func (ctx *renderContext) Config() string { func (ctx *renderContext) Config() string {
if ctx.ConfigTemplate == nil { if ctx.ConfigTemplate == nil {
log.Fatalf("no such config: %q", ctx.Group.Config) log.Fatalf("no such config: %q", ctx.Group.Config)
} }
return ctx.renderConfig(ctx.ConfigTemplate)
}
func (ctx *renderContext) renderConfig(configTemplate *clustersconfig.Template) string {
buf := new(strings.Builder)
ctx.renderConfigTo(buf, configTemplate)
return buf.String()
}
func (ctx *renderContext) renderConfigTo(buf io.Writer, configTemplate *clustersconfig.Template) {
ctxName := ctx.Name() ctxName := ctx.Name()
ctxMap := ctx.asMap() ctxMap := ctx.asMap()
@ -174,7 +197,7 @@ func (ctx *renderContext) Config() string {
} }
extraFuncs["bootstrap_pods_files"] = func(dir string) (string, error) { extraFuncs["bootstrap_pods_files"] = func(dir string) (string, error) {
namePods := renderBootstrapPods(ctx.Cluster) namePods := ctx.renderBootstrapPods()
defs := make([]config.FileDef, 0) defs := make([]config.FileDef, 0)
@ -202,12 +225,9 @@ func (ctx *renderContext) Config() string {
return hex.EncodeToString(ba[:]) return hex.EncodeToString(ba[:])
} }
buf := bytes.NewBuffer(make([]byte, 0, 4096)) if err := configTemplate.Execute(ctxName, "config", buf, ctxMap, extraFuncs); err != nil {
if err := ctx.ConfigTemplate.Execute(ctxName, "config", buf, ctxMap, extraFuncs); err != nil {
log.Fatalf("failed to render config %q for host %q: %v", ctx.Group.Config, ctx.Host.Name, err) log.Fatalf("failed to render config %q for host %q: %v", ctx.Group.Config, ctx.Host.Name, err)
} }
return buf.String()
} }
func (ctx *renderContext) StaticPods() (ba []byte, err error) { func (ctx *renderContext) StaticPods() (ba []byte, err error) {

View File

@ -0,0 +1,77 @@
package main
import (
"bytes"
"fmt"
"io"
"log"
yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/local-server/pkg/clustersconfig"
)
func (ctx *renderContext) renderBootstrapPods() (pods []namePod) {
if ctx.Cluster.BootstrapPods == "" {
return
}
bootstrapPods, ok := src.BootstrapPods[ctx.Cluster.BootstrapPods]
if !ok {
log.Fatalf("no bootstrap pods template named %q", ctx.Cluster.BootstrapPods)
}
// render bootstrap pods
parts := bytes.Split(ctx.renderHostTemplates("bootstrap-pods", bootstrapPods), []byte("\n---\n"))
for _, part := range parts {
buf := bytes.NewBuffer(part)
dec := yaml.NewDecoder(buf)
for n := 0; ; n++ {
str := buf.String()
podMap := map[string]interface{}{}
err := dec.Decode(podMap)
if err == io.EOF {
break
} else if err != nil {
log.Fatalf("bootstrap pod %d: failed to parse: %v\n%s", n, err, str)
}
if len(podMap) == 0 {
continue
}
if podMap["metadata"] == nil {
log.Fatalf("bootstrap pod %d: no metadata\n%s", n, buf.String())
}
md := podMap["metadata"].(map[interface{}]interface{})
namespace := md["namespace"].(string)
name := md["name"].(string)
pods = append(pods, namePod{namespace, name, podMap})
}
}
return
}
func (ctx *renderContext) renderHostTemplates(setName string,
templates []*clustersconfig.Template) []byte {
log.Print("rendering host templates in ", setName)
buf := &bytes.Buffer{}
for _, t := range templates {
log.Print("- template: ", setName, ": ", t.Name)
fmt.Fprintf(buf, "---\n# %s: %s\n", setName, t.Name)
ctx.renderConfigTo(buf, t)
fmt.Fprintln(buf)
}
return buf.Bytes()
}

View File

@ -26,11 +26,32 @@ func authorizeToken(r *http.Request, token string) bool {
} }
reqToken := r.Header.Get("Authorization") reqToken := r.Header.Get("Authorization")
if reqToken != "" {
return reqToken == "Bearer "+token return reqToken == "Bearer "+token
}
return r.URL.Query().Get("token") == token
} }
func forbidden(w http.ResponseWriter, r *http.Request) { func forbidden(w http.ResponseWriter, r *http.Request) {
log.Printf("denied access to %s from %s", r.RequestURI, r.RemoteAddr) log.Printf("denied access to %s from %s", r.RequestURI, r.RemoteAddr)
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
} }
func requireToken(token string, handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if !authorizeToken(req, token) {
forbidden(w, req)
return
}
handler.ServeHTTP(w, req)
})
}
func requireAdmin(handler http.Handler) http.Handler {
return requireToken(*adminToken, handler)
}
func requireHosts(handler http.Handler) http.Handler {
return requireToken(*hostsToken, handler)
}

View File

@ -3,6 +3,7 @@ package main
import ( import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"flag"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@ -56,8 +57,10 @@ func buildBootImgGZ(out io.Writer, ctx *renderContext) (err error) {
return return
} }
var grubSupportVersion = flag.String("grub-support", "1.0.1", "GRUB support version")
func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) { func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
path, err := ctx.distFetch("grub-support", "1.0.0") path, err := ctx.distFetch("grub-support", *grubSupportVersion)
if err != nil { if err != nil {
return return
} }

View File

@ -8,8 +8,12 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"github.com/cespare/xxhash"
) )
// deprecated
func buildBootISO(out io.Writer, ctx *renderContext) error { func buildBootISO(out io.Writer, ctx *renderContext) error {
tempDir, err := ioutil.TempDir("/tmp", "iso-") tempDir, err := ioutil.TempDir("/tmp", "iso-")
if err != nil { if err != nil {
@ -189,3 +193,159 @@ menuentry "Direktil" {
return cmd.Run() return cmd.Run()
} }
func buildBootISOv2(out io.Writer, ctx *renderContext) (err error) {
tempDir, err := ioutil.TempDir("/tmp", "iso-v2-")
if err != nil {
return
}
defer os.RemoveAll(tempDir)
buildRes := func(build func(out io.Writer, ctx *renderContext) error, dst string) (err error) {
log.Printf("iso-v2: building %s", dst)
outPath := filepath.Join(tempDir, dst)
if err = os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
err = build(out, ctx)
if err != nil {
return
}
return err
}
err = func() (err error) {
// grub
if err = os.MkdirAll(filepath.Join(tempDir, "grub"), 0755); err != nil {
return
}
// create a tag file
bootstrapBytes, _, err := ctx.BootstrapConfig()
if err != nil {
return
}
h := xxhash.New()
fmt.Fprintln(h, ctx.Host.Kernel)
h.Write(bootstrapBytes)
tag := "dkl-" + strconv.FormatUint(h.Sum64(), 32) + ".tag"
f, err := os.Create(filepath.Join(tempDir, tag))
if err != nil {
return
}
f.Write([]byte("direktil marker file\n"))
f.Close()
err = ioutil.WriteFile(filepath.Join(tempDir, "grub", "grub.cfg"), []byte(`
search --set=root --file /`+tag+`
insmod all_video
set timeout=3
menuentry "Direktil" {
linux /vmlinuz `+ctx.CmdLine+`
initrd /initrd
}
`), 0644)
if err != nil {
return
}
coreImgPath := filepath.Join(tempDir, "grub", "core.img")
grubCfgPath := filepath.Join(tempDir, "grub", "grub.cfg")
cmd := exec.Command("grub-mkstandalone",
"--format=i386-pc",
"--output="+coreImgPath,
"--install-modules=linux normal iso9660 biosdisk memdisk search tar ls",
"--modules=linux normal iso9660 biosdisk search",
"--locales=",
"--fonts=",
"boot/grub/grub.cfg="+grubCfgPath,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return err
}
defer os.Remove(coreImgPath)
defer os.Remove(grubCfgPath)
out, err := os.Create(filepath.Join(tempDir, "grub", "bios.img"))
if err != nil {
return err
}
defer out.Close()
b, err := ioutil.ReadFile("/usr/lib/grub/i386-pc/cdboot.img")
if err != nil {
return err
}
if _, err := out.Write(b); err != nil {
return err
}
b, err = ioutil.ReadFile(coreImgPath)
if err != nil {
return err
}
if _, err := out.Write(b); err != nil {
return err
}
return nil
}()
if err != nil {
return err
}
// kernel and initrd
buildRes(fetchKernel, "vmlinuz")
buildRes(buildInitrdV2, "initrd")
// build the ISO
mkisofs, err := exec.LookPath("genisoimage")
if err != nil {
mkisofs, err = exec.LookPath("mkisofs")
}
if err != nil {
return err
}
cmd := exec.Command(mkisofs,
"-quiet",
"-joliet",
"-joliet-long",
"-rock",
"-translation-table",
"-no-emul-boot",
"-boot-load-size", "4",
"-boot-info-table",
"-eltorito-boot", "grub/bios.img",
"-eltorito-catalog", "grub/boot.cat",
tempDir,
)
cmd.Stdout = out
cmd.Stderr = os.Stderr
return cmd.Run()
}

View File

@ -2,11 +2,13 @@ package main
import ( import (
"archive/tar" "archive/tar"
"fmt" "bytes"
"io" "io"
"io/ioutil"
"log" "log"
"os" "os"
"path/filepath"
"novit.tech/direktil/local-server/pkg/utf16"
) )
func rmTempFile(f *os.File) { func rmTempFile(f *os.File) {
@ -21,7 +23,7 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
defer arch.Close() defer arch.Close()
archAdd := func(path string, ba []byte) (err error) { archAdd := func(path string, ba []byte) (err error) {
err = arch.WriteHeader(&tar.Header{Name: path, Size: int64(len(ba))}) err = arch.WriteHeader(&tar.Header{Name: path, Mode: 0640, Size: int64(len(ba))})
if err != nil { if err != nil {
return return
} }
@ -29,70 +31,95 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
return return
} }
// config // kernel
cfgBytes, cfg, err := ctx.Config() kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
if err != nil { if err != nil {
return err return
} }
archAdd("config.yaml", cfgBytes) kernelBytes, err := ioutil.ReadFile(kernelPath)
// add "current" elements
type distCopy struct {
Src []string
Dst string
}
// kernel and initrd
copies := []distCopy{
{Src: []string{"kernels", ctx.Host.Kernel}, Dst: "current/vmlinuz"},
{Src: []string{"initrd", ctx.Host.Initrd}, Dst: "current/initrd"},
}
// layers
for _, layer := range cfg.Layers {
layerVersion := ctx.Host.Versions[layer]
if layerVersion == "" {
return fmt.Errorf("layer %q not mapped to a version", layer)
}
copies = append(copies,
distCopy{
Src: []string{"layers", layer, layerVersion},
Dst: filepath.Join("current", "layers", layer+".fs"),
})
}
for _, copy := range copies {
outPath, err := ctx.distFetch(copy.Src...)
if err != nil { if err != nil {
return err return
} }
f, err := os.Open(outPath) err = archAdd("current/vmlinuz", kernelBytes)
if err != nil { if err != nil {
return err return
} }
defer f.Close() // initrd
initrd := new(bytes.Buffer)
stat, err := f.Stat() err = buildInitrdV2(initrd, ctx)
if err != nil { if err != nil {
return err return
} }
if err = arch.WriteHeader(&tar.Header{ err = archAdd("current/initrd", initrd.Bytes())
Name: copy.Dst,
Size: stat.Size(),
}); err != nil {
return err
}
_, err = io.Copy(arch, f)
if err != nil { if err != nil {
return err return
}
} }
// done
return nil
}
func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) {
arch := tar.NewWriter(out)
defer arch.Close()
archAdd := func(path string, ba []byte) (err error) {
err = arch.WriteHeader(&tar.Header{Name: path, Mode: 0640, Size: int64(len(ba))})
if err != nil {
return
}
_, err = arch.Write(ba)
return
}
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
}
// kernel
kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return
}
kernelBytes, err := ioutil.ReadFile(kernelPath)
if err != nil {
return
}
err = archAdd(prefix+"current_kernel.efi", kernelBytes)
if err != nil {
return
}
// initrd
initrd := new(bytes.Buffer)
err = buildInitrdV2(initrd, ctx)
if err != nil {
return
}
err = archAdd(prefix+"current_initrd.img", initrd.Bytes())
if err != nil {
return
}
// done
return nil return nil
} }

View File

@ -0,0 +1,143 @@
package main
import (
"archive/tar"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/cpiocat"
)
var initrdV2 = flag.String("initrd-v2", "2.1.0", "initrd V2 version (temporary flag)") // FIXME
func renderBootstrapConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) {
log.Printf("sending bootstrap config for %q", ctx.Host.Name)
_, cfg, err := ctx.BootstrapConfig()
if err != nil {
return err
}
if asJson {
err = json.NewEncoder(w).Encode(cfg)
} else {
err = yaml.NewEncoder(w).Encode(cfg)
}
return nil
}
func buildInitrdV2(out io.Writer, ctx *renderContext) (err error) {
_, cfg, err := ctx.Config()
if err != nil {
return
}
cat := cpiocat.New(out)
// initrd
initrdPath, err := ctx.distFetch("initrd", *initrdV2)
if err != nil {
return
}
cat.AppendArchFile(initrdPath)
// embedded layers (modules)
for _, layer := range cfg.Layers {
switch layer {
case "modules":
layerVersion := ctx.Host.Versions[layer]
modulesPath, err := ctx.distFetch("layers", layer, layerVersion)
if err != nil {
return err
}
cat.AppendFile(modulesPath, "modules.sqfs")
}
}
// config
cfgBytes, _, err := ctx.BootstrapConfig()
if err != nil {
return
}
cat.AppendBytes(cfgBytes, "config.yaml", 0600)
// ssh keys
// FIXME we want a bootstrap-stage key instead of the real host key
cat.AppendBytes(cfg.FileContent("/etc/ssh/ssh_host_rsa_key"), "id_rsa", 0600)
return cat.Close()
}
func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
arch := tar.NewWriter(out)
defer arch.Close()
// config
cfgBytes, cfg, err := ctx.Config()
if err != nil {
return err
}
err = arch.WriteHeader(&tar.Header{Name: "config.yaml", Size: int64(len(cfgBytes))})
if err != nil {
return
}
_, err = arch.Write(cfgBytes)
if err != nil {
return
}
// layers
for _, layer := range cfg.Layers {
if layer == "modules" {
continue // modules are with the kernel in boot v2
}
layerVersion := ctx.Host.Versions[layer]
if layerVersion == "" {
return fmt.Errorf("layer %q not mapped to a version", layer)
}
outPath, err := ctx.distFetch("layers", layer, layerVersion)
if err != nil {
return err
}
f, err := os.Open(outPath)
if err != nil {
return err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return err
}
if err = arch.WriteHeader(&tar.Header{
Name: layer + ".fs",
Size: stat.Size(),
}); err != nil {
return err
}
_, err = io.Copy(arch, f)
if err != nil {
return err
}
}
return nil
}

View File

@ -8,7 +8,8 @@ import (
"github.com/cloudflare/cfssl/csr" "github.com/cloudflare/cfssl/csr"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.nc/direktil/pkg/config"
"novit.tech/direktil/pkg/config"
) )
var templateFuncs = map[string]interface{}{ var templateFuncs = map[string]interface{}{
@ -146,7 +147,7 @@ var templateFuncs = map[string]interface{}{
func getKeyCert(cluster, caName, name, profile, label, reqJson string) (kc *KeyCert, err error) { func getKeyCert(cluster, caName, name, profile, label, reqJson string) (kc *KeyCert, err error) {
certReq := &csr.CertificateRequest{ certReq := &csr.CertificateRequest{
KeyRequest: csr.NewBasicKeyRequest(), KeyRequest: csr.NewKeyRequest(),
} }
err = json.Unmarshal([]byte(reqJson), certReq) err = json.Unmarshal([]byte(reqJson), certReq)

View File

@ -4,7 +4,7 @@ import (
"flag" "flag"
"path/filepath" "path/filepath"
"novit.nc/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
) )
var ( var (

View File

@ -8,7 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
cpio "github.com/cavaliercoder/go-cpio" cpio "github.com/cavaliergopher/cpio"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
) )
@ -58,7 +58,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) error {
} { } {
archive.WriteHeader(&cpio.Header{ archive.WriteHeader(&cpio.Header{
Name: dir, Name: dir,
Mode: 0600 | cpio.ModeDir, Mode: cpio.FileMode(0600 | os.ModeDir),
}) })
} }

View File

@ -1,8 +1,10 @@
package main package main
import ( import (
"io"
"log" "log"
"net/http" "net/http"
"os"
) )
func renderKernel(w http.ResponseWriter, r *http.Request, ctx *renderContext) error { func renderKernel(w http.ResponseWriter, r *http.Request, ctx *renderContext) error {
@ -15,3 +17,19 @@ func renderKernel(w http.ResponseWriter, r *http.Request, ctx *renderContext) er
http.ServeFile(w, r, path) http.ServeFile(w, r, path)
return nil return nil
} }
func fetchKernel(out io.Writer, ctx *renderContext) (err error) {
path, err := ctx.distFetch("kernels", ctx.Host.Kernel)
if err != nil {
return err
}
in, err := os.Open(path)
if err != nil {
return
}
defer in.Close()
_, err = io.Copy(out, in)
return
}

View File

@ -4,13 +4,17 @@ import (
"flag" "flag"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
swaggerui "github.com/mcluseau/go-swagger-ui" swaggerui "github.com/mcluseau/go-swagger-ui"
"novit.nc/direktil/pkg/cas" "m.cluseau.fr/go/watchable/streamsse"
"novit.nc/direktil/local-server/pkg/apiutils" "novit.tech/direktil/pkg/cas"
dlshtml "novit.tech/direktil/local-server/html"
"novit.tech/direktil/local-server/pkg/apiutils"
) )
const ( const (
@ -23,6 +27,8 @@ var (
certFile = flag.String("tls-cert", etcDir+"/server.crt", "Server TLS certificate") certFile = flag.String("tls-cert", etcDir+"/server.crt", "Server TLS certificate")
keyFile = flag.String("tls-key", etcDir+"/server.key", "Server TLS key") keyFile = flag.String("tls-key", etcDir+"/server.key", "Server TLS key")
autoUnlock = flag.String("auto-unlock", "", "Auto-unlock store (testing only!)")
casStore cas.Store casStore cas.Store
) )
@ -35,6 +41,28 @@ func main() {
log.Fatal("no listen address given") log.Fatal("no listen address given")
} }
computeUIHash()
openSecretStore()
{
autoUnlock := *autoUnlock
if autoUnlock == "" {
autoUnlock = os.Getenv("DLS_AUTO_UNLOCK")
}
if autoUnlock != "" {
log.Printf("auto-unlocking the store")
err := unlockSecretStore([]byte(autoUnlock))
if err != nil {
log.Fatal(err)
}
log.Print("store auto-unlocked, admin token is ", *adminToken)
}
os.Setenv("DLS_AUTO_UNLOCK", "")
}
casStore = cas.NewDir(filepath.Join(*dataDir, "cache")) casStore = cas.NewDir(filepath.Join(*dataDir, "cache"))
go casCleaner() go casCleaner()
@ -44,6 +72,13 @@ func main() {
swaggerui.HandleAt("/swagger-ui/") swaggerui.HandleAt("/swagger-ui/")
staticHandler := http.FileServer(http.FS(dlshtml.FS))
http.Handle("/favicon.ico", staticHandler)
http.Handle("/ui/", staticHandler)
http.Handle("/public-state", streamsse.StreamHandler(wPublicState))
http.Handle("/state", requireAdmin(streamsse.StreamHandler(wState)))
if *address != "" { if *address != "" {
log.Print("HTTP listening on ", *address) log.Print("HTTP listening on ", *address)
go log.Fatal(http.ListenAndServe(*address, nil)) go log.Fatal(http.ListenAndServe(*address, nil))

View File

@ -15,8 +15,10 @@ import (
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.nc/direktil/pkg/config" "novit.tech/direktil/pkg/config"
"novit.nc/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
bsconfig "novit.tech/direktil/pkg/bootstrapconfig"
) )
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition") var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
@ -89,9 +91,37 @@ func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *ren
} }
func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) { func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
ba, err = ctx.render(ctx.Host.Config)
if err != nil {
return
}
cfg = &config.Config{}
if err = yaml.Unmarshal(ba, cfg); err != nil {
return
}
return
}
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 {
return
}
return
}
func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
tmpl, err := template.New(ctx.Host.Name + "/config"). tmpl, err := template.New(ctx.Host.Name + "/config").
Funcs(templateFuncs). Funcs(templateFuncs).
Parse(ctx.Host.Config) Parse(templateText)
if err != nil { if err != nil {
return return
@ -110,13 +140,6 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
} }
ba = buf.Bytes() ba = buf.Bytes()
cfg = &config.Config{}
if err = yaml.Unmarshal(buf.Bytes(), cfg); err != nil {
return
}
return return
} }

View File

@ -0,0 +1,171 @@
package main
import (
"crypto/rand"
"encoding/base32"
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"m.cluseau.fr/go/httperr"
"novit.tech/direktil/local-server/secretstore"
)
var secStore *secretstore.Store
func secStorePath(name string) string { return filepath.Join(*dataDir, "secrets", name) }
func secKeysStorePath() string { return secStorePath(".keys") }
func openSecretStore() {
var err error
keysPath := secKeysStorePath()
if err := os.MkdirAll(filepath.Dir(filepath.Dir(keysPath)), 0755); err != nil {
log.Fatal("failed to create dirs: ", err)
}
if err := os.MkdirAll(filepath.Dir(keysPath), 0700); err != nil {
log.Fatal("failed to secret store dir: ", err)
}
secStore, err = secretstore.Open(keysPath)
switch {
case err == nil:
wPublicState.Change(func(v *PublicState) {
v.Store.New = false
v.Store.Open = false
})
case os.IsNotExist(err):
secStore = secretstore.New()
wPublicState.Change(func(v *PublicState) {
v.Store.New = true
v.Store.Open = false
})
default:
log.Fatal("failed to open keys store: ", err)
}
}
var (
unlockMutex = sync.Mutex{}
ErrStoreAlreadyUnlocked = httperr.NewStd(http.StatusConflict, 1, "store already unlocked")
ErrInvalidPassphrase = httperr.NewStd(http.StatusBadRequest, 2, "invalid passphrase")
)
func unlockSecretStore(passphrase []byte) *httperr.Error {
unlockMutex.Lock()
defer unlockMutex.Unlock()
if secStore.Unlocked() {
return ErrStoreAlreadyUnlocked
}
if secStore.IsNew() {
err := secStore.Init(passphrase)
if err != nil {
return httperr.New(http.StatusInternalServerError, err)
}
err = secStore.SaveTo(secKeysStorePath())
if err != nil {
log.Print("secret store save error: ", err)
secStore.Close()
return httperr.New(http.StatusInternalServerError, err)
}
} else {
if !secStore.Unlock([]byte(passphrase)) {
return ErrInvalidPassphrase
}
}
token := ""
if err := readSecret("admin-token", &token); err != nil {
if !os.IsNotExist(err) {
log.Print("failed to read admin token: ", err)
secStore.Close()
return httperr.New(http.StatusInternalServerError, err)
}
randBytes := make([]byte, 32)
_, err := rand.Read(randBytes)
if err != nil {
log.Print("rand read error: ", err)
secStore.Close()
return httperr.New(http.StatusInternalServerError, err)
}
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randBytes)
err = writeSecret("admin-token", token)
if err != nil {
log.Print("write error: ", err)
secStore.Close()
return httperr.New(http.StatusInternalServerError, err)
}
log.Print("wrote new admin token")
}
*adminToken = token
wPublicState.Change(func(v *PublicState) {
v.Store.New = false
v.Store.Open = true
})
go updateState()
return nil
}
func readSecret(name string, value any) (err error) {
f, err := os.Open(secStorePath(name + ".data"))
if err != nil {
return
}
defer f.Close()
in, err := secStore.NewReader(f)
if err != nil {
return
}
return json.NewDecoder(in).Decode(value)
}
func writeSecret(name string, value any) (err error) {
f, err := os.Create(secStorePath(name + ".data.new"))
if err != nil {
return
}
err = func() (err error) {
defer f.Close()
out, err := secStore.NewWriter(f)
if err != nil {
return
}
return json.NewEncoder(out).Encode(value)
}()
if err != nil {
return
}
return os.Rename(f.Name(), secStorePath(name+".data"))
}

View File

@ -3,7 +3,6 @@ package main
import ( import (
"crypto" "crypto"
"crypto/rand" "crypto/rand"
"crypto/x509"
"encoding/base32" "encoding/base32"
"encoding/json" "encoding/json"
"errors" "errors"
@ -221,19 +220,13 @@ func (sd *SecretData) RenewCACert(cluster, name string) (err error) {
ca := cs.CAs[name] ca := cs.CAs[name]
var cert *x509.Certificate
cert, err = helpers.ParseCertificatePEM(ca.Cert)
if err != nil {
return
}
var signer crypto.Signer var signer crypto.Signer
signer, err = helpers.ParsePrivateKeyPEM(ca.Key) signer, err = helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil { if err != nil {
return return
} }
newCert, err := initca.RenewFromSigner(cert, signer) newCert, _, err := initca.NewFromSigner(newCACertReq(), signer)
if err != nil { if err != nil {
return return
} }
@ -247,29 +240,10 @@ func (sd *SecretData) RenewCACert(cluster, name string) (err error) {
return return
} }
func (sd *SecretData) CA(cluster, name string) (ca *CA, err error) { func newCACertReq() *csr.CertificateRequest {
cs := sd.cluster(cluster) return &csr.CertificateRequest{
ca, ok := cs.CAs[name]
if ok {
checkErr := checkCertUsable(ca.Cert)
if checkErr != nil {
log.Infof("secret-data cluster %s: CA %s: regenerating certificate: %v", cluster, name, checkErr)
err = sd.RenewCACert(cluster, name)
}
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new CA in cluster ", cluster, ": ", name)
req := &csr.CertificateRequest{
CN: "Direktil Local Server", CN: "Direktil Local Server",
KeyRequest: &csr.BasicKeyRequest{ KeyRequest: &csr.KeyRequest{
A: "ecdsa", A: "ecdsa",
S: 521, // 256, 384, 521 S: 521, // 256, 384, 521
}, },
@ -280,9 +254,43 @@ func (sd *SecretData) CA(cluster, name string) (ca *CA, err error) {
}, },
}, },
} }
}
func (sd *SecretData) CA(cluster, name string) (ca *CA, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("cluster %s CA %s: %w", cluster, name, err)
}
}()
cs := sd.cluster(cluster)
ca, ok := cs.CAs[name]
if ok {
checkErr := checkCertUsable(ca.Cert)
if checkErr != nil {
log.Infof("secret-data cluster %s: CA %s: regenerating certificate: %v", cluster, name, checkErr)
err = sd.RenewCACert(cluster, name)
if err != nil {
err = fmt.Errorf("renew: %w", err)
}
}
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new CA in cluster ", cluster, ": ", name)
req := newCACertReq()
cert, _, key, err := initca.New(req) cert, _, key, err := initca.New(req)
if err != nil { if err != nil {
err = fmt.Errorf("initca: %w", err)
return return
} }

View File

@ -0,0 +1,92 @@
package main
import (
"log"
"m.cluseau.fr/go/watchable"
"novit.tech/direktil/pkg/localconfig"
)
type PublicState struct {
UIHash string
Store struct {
New bool
Open bool
}
}
var wPublicState = watchable.New[PublicState]()
type State struct {
HasConfig bool
Clusters []ClusterState
Hosts []HostState
Config *localconfig.Config
Downloads map[string]DownloadSpec
}
type ClusterState struct {
Name string
Addons bool
// TODO CAs
// TODO passwords
// TODO tokens
}
type HostState struct {
Name string
Cluster string
IPs []string
}
var wState = watchable.New[State]()
func init() {
wState.Set(State{Downloads: map[string]DownloadSpec{}})
}
func updateState() {
log.Print("updating state")
cfg, err := readConfig()
if err != nil {
wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil })
return
}
if secStore.IsNew() || !secStore.Unlocked() {
wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil })
return
}
// remove heavy data
clusters := make([]ClusterState, 0, len(cfg.Clusters))
for _, cluster := range cfg.Clusters {
c := ClusterState{
Name: cluster.Name,
Addons: len(cluster.Addons) != 0,
}
clusters = append(clusters, c)
}
hosts := make([]HostState, 0, len(cfg.Hosts))
for _, host := range cfg.Hosts {
h := HostState{
Name: host.Name,
Cluster: host.ClusterName,
IPs: host.IPs,
}
hosts = append(hosts, h)
}
// done
wState.Change(func(v *State) {
v.HasConfig = true
//v.Config = cfg
v.Clusters = clusters
v.Hosts = hosts
})
}

View File

@ -0,0 +1,45 @@
package main
import (
"encoding/base32"
"io"
"io/fs"
"log"
"strings"
"github.com/cespare/xxhash"
dlshtml "novit.tech/direktil/local-server/html"
)
func computeUIHash() {
xxh := xxhash.New()
err := fs.WalkDir(dlshtml.FS, "ui", func(path string, entry fs.DirEntry, walkErr error) (err error) {
if walkErr != nil {
err = walkErr
return
}
if entry.IsDir() {
return
}
f, err := dlshtml.FS.Open(path)
if err != nil {
return
}
defer f.Close()
io.Copy(xxh, f)
return nil
})
if err != nil {
log.Fatal("failed to hash UI: ", err)
}
h := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(xxh.Sum(nil)))[:5]
log.Printf("UI hash: %s", h)
wPublicState.Change(func(v *PublicState) { v.UIHash = h })
}

View File

@ -33,6 +33,10 @@ func getToken(req *restful.Request) string {
token := req.HeaderParameter("Authorization") token := req.HeaderParameter("Authorization")
if token == "" {
return req.QueryParameter("token")
}
if !strings.HasPrefix(token, bearerPrefix) { if !strings.HasPrefix(token, bearerPrefix) {
return "" return ""
} }

View File

@ -6,7 +6,7 @@ import (
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.nc/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
) )
func wsListClusters(req *restful.Request, resp *restful.Response) { func wsListClusters(req *restful.Request, resp *restful.Response) {

View File

@ -45,6 +45,8 @@ func writeNewConfig(reader io.Reader) (err error) {
} }
err = os.Rename(out.Name(), cfgPath) err = os.Rename(out.Name(), cfgPath)
updateState()
return return
} }

View File

@ -0,0 +1,150 @@
package main
import (
"crypto/rand"
"encoding/base32"
"log"
"net/http"
"strconv"
"time"
restful "github.com/emicklei/go-restful"
"m.cluseau.fr/go/cow"
)
type DownloadSpec struct {
Kind string
Name string
Assets []string
createdAt time.Time
}
func wsAuthorizeDownload(req *restful.Request, resp *restful.Response) {
var spec DownloadSpec
if err := req.ReadEntity(&spec); err != nil {
wsError(resp, err)
return
}
if spec.Kind == "" || spec.Name == "" || len(spec.Assets) == 0 {
resp.WriteErrorString(http.StatusBadRequest, "missing data")
return
}
randBytes := make([]byte, 32)
_, err := rand.Read(randBytes)
if err != nil {
wsError(resp, err)
return
}
token := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randBytes)
spec.createdAt = time.Now()
wState.Change(func(v *State) {
cow.MapSet(&v.Downloads, token, spec)
})
log.Printf("download token created for %s %q, assets %q", spec.Kind, spec.Name, spec.Assets)
resp.WriteAsJson(token)
}
func wsDownload(req *restful.Request, resp *restful.Response) {
token := req.PathParameter("token")
asset := req.PathParameter("asset")
if token == "" || asset == "" {
wsNotFound(req, resp)
return
}
var spec DownloadSpec
found := false
wState.Change(func(v *State) {
var ok bool
spec, ok = v.Downloads[token]
if !ok {
return
}
newAssets := make([]string, 0, len(spec.Assets))
for _, a := range spec.Assets {
if a == asset {
found = true
} else {
newAssets = append(newAssets, a)
}
}
if !found {
return
}
cow.Map(&v.Downloads)
if len(newAssets) == 0 {
delete(v.Downloads, token)
} else {
spec.Assets = newAssets
v.Downloads[token] = spec
}
})
if !found {
wsNotFound(req, resp)
return
}
log.Printf("download via token %q", token)
cfg, err := readConfig()
if err != nil {
wsError(resp, err)
return
}
setHeader := func(ext string) {
resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(spec.Kind+"_"+spec.Name+"_"+asset+ext))
}
switch spec.Kind {
case "cluster":
cluster := cfg.ClusterByName(spec.Name)
if cluster == nil {
wsNotFound(req, resp)
return
}
switch asset {
case "addons":
setHeader(".yaml")
resp.Write([]byte(cluster.Addons))
default:
wsNotFound(req, resp)
}
case "host":
host := cfg.Host(spec.Name)
if host == nil {
wsNotFound(req, resp)
return
}
switch asset {
case "config", "bootstrap-config":
setHeader(".yaml")
default:
setHeader("")
}
renderHost(resp.ResponseWriter, req.Request, asset, host, cfg)
default:
wsNotFound(req, resp)
}
}

View File

@ -8,8 +8,9 @@ import (
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.nc/direktil/local-server/pkg/mime" "novit.tech/direktil/pkg/localconfig"
"novit.nc/direktil/pkg/localconfig"
"novit.tech/direktil/local-server/pkg/mime"
) )
var trustXFF = flag.Bool("trust-xff", true, "Trust the X-Forwarded-For header") var trustXFF = flag.Bool("trust-xff", true, "Trust the X-Forwarded-For header")
@ -55,6 +56,9 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
b("boot.tar"). b("boot.tar").
Produces(mime.TAR). Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"), Doc("Get the " + ws.hostDoc + "'s /boot archive (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 // read-only ISO support
b("boot.iso"). b("boot.iso").
@ -74,6 +78,26 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
b("initrd"). b("initrd").
Produces(mime.OCTET). Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s initial RAM disk (ie: for netboot)"), Doc("Get the " + ws.hostDoc + "'s initial RAM disk (ie: for netboot)"),
// boot v2
// - bootstrap config
b("bootstrap-config").
Produces(mime.YAML).
Doc("Get the " + ws.hostDoc + "'s bootstrap configuration"),
b("bootstrap-config.json").
Doc("Get the " + ws.hostDoc + "'s bootstrap configuration (as JSON)"),
// - initrd
b("initrd-v2").
Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s initial RAM disk (v2)"),
// - bootstrap
b("bootstrap.tar").
Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s bootstrap seed archive"),
b("boot-v2.iso").
Produces(mime.ISO).
Param(cmdlineParam).
Doc("Get the " + ws.hostDoc + "'s boot CD-ROM image (v2)"),
} { } {
alterRB(rb) alterRB(rb)
rws.Route(rb) rws.Route(rb)
@ -151,6 +175,8 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "boot.tar": case "boot.tar":
err = renderCtx(w, r, ctx, what, buildBootTar) err = renderCtx(w, r, ctx, what, buildBootTar)
case "boot-efi.tar":
err = renderCtx(w, r, ctx, what, buildBootEFITar)
case "boot.img": case "boot.img":
err = renderCtx(w, r, ctx, what, buildBootImg) err = renderCtx(w, r, ctx, what, buildBootImg)
@ -161,6 +187,18 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "boot.img.lz4": case "boot.img.lz4":
err = renderCtx(w, r, ctx, what, buildBootImgLZ4) err = renderCtx(w, r, ctx, what, buildBootImgLZ4)
// boot v2
case "bootstrap-config":
err = renderBootstrapConfig(w, r, ctx, false)
case "bootstrap-config.json":
err = renderBootstrapConfig(w, r, ctx, true)
case "initrd-v2":
err = renderCtx(w, r, ctx, what, buildInitrdV2)
case "bootstrap.tar":
err = renderCtx(w, r, ctx, what, buildBootstrap)
case "boot-v2.iso":
err = renderCtx(w, r, ctx, what, buildBootISOv2)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View File

@ -0,0 +1,23 @@
package main
import (
"net/http"
restful "github.com/emicklei/go-restful"
)
func wsUnlockStore(req *restful.Request, resp *restful.Response) {
var passphrase string
err := req.ReadEntity(&passphrase)
if err != nil {
resp.WriteError(http.StatusBadRequest, err)
return
}
if err := unlockSecretStore([]byte(passphrase)); err != nil {
err.WriteJSON(resp.ResponseWriter)
return
}
resp.WriteEntity(*adminToken)
}

View File

@ -10,16 +10,41 @@ import (
"github.com/emicklei/go-restful" "github.com/emicklei/go-restful"
"novit.nc/direktil/local-server/pkg/mime" "novit.tech/direktil/pkg/localconfig"
"novit.nc/direktil/pkg/localconfig"
"novit.tech/direktil/local-server/pkg/mime"
) )
func registerWS(rest *restful.Container) { func registerWS(rest *restful.Container) {
// public-level APIs
{
ws := &restful.WebService{}
ws.
Path("/public").
Produces("application/json").
Consumes("application/json").
Route(ws.POST("/unlock-store").To(wsUnlockStore).
Reads("").
Writes("").
Doc("Try to unlock the store")).
Route(ws.GET("/downloads/{token}/{asset}").To(wsDownload).
Param(ws.PathParameter("token", "the download token")).
Param(ws.PathParameter("asset", "the requested asset")).
Doc("Fetch an asset via a download token"))
rest.Add(ws)
}
// Admin-level APIs // Admin-level APIs
ws := &restful.WebService{} ws := &restful.WebService{}
ws.Filter(adminAuth). ws.
Filter(adminAuth).
HeaderParameter("Authorization", "Admin bearer token") HeaderParameter("Authorization", "Admin bearer token")
// - downloads
ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload).
Doc("Create a download token for the given download"))
// - configs API // - configs API
ws.Route(ws.POST("/configs").To(wsUploadConfig). ws.Route(ws.POST("/configs").To(wsUploadConfig).
Doc("Upload a new current configuration, archiving the previous one")) Doc("Upload a new current configuration, archiving the previous one"))
@ -83,6 +108,7 @@ func registerWS(rest *restful.Container) {
// Hosts API // Hosts API
ws = &restful.WebService{} ws = &restful.WebService{}
ws.Produces("application/json")
ws.Path("/me") ws.Path("/me")
ws.Filter(hostsAuth). ws.Filter(hostsAuth).
HeaderParameter("Authorization", "Host or admin bearer token") HeaderParameter("Authorization", "Host or admin bearer token")

1
gen-api-js.sh Executable file
View File

@ -0,0 +1 @@
docker run --rm --net=host --user $(id -u) -v ${PWD}:/local swaggerapi/swagger-codegen-cli generate -i http://[::1]:7606/swagger.json -l javascript -o /local/js/api/

88
go.mod
View File

@ -1,55 +1,71 @@
module novit.nc/direktil/local-server module novit.tech/direktil/local-server
go 1.17 go 1.19
require ( require (
github.com/cavaliercoder/go-cpio v0.0.0-20180626203310-925f9528c45e github.com/cavaliergopher/cpio v1.0.1
github.com/cespare/xxhash v1.1.0 github.com/cespare/xxhash v1.1.0
github.com/cloudflare/cfssl v0.0.0-20181213083726-b94e044bb51e github.com/cloudflare/cfssl v1.6.3
github.com/dustin/go-humanize v1.0.0 github.com/dustin/go-humanize v1.0.1
github.com/emicklei/go-restful v2.10.0+incompatible github.com/emicklei/go-restful v2.16.0+incompatible
github.com/emicklei/go-restful-openapi v1.2.0 github.com/emicklei/go-restful-openapi v1.4.1
github.com/mcluseau/go-swagger-ui v0.0.0-20191019002626-fd9128c24a34 github.com/mcluseau/go-swagger-ui v0.0.0-20191019002626-fd9128c24a34
github.com/miolini/datacounter v1.0.1 github.com/miolini/datacounter v1.0.3
github.com/oklog/ulid v1.3.1 github.com/oklog/ulid v1.3.1
github.com/pierrec/lz4 v2.3.0+incompatible github.com/pierrec/lz4 v2.6.1+incompatible
golang.org/x/crypto v0.5.0
gopkg.in/src-d/go-billy.v4 v4.3.2 gopkg.in/src-d/go-billy.v4 v4.3.2
gopkg.in/src-d/go-git.v4 v4.13.1 gopkg.in/src-d/go-git.v4 v4.13.1
gopkg.in/yaml.v2 v2.2.4 gopkg.in/yaml.v2 v2.4.0
k8s.io/apimachinery v0.0.0-20191203211716-adc6f4cd9e7d k8s.io/apimachinery v0.26.1
novit.nc/direktil/pkg v0.0.0-20191211161950-96b0448b84c2 m.cluseau.fr/go v0.0.0-20230206224905-5322a9bff2ec
novit.tech/direktil/pkg v0.0.0-20230201224712-5e39572dc50e
) )
replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1
require ( require (
github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/frankban/quicktest v1.5.0 // indirect github.com/frankban/quicktest v1.5.0 // indirect
github.com/go-openapi/jsonpointer v0.19.3 // indirect github.com/go-logr/logr v1.2.3 // indirect
github.com/go-openapi/jsonreference v0.19.3 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/spec v0.19.3 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.19.5 // indirect github.com/go-openapi/spec v0.20.8 // indirect
github.com/gobuffalo/envy v1.7.1 // indirect github.com/go-openapi/swag v0.22.3 // indirect
github.com/gobuffalo/packd v0.3.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/gobuffalo/envy v1.10.2 // indirect
github.com/gobuffalo/packd v1.0.2 // indirect
github.com/gobuffalo/packr v1.30.1 // indirect github.com/gobuffalo/packr v1.30.1 // indirect
github.com/golang/protobuf v1.3.2 // indirect github.com/google/certificate-transparency-go v1.1.4 // indirect
github.com/google/certificate-transparency-go v1.0.21 // indirect
github.com/google/go-cmp v0.3.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/joho/godotenv v1.3.0 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/json-iterator/go v1.1.8 // indirect github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.0 // indirect 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/lib/pq v1.10.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.5.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/sergi/go-diff v1.0.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect
github.com/src-d/gcfg v1.4.0 // indirect github.com/src-d/gcfg v1.4.0 // indirect
github.com/xanzy/ssh-agent v0.2.1 // indirect github.com/weppos/publicsuffix-go v0.20.0 // indirect
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 // indirect github.com/zmap/zcrypto v0.0.0-20230205235340-d51ce4775101 // indirect
golang.org/x/sys v0.0.0-20191018095205-727590c5006e // indirect github.com/zmap/zlint/v3 v3.1.0 // indirect
golang.org/x/text v0.3.2 // indirect golang.org/x/mod v0.7.0 // indirect
golang.org/x/net v0.5.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/tools v0.5.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.90.0 // indirect
k8s.io/utils v0.0.0-20230202215443-34013725500c // indirect
) )

1260
go.sum

File diff suppressed because it is too large Load Diff

BIN
html/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

6
html/html.go Normal file
View File

@ -0,0 +1,6 @@
package dlshtml
import "embed"
//go:embed favicon.ico ui
var FS embed.FS

19
html/ui/app.css Normal file
View File

@ -0,0 +1,19 @@
.downloads {
display: flex;
align-content: stretch;
}
.downloads > * {
margin-left: 6pt;
}
.downloads > *:first-child {
margin-left: 0;
}
.downloads > div {
display: flex;
flex-flow: column;
max-height: 100pt;
overflow: auto;
}

87
html/ui/index.html Normal file
View File

@ -0,0 +1,87 @@
<!doctype html>
<html>
<head>
<title>Direktil Local Server</title>
<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" />
<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)">&#x1F5D0;</button>
</span>
<span id="uiHash">ui <code>{{ uiHash || '-----' }}</code></span>
<span class="green" v-if="publicState">&#x1F5F2;</span>
<span class="red" v-else >&#x1F5F2;</span>
</div>
</header>
<div class="error" v-if="error">
<button class="btn-close" @click="error=null">&times;</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>
<form @submit="unlockStore" action="/public/unlock-store">
<input type="password" v-model="forms.store.pass1" name="passphrase" />
<input type="password" v-model="forms.store.pass2" />
<input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
</form>
</template>
<template v-else-if="!publicState.Store.Open">
<p>Store is not open.</p>
<form @submit="unlockStore" action="/public/unlock-store">
<input type="password" name="passphrase" v-model="forms.store.pass1" />
<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="setToken">
<input type="password" v-model="forms.setToken" />
<input type="submit" value="set token"/>
</form>
</template>
<template v-else>
<div v-if="state.Clusters" id="clusters">
<h2>Clusters</h2>
<div class="sheets">
<Cluster v-for="c in state.Clusters" :cluster="c" :token="session.token" :state="state" />
</div>
</div>
<div v-if="state.Hosts" id="hosts">
<h2>Hosts</h2>
<div class="sheets">
<Host v-for="h in state.Hosts" :host="h" :token="session.token" :state="state" />
</div>
</div>
<pre v-if="false">{{ state }}</pre>
</template>
</div>
</body>
</html>

16
html/ui/js/Cluster.js Normal file
View File

@ -0,0 +1,16 @@
import Downloads from './Downloads.js';
export default {
components: { Downloads },
props: [ 'cluster', 'token', 'state' ],
template: `
<div class="cluster">
<div class="title">Cluster {{ cluster.Name }}</div>
<div class="section">Downloads</div>
<section class="downloads">
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
</section>
</div>
`
}

66
html/ui/js/Downloads.js Normal file
View File

@ -0,0 +1,66 @@
export default {
props: [ 'kind', 'name', 'token', 'state' ],
data() {
return { createDisabled: false, selectedAssets: {} }
},
computed: {
availableAssets() {
return {
cluster: ['addons'],
host: [
"kernel",
"initrd-v2",
"bootstrap.tar",
"boot-v2.iso",
"config",
"boot.iso",
"boot.tar",
"boot-efi.tar",
"boot.img",
"boot.img.gz",
"boot.img.lz4",
"bootstrap-config",
"initrd",
"ipxe",
],
}[this.kind]
},
downloads() {
let ret = []
Object.entries(this.state.Downloads)
.filter(e => { let d=e[1]; return d.Kind == this.kind && d.Name == this.name })
.forEach(e => {
let token= e[0], d = e[1]
d.Assets.forEach(asset => {
ret.push({name: asset, url: '/public/downloads/'+token+'/'+asset})
})
})
return ret
},
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 },
}).then((resp) => resp.json())
.then((token) => { this.selectedAssets = {}; this.createDisabled = false })
.catch((e) => { alert('failed to create link'); this.createDisabled = false })
},
},
template: `<div class="downloads">
<div class="options">
<span v-for="asset in availableAssets"><label><input type="checkbox" v-model="selectedAssets[asset]" />&nbsp;{{ asset }}</label></span>
</div>
<button :disabled="createDisabled || assets.length==0" @click="createToken">+</button>
<div><a v-for="d in downloads" target="_blank" :href="d.url">{{ d.name }}</a></div>
</div>`
}

21
html/ui/js/Host.js Normal file
View File

@ -0,0 +1,21 @@
import Downloads from './Downloads.js';
export default {
components: { Downloads },
props: [ 'host', 'token', 'state' ],
template: `
<div class="host">
<div class="title">Host {{ host.Name }}</div>
<section>
<template v-for="ip in host.IPs">
{{ ip }}
</template>
</section>
<div class="section">Downloads</div>
<section>
<Downloads :token="token" :state="state" kind="host" :name="host.Name" />
</section>
</div>
`
}

162
html/ui/js/app.js Normal file
View File

@ -0,0 +1,162 @@
import { createApp } from './vue.esm-browser.js';
import Cluster from './Cluster.js';
import Host from './Host.js';
createApp({
components: { Cluster, Host },
data() {
return {
forms: {
store: { },
},
session: {},
error: null,
publicState: 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) {
if (this.uiHash && v.UIHash != this.uiHash) {
console.log("reloading")
location.reload()
} else {
this.uiHash = v.UIHash
}
}
},
}
},
methods: {
copyText(text) {
event.preventDefault()
window.navigator.clipboard.writeText(text)
},
setToken() {
event.preventDefault()
this.session.token = this.forms.setToken
this.forms.setToken = null
},
unlockStore() {
this.apiPost('/public/unlock-store', this.forms.store.pass1, (v) => {
this.forms.store = {}
if (v) {
this.session.token = v
if (!this.watchingState) {
this.watchState()
this.watchingState = true
}
}
})
},
apiPost(action, data, onload) {
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 aciton 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', 'application/json')
if (this.session.token) {
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
}
xhr.send(JSON.stringify(data))
},
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')

36
html/ui/js/jsonpatch.min.js vendored Normal file

File diff suppressed because one or more lines are too long

16172
html/ui/js/vue.esm-browser.js Normal file

File diff suppressed because it is too large Load Diff

127
html/ui/style.css Normal file
View File

@ -0,0 +1,127 @@
body {
background: white;
}
button[disabled] {
opacity: 0.5;
}
a[href], a[href]:visited, button.link {
border: none;
color: blue;
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 {
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) {
body {
background: black;
color: orange;
}
button, input[type=submit] {
background: #333;
color: #eee;
}
a[href], button.link {
border: none;
color: #31b0fa;
}
.red { color: #c00; }
}
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;
}

2
install Executable file
View File

@ -0,0 +1,2 @@
#! /bin/sh
go install -trimpath ./cmd/...

View File

@ -1,10 +1,22 @@
**/*.go go.mod go.sum Dockerfile { modd.conf {}
**/*.go go.mod go.sum {
prep: go test ./... prep: go test ./...
prep: go install -trimpath ./cmd/... prep: mkdir -p dist
prep: docker build --build-arg GOPROXY=$GOPROXY -t dls . prep: go build -o dist/ -trimpath ./...
#prep: docker build --build-arg GOPROXY=$GOPROXY -t dls .
#daemon +sigterm: /var/lib/direktil/test-run #daemon +sigterm: /var/lib/direktil/test-run
} }
html/**/* {
prep: go build -o dist/ -trimpath ./cmd/dkl-local-server
}
dist/dkl-local-server {
prep: mkdir -p tmp
daemon +sigterm: dist/dkl-local-server -data tmp -auto-unlock test
}
**/*.proto !dist/**/* { **/*.proto !dist/**/* {
prep: for mod in @mods; do protoc -I ./ --go_out=plugins=grpc,paths=source_relative:. $mod; done prep: for mod in @mods; do protoc -I ./ --go_out=plugins=grpc,paths=source_relative:. $mod; done
} }

View File

@ -235,6 +235,7 @@ type Group struct {
IPXE string IPXE string
Kernel string Kernel string
Initrd string Initrd string
BootstrapConfig string `yaml:"bootstrap_config"`
Config string Config string
StaticPods string `yaml:"static_pods"` StaticPods string `yaml:"static_pods"`
Versions map[string]string Versions map[string]string

View File

@ -124,6 +124,11 @@ func FromDir(dirPath, defaultsPath string) (*Config, error) {
return nil, err return nil, err
} }
group.BootstrapConfig, err = template(group.Rev(), "configs", group.BootstrapConfig, &config.Configs)
if err != nil {
return nil, fmt.Errorf("failed to load config for group %q: %v", name, err)
}
group.Config, err = template(group.Rev(), "configs", group.Config, &config.Configs) group.Config, err = template(group.Rev(), "configs", group.Config, &config.Configs)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load config for group %q: %v", name, err) return nil, fmt.Errorf("failed to load config for group %q: %v", name, err)

View File

@ -0,0 +1,61 @@
package config
type Config struct {
AntiPhishingCode string `json:"anti_phishing_code"`
Keymap string
Modules string
Auths []Auth
Networks []struct {
Name string
Interfaces []struct {
Var string
N int
Regexps []string
}
Script string
}
LVM []LvmVG
Bootstrap Bootstrap
}
type Auth struct {
Name string
SSHKey string `yaml:"sshKey"`
Password string `yaml:"password"`
}
type LvmVG struct {
VG string
PVs struct {
N int
Regexps []string
}
Defaults struct {
FS string
Raid *RaidConfig
}
LVs []struct {
Name string
Crypt string
FS string
Raid *RaidConfig
Size string
Extents string
}
}
type RaidConfig struct {
Mirrors int
Stripes int
}
type Bootstrap struct {
Dev string
Seed string
}

29
pkg/utf16/utf16.go Normal file
View File

@ -0,0 +1,29 @@
package utf16
import (
"encoding/binary"
"fmt"
"unicode/utf8"
)
func FromUTF8(data []byte) (res []byte) {
endian := binary.LittleEndian
res = make([]byte, (len(data)+1)*2)
res = res[:2]
endian.PutUint16(res, 0xfeff)
for len(data) > 0 {
r, size := utf8.DecodeRune(data)
if r > 65535 {
panic(fmt.Errorf("r=0x%x > 0xffff", r))
}
slen := len(res)
res = res[:slen+2]
endian.PutUint16(res[slen:], uint16(r))
data = data[size:]
}
return
}

30
secretstore/io.go Normal file
View File

@ -0,0 +1,30 @@
package secretstore
import (
"crypto/rand"
"encoding/binary"
"fmt"
"io"
)
func readFull(in io.Reader, ba []byte) (err error) {
_, err = io.ReadFull(in, ba)
return
}
func read[T any](in io.Reader) (v T, err error) {
err = binary.Read(in, binary.BigEndian, &v)
return
}
var readSize = read[uint16]
func randRead(ba []byte) (err error) {
err = readFull(rand.Reader, ba)
if err != nil {
err = fmt.Errorf("failed to read random bytes: %w", err)
return
}
return
}

7
secretstore/mem.go Normal file
View File

@ -0,0 +1,7 @@
package secretstore
func memzero(ba []byte) {
for i := range ba {
ba[i] = 0
}
}

68
secretstore/reader.go Normal file
View File

@ -0,0 +1,68 @@
package secretstore
import (
"crypto/aes"
"crypto/cipher"
"io"
)
func (s *Store) NewReader(reader io.Reader) (r io.Reader, err error) {
iv := [aes.BlockSize]byte{}
err = readFull(reader, iv[:])
if err != nil {
return
}
r = storeReader{reader, s.NewDecrypter(iv)}
return
}
type storeReader struct {
reader io.Reader
decrypter cipher.Stream
}
func (r storeReader) Read(ba []byte) (n int, err error) {
n, err = r.reader.Read(ba)
if n > 0 {
r.decrypter.XORKeyStream(ba[:n], ba[:n])
}
return
}
func (s *Store) NewWriter(writer io.Writer) (r io.Writer, err error) {
iv := [aes.BlockSize]byte{}
if err = randRead(iv[:]); err != nil {
return
}
_, err = writer.Write(iv[:])
if err != nil {
return
}
r = storeWriter{writer, s.NewEncrypter(iv)}
return
}
type storeWriter struct {
writer io.Writer
encrypter cipher.Stream
}
func (r storeWriter) Write(ba []byte) (n int, err error) {
if len(ba) == 0 {
return
}
encBA := make([]byte, len(ba))
r.encrypter.XORKeyStream(encBA, ba)
n, err = r.writer.Write(encBA)
return
}

263
secretstore/secret-store.go Normal file
View File

@ -0,0 +1,263 @@
package secretstore
import (
"bufio"
"crypto/aes"
"crypto/cipher"
"crypto/sha512"
"errors"
"fmt"
"io"
"log"
"os"
"syscall"
"golang.org/x/crypto/argon2"
)
type Store struct {
unlocked bool
key [32]byte
salt [aes.BlockSize]byte
keys []keyEntry
}
type keyEntry struct {
hash [64]byte
encKey [32]byte
}
func New() (s *Store) {
s = &Store{}
syscall.Mlock(s.key[:])
return
}
func Open(path string) (s *Store, err error) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
s = New()
_, err = s.ReadFrom(bufio.NewReader(f))
return
}
func (s *Store) SaveTo(path string) (err error) {
f, err := os.OpenFile(path, syscall.O_CREAT|syscall.O_TRUNC|syscall.O_WRONLY, 0600)
if err != nil {
return
}
defer f.Close()
out := bufio.NewWriter(f)
_, err = s.WriteTo(out)
if err != nil {
return
}
err = out.Flush()
if err != nil {
return
}
return
}
func (s *Store) Close() {
memzero(s.key[:])
syscall.Munlock(s.key[:])
s.unlocked = false
}
func (s *Store) IsNew() bool {
return len(s.keys) == 0
}
func (s *Store) Unlocked() bool {
return s.unlocked
}
func (s *Store) Init(passphrase []byte) (err error) {
err = randRead(s.key[:])
if err != nil {
return
}
err = randRead(s.salt[:])
if err != nil {
return
}
s.AddKey(passphrase)
s.unlocked = true
return
}
func (s *Store) ReadFrom(in io.Reader) (n int64, err error) {
memzero(s.key[:])
s.unlocked = false
defer func() {
if err != nil {
log.Output(2, fmt.Sprintf("failed after %d bytes", n))
}
}()
readFull := func(ba []byte) {
var nr int
nr, err = io.ReadFull(in, ba)
n += int64(nr)
}
// read the salt
readFull(s.salt[:])
if err != nil {
return
}
// read the (encrypted) keys
s.keys = make([]keyEntry, 0)
for {
k := keyEntry{}
readFull(k.hash[:])
if err != nil {
if err == io.EOF {
err = nil
}
return
}
readFull(k.encKey[:])
if err != nil {
return
}
s.keys = append(s.keys, k)
}
}
func (s *Store) WriteTo(out io.Writer) (n int64, err error) {
write := func(ba []byte) {
var nr int
nr, err = out.Write(ba)
n += int64(nr)
}
write(s.salt[:])
if err != nil {
return
}
for _, k := range s.keys {
write(k.hash[:])
if err != nil {
return
}
write(k.encKey[:])
if err != nil {
return
}
}
return
}
var ErrNoSuchKey = errors.New("no such key")
func (s *Store) Unlock(passphrase []byte) (ok bool) {
key, hash := s.keyPairFromPassword(passphrase)
memzero(passphrase)
defer memzero(key[:])
var idx = -1
for i := range s.keys {
if hash == s.keys[i].hash {
idx = i
break
}
}
if idx == -1 {
return
}
s.decryptTo(s.key[:], s.keys[idx].encKey[:], &key)
s.unlocked = true
return true
}
func (s *Store) AddKey(passphrase []byte) {
key, hash := s.keyPairFromPassword(passphrase)
memzero(passphrase)
defer memzero(key[:])
k := keyEntry{hash: hash}
encKey := s.encrypt(s.key[:], &key)
copy(k.encKey[:], encKey)
s.keys = append(s.keys, k)
}
func (s *Store) keyPairFromPassword(password []byte) (key [32]byte, hash [64]byte) {
keySlice := argon2.IDKey(password, s.salt[:], 1, 64*1024, 4, 32)
copy(key[:], keySlice)
memzero(keySlice)
hash = sha512.Sum512(key[:])
return
}
func (s *Store) NewEncrypter(iv [aes.BlockSize]byte) cipher.Stream {
if !s.unlocked {
panic("not unlocked")
}
return newEncrypter(iv, &s.key)
}
func (s *Store) NewDecrypter(iv [aes.BlockSize]byte) cipher.Stream {
if !s.unlocked {
panic("not unlocked")
}
return newDecrypter(iv, &s.key)
}
func (s *Store) encrypt(src []byte, key *[32]byte) (dst []byte) {
dst = make([]byte, len(src))
newEncrypter(s.salt, key).XORKeyStream(dst, src)
return
}
func (s *Store) decryptTo(dst []byte, src []byte, key *[32]byte) {
newDecrypter(s.salt, key).XORKeyStream(dst, src)
}
func newEncrypter(iv [aes.BlockSize]byte, key *[32]byte) cipher.Stream {
c, err := aes.NewCipher(key[:])
if err != nil {
panic(fmt.Errorf("failed to init AES: %w", err))
}
return cipher.NewCFBEncrypter(c, iv[:])
}
func newDecrypter(iv [aes.BlockSize]byte, key *[32]byte) cipher.Stream {
c, err := aes.NewCipher(key[:])
if err != nil {
panic(fmt.Errorf("failed to init AES: %w", err))
}
return cipher.NewCFBDecrypter(c, iv[:])
}