Compare commits

..

1 Commits

Author SHA1 Message Date
78947747be boot efi 2022-05-31 11:52:26 +02:00
78 changed files with 3264 additions and 21387 deletions

View File

@ -1,3 +0,0 @@
tmp
dist
test-run

6
.gitignore vendored
View File

@ -1,8 +1,2 @@
*.sw[po] *.sw[po]
modd-local.conf modd-local.conf
/tmp
/test-dir2config
/config.yaml
/dist
/go.work
/go.work.sum

View File

@ -1,33 +1,23 @@
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
from golang:1.24.4-bookworm as build from mcluseau/golang-builder:1.18.2 as build
run apt-get update && apt-get install -y git
workdir /src
copy go.mod go.sum ./
run \
--mount=type=cache,id=gomod,target=/go/pkg/mod \
--mount=type=cache,id=gobuild,target=/root/.cache/go-build \
go mod download
arg GIT_TAG
copy . ./
run \
--mount=type=cache,id=gomod,target=/go/pkg/mod \
--mount=type=cache,id=gobuild,target=/root/.cache/go-build \
go test ./... && \
hack/build ./...
# ------------------------------------------------------------------------ # ------------------------------------------------------------------------
from debian:bookworm from debian:stretch
entrypoint ["/bin/dkl-local-server"] entrypoint ["/bin/dkl-local-server"]
env _uncache=1 env _uncache 1
run apt-get update \ run apt-get update \
&& yes |apt-get install -y genisoimage gdisk dosfstools util-linux udev binutils systemd \ && 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 && apt-get clean
copy --from=build /src/dist/ /bin/ run yes |apt-get install -y grub2 grub-pc-bin grub-efi-amd64-bin \
&& apt-get clean
run apt-get install -y ca-certificates curl openssh-client \
&& apt-get clean
run curl -L https://github.com/vmware/govmomi/releases/download/v0.21.0/govc_linux_amd64.gz | gunzip > /bin/govc && chmod +x /bin/govc
copy upload-vmware.sh govc.env /var/lib/direktil/
copy --from=build /go/bin/ /bin/

View File

@ -1,155 +0,0 @@
package main
import (
"bytes"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"gopkg.in/yaml.v2"
)
func mergeIn(tgt, add map[any]any) {
mergeLoop:
for k, v := range add {
switch v := v.(type) {
case map[any]any:
if tgtV, ok := tgt[k]; ok {
switch tgtV := tgtV.(type) {
case map[any]any:
mergeIn(tgtV, v)
continue mergeLoop
}
}
}
tgt[k] = v
}
}
func assemble(path string) (yamlBytes []byte, err error) {
obj := map[any]any{}
if Debug {
log.Printf("assemble %q", path)
}
err = eachFragment(path, searchList, func(r io.Reader) (err error) {
m := map[any]any{}
err = yaml.NewDecoder(r).Decode(&m)
if err != nil {
return
}
mergeIn(obj, m)
return
})
if err != nil {
err = fmt.Errorf("failed to assemble %q: %w", path, err)
return
}
yamlBytes, err = yaml.Marshal(obj)
if err != nil {
return
}
if Debug {
log.Printf("assemble %q result:\n%s", path, yamlBytes)
}
return
}
func eachFragment(path string, searchList []FS, walk func(io.Reader) error) (err error) {
var r io.ReadCloser
for len(searchList) != 0 {
fs := searchList[0]
r, err = fs.Open(path + ".yaml")
if os.IsNotExist(err) {
searchList = searchList[1:]
continue
}
if err != nil {
return
}
// found and open
break
}
if r == nil {
err = fmt.Errorf("%s: %w", path, os.ErrNotExist)
return
}
ba, err := io.ReadAll(r)
r.Close()
if err != nil {
return
}
if Debug {
log.Print("fragment:\n", string(ba))
}
in := bytes.NewBuffer(ba)
for {
var line string
line, err = in.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return
}
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
genCmd, found := strings.CutPrefix(line, "#!gen ")
if found {
cmdArgs := strings.Fields(genCmd)
if Debug {
log.Print("#!gen ", cmdArgs)
}
cmd := *dir + "/gen/" + cmdArgs[0]
args := cmdArgs[1:]
genOutput, err := exec.Command(cmd, args...).Output()
if err != nil {
return fmt.Errorf("gen %v: %w", cmdArgs, err)
}
walk(bytes.NewBuffer(genOutput))
continue
}
includePath, found := strings.CutPrefix(line, "#!include ")
if !found {
continue
}
includePath = strings.TrimSpace(includePath)
if Debug {
log.Print("#!include ", includePath)
}
err = eachFragment(includePath, searchList, walk)
if err != nil {
return fmt.Errorf("include %q: %w", includePath, err)
}
}
in = bytes.NewBuffer(ba)
err = walk(in)
return
}

View File

@ -1,34 +0,0 @@
package main
import (
"io"
iofs "io/fs"
)
type FS interface {
Open(path string) (io.ReadCloser, error)
List(path string) ([]string, error)
}
type fsFS struct{ iofs.FS }
func (fs fsFS) Open(path string) (io.ReadCloser, error) {
return fs.FS.Open(path)
}
func (fs fsFS) List(path string) (entries []string, err error) {
dirEnts, err := iofs.ReadDir(fs.FS, path)
if err != nil {
return
}
entries = make([]string, 0, len(dirEnts))
for _, ent := range dirEnts {
if ent.IsDir() {
continue
}
entries = append(entries, ent.Name())
}
return
}

View File

@ -1,38 +0,0 @@
package main
import (
"io"
"sort"
"github.com/go-git/go-git/v5/plumbing/object"
)
type gitFS struct{ *object.Tree }
func (fs gitFS) Open(path string) (r io.ReadCloser, err error) {
f, err := fs.Tree.File(path)
if err != nil {
return
}
return f.Reader()
}
func (fs gitFS) List(path string) (entries []string, err error) {
tree, err := fs.Tree.Tree(path)
if err != nil {
return
}
entries = make([]string, 0, len(tree.Entries))
for _, ent := range tree.Entries {
if !ent.Mode.IsFile() {
continue
}
entries = append(entries, ent.Name)
}
sort.Strings(entries)
return
}

View File

@ -2,13 +2,9 @@ package main
import ( import (
"flag" "flag"
"io/fs"
"log" "log"
"os" "os"
"path/filepath"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
@ -16,27 +12,18 @@ import (
"novit.tech/direktil/local-server/pkg/clustersconfig" "novit.tech/direktil/local-server/pkg/clustersconfig"
) )
var Version = "dev"
var ( var (
Debug = false
dir = flag.String("in", ".", "Source directory") dir = flag.String("in", ".", "Source directory")
outPath = flag.String("out", "config.yaml", "Output file") outPath = flag.String("out", "config.yaml", "Output file")
defaultsPath = flag.String("defaults", "defaults", "Path to the defaults")
base fs.FS
src *clustersconfig.Config src *clustersconfig.Config
dst *localconfig.Config dst *localconfig.Config
) )
func init() {
flag.BoolVar(&Debug, "debug", Debug, "debug")
}
func loadSrc() { func loadSrc() {
var err error var err error
src, err = clustersconfig.FromDir(read, assemble, listBase, listMerged) src, err = clustersconfig.FromDir(*dir, *defaultsPath)
if err != nil { if err != nil {
log.Fatal("failed to load config from dir: ", err) log.Fatal("failed to load config from dir: ", err)
} }
@ -47,11 +34,6 @@ func main() {
log.SetFlags(log.Ltime | log.Lmicroseconds | log.Lshortfile) log.SetFlags(log.Ltime | log.Lmicroseconds | log.Lshortfile)
base = os.DirFS(*dir)
searchList = append(searchList, fsFS{base})
openIncludes()
loadSrc() loadSrc()
dst = &localconfig.Config{ dst = &localconfig.Config{
@ -63,11 +45,14 @@ 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),
}) })
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
for _, host := range src.Hosts { for _, host := range src.Hosts {
loadSrc() // FIXME ugly fix of some template caching or something
log.Print("rendering host ", host.Name) log.Print("rendering host ", host.Name)
ctx, err := newRenderContext(host, src) ctx, err := newRenderContext(host, src)
@ -86,12 +71,12 @@ func main() {
} }
ips = append(ips, host.IPs...) ips = append(ips, host.IPs...)
if ctx.Host.Versions["modules"] == "" { if ctx.Group.Versions["modules"] == "" {
// default modules' version to kernel's version // default modules' version to kernel's version
ctx.Host.Versions["modules"] = ctx.Host.Kernel ctx.Group.Versions["modules"] = ctx.Group.Kernel
} }
renderedHost := &localconfig.Host{ dst.Hosts = append(dst.Hosts, &localconfig.Host{
Name: host.Name, Name: host.Name,
ClusterName: ctx.Cluster.Name, ClusterName: ctx.Cluster.Name,
@ -102,21 +87,15 @@ func main() {
MACs: macs, MACs: macs,
IPs: ips, IPs: ips,
IPXE: ctx.Host.IPXE, // TODO render IPXE: ctx.Group.IPXE, // TODO render
Kernel: ctx.Host.Kernel, Kernel: ctx.Group.Kernel,
Initrd: ctx.Host.Initrd, Initrd: ctx.Group.Initrd,
Versions: ctx.Host.Versions, Versions: ctx.Group.Versions,
BootstrapConfig: ctx.BootstrapConfig(), BootstrapConfig: ctx.BootstrapConfig(),
Config: ctx.Config(), Config: ctx.Config(),
} })
if host.Template {
dst.HostTemplates = append(dst.HostTemplates, renderedHost)
} else {
dst.Hosts = append(dst.Hosts, renderedHost)
}
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -127,83 +106,8 @@ func main() {
defer out.Close() defer out.Close()
out.Write([]byte("# dkl-dir2config " + Version + "\n"))
if err = yaml.NewEncoder(out).Encode(dst); err != nil { if err = yaml.NewEncoder(out).Encode(dst); err != nil {
log.Fatal("failed to render output: ", err) log.Fatal("failed to render output: ", err)
} }
} }
func cfgPath(subPath string) string { return filepath.Join(*dir, subPath) }
func openIncludes() {
includesFile, err := base.Open("includes.yaml")
if os.IsNotExist(err) {
return
}
if err != nil {
log.Fatal("failed to open includes: ", err)
}
includes := make([]struct {
Path string
Branch string
Tag string
}, 0)
err = yaml.NewDecoder(includesFile).Decode(&includes)
if err != nil {
log.Fatal("failed to parse includes: ", err)
}
for _, include := range includes {
switch {
case include.Branch != "" || include.Tag != "":
p := cfgPath(include.Path) // FIXME parse git path to allow remote repos
var rev plumbing.Revision
switch {
case include.Branch != "":
log.Printf("opening include path %q as git, branch %q", p, include.Branch)
rev = plumbing.Revision(plumbing.NewBranchReferenceName(include.Branch))
case include.Tag != "":
log.Printf("opening include path %q as git, tag %q", p, include.Branch)
rev = plumbing.Revision(plumbing.NewTagReferenceName(include.Branch))
}
repo, err := git.PlainOpen(p)
if err != nil {
log.Fatal("failed to open: ", err)
}
revH, err := repo.ResolveRevision(rev)
if err != nil {
log.Fatalf("failed to resolve revision %s: %v", rev, err)
}
log.Print(" -> resolved to commit ", *revH)
commit, err := repo.CommitObject(*revH)
if err != nil {
log.Fatal("failed to get commit object: ", err)
}
tree, err := commit.Tree()
if err != nil {
log.Fatal("failed to open git tree: ", err)
}
searchList = append(searchList, gitFS{tree})
default:
p := cfgPath(include.Path)
log.Printf("opening include path %q as raw dir", p)
searchList = append(searchList, fsFS{os.DirFS(p)})
}
}
}

View File

@ -3,18 +3,21 @@ 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.tech/direktil/local-server/pkg/clustersconfig"
) )
func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]any { func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]interface{} {
cluster := clusterSpec.Name cluster := clusterSpec.Name
return map[string]any{ return map[string]interface{}{
"password": func(name, hash string) (s string) { "password": func(name string) (s string) {
return fmt.Sprintf("{{ password %q %q %q | quote }}", cluster, name, hash) return fmt.Sprintf("{{ password %q %q }}", cluster, name)
}, },
"token": func(name string) (s string) { "token": func(name string) (s string) {
@ -36,7 +39,7 @@ func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]any {
return fmt.Sprintf("{{ ca_dir %q %q }}", cluster, name), nil return fmt.Sprintf("{{ ca_dir %q %q }}", cluster, name), nil
}, },
"hosts_by_cluster": func(cluster string) (hosts []any) { "hosts_by_cluster": func(cluster string) (hosts []interface{}) {
for _, host := range src.Hosts { for _, host := range src.Hosts {
if host.Cluster == cluster { if host.Cluster == cluster {
hosts = append(hosts, asMap(host)) hosts = append(hosts, asMap(host))
@ -50,9 +53,9 @@ func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]any {
return return
}, },
"hosts_by_group": func(group string) (hosts []any) { "hosts_by_group": func(group string) (hosts []interface{}) {
for _, host := range src.Hosts { for _, host := range src.Hosts {
if host.Cluster == cluster && host.Group == group { if host.Group == group {
hosts = append(hosts, asMap(host)) hosts = append(hosts, asMap(host))
} }
} }
@ -63,26 +66,6 @@ func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]any {
return return
}, },
"host_ip_from": func(hostName, net string) string {
host := src.Host(hostName)
if host == nil {
log.Printf("WARNING: no host named %q", hostName)
return "<no value>"
}
ipFrom := host.IPFrom
if ipFrom == nil {
ipFrom = map[string]string{}
}
ip, ok := ipFrom[net]
if !ok {
ip = host.IP
}
return ip
},
} }
} }
@ -121,18 +104,12 @@ func renderAddons(cluster *clustersconfig.Cluster) string {
return "" return ""
} }
buf := new(bytes.Buffer) addons := src.Addons[cluster.Addons]
for _, addonSet := range cluster.Addons {
addons := src.Addons[addonSet]
if addons == nil { if addons == nil {
log.Fatalf("cluster %q: no addons with name %q", cluster.Name, addonSet) log.Fatalf("cluster %q: no addons with name %q", cluster.Name, cluster.Addons)
} }
buf.Write(renderClusterTemplates(cluster, "addons", addons)) return string(renderClusterTemplates(cluster, "addons", addons))
}
return buf.String()
} }
type namePod struct { type namePod struct {
@ -140,3 +117,106 @@ 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

@ -2,15 +2,13 @@ package main
import ( import (
"bytes" "bytes"
"crypto/sha1"
"encoding/hex"
"fmt" "fmt"
"io"
"log" "log"
"math/rand"
"path" "path"
"reflect" "reflect"
"strings"
"github.com/cespare/xxhash"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/config" "novit.tech/direktil/pkg/config"
@ -23,8 +21,9 @@ type renderContext struct {
Annotations map[string]string Annotations map[string]string
Host *clustersconfig.Host Host *clustersconfig.Host
Group *clustersconfig.Group
Cluster *clustersconfig.Cluster Cluster *clustersconfig.Cluster
Vars map[string]any Vars map[string]interface{}
BootstrapConfigTemplate *clustersconfig.Template BootstrapConfigTemplate *clustersconfig.Template
ConfigTemplate *clustersconfig.Template ConfigTemplate *clustersconfig.Template
@ -40,25 +39,34 @@ func newRenderContext(host *clustersconfig.Host, cfg *clustersconfig.Config) (ct
return return
} }
vars := make(map[string]any) group := cfg.Group(host.Group)
if group == nil {
err = fmt.Errorf("no group named %q", host.Group)
return
}
for _, oVars := range []map[string]any{ vars := make(map[string]interface{})
for _, oVars := range []map[string]interface{}{
cluster.Vars, cluster.Vars,
group.Vars,
host.Vars, host.Vars,
} { } {
mapMerge(vars, oVars) mapMerge(vars, oVars)
} }
return &renderContext{ return &renderContext{
Labels: mergeLabels(cluster.Labels, host.Labels), Labels: mergeLabels(cluster.Labels, group.Labels, host.Labels),
Annotations: mergeLabels(cluster.Annotations, host.Annotations), Annotations: mergeLabels(cluster.Annotations, group.Annotations, host.Annotations),
Host: host, Host: host,
Group: group,
Cluster: cluster, Cluster: cluster,
Vars: vars, Vars: vars,
BootstrapConfigTemplate: cfg.ConfigTemplate(host.BootstrapConfig), BootstrapConfigTemplate: cfg.ConfigTemplate(group.BootstrapConfig),
ConfigTemplate: cfg.ConfigTemplate(host.Config), ConfigTemplate: cfg.ConfigTemplate(group.Config),
StaticPodsTemplate: cfg.StaticPodsTemplate(group.StaticPods),
clusterConfig: cfg, clusterConfig: cfg,
}, nil }, nil
@ -122,6 +130,8 @@ func (ctx *renderContext) Name() string {
switch { switch {
case ctx.Host != nil: case ctx.Host != nil:
return "host:" + ctx.Host.Name return "host:" + ctx.Host.Name
case ctx.Group != nil:
return "group:" + ctx.Group.Name
case ctx.Cluster != nil: case ctx.Cluster != nil:
return "cluster:" + ctx.Cluster.Name return "cluster:" + ctx.Cluster.Name
default: default:
@ -131,33 +141,55 @@ func (ctx *renderContext) Name() string {
func (ctx *renderContext) BootstrapConfig() string { func (ctx *renderContext) BootstrapConfig() string {
if ctx.BootstrapConfigTemplate == nil { if ctx.BootstrapConfigTemplate == nil {
log.Fatalf("no such (bootstrap) config: %q", ctx.Host.BootstrapConfig) log.Fatalf("no such (bootstrap) config: %q", ctx.Group.BootstrapConfig)
} }
return ctx.renderConfig(ctx.BootstrapConfigTemplate) 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.Host.Config) log.Fatalf("no such config: %q", ctx.Group.Config)
} }
return ctx.renderConfig(ctx.ConfigTemplate) return ctx.renderConfig(ctx.ConfigTemplate)
} }
func (ctx *renderContext) renderConfig(configTemplate *clustersconfig.Template) string { 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()
templateFuncs := ctx.templateFuncs(ctxMap)
render := func(what string, t *clustersconfig.Template) (s string, err error) {
buf := &bytes.Buffer{}
err = t.Execute(ctxName, what, buf, ctxMap, templateFuncs)
if err != nil {
log.Printf("host %s: failed to render %s [%q]: %v", ctx.Host.Name, what, t.Name, err)
return
}
s = buf.String()
return
}
extraFuncs := ctx.templateFuncs(ctxMap) extraFuncs := ctx.templateFuncs(ctxMap)
extraFuncs["static_pods_files"] = func(dir string) (string, error) { extraFuncs["static_pods"] = func() (string, error) {
namePods := ctx.renderStaticPods() name := ctx.Group.StaticPods
if len(name) == 0 {
return "", fmt.Errorf("group %q has no static pods defined", ctx.Group.Name)
}
t := ctx.clusterConfig.StaticPodsTemplate(name)
if t == nil {
return "", fmt.Errorf("no static pods template named %q", name)
}
return render("static-pods", t)
}
extraFuncs["bootstrap_pods_files"] = func(dir string) (string, error) {
namePods := renderBootstrapPods(ctx.Cluster)
defs := make([]config.FileDef, 0) defs := make([]config.FileDef, 0)
@ -166,7 +198,7 @@ func (ctx *renderContext) renderConfigTo(buf io.Writer, configTemplate *clusters
ba, err := yaml.Marshal(namePod.Pod) ba, err := yaml.Marshal(namePod.Pod)
if err != nil { if err != nil {
return "", fmt.Errorf("static pod %s: failed to render: %v", name, err) return "", fmt.Errorf("bootstrap pod %s: failed to render: %v", name, err)
} }
defs = append(defs, config.FileDef{ defs = append(defs, config.FileDef{
@ -180,30 +212,36 @@ func (ctx *renderContext) renderConfigTo(buf io.Writer, configTemplate *clusters
return string(ba), err return string(ba), err
} }
extraFuncs["host_ip"] = func() string {
if ctx.Host.Template {
return "{{ host_ip }}"
}
return ctx.Host.IP
}
extraFuncs["host_name"] = func() string {
if ctx.Host.Template {
return "{{ host_name }}"
}
return ctx.Host.Name
}
extraFuncs["machine_id"] = func() string { extraFuncs["machine_id"] = func() string {
return "{{ machine_id }}" ba := sha1.Sum([]byte(ctx.Cluster.Name + "/" + ctx.Host.Name)) // TODO: check semantics of machine-id
return hex.EncodeToString(ba[:])
} }
extraFuncs["version"] = func() string { return Version } buf := bytes.NewBuffer(make([]byte, 0, 4096))
if err := configTemplate.Execute(ctxName, "config", buf, ctxMap, extraFuncs); err != nil { if err := configTemplate.Execute(ctxName, "config", buf, ctxMap, extraFuncs); err != nil {
log.Fatalf("failed to render config %q for host %q: %v", ctx.Host.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) templateFuncs(ctxMap map[string]any) map[string]any { func (ctx *renderContext) StaticPods() (ba []byte, err error) {
if ctx.StaticPodsTemplate == nil {
log.Fatalf("no such static-pods: %q", ctx.Group.StaticPods)
}
ctxMap := ctx.asMap()
buf := bytes.NewBuffer(make([]byte, 0, 4096))
if err = ctx.StaticPodsTemplate.Execute(ctx.Name(), "static-pods", buf, ctxMap, ctx.templateFuncs(ctxMap)); err != nil {
return
}
ba = buf.Bytes()
return
}
func (ctx *renderContext) templateFuncs(ctxMap map[string]interface{}) map[string]interface{} {
cluster := ctx.Cluster.Name cluster := ctx.Cluster.Name
getKeyCert := func(name, funcName string) (s string, err error) { getKeyCert := func(name, funcName string) (s string, err error) {
@ -229,15 +267,14 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
key += "/" + ctx.Host.Name key += "/" + ctx.Host.Name
} }
switch funcName { if funcName == "tls_dir" {
case "tls_dir":
// needs the dir name // needs the dir name
dir := "/etc/tls/" + name dir := "/etc/tls/" + name
s = fmt.Sprintf("{{ %s %q %q %q %q %q %q %q }}", funcName, s = fmt.Sprintf("{{ %s %q %q %q %q %q %q %q }}", funcName,
dir, cluster, req.CA, key, req.Profile, req.Label, buf.String()) dir, cluster, req.CA, key, req.Profile, req.Label, buf.String())
default: } else {
s = fmt.Sprintf("{{ %s %q %q %q %q %q %q }}", funcName, s = fmt.Sprintf("{{ %s %q %q %q %q %q %q }}", funcName,
cluster, req.CA, key, req.Profile, req.Label, buf.String()) cluster, req.CA, key, req.Profile, req.Label, buf.String())
} }
@ -245,31 +282,10 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
} }
funcs := clusterFuncs(ctx.Cluster) funcs := clusterFuncs(ctx.Cluster)
for k, v := range map[string]any{ for k, v := range map[string]interface{}{
"default": func(value, defaultValue any) any {
switch v := value.(type) {
case string:
if v != "" {
return v
}
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, float32, float64:
if v != 0 {
return v
}
default:
if v != nil {
return v
}
}
return defaultValue
},
"tls_key": func(name string) (string, error) { "tls_key": func(name string) (string, error) {
return getKeyCert(name, "tls_key") return getKeyCert(name, "tls_key")
}, },
"tls_pubkey": func(name string) string {
return fmt.Sprintf("{{ tls_pubkey %q %q }}", ctx.Cluster.Name, name)
},
"tls_crt": func(name string) (s string, err error) { "tls_crt": func(name string) (s string, err error) {
return getKeyCert(name, "tls_crt") return getKeyCert(name, "tls_crt")
@ -279,23 +295,16 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
return getKeyCert(name, "tls_dir") return getKeyCert(name, "tls_dir")
}, },
"ssh_user_ca": func(path string) (s string) {
return fmt.Sprintf("{{ ssh_user_ca %q %q}}",
path, cluster)
},
"ssh_host_keys": func(dir string) (s string) { "ssh_host_keys": func(dir string) (s string) {
return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}", return fmt.Sprintf("{{ ssh_host_keys %q %q %q}}",
dir, cluster) dir, cluster, ctx.Host.Name)
},
"host_download_token": func() (s string) {
return "{{ host_download_token }}"
}, },
"hosts_of_group": func() (hosts []any) { "hosts_of_group": func() (hosts []interface{}) {
hosts = make([]any, 0) hosts = make([]interface{}, 0)
for _, host := range ctx.clusterConfig.Hosts { for _, host := range ctx.clusterConfig.Hosts {
if host.Cluster == ctx.Cluster.Name && host.Group != ctx.Host.Group { if host.Group != ctx.Host.Group {
continue continue
} }
@ -307,31 +316,12 @@ func (ctx *renderContext) templateFuncs(ctxMap map[string]any) map[string]any {
"hosts_of_group_count": func() (count int) { "hosts_of_group_count": func() (count int) {
for _, host := range ctx.clusterConfig.Hosts { for _, host := range ctx.clusterConfig.Hosts {
if host.Cluster == ctx.Cluster.Name && host.Group == ctx.Host.Group { if host.Group == ctx.Host.Group {
count++ count++
} }
} }
return return
}, },
"shuffled_hosts_by_group": func(group string) (hosts []any) {
for _, host := range src.Hosts {
if host.Cluster == ctx.Cluster.Name && host.Group == group {
hosts = append(hosts, asMap(host))
}
}
if len(hosts) == 0 {
log.Printf("WARNING: no hosts in group %q", group)
return
}
seed := xxhash.Sum64String(ctx.Host.Name)
rng := rand.New(rand.NewSource(int64(seed)))
rng.Shuffle(len(hosts), func(i, j int) { hosts[i], hosts[j] = hosts[j], hosts[i] })
return
},
} { } {
funcs[k] = v funcs[k] = v
} }

View File

@ -1,77 +0,0 @@
package main
import (
"bytes"
"fmt"
"io"
"log"
yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/local-server/pkg/clustersconfig"
)
func (ctx *renderContext) renderStaticPods() (pods []namePod) {
if ctx.Host.StaticPods == "" {
return
}
staticPods, ok := src.StaticPods[ctx.Host.StaticPods]
if !ok {
log.Fatalf("no static pods template named %q", ctx.Host.StaticPods)
}
// render static pods
parts := bytes.Split(ctx.renderHostTemplates("static-pods", staticPods), []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("static pod %d: failed to parse: %v\n%s", n, err, str)
}
if len(podMap) == 0 {
continue
}
if podMap["metadata"] == nil {
log.Fatalf("static 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.NewBuffer(make([]byte, 0, 16<<10))
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

@ -1,59 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"sort"
)
var searchList = make([]FS, 0)
// read the first file matching path in the search list
func read(path string) (ba []byte, err error) {
for _, fs := range searchList {
var r io.ReadCloser
r, err = fs.Open(path)
if os.IsNotExist(err) {
continue
}
if err != nil {
return
}
defer r.Close()
return io.ReadAll(r)
}
err = fmt.Errorf("%s: %w", path, os.ErrNotExist)
return
}
func listBase(path string) ([]string, error) {
return fsFS{base}.List(path)
}
func listMerged(path string) (entries []string, err error) {
seen := map[string]bool{}
for _, fs := range searchList {
var fsEnts []string
fsEnts, err = fs.List(path)
if os.IsNotExist(err) {
err = nil
continue
}
if err != nil {
return
}
for _, ent := range fsEnts {
if !seen[ent] {
entries = append(entries, ent)
seen[ent] = true
}
}
}
sort.Strings(entries)
return
}

View File

@ -1,44 +1,36 @@
package main package main
import ( import (
"flag"
"log" "log"
"net/http" "net/http"
) )
var adminToken string var (
hostsToken = flag.String("hosts-token", "", "Token to give to access /hosts (open is none)")
adminToken = flag.String("admin-token", "", "Token to give to access to admin actions (open is none)")
)
func authorizeHosts(r *http.Request) bool {
return authorizeToken(r, *hostsToken)
}
func authorizeAdmin(r *http.Request) bool { func authorizeAdmin(r *http.Request) bool {
return authorizeToken(r, adminToken) return authorizeToken(r, *adminToken)
} }
func authorizeToken(r *http.Request, token string) bool { func authorizeToken(r *http.Request, token string) bool {
if token == "" { if token == "" {
return false // access is open
return true
} }
reqToken := r.Header.Get("Authorization") reqToken := r.Header.Get("Authorization")
if reqToken != "" {
return reqToken == "Bearer "+token
}
return r.URL.Query().Get("token") == token return reqToken == "Bearer "+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.URL.Path, 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)
}

View File

@ -3,9 +3,9 @@ package main
import ( import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"flag"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"os" "os"
"os/exec" "os/exec"
@ -17,7 +17,7 @@ import (
) )
func buildBootImg(out io.Writer, ctx *renderContext) (err error) { func buildBootImg(out io.Writer, ctx *renderContext) (err error) {
bootImg, err := os.CreateTemp(os.TempDir(), "boot.img-") bootImg, err := ioutil.TempFile(os.TempDir(), "boot.img-")
if err != nil { if err != nil {
return return
} }
@ -29,7 +29,7 @@ func buildBootImg(out io.Writer, ctx *renderContext) (err error) {
} }
// send the result // send the result
bootImg.Seek(0, io.SeekStart) bootImg.Seek(0, os.SEEK_SET)
io.Copy(out, bootImg) io.Copy(out, bootImg)
return return
} }
@ -56,60 +56,8 @@ func buildBootImgGZ(out io.Writer, ctx *renderContext) (err error) {
return return
} }
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 {
return
}
defer rmTempFile(bootImg)
err = setupBootImage(bootImg, ctx)
if err != nil {
return
}
err = bootImg.Sync()
if err != nil {
return
}
imgPath = bootImg.Name() + "." + format
err = run("qemu-img", "convert", "-f", "raw", "-O", format, bootImg.Name(), imgPath)
if err != nil {
return
}
return
}()
if imgPath != "" {
defer os.Remove(imgPath)
}
if err != nil {
return
}
// send the result
img, err := os.Open(imgPath)
if err != nil {
return
}
io.Copy(out, img)
return
}
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)
}
}
var grubSupportVersion = flag.String("grub-support", "1.1.0", "GRUB support version")
func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) { func setupBootImage(bootImg *os.File, ctx *renderContext) (err error) {
path, err := distFetch("grub-support", *grubSupportVersion) path, err := ctx.distFetch("grub-support", "1.0.0")
if err != nil { if err != nil {
return return
} }

View File

@ -3,29 +3,33 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"github.com/cespare/xxhash"
) )
func buildBootISO(out io.Writer, ctx *renderContext) (err error) { func buildBootISO(out io.Writer, ctx *renderContext) error {
tempDir, err := os.MkdirTemp("/tmp", "iso-v2-") tempDir, err := ioutil.TempDir("/tmp", "iso-")
if err != nil { if err != nil {
return return err
} }
defer os.RemoveAll(tempDir) defer os.RemoveAll(tempDir)
buildRes := func(build func(out io.Writer, ctx *renderContext) error, dst string) (err error) { cp := func(src, dst string) error {
log.Printf("iso-v2: building %s", dst) log.Printf("iso: adding %s as %s", src, dst)
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
outPath := filepath.Join(tempDir, dst) outPath := filepath.Join(tempDir, dst)
if err = os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err return err
} }
@ -33,55 +37,32 @@ func buildBootISO(out io.Writer, ctx *renderContext) (err error) {
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
err = build(out, ctx) _, err = io.Copy(out, in)
if err != nil {
return
}
return err return err
} }
err = func() (err error) { err = func() error {
// grub // grub
if err = os.MkdirAll(filepath.Join(tempDir, "grub"), 0755); err != nil { if err := os.MkdirAll(filepath.Join(tempDir, "grub"), 0755); err != nil {
return return err
} }
err = ioutil.WriteFile(filepath.Join(tempDir, "grub", "grub.cfg"), []byte(`
// create a tag file search --set=root --file /config.yaml
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 = os.WriteFile(filepath.Join(tempDir, "grub", "grub.cfg"), []byte(`
search --set=root --file /`+tag+`
insmod all_video insmod all_video
set timeout=3 set timeout=3
menuentry "Direktil" { menuentry "Direktil" {
linux /vmlinuz `+ctx.CmdLine+` linux /vmlinuz direktil.boot=DEVNAME=sr0 direktil.boot.fs=iso9660 `+ctx.CmdLine+`
initrd /initrd initrd /initrd
} }
`), 0644) `), 0644)
if err != nil { if err != nil {
return return err
} }
coreImgPath := filepath.Join(tempDir, "grub", "core.img") coreImgPath := filepath.Join(tempDir, "grub", "core.img")
@ -112,7 +93,7 @@ menuentry "Direktil" {
defer out.Close() defer out.Close()
b, err := os.ReadFile("/usr/lib/grub/i386-pc/cdboot.img") b, err := ioutil.ReadFile("/usr/lib/grub/i386-pc/cdboot.img")
if err != nil { if err != nil {
return err return err
} }
@ -121,7 +102,7 @@ menuentry "Direktil" {
return err return err
} }
b, err = os.ReadFile(coreImgPath) b, err = ioutil.ReadFile(coreImgPath)
if err != nil { if err != nil {
return err return err
} }
@ -136,9 +117,50 @@ menuentry "Direktil" {
return err return err
} }
// config
cfgBytes, cfg, err := ctx.Config()
if err != nil {
return err
}
ioutil.WriteFile(filepath.Join(tempDir, "config.yaml"), cfgBytes, 0600)
// kernel and initrd // kernel and initrd
buildRes(fetchKernel, "vmlinuz") type distCopy struct {
buildRes(buildInitrd, "initrd") Src []string
Dst string
}
copies := []distCopy{
{Src: []string{"kernels", ctx.Host.Kernel}, Dst: "vmlinuz"},
{Src: []string{"initrd", ctx.Host.Initrd}, Dst: "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 {
return err
}
err = cp(outPath, copy.Dst)
if err != nil {
return err
}
}
// build the ISO // build the ISO
mkisofs, err := exec.LookPath("genisoimage") mkisofs, err := exec.LookPath("genisoimage")

View File

@ -4,6 +4,7 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"io" "io"
"io/ioutil"
"log" "log"
"os" "os"
@ -31,12 +32,12 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
} }
// kernel // kernel
kernelPath, err := distFetch("kernels", ctx.Host.Kernel) kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
if err != nil { if err != nil {
return return
} }
kernelBytes, err := os.ReadFile(kernelPath) kernelBytes, err := ioutil.ReadFile(kernelPath)
if err != nil { if err != nil {
return return
} }
@ -48,7 +49,7 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
// initrd // initrd
initrd := new(bytes.Buffer) initrd := new(bytes.Buffer)
err = buildInitrd(initrd, ctx) err = buildInitrdV2(initrd, ctx)
if err != nil { if err != nil {
return return
} }
@ -92,12 +93,12 @@ func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) {
} }
// kernel // kernel
kernelPath, err := distFetch("kernels", ctx.Host.Kernel) kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
if err != nil { if err != nil {
return return
} }
kernelBytes, err := os.ReadFile(kernelPath) kernelBytes, err := ioutil.ReadFile(kernelPath)
if err != nil { if err != nil {
return return
} }
@ -109,7 +110,7 @@ func buildBootEFITar(out io.Writer, ctx *renderContext) (err error) {
// initrd // initrd
initrd := new(bytes.Buffer) initrd := new(bytes.Buffer)
err = buildInitrd(initrd, ctx) err = buildInitrdV2(initrd, ctx)
if err != nil { if err != nil {
return return
} }

View File

@ -2,8 +2,6 @@ package main
import ( import (
"archive/tar" "archive/tar"
"bytes"
"crypto"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -33,7 +31,7 @@ func renderBootstrapConfig(w http.ResponseWriter, r *http.Request, ctx *renderCo
return nil return nil
} }
func buildInitrd(out io.Writer, ctx *renderContext) (err error) { func buildInitrdV2(out io.Writer, ctx *renderContext) (err error) {
_, cfg, err := ctx.Config() _, cfg, err := ctx.Config()
if err != nil { if err != nil {
return return
@ -42,7 +40,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
cat := cpiocat.New(out) cat := cpiocat.New(out)
// initrd // initrd
initrdPath, err := distFetch("initrd", ctx.Host.Initrd) initrdPath, err := ctx.distFetch("initrd", "2.0.0" /* FIXME */)
if err != nil { if err != nil {
return return
} }
@ -54,7 +52,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
case "modules": case "modules":
layerVersion := ctx.Host.Versions[layer] layerVersion := ctx.Host.Versions[layer]
modulesPath, err := distFetch("layers", layer, layerVersion) modulesPath, err := ctx.distFetch("layers", layer, layerVersion)
if err != nil { if err != nil {
return err return err
} }
@ -68,25 +66,11 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
if err != nil { if err != nil {
return return
} }
cat.AppendBytes(cfgBytes, "config.yaml", 0o600) cat.AppendBytes(cfgBytes, "config.yaml", 0600)
// ssh keys // ssh keys
cat.AppendDir("/etc", 0o755) // FIXME we want a bootstrap-stage key instead of the real host key
cat.AppendDir("/etc/ssh", 0o700) cat.AppendBytes(cfg.FileContent("/etc/ssh/ssh_host_rsa_key"), "id_rsa", 0600)
// XXX do we want bootstrap-stage keys instead of the real host key?
for _, format := range []string{"rsa", "dsa", "ecdsa", "ed25519"} {
keyPath := "/etc/ssh/ssh_host_" + format + "_key"
cat.AppendBytes(cfg.FileContent(keyPath), keyPath, 0o600)
}
// ssh user CA
userCA, err := sshCAPubKey(ctx.Host.ClusterName)
if err != nil {
return fmt.Errorf("failed to get SSH user CA: %w", err)
}
cat.AppendBytes(userCA, "user_ca.pub", 0600)
return cat.Close() return cat.Close()
} }
@ -95,47 +79,13 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
arch := tar.NewWriter(out) arch := tar.NewWriter(out)
defer arch.Close() defer arch.Close()
ca, err := getUsableClusterCA(ctx.Host.ClusterName, "boot-signer")
if err != nil {
return
}
signer, err := ca.ParseKey()
if err != nil {
return
}
hash := crypto.SHA512
sign := func(name string, digest []byte) (err error) {
sigBytes, err := signer.Sign(nil, digest, hash)
if err != nil {
err = fmt.Errorf("signing to %s failed: %w", name, err)
return err
}
if err = arch.WriteHeader(&tar.Header{
Name: name,
Size: int64(len(sigBytes)),
Mode: 0o644,
}); err != nil {
return
}
_, err = io.Copy(arch, bytes.NewReader(sigBytes))
return
}
// config // config
cfgBytes, cfg, err := ctx.Config() cfgBytes, cfg, err := ctx.Config()
if err != nil { if err != nil {
return err return err
} }
err = arch.WriteHeader(&tar.Header{ err = arch.WriteHeader(&tar.Header{Name: "config.yaml", Size: int64(len(cfgBytes))})
Name: "config.yaml",
Size: int64(len(cfgBytes)),
Mode: 0o600,
})
if err != nil { if err != nil {
return return
} }
@ -145,19 +95,10 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
return return
} }
{
h := hash.New()
h.Write(cfgBytes)
err = sign("config.yaml.sig", h.Sum(nil))
if err != nil {
return
}
}
// layers // layers
for _, layer := range cfg.Layers { for _, layer := range cfg.Layers {
if layer == "modules" { if layer == "modules" {
continue // modules are in the initrd with boot v2 continue // modules are with the kernel in boot v2
} }
layerVersion := ctx.Host.Versions[layer] layerVersion := ctx.Host.Versions[layer]
@ -165,7 +106,7 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
return fmt.Errorf("layer %q not mapped to a version", layer) return fmt.Errorf("layer %q not mapped to a version", layer)
} }
outPath, err := distFetch("layers", layer, layerVersion) outPath, err := ctx.distFetch("layers", layer, layerVersion)
if err != nil { if err != nil {
return err return err
} }
@ -182,24 +123,14 @@ func buildBootstrap(out io.Writer, ctx *renderContext) (err error) {
return err return err
} }
h := hash.New()
reader := io.TeeReader(f, h)
if err = arch.WriteHeader(&tar.Header{ if err = arch.WriteHeader(&tar.Header{
Name: layer + ".fs", Name: layer + ".fs",
Size: stat.Size(), Size: stat.Size(),
Mode: 0o600,
}); err != nil { }); err != nil {
return err return err
} }
_, err = io.Copy(arch, reader) _, err = io.Copy(arch, f)
if err != nil {
return err
}
digest := h.Sum(nil)
err = sign(layer+".fs.sig", digest)
if err != nil { if err != nil {
return err return err
} }

View File

@ -0,0 +1,75 @@
package main
import (
"flag"
"log"
"sort"
"time"
)
var (
cacheCleanDelay = flag.Duration("cache-clean-delay", 10*time.Minute, "Time between cache cleanups")
)
func casCleaner() {
for {
err := cleanCAS()
if err != nil {
log.Print("warn: couldn't clean cache: ", err)
}
time.Sleep(*cacheCleanDelay)
}
}
func cleanCAS() error {
cfg, err := readConfig()
if err != nil {
return nil
}
activeTags := make([]string, len(cfg.Hosts))
for i, host := range cfg.Hosts {
// FIXME ugly hack, same as in dir2config
cfg, err := readConfig()
if err != nil {
return err
}
ctx, err := newRenderContext(host, cfg)
if err != nil {
return err
}
tag, err := ctx.Tag()
if err != nil {
return err
}
activeTags[i] = tag
}
tags, err := casStore.Tags()
if err != nil {
return err
}
sort.Strings(activeTags)
for _, tag := range tags {
idx := sort.SearchStrings(activeTags, tag)
if idx < len(activeTags) && activeTags[idx] == tag {
continue
}
// tag is not present in active tags
log.Print("cache cleaner: removing tag ", tag)
if err := casStore.Remove(tag); err != nil {
log.Printf("cache cleaner: failed to remove tag %s: %v", tag, err)
}
}
return nil
}

View File

@ -1,100 +1,32 @@
package main package main
import ( import (
"crypto"
"crypto/rand"
"crypto/x509"
"encoding/base32"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"path" "path"
"strconv"
"strings"
cfsslconfig "github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr" "github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/helpers"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/bootstrapconfig"
"novit.tech/direktil/pkg/config" "novit.tech/direktil/pkg/config"
) )
func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any { var templateFuncs = map[string]interface{}{
getKey := func(cluster, caName string) (key crypto.Signer, err error) { "password": func(cluster, name string) (password string, err error) {
ca, err := getUsableClusterCA(cluster, caName) password = secretData.Password(cluster, name)
if err != nil { if len(password) == 0 {
err = fmt.Errorf("password %q not defined for cluster %q", name, cluster)
}
return return
}
key, err = helpers.ParsePrivateKeyPEM(ca.Key)
return
}
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, sslCfg)
}
hash := func(plain, seed []byte, hashAlg string) (hashed string, err error) {
switch hashAlg {
case "sha512crypt":
return sha512crypt(plain, seed)
case "bootstrap":
return bootstrapconfig.JoinSeedAndHash(seed, bootstrapconfig.PasswordHashFromSeed(seed, plain)), nil
default:
return "", fmt.Errorf("unknown hash alg: %q", hashAlg)
}
}
return map[string]any{
"quote": strconv.Quote,
"password": func(cluster, name, hashAlg string) (password string, err error) {
key := cluster + "/" + name
seed, err := seeds.GetOrCreate(key, func() (seed []byte, err error) {
seed = make([]byte, 16)
_, err = rand.Read(seed)
return
})
if err != nil {
return "", fmt.Errorf("failed to get seed: %w", err)
}
password, err = clusterPasswords.GetOrCreate(key, func() (password string, err error) {
raw := make([]byte, 10)
_, err = rand.Read(raw)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
password = strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw))
return
})
if err != nil {
return
}
return hash([]byte(password), seed, hashAlg)
}, },
"token": getOrCreateClusterToken, "token": func(cluster, name string) (s string, err error) {
return secretData.Token(cluster, name)
},
"ca_key": func(cluster, name string) (s string, err error) { "ca_key": func(cluster, name string) (s string, err error) {
ca, err := getUsableClusterCA(cluster, name) ca, err := secretData.CA(cluster, name)
if err != nil { if err != nil {
return return
} }
@ -104,7 +36,7 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
}, },
"ca_crt": func(cluster, name string) (s string, err error) { "ca_crt": func(cluster, name string) (s string, err error) {
ca, err := getUsableClusterCA(cluster, name) ca, err := secretData.CA(cluster, name)
if err != nil { if err != nil {
return return
} }
@ -114,7 +46,7 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
}, },
"ca_dir": func(cluster, name string) (s string, err error) { "ca_dir": func(cluster, name string) (s string, err error) {
ca, err := getUsableClusterCA(cluster, name) ca, err := secretData.CA(cluster, name)
if err != nil { if err != nil {
return return
} }
@ -145,22 +77,6 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
return return
}, },
"tls_pubkey": func(cluster, caName string) (s string, err error) {
priv, err := getKey(cluster, caName)
if err != nil {
return
}
ba, err := x509.MarshalPKIXPublicKey(priv.Public())
if err != nil {
err = fmt.Errorf("marshal public key failed: %w", err)
return
}
s = base64.StdEncoding.EncodeToString(ba)
return
},
"tls_crt": func(cluster, caName, name, profile, label, reqJson string) (s string, err error) { "tls_crt": func(cluster, caName, name, profile, label, reqJson string) (s string, err error) {
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson) kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
if err != nil { if err != nil {
@ -172,7 +88,7 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
}, },
"tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) { "tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) {
ca, err := getUsableClusterCA(cluster, caName) ca, err := secretData.CA(cluster, caName)
if err != nil { if err != nil {
return return
} }
@ -200,7 +116,47 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
}, },
}) })
}, },
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
pairs, err := secretData.SSHKeyPairs(cluster, host)
if err != nil {
return
} }
files := make([]config.FileDef, 0, len(pairs)*2)
for _, pair := range pairs {
basePath := path.Join(dir, "ssh_host_"+pair.Type+"_key")
files = append(files, []config.FileDef{
{
Path: basePath,
Mode: 0600,
Content: pair.Private,
},
{
Path: basePath + ".pub",
Mode: 0644,
Content: pair.Public,
},
}...)
}
return asYaml(files)
},
}
func getKeyCert(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 secretData.KeyCert(cluster, caName, name, profile, label, certReq)
} }
func asYaml(v interface{}) (string, error) { func asYaml(v interface{}) (string, error) {

View File

@ -1,3 +0,0 @@
package main
var hostDownloadTokens = KVSecrets[string]{"hosts/download-tokens"}

View File

@ -1,16 +0,0 @@
package main
import (
"net/http"
"m.cluseau.fr/go/httperr"
)
var (
ErrNotFound = httperr.NotFound
ErrUnauthorized = httperr.StdStatus(http.StatusUnauthorized)
ErrForbidden = httperr.StdStatus(http.StatusForbidden)
ErrInternal = httperr.StdStatus(http.StatusInternalServerError)
ErrInvalidToken = httperr.NewStd(1000, http.StatusForbidden, "invalid token")
ErrStoreLocked = httperr.NewStd(1001, http.StatusServiceUnavailable, "store is locked")
)

View File

@ -2,9 +2,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io"
"log" "log"
"net/http" "net/http"
"os"
cpio "github.com/cavaliergopher/cpio"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
) )
@ -24,3 +28,84 @@ func renderConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, as
return nil return nil
} }
func buildInitrd(out io.Writer, ctx *renderContext) error {
_, cfg, err := ctx.Config()
if err != nil {
return err
}
// send initrd basis
initrdPath, err := ctx.distFetch("initrd", ctx.Host.Initrd)
if err != nil {
return err
}
err = writeFile(out, initrdPath)
if err != nil {
return err
}
// and our extra archive
archive := cpio.NewWriter(out)
// - required dirs
for _, dir := range []string{
"boot",
"boot/current",
"boot/current/layers",
} {
archive.WriteHeader(&cpio.Header{
Name: dir,
Mode: cpio.FileMode(0600 | os.ModeDir),
})
}
// - the layers
for _, layer := range cfg.Layers {
layerVersion := ctx.Host.Versions[layer]
if layerVersion == "" {
return fmt.Errorf("layer %q not mapped to a version", layer)
}
path, err := ctx.distFetch("layers", layer, layerVersion)
if err != nil {
return err
}
stat, err := os.Stat(path)
if err != nil {
return err
}
archive.WriteHeader(&cpio.Header{
Name: "boot/current/layers/" + layer + ".fs",
Mode: 0600,
Size: stat.Size(),
})
if err = writeFile(archive, path); err != nil {
return err
}
}
// - the configuration
ba, err := yaml.Marshal(cfg)
if err != nil {
return err
}
archive.WriteHeader(&cpio.Header{
Name: "boot/config.yaml",
Mode: 0600,
Size: int64(len(ba)),
})
archive.Write(ba)
// finalize the archive
archive.Flush()
archive.Close()
return nil
}

View File

@ -1,14 +1,12 @@
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 {
path, err := distFetch("kernels", ctx.Host.Kernel) path, err := ctx.distFetch("kernels", ctx.Host.Kernel)
if err != nil { if err != nil {
return err return err
} }
@ -17,19 +15,3 @@ 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 := 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,13 @@ import (
"flag" "flag"
"log" "log"
"net/http" "net/http"
"os" "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"
"m.cluseau.fr/go/watchable/streamsse"
dlshtml "novit.tech/direktil/local-server/html" "novit.tech/direktil/pkg/cas"
"novit.tech/direktil/local-server/pkg/apiutils" "novit.tech/direktil/local-server/pkg/apiutils"
) )
@ -18,15 +18,13 @@ const (
etcDir = "/etc/direktil" etcDir = "/etc/direktil"
) )
var Version = "dev"
var ( var (
address = flag.String("address", ":7606", "HTTP listen address") address = flag.String("address", ":7606", "HTTP listen address")
tlsAddress = flag.String("tls-address", "", "HTTPS listen address") tlsAddress = flag.String("tls-address", "", "HTTPS listen address")
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!) env: DLS_AUTO_UNLOCK") casStore cas.Store
) )
func main() { func main() {
@ -38,30 +36,8 @@ func main() {
log.Fatal("no listen address given") log.Fatal("no listen address given")
} }
log.Print("Direktil local-server version ", Version) casStore = cas.NewDir(filepath.Join(*dataDir, "cache"))
wPublicState.Change(func(s *PublicState) { s.ServerVersion = Version }) go casCleaner()
computeUIHash()
openSecretStore()
{
autoUnlock := *autoUnlock
if autoUnlock == "" {
autoUnlock = os.Getenv("DLS_AUTO_UNLOCK")
}
if autoUnlock != "" {
log.Printf("auto-unlocking the store")
err := unlockSecretStore("test", []byte(autoUnlock))
if err.Any() {
log.Fatal(err)
}
log.Print("store auto-unlocked")
}
os.Setenv("DLS_AUTO_UNLOCK", "")
}
apiutils.Setup(func() { apiutils.Setup(func() {
registerWS(restful.DefaultContainer) registerWS(restful.DefaultContainer)
@ -69,14 +45,6 @@ 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("/dist/", http.StripPrefix("/dist/", upstreamServer{}))
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

@ -1,23 +0,0 @@
package main
import (
"fmt"
"time"
)
func Example_parseCertDuration() {
now := time.Date(2020, time.April, 28, 12, 30, 0, 0, time.UTC)
fmt.Println(parseCertDuration("", now))
fmt.Println(parseCertDuration("hi!", now))
fmt.Println(parseCertDuration("-2d3h", now))
fmt.Println(parseCertDuration("2d3h", now))
fmt.Println(parseCertDuration("+1y-1s", now))
// output:
// 0001-01-01 00:00:00 +0000 UTC <nil>
// 0001-01-01 00:00:00 +0000 UTC invalid duration: "hi!"
// 2020-04-26 09:30:00 +0000 UTC <nil>
// 2020-04-30 15:30:00 +0000 UTC <nil>
// 2021-04-28 12:29:59 +0000 UTC <nil>
}

View File

@ -2,16 +2,12 @@ package main
import ( import (
"bytes" "bytes"
"crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path"
"path/filepath" "path/filepath"
"text/template" "text/template"
@ -29,14 +25,19 @@ var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addit
type renderContext struct { type renderContext struct {
Host *localconfig.Host Host *localconfig.Host
SSLConfig *cfsslconfig.Config SSLConfig string
// Linux kernel extra cmdline // Linux kernel extra cmdline
CmdLine string `yaml:"-"` CmdLine string `yaml:"-"`
} }
func renderCtx(w http.ResponseWriter, r *http.Request, ctx *renderContext, what string, func renderCtx(w http.ResponseWriter, r *http.Request, ctx *renderContext, what string,
create func(out io.Writer, ctx *renderContext) error) (err error) { create func(out io.Writer, ctx *renderContext) error) error {
tag, err := ctx.Tag()
if err != nil {
return err
}
ctx.CmdLine = r.URL.Query().Get(cmdlineParam.Data().Name) ctx.CmdLine = r.URL.Query().Get(cmdlineParam.Data().Name)
@ -45,29 +46,27 @@ func renderCtx(w http.ResponseWriter, r *http.Request, ctx *renderContext, what
} }
// get it or create it // get it or create it
outfile, err := os.CreateTemp("/tmp", "dls."+what+".") content, meta, err := casStore.GetOrCreate(tag, what, func(out io.Writer) error {
if err != nil {
return
}
defer os.Remove(outfile.Name())
log.Printf("building %s for %q", what, ctx.Host.Name) log.Printf("building %s for %q", what, ctx.Host.Name)
err = create(outfile, ctx) return create(out, ctx)
})
if err != nil { if err != nil {
return return err
} }
// serve it // serve it
log.Printf("sending %s for %q", what, ctx.Host.Name) log.Printf("sending %s for %q", what, ctx.Host.Name)
http.ServeContent(w, r, what, meta.ModTime(), content)
outfile.Seek(0, io.SeekStart) return nil
io.Copy(w, outfile)
return
} }
func sslConfigFromLocalConfig(cfg *localconfig.Config) (sslCfg *cfsslconfig.Config, err error) { var prevSSLConfig = "-"
func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) {
if prevSSLConfig != cfg.SSLConfig {
var sslCfg *cfsslconfig.Config
if len(cfg.SSLConfig) == 0 { if len(cfg.SSLConfig) == 0 {
sslCfg = &cfsslconfig.Config{} sslCfg = &cfsslconfig.Config{}
} else { } else {
@ -76,18 +75,18 @@ func sslConfigFromLocalConfig(cfg *localconfig.Config) (sslCfg *cfsslconfig.Conf
return return
} }
} }
return
}
func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) { err = loadSecretData(sslCfg)
sslCfg, err := sslConfigFromLocalConfig(cfg)
if err != nil { if err != nil {
return return
} }
prevSSLConfig = cfg.SSLConfig
}
return &renderContext{ return &renderContext{
SSLConfig: cfg.SSLConfig,
Host: host, Host: host,
SSLConfig: sslCfg,
}, nil }, nil
} }
@ -113,7 +112,6 @@ func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, er
cfg = &bsconfig.Config{} cfg = &bsconfig.Config{}
if err = yaml.Unmarshal(ba, cfg); err != nil { if err = yaml.Unmarshal(ba, cfg); err != nil {
log.Print("invalid bootstrap config yaml:\n", string(ba))
return return
} }
@ -122,7 +120,7 @@ func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, er
func (ctx *renderContext) render(templateText string) (ba []byte, err error) { 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(ctx.TemplateFuncs()). Funcs(templateFuncs).
Parse(templateText) Parse(templateText)
if err != nil { if err != nil {
@ -134,11 +132,18 @@ func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
return return
} }
if secretData.Changed() {
err = secretData.Save()
if err != nil {
return
}
}
ba = buf.Bytes() ba = buf.Bytes()
return return
} }
func distFilePath(path ...string) string { func (ctx *renderContext) distFilePath(path ...string) string {
return filepath.Join(append([]string{*dataDir, "dist"}, path...)...) return filepath.Join(append([]string{*dataDir, "dist"}, path...)...)
} }
@ -152,7 +157,7 @@ func (ctx *renderContext) Tag() (string, error) {
enc := yaml.NewEncoder(h) enc := yaml.NewEncoder(h)
for _, o := range []any{cfg, ctx} { for _, o := range []interface{}{cfg, ctx} {
if err := enc.Encode(o); err != nil { if err := enc.Encode(o); err != nil {
return "", err return "", err
} }
@ -161,87 +166,17 @@ func (ctx *renderContext) Tag() (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
} }
func (ctx *renderContext) TemplateFuncs() map[string]any { func asMap(v interface{}) map[string]interface{} {
funcs := templateFuncs(ctx.SSLConfig) ba, err := yaml.Marshal(v)
for name, method := range map[string]any{
"host_ip": func() (s string) {
return ctx.Host.IPs[0]
},
"host_name": func() (s string) {
return ctx.Host.Name
},
"machine_id": func() (s string) {
ba := sha1.Sum([]byte(ctx.Host.ClusterName + "/" + ctx.Host.Name))
return hex.EncodeToString(ba[:])
},
"ssh_user_ca": func(path, cluster string) (s string, err error) {
userCA, err := sshCAPubKey(cluster)
return asYaml([]config.FileDef{{
Path: path,
Mode: 0644,
Content: string(userCA),
}})
},
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
if host == "" {
host = ctx.Host.Name
}
if host != ctx.Host.Name {
err = fmt.Errorf("wrong host name")
return
}
pairs, err := getSSHKeyPairs(host)
if err != nil { if err != nil {
return panic(err) // shouldn't happen
} }
files := make([]config.FileDef, 0, len(pairs)*2) result := make(map[string]interface{})
for _, pair := range pairs { if err := yaml.Unmarshal(ba, result); err != nil {
basePath := path.Join(dir, "ssh_host_"+pair.Type+"_key") panic(err) // shouldn't happen
files = append(files, []config.FileDef{
{
Path: basePath,
Mode: 0600,
Content: pair.Private,
},
{
Path: basePath + ".pub",
Mode: 0644,
Content: pair.Public,
},
}...)
} }
return asYaml(files) return result
},
"host_download_token": func() (token string, err error) {
key := ctx.Host.Name
token, found, err := hostDownloadTokens.Get(key)
if err != nil {
return
}
if !found {
token, err = newToken(32)
if err != nil {
return
}
err = hostDownloadTokens.Put(key, token)
if err != nil {
return
}
}
return
},
} {
funcs[name] = method
}
return funcs
} }

View File

@ -1,397 +0,0 @@
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
restful "github.com/emicklei/go-restful"
"m.cluseau.fr/go/httperr"
"novit.tech/direktil/local-server/secretstore"
)
var secStore *secretstore.Store
func secStoreRoot() string { return filepath.Join(*dataDir, "secrets") }
func secStorePath(name string) string { return filepath.Join(secStoreRoot(), 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(1, http.StatusConflict, "store already unlocked")
ErrInvalidPassphrase = httperr.NewStd(2, http.StatusBadRequest, "invalid passphrase")
)
func unlockSecretStore(name string, passphrase []byte) (err httperr.Error) {
unlockMutex.Lock()
defer unlockMutex.Unlock()
if secStore.Unlocked() {
return ErrStoreAlreadyUnlocked
}
if secStore.IsNew() {
err := secStore.Init(name, passphrase)
if err != nil {
return httperr.Internal(err)
}
err = secStore.SaveTo(secKeysStorePath())
if err != nil {
log.Print("secret store save error: ", err)
secStore.Close()
return httperr.Internal(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.Internal(err)
}
token, err = newToken(32)
if err != nil {
secStore.Close()
return httperr.Internal(err)
}
err = writeSecret("admin-token", token)
if err != nil {
log.Print("write error: ", err)
secStore.Close()
return httperr.Internal(err)
}
log.Print("wrote new admin token")
}
adminToken = token
{
token, err := newToken(16)
if err != nil {
secStore.Close()
return httperr.Internal(err)
}
wState.Change(func(v *State) {
v.Store.DownloadToken = token
})
}
wPublicState.Change(func(v *PublicState) {
v.Store.New = false
v.Store.Open = true
})
go updateState()
go migrateSecrets()
return
}
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) {
path := secStorePath(name + ".data.new")
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return
}
f, err := os.Create(path)
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
}
err = os.Rename(f.Name(), secStorePath(name+".data"))
if err != nil {
return
}
go updateState()
return
}
var secL sync.Mutex
func updateSecret[T any](name string, update func(*T)) (err error) {
secL.Lock()
defer secL.Unlock()
v := new(T)
err = readSecret(name, v)
if err != nil {
if !os.IsNotExist(err) {
return
}
err = nil
}
update(v)
return writeSecret(name, *v)
}
func updateSecretWithKey[T any](name, key string, update func(v *T)) (err error) {
secL.Lock()
defer secL.Unlock()
kvs := map[string]*T{}
err = readSecret(name, &kvs)
if err != nil {
if !os.IsNotExist(err) {
return
}
err = nil
}
update(kvs[key])
return writeSecret(name, kvs)
}
type KVSecrets[T any] struct{ Name string }
func (s KVSecrets[T]) Data() (kvs map[string]T, err error) {
kvs = make(map[string]T)
err = readSecret(s.Name, &kvs)
if err != nil {
if !os.IsNotExist(err) {
return
}
err = nil
}
return
}
type KV[T any] struct {
K string
V T
}
func (s KVSecrets[T]) List(prefix string) (list []KV[T], err error) {
data, err := s.Data()
if err != nil {
return
}
list = make([]KV[T], 0, len(data))
for k, v := range data {
if !strings.HasPrefix(k, prefix) {
continue
}
list = append(list, KV[T]{k, v})
}
sort.Slice(list, func(i, j int) bool { return list[i].K < list[j].K })
return
}
func (s KVSecrets[T]) Keys(prefix string) (keys []string, err error) {
kvs, err := s.Data()
if err != nil {
return
}
keys = make([]string, 0, len(kvs))
for k := range kvs {
if !strings.HasPrefix(k, prefix) {
continue
}
keys = append(keys, k[len(prefix):])
}
sort.Strings(keys)
return
}
func (s KVSecrets[T]) Get(key string) (v T, found bool, err error) {
kvs, err := s.Data()
if err != nil {
return
}
v, found = kvs[key]
return
}
func (s KVSecrets[T]) Put(key string, v T) (err error) {
secL.Lock()
defer secL.Unlock()
kvs, err := s.Data()
if err != nil {
return
}
kvs[key] = v
err = writeSecret(s.Name, kvs)
return
}
func (s KVSecrets[T]) Del(key string) (err error) {
secL.Lock()
defer secL.Unlock()
kvs, err := s.Data()
if err != nil {
return
}
delete(kvs, key)
err = writeSecret(s.Name, kvs)
return
}
func (s KVSecrets[T]) GetOrCreate(key string, create func() (T, error)) (v T, err error) {
v, found, err := s.Get(key)
if err != nil {
return
}
if !found {
v, err = create()
if err != nil {
return
}
err = s.Put(key, v)
}
return
}
func (s KVSecrets[T]) WsList(resp *restful.Response, prefix string) {
keys, err := s.Keys(prefix)
if err != nil {
wsError(resp, err)
return
}
resp.WriteEntity(keys)
}
func (s KVSecrets[T]) WsGet(resp *restful.Response, key string) {
keys, found, err := s.Get(key)
if err != nil {
wsError(resp, err)
return
}
if !found {
wsNotFound(resp)
return
}
resp.WriteEntity(keys)
}
func (s KVSecrets[T]) WsPut(req *restful.Request, resp *restful.Response, key string) {
v := new(T)
err := req.ReadEntity(v)
if err != nil {
wsBadRequest(resp, err.Error())
return
}
err = s.Put(key, *v)
if err != nil {
wsError(resp, err)
return
}
}
func (s KVSecrets[T]) WsDel(req *restful.Request, resp *restful.Response, key string) {
err := s.Del(key)
if err != nil {
wsError(resp, err)
return
}
}

View File

@ -1,88 +0,0 @@
package main
import (
"log"
"os"
cfsslconfig "github.com/cloudflare/cfssl/config"
)
func migrateSecrets() {
if _, err := os.Stat(secretDataPath()); err != nil {
if os.IsNotExist(err) {
return
}
log.Print("not migrating old secrets: ", err)
return
}
log.Print("migrating old secrets")
log := log.New(log.Default().Writer(), "secrets migration: ", log.Flags()|log.Lmsgprefix)
// load secrets
cfg, err := readConfig()
if err != nil {
log.Fatal(err)
return
}
var sslCfg *cfsslconfig.Config
if len(cfg.SSLConfig) == 0 {
sslCfg = &cfsslconfig.Config{}
} else {
sslCfg, err = cfsslconfig.LoadConfig([]byte(cfg.SSLConfig))
if err != nil {
return
}
}
secretData, err := loadSecretData(sslCfg)
if err != nil {
log.Fatal(err)
return
}
for clusterName, cluster := range secretData.clusters {
for k, v := range cluster.Tokens {
err = clusterTokens.Put(clusterName+"/"+k, v)
if err != nil {
log.Fatal(err)
return
}
}
for k, v := range cluster.Passwords {
err = clusterPasswords.Put(clusterName+"/"+k, v)
if err != nil {
log.Fatal(err)
return
}
}
for caName, ca := range cluster.CAs {
clusterCAs.Put(clusterName+"/"+caName, CA{Key: ca.Key, Cert: ca.Cert})
for signedName, signed := range ca.Signed {
err = clusterCASignedKeys.Put(clusterName+"/"+caName+"/"+signedName, *signed)
if err != nil {
log.Fatal(err)
}
}
}
for hostName, pairs := range cluster.SSHKeyPairs {
err = sshHostKeys.Put(hostName, pairs)
if err != nil {
log.Fatal(err)
}
}
}
if err := os.Rename(secretDataPath(), secretDataPath()+".migrated"); err != nil {
log.Fatal("failed to rename migrated secrets: ", err)
}
}

View File

@ -1,19 +1,46 @@
package main package main
import ( import (
"crypto"
"crypto/rand"
"crypto/x509"
"encoding/base32"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io/ioutil"
"net"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"sync"
"time" "time"
"github.com/cespare/xxhash"
"github.com/cloudflare/cfssl/certinfo" "github.com/cloudflare/cfssl/certinfo"
"github.com/cloudflare/cfssl/config" "github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/helpers"
"github.com/cloudflare/cfssl/initca"
"github.com/cloudflare/cfssl/log" "github.com/cloudflare/cfssl/log"
"github.com/cloudflare/cfssl/signer"
"github.com/cloudflare/cfssl/signer/local"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
)
var (
secretData *SecretData
DontSave = false
) )
type SecretData struct { type SecretData struct {
l sync.Mutex
prevHash uint64
clusters map[string]*ClusterSecrets clusters map[string]*ClusterSecrets
changed bool
config *config.Config config *config.Config
} }
@ -24,6 +51,13 @@ type ClusterSecrets struct {
SSHKeyPairs map[string][]SSHKeyPair SSHKeyPairs map[string][]SSHKeyPair
} }
type CA struct {
Key []byte
Cert []byte
Signed map[string]*KeyCert
}
type KeyCert struct { type KeyCert struct {
Key []byte Key []byte
Cert []byte Cert []byte
@ -34,18 +68,21 @@ func secretDataPath() string {
return filepath.Join(*dataDir, "secret-data.json") return filepath.Join(*dataDir, "secret-data.json")
} }
func loadSecretData(config *config.Config) (sd *SecretData, err error) { func loadSecretData(config *config.Config) (err error) {
log.Info("Loading secret data") log.Info("Loading secret data")
sd = &SecretData{ sd := &SecretData{
clusters: make(map[string]*ClusterSecrets), clusters: make(map[string]*ClusterSecrets),
changed: false,
config: config, config: config,
} }
ba, err := os.ReadFile(secretDataPath()) ba, err := ioutil.ReadFile(secretDataPath())
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
sd.changed = true
err = nil err = nil
secretData = sd
return return
} }
return return
@ -55,6 +92,209 @@ func loadSecretData(config *config.Config) (sd *SecretData, err error) {
return return
} }
sd.prevHash = xxhash.Sum64(ba)
secretData = sd
return
}
func (sd *SecretData) Changed() bool {
return sd.changed
}
func (sd *SecretData) Save() (err error) {
if DontSave {
return
}
sd.l.Lock()
defer sd.l.Unlock()
ba, err := json.Marshal(sd.clusters)
if err != nil {
return
}
h := xxhash.Sum64(ba)
if h == sd.prevHash {
return
}
log.Info("Saving secret data")
err = ioutil.WriteFile(secretDataPath(), ba, 0600)
if err == nil {
sd.prevHash = h
}
return
}
func newClusterSecrets() *ClusterSecrets {
return &ClusterSecrets{
CAs: make(map[string]*CA),
Tokens: make(map[string]string),
Passwords: make(map[string]string),
}
}
func (sd *SecretData) cluster(name string) (cs *ClusterSecrets) {
cs, ok := sd.clusters[name]
if ok {
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new cluster: ", name)
cs = newClusterSecrets()
sd.clusters[name] = cs
sd.changed = true
return
}
func (sd *SecretData) Passwords(cluster string) (passwords []string) {
cs := sd.cluster(cluster)
passwords = make([]string, 0, len(cs.Passwords))
for name := range cs.Passwords {
passwords = append(passwords, name)
}
sort.Strings(passwords)
return
}
func (sd *SecretData) Password(cluster, name string) (password string) {
cs := sd.cluster(cluster)
if cs.Passwords == nil {
cs.Passwords = make(map[string]string)
}
password = cs.Passwords[name]
return
}
func (sd *SecretData) SetPassword(cluster, name, password string) {
cs := sd.cluster(cluster)
if cs.Passwords == nil {
cs.Passwords = make(map[string]string)
}
cs.Passwords[name] = password
sd.changed = true
}
func (sd *SecretData) Token(cluster, name string) (token string, err error) {
cs := sd.cluster(cluster)
token = cs.Tokens[name]
if token != "" {
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new token in cluster ", cluster, ": ", name)
b := make([]byte, 16)
_, err = rand.Read(b)
if err != nil {
return
}
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
cs.Tokens[name] = token
sd.changed = true
return
}
func (sd *SecretData) RenewCACert(cluster, name string) (err error) {
cs := sd.cluster(cluster)
ca := cs.CAs[name]
var cert *x509.Certificate
cert, err = helpers.ParseCertificatePEM(ca.Cert)
if err != nil {
return
}
var signer crypto.Signer
signer, err = helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
newCert, err := initca.RenewFromSigner(cert, signer)
if err != nil {
return
}
sd.l.Lock()
defer sd.l.Unlock()
cs.CAs[name].Cert = newCert
sd.changed = true
return
}
func (sd *SecretData) CA(cluster, name string) (ca *CA, err error) {
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)
}
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",
KeyRequest: &csr.KeyRequest{
A: "ecdsa",
S: 521, // 256, 384, 521
},
Names: []csr.Name{
{
C: "NC",
O: "novit.nc",
},
},
}
cert, _, key, err := initca.New(req)
if err != nil {
return
}
ca = &CA{
Key: key,
Cert: cert,
Signed: make(map[string]*KeyCert),
}
cs.CAs[name] = ca
sd.changed = true
return return
} }
@ -73,3 +313,104 @@ func checkCertUsable(certPEM []byte) error {
return nil return nil
} }
func (sd *SecretData) KeyCert(cluster, caName, name, profile, label string, req *csr.CertificateRequest) (kc *KeyCert, err error) {
for idx, host := range req.Hosts {
if ip := net.ParseIP(host); ip != nil {
// valid IP (v4 or v6)
continue
}
if host == "*" {
continue
}
if errs := validation.IsDNS1123Subdomain(host); len(errs) == 0 {
continue
}
if errs := validation.IsWildcardDNS1123Subdomain(host); len(errs) == 0 {
continue
}
path := field.NewPath(cluster, name, "hosts").Index(idx)
return nil, fmt.Errorf("%v: %q is not an IP or FQDN", path, host)
}
if req.CA != nil {
err = errors.New("no CA section allowed here")
return
}
ca, err := sd.CA(cluster, caName)
if err != nil {
return
}
logPrefix := fmt.Sprintf("secret-data: cluster %s: CA %s:", cluster, caName)
rh := hash(req)
kc, ok := ca.Signed[name]
if ok && rh == kc.ReqHash {
err = checkCertUsable(kc.Cert)
if err == nil {
return
}
log.Infof("%s regenerating certificate: ", err)
} else if ok {
log.Infof("%s CSR changed for %s: hash=%q previous=%q", name, rh, kc.ReqHash)
} else {
log.Infof("%s new CSR for %s", logPrefix, name)
}
sd.l.Lock()
defer sd.l.Unlock()
sgr, err := ca.Signer(sd.config.Signing)
if err != nil {
return
}
generator := &csr.Generator{Validator: func(_ *csr.CertificateRequest) error { return nil }}
csr, key, err := generator.ProcessRequest(req)
if err != nil {
return
}
signReq := signer.SignRequest{
Request: string(csr),
Profile: profile,
Label: label,
}
cert, err := sgr.Sign(signReq)
if err != nil {
return
}
kc = &KeyCert{
Key: key,
Cert: cert,
ReqHash: rh,
}
ca.Signed[name] = kc
sd.changed = true
return
}
func (ca *CA) Signer(policy *config.Signing) (result *local.Signer, err error) {
caCert, err := helpers.ParseCertificatePEM(ca.Cert)
if err != nil {
return
}
caKey, err := helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
return local.NewSigner(caKey, caCert, signer.DefaultSigAlgo(caKey), policy)
}

View File

@ -1,39 +0,0 @@
package main
import (
"encoding/base64"
crypthash "github.com/sergeymakinen/go-crypt/hash"
"github.com/sergeymakinen/go-crypt/sha512"
)
// for some reason, no implementation of crypt's sha512 is clean enough :(
func sha512crypt(password, seed []byte) (string, error) {
// loose salt entropy because of character restriction in the salt
salt := []byte(base64.RawStdEncoding.EncodeToString(seed))[:sha512.MaxSaltLength]
// - base64 allows '+' where the salt accepts '.'
for i, c := range salt {
if c == '+' {
salt[i] = '.'
}
}
scheme := struct {
HashPrefix string
Rounds uint32 `hash:"param:rounds,omitempty"`
Salt []byte
Sum [86]byte
}{
HashPrefix: sha512.Prefix,
Rounds: sha512.DefaultRounds,
Salt: salt,
}
key, err := sha512.Key([]byte(password), scheme.Salt, scheme.Rounds)
if err != nil {
return "", err
}
crypthash.LittleEndianEncoding.Encode(scheme.Sum[:], key)
return crypthash.Marshal(scheme)
}

View File

@ -1,31 +1,48 @@
package main package main
import ( import (
"bytes" "crypto/dsa"
"crypto" "crypto/ecdsa"
"crypto/ed25519" "crypto/ed25519"
"encoding/pem" "crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"fmt" "fmt"
"io" "io/ioutil"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
) )
var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"}
type SSHKeyPair struct { type SSHKeyPair struct {
Type string Type string
Public string Public string
Private string Private string
} }
func getSSHKeyPairs(host string) (pairs []SSHKeyPair, err error) { func (sd *SecretData) SSHKeyPairs(cluster, host string) (pairs []SSHKeyPair, err error) {
pairs, _, err = sshHostKeys.Get(host) cs := sd.cluster(cluster)
if cs.SSHKeyPairs == nil {
cs.SSHKeyPairs = map[string][]SSHKeyPair{}
}
outFile, err := ioutil.TempFile("/tmp", "dls-key.")
if err != nil {
return
}
outPath := outFile.Name()
removeTemp := func() {
os.Remove(outPath)
os.Remove(outPath + ".pub")
}
defer removeTemp()
pairs = cs.SSHKeyPairs[host]
didGenerate := false didGenerate := false
@ -42,170 +59,109 @@ genLoop:
} }
} }
err = func() (err error) { didGenerate = true
outFile, err := os.CreateTemp("/tmp", "dls-key.")
if err != nil {
return
}
outPath := outFile.Name()
removeTemp := func() {
os.Remove(outPath)
os.Remove(outPath + ".pub")
}
removeTemp() removeTemp()
defer removeTemp()
var out, privKey, pubKey []byte var out, privKey, pubKey []byte
cmd := exec.Command("ssh-keygen", out, err = exec.Command("ssh-keygen",
"-N", "", "-N", "",
"-C", "root@"+host, "-C", "root@"+host,
"-f", outPath, "-f", outPath,
"-t", keyType) "-t", keyType).CombinedOutput()
out, err = cmd.CombinedOutput()
if err != nil { if err != nil {
err = fmt.Errorf("ssh-keygen failed: %v: %s", err, string(out)) err = fmt.Errorf("ssh-keygen failed: %v: %s", err, string(out))
return return
} }
privKey, err = os.ReadFile(outPath) privKey, err = ioutil.ReadFile(outPath)
if err != nil { if err != nil {
return return
} }
pubKey, err = os.ReadFile(outPath + ".pub") os.Remove(outPath)
pubKey, err = ioutil.ReadFile(outPath + ".pub")
if err != nil { if err != nil {
return return
} }
os.Remove(outPath + ".pub")
pairs = append(pairs, SSHKeyPair{ pairs = append(pairs, SSHKeyPair{
Type: keyType, Type: keyType,
Public: string(pubKey), Public: string(pubKey),
Private: string(privKey), Private: string(privKey),
}) })
didGenerate = true
return
}()
if err != nil {
return
}
} }
if didGenerate { if didGenerate {
err = sshHostKeys.Put(host, pairs) cs.SSHKeyPairs[host] = pairs
if err != nil { err = sd.Save()
return
}
} }
return return
} }
var sshCAKeys = KVSecrets[string]{"ssh-ca-keys"} func sshKeyGenDSA() (data []byte, pubKey interface{}, err error) {
privKey := &dsa.PrivateKey{}
func sshCAKey(cluster string) (caKeyPem string, err error) { err = dsa.GenerateParameters(&privKey.Parameters, rand.Reader, dsa.L1024N160)
storeKey := "clusters/" + cluster
caKeyPem, _, err = sshCAKeys.Get(storeKey)
if err != nil { if err != nil {
return return
} }
if caKeyPem == "" { err = dsa.GenerateKey(privKey, rand.Reader)
_, pk, err := ed25519.GenerateKey(nil)
if err != nil { if err != nil {
return "", err return
} }
pemBlock, err := ssh.MarshalPrivateKey(crypto.PrivateKey(pk), "") data, err = asn1.Marshal(*privKey)
//data, err = x509.MarshalPKCS8PrivateKey(privKey)
if err != nil { if err != nil {
return "", err return
} }
caKeyPem = string(pem.EncodeToMemory(pemBlock)) pubKey = privKey.PublicKey
sshCAKeys.Put(storeKey, caKeyPem) return
}
func sshKeyGenRSA() (data []byte, pubKey interface{}, err error) {
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return
} }
data = x509.MarshalPKCS1PrivateKey(privKey)
pubKey = privKey.Public()
return return
} }
func sshCAPubKey(cluster string) (pubKey []byte, err error) { func sshKeyGenECDSA() (data []byte, pubKey interface{}, err error) {
keyPem, err := sshCAKey(cluster) privKey, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
if err != nil { if err != nil {
return return
} }
k, err := ssh.ParsePrivateKey([]byte(keyPem)) data, err = x509.MarshalPKCS8PrivateKey(privKey)
if err != nil { if err != nil {
return return
} }
pubKey = ssh.MarshalAuthorizedKey(k.PublicKey()) pubKey = privKey.Public()
return return
} }
// principal: user (login) to allow (ie: "root") func sshKeyGenED25519() (data []byte, pubKey interface{}, err error) {
// validity: ssh-keygen validity string (ie: "+1h", "202506280811:202506281011", ""=forever) pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
// options: ssh-keygen options (ie: "force-command=/bin/date +\"%F %T\"", "source-address=192.168.1.0/24,192.168.42.0/24"
func sshCASign(cluster string, userPubKey []byte, principal, validity string, options ...string) (cert []byte, err error) { data, err = x509.MarshalPKCS8PrivateKey(privKey)
caKey, err := sshCAKey(cluster)
if err != nil { if err != nil {
return return
} }
_, identity, _, _, err := ssh.ParseAuthorizedKey(userPubKey)
if err != nil {
return
}
userPubKeyFile, err := os.CreateTemp("/tmp", "user.pub")
if err != nil {
return
}
defer os.Remove(userPubKeyFile.Name())
_, 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)
if validity != "" {
cmd.Args = append(cmd.Args, "-V", validity)
}
for _, opt := range options {
cmd.Args = append(cmd.Args, "-O", opt)
}
cmd.Args = append(cmd.Args, userPubKeyFile.Name())
stderr := new(bytes.Buffer)
cmd.Stdin = bytes.NewBuffer([]byte(caKey))
cmd.Stderr = stderr
err = cmd.Run()
if err != nil {
err = fmt.Errorf("ssh-keygen sign failed: %s", strings.TrimSpace(stderr.String()))
return
}
certFile := userPubKeyFile.Name() + "-cert.pub"
cert, err = os.ReadFile(certFile)
os.Remove(certFile)
return return
} }

View File

@ -2,9 +2,16 @@ package main
import "testing" import "testing"
func TestSSHKeyGet(t *testing.T) { func init() {
// TODO needs fake secret store DontSave = true
// if _, err := getSSHKeyPairs("host"); err != nil { }
// t.Error(err)
// } func TestSSHKeyGet(t *testing.T) {
sd := &SecretData{
clusters: make(map[string]*ClusterSecrets),
}
if _, err := sd.SSHKeyPairs("test", "host"); err != nil {
t.Error(err)
}
} }

View File

@ -1,164 +0,0 @@
package main
import (
"log"
"m.cluseau.fr/go/watchable"
"novit.tech/direktil/pkg/localconfig"
)
type PublicState struct {
ServerVersion string
UIHash string
Store struct {
New bool
Open bool
}
}
var wPublicState = watchable.New[PublicState]()
type State struct {
HasConfig bool
Store struct {
DownloadToken string
KeyNames []string
}
Clusters []ClusterState
Hosts []HostState
Config *localconfig.Config
Downloads map[string]DownloadSpec
HostTemplates []string
}
type ClusterState struct {
Name string
Addons bool
Passwords []string
Tokens []string
CAs []CAState
}
type HostState struct {
Name string
Cluster string
IPs []string
Template string `json:",omitempty"`
}
type CAState struct {
Name string
Signed []string
}
var wState = watchable.New[State]()
func init() {
wState.Set(State{Downloads: map[string]DownloadSpec{}})
}
func updateState() {
log.Print("updating state")
// store key names
keyNames := make([]string, 0, len(secStore.Keys))
for _, key := range secStore.Keys {
keyNames = append(keyNames, key.Name)
}
// config
cfg, err := readConfig()
if err != nil {
wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil; v.Store.KeyNames = keyNames })
return
}
if secStore.IsNew() || !secStore.Unlocked() {
wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil; v.Store.KeyNames = keyNames })
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,
}
c.Passwords, err = clusterPasswords.Keys(c.Name + "/")
if err != nil {
log.Print("failed to read cluster passwords: ", err)
}
c.Tokens, err = clusterTokens.Keys(c.Name + "/")
if err != nil {
log.Print("failed to read cluster tokens: ", err)
}
caNames, err := clusterCAs.Keys(c.Name + "/")
if err != nil {
log.Print("failed to read cluster CAs: ", err)
}
for _, caName := range caNames {
ca := CAState{Name: caName}
signedNames, err := clusterCASignedKeys.Keys(c.Name + "/" + caName + "/")
if err != nil {
log.Print("failed to read cluster CA signed keys: ", err)
}
for _, signedName := range signedNames {
ca.Signed = append(ca.Signed, signedName)
}
c.CAs = append(c.CAs, ca)
}
clusters = append(clusters, c)
}
hfts, err := hostsFromTemplate.List("")
if err != nil {
log.Print("failed to read hosts from template: ", err)
}
hosts := make([]HostState, 0, len(cfg.Hosts)+len(hfts))
for _, host := range cfg.Hosts {
h := HostState{
Name: host.Name,
Cluster: host.ClusterName,
IPs: host.IPs,
}
hosts = append(hosts, h)
}
for _, kv := range hfts {
name, hft := kv.K, kv.V
h := HostState{
Name: name,
Cluster: hft.ClusterName(cfg),
IPs: []string{hft.IP},
Template: hft.Template,
}
hosts = append(hosts, h)
}
hostTemplates := make([]string, len(cfg.HostTemplates))
for i, ht := range cfg.HostTemplates {
hostTemplates[i] = ht.Name
}
// done
wState.Change(func(v *State) {
v.HasConfig = true
v.Store.KeyNames = keyNames
v.Clusters = clusters
v.Hosts = hosts
v.HostTemplates = hostTemplates
})
}

View File

@ -1,206 +0,0 @@
package main
import (
"crypto"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log"
"net"
"github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/helpers"
"github.com/cloudflare/cfssl/initca"
"github.com/cloudflare/cfssl/signer"
"github.com/cloudflare/cfssl/signer/local"
"k8s.io/apimachinery/pkg/util/validation"
)
type CA struct {
Key []byte
Cert []byte
Signed map[string]*KeyCert
}
func (ca *CA) Init() (err error) {
req := ca.newReq()
cert, _, key, err := initca.New(req)
if err != nil {
err = fmt.Errorf("initca: %w", err)
return
}
ca.Key = key
ca.Cert = cert
return
}
func (ca *CA) RenewCert() (err error) {
var signer crypto.Signer
signer, err = helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
newCert, _, err := initca.NewFromSigner(ca.newReq(), signer)
if err != nil {
return
}
ca.Cert = newCert
return
}
func (_ CA) newReq() *csr.CertificateRequest {
return &csr.CertificateRequest{
CN: "Direktil Local Server",
KeyRequest: &csr.KeyRequest{
A: "ecdsa",
S: 521, // 256, 384, 521
},
Names: []csr.Name{
{
O: "novit.io",
},
},
}
}
func (ca CA) ParseKey() (key crypto.Signer, err error) {
return helpers.ParsePrivateKeyPEM(ca.Key)
}
func (ca CA) ParseCert() (cert *x509.Certificate, err error) {
return helpers.ParseCertificatePEM(ca.Cert)
}
func (ca CA) Signer(policy *config.Signing) (result *local.Signer, err error) {
caCert, err := ca.ParseCert()
if err != nil {
return
}
caKey, err := ca.ParseKey()
if err != nil {
return
}
return local.NewSigner(caKey, caCert, signer.DefaultSigAlgo(caKey), policy)
}
func getUsableKeyCert(cluster, caName, name, profile, label string, req *csr.CertificateRequest, cfg *config.Config) (kc KeyCert, err error) {
log := log.New(log.Default().Writer(), cluster+": CA "+caName+": ", log.Flags()|log.Lmsgprefix)
ca, err := getUsableClusterCA(cluster, caName)
if err != nil {
return
}
for _, host := range req.Hosts {
if ip := net.ParseIP(host); ip != nil {
// valid IP (v4 or v6)
continue
}
if host == "*" {
continue
}
if errs := validation.IsDNS1123Subdomain(host); len(errs) == 0 {
continue
}
if errs := validation.IsWildcardDNS1123Subdomain(host); len(errs) == 0 {
continue
}
err = fmt.Errorf("%q is not an IP or FQDN", host)
return
}
if req.CA != nil {
err = errors.New("no CA section allowed here")
return
}
rh := hash(req)
key := cluster + "/" + caName + "/" + name
kc, found, err := clusterCASignedKeys.Get(key)
if err != nil {
return
}
if found {
if rh == kc.ReqHash {
err = func() (err error) {
err = checkCertUsable(kc.Cert)
if err != nil {
return
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(ca.Cert) {
panic("unexpected invalid CA certificate at this point")
}
certBlock, _ := pem.Decode(kc.Cert)
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return
}
_, err = cert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}})
return
}()
if err == nil {
return // all good, no need to create or renew
}
log.Print("regenerating certificate: ", err)
} else {
log.Printf("CSR changed for %s: hash=%q previous=%q", name, rh, kc.ReqHash)
}
} else {
log.Print("new CSR for ", name)
}
sgr, err := ca.Signer(cfg.Signing)
if err != nil {
return
}
generator := &csr.Generator{Validator: func(_ *csr.CertificateRequest) error { return nil }}
csr, tlsKey, err := generator.ProcessRequest(req)
if err != nil {
return
}
signReq := signer.SignRequest{
Request: string(csr),
Profile: profile,
Label: label,
}
cert, err := sgr.Sign(signReq)
if err != nil {
return
}
kc = KeyCert{
Key: tlsKey,
Cert: cert,
ReqHash: rh,
}
err = clusterCASignedKeys.Put(key, kc)
return
}

View File

@ -1,24 +0,0 @@
package main
import (
"crypto/rand"
"encoding/base32"
"log"
"net/http"
"m.cluseau.fr/go/httperr"
)
func newToken(sizeInBytes int) (token string, err error) {
randBytes := make([]byte, sizeInBytes)
_, err = rand.Read(randBytes)
if err != nil {
log.Print("rand read error: ", err)
err = httperr.New(http.StatusInternalServerError, err)
return
}
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randBytes)
return
}

View File

@ -1,45 +0,0 @@
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

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

View File

@ -7,20 +7,24 @@ import (
) )
func adminAuth(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) { func adminAuth(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {
tokenAuth(req, resp, chain, adminToken) tokenAuth(req, resp, chain, *adminToken)
}
func hostsAuth(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {
tokenAuth(req, resp, chain, *hostsToken, *adminToken)
} }
func tokenAuth(req *restful.Request, resp *restful.Response, chain *restful.FilterChain, allowedTokens ...string) { func tokenAuth(req *restful.Request, resp *restful.Response, chain *restful.FilterChain, allowedTokens ...string) {
token := getToken(req) token := getToken(req)
for _, allowedToken := range allowedTokens { for _, allowedToken := range allowedTokens {
if allowedToken != "" && token == allowedToken { if allowedToken == "" || token == allowedToken {
chain.ProcessFilter(req, resp) chain.ProcessFilter(req, resp)
return return
} }
} }
wsError(resp, ErrUnauthorized) resp.WriteErrorString(401, "401: Not Authorized")
return return
} }
@ -29,12 +33,8 @@ 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 token return ""
} }
return token[len(bearerPrefix):] return token[len(bearerPrefix):]

View File

@ -1,96 +0,0 @@
package main
import (
"fmt"
"time"
"github.com/cloudflare/cfssl/helpers"
"github.com/cloudflare/cfssl/log"
restful "github.com/emicklei/go-restful"
)
var clusterCAs = newClusterSecretKV[CA]("CAs")
func wsClusterCAs(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
clusterCAs.WsList(resp, clusterName+"/")
}
func wsClusterCA(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
name := req.PathParameter("ca-name")
clusterCAs.WsGet(resp, clusterName+"/"+name)
}
func getUsableClusterCA(cluster, name string) (ca CA, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("cluster %s CA %s: %w", cluster, name, err)
}
}()
key := cluster + "/" + name
ca, found, err := clusterCAs.Get(key)
if err != nil {
return
}
if !found {
log.Info("new CA in cluster ", cluster, ": ", name)
err = ca.Init()
if err != nil {
return
}
err = clusterCAs.Put(key, ca)
if err != nil {
return
}
return
}
checkErr := checkCertUsable(ca.Cert)
if checkErr != nil {
log.Infof("cluster %s: CA %s: regenerating certificate: %v", cluster, name, checkErr)
prevCerts, _ := helpers.ParseCertificatesPEM(ca.Cert)
err = ca.RenewCert()
if err != nil {
err = fmt.Errorf("renew: %w", err)
}
now := time.Now()
for _, cert := range prevCerts {
if cert.NotAfter.After(now) {
continue
}
certPEM := helpers.EncodeCertificatePEM(cert)
ca.Cert = append(ca.Cert, certPEM...)
}
err = clusterCAs.Put(key, ca)
}
return
}
var clusterCASignedKeys = newClusterSecretKV[KeyCert]("CA-signed-keys")
func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
caName := req.PathParameter("ca-name")
clusterCASignedKeys.WsList(resp, clusterName+"/"+caName+"/")
}
func wsClusterCASignedKey(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
caName := req.PathParameter("ca-name")
name := req.PathParameter("signed-name")
clusterCASignedKeys.WsGet(resp, clusterName+"/"+caName+"/"+name)
}

View File

@ -1,32 +0,0 @@
package main
import (
restful "github.com/emicklei/go-restful"
)
var seeds = newClusterSecretKV[[]byte]("seeds")
var clusterPasswords = newClusterSecretKV[string]("passwords")
func wsClusterPasswords(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
clusterPasswords.WsList(resp, clusterName+"/")
}
func wsClusterPassword(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
name := req.PathParameter("password-name")
clusterPasswords.WsGet(resp, clusterName+"/"+name)
}
func wsClusterSetPassword(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return
}
name := req.PathParameter("password-name")
clusterPasswords.WsPut(req, resp, cluster.Name+"/"+name)
}

View File

@ -1,43 +0,0 @@
package main
import (
"crypto/rand"
"encoding/base32"
restful "github.com/emicklei/go-restful"
)
var clusterTokens = newClusterSecretKV[string]("tokens")
func getOrCreateClusterToken(cluster, name string) (token string, err error) {
key := cluster + "/" + name
token, found, err := clusterTokens.Get(key)
if err != nil || found {
return
}
b := make([]byte, 16)
_, err = rand.Read(b)
if err != nil {
return
}
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
err = clusterTokens.Put(key, token)
return
}
func wsClusterTokens(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
clusterTokens.WsList(resp, clusterName+"/")
}
func wsClusterToken(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
name := req.PathParameter("token-name")
clusterTokens.WsGet(resp, clusterName+"/"+name)
}

View File

@ -1,31 +1,14 @@
package main package main
import ( import (
"errors"
"fmt"
"log" "log"
"net/url" "sort"
"regexp"
"strconv"
"strings"
"time"
"github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/signer"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.tech/direktil/local-server/pkg/mime"
"novit.tech/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
) )
var clusterSecretKVs = []string{}
func newClusterSecretKV[T any](name string) KVSecrets[T] {
clusterSecretKVs = append(clusterSecretKVs, name)
return KVSecrets[T]{"clusters/" + name}
}
func wsListClusters(req *restful.Request, resp *restful.Response) { func wsListClusters(req *restful.Request, resp *restful.Response) {
cfg := wsReadConfig(resp) cfg := wsReadConfig(resp)
if cfg == nil { if cfg == nil {
@ -50,7 +33,7 @@ func wsReadCluster(req *restful.Request, resp *restful.Response) (cluster *local
cluster = cfg.Cluster(clusterName) cluster = cfg.Cluster(clusterName)
if cluster == nil { if cluster == nil {
wsNotFound(resp) wsNotFound(req, resp)
return return
} }
@ -74,258 +57,152 @@ func wsClusterAddons(req *restful.Request, resp *restful.Response) {
if len(cluster.Addons) == 0 { if len(cluster.Addons) == 0 {
log.Printf("cluster %q has no addons defined", cluster.Name) log.Printf("cluster %q has no addons defined", cluster.Name)
wsNotFound(resp) wsNotFound(req, resp)
return return
} }
cfg := wsReadConfig(resp) wsRender(resp, cluster.Addons, cluster)
if cfg == nil { }
func wsClusterPasswords(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return return
} }
sslCfg, err := sslConfigFromLocalConfig(cfg) resp.WriteEntity(secretData.Passwords(cluster.Name))
}
func wsClusterPassword(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return
}
name := req.PathParameter("password-name")
resp.WriteEntity(secretData.Password(cluster.Name, name))
}
func wsClusterSetPassword(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return
}
name := req.PathParameter("password-name")
var password string
if err := req.ReadEntity(&password); err != nil {
wsError(resp, err) // FIXME this is a BadRequest
return
}
secretData.SetPassword(cluster.Name, name, password)
if err := secretData.Save(); err != nil {
wsError(resp, err)
return
}
}
func wsClusterToken(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return
}
name := req.PathParameter("token-name")
token, err := secretData.Token(cluster.Name, name)
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
return return
} }
wsRender(resp, sslCfg, cluster.Addons, cluster) resp.WriteEntity(token)
}
func wsClusterBootstrapPods(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return
}
if len(cluster.BootstrapPods) == 0 {
log.Printf("cluster %q has no bootstrap pods defined", cluster.Name)
wsNotFound(req, resp)
return
}
wsRender(resp, cluster.BootstrapPods, cluster)
}
func wsClusterCAs(req *restful.Request, resp *restful.Response) {
cs := secretData.clusters[req.PathParameter("cluster-name")]
if cs == nil {
wsNotFound(req, resp)
return
}
keys := make([]string, 0, len(cs.CAs))
for k := range cs.CAs {
keys = append(keys, k)
}
sort.Strings(keys)
resp.WriteJson(keys, restful.MIME_JSON)
} }
func wsClusterCACert(req *restful.Request, resp *restful.Response) { func wsClusterCACert(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name") cs := secretData.clusters[req.PathParameter("cluster-name")]
caName := req.PathParameter("ca-name") if cs == nil {
wsNotFound(req, resp)
ca, found, err := clusterCAs.Get(clusterName + "/" + caName) return
if err != nil { }
wsError(resp, err)
return ca := cs.CAs[req.PathParameter("ca-name")]
} if ca == nil {
if !found { wsNotFound(req, resp)
wsNotFound(resp)
return return
} }
resp.Header().Set("Content-Type", mime.CERT)
resp.Write(ca.Cert) resp.Write(ca.Cert)
} }
func wsClusterSignedCert(req *restful.Request, resp *restful.Response) { func wsClusterSignedCert(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name") cs := secretData.clusters[req.PathParameter("cluster-name")]
caName := req.PathParameter("ca-name") if cs == nil {
wsNotFound(req, resp)
return
}
ca := cs.CAs[req.PathParameter("ca-name")]
if ca == nil {
wsNotFound(req, resp)
return
}
name := req.QueryParameter("name") name := req.QueryParameter("name")
kc, found, err := clusterCASignedKeys.Get(clusterName + "/" + caName + "/" + name) if name == "" {
if err != nil { keys := make([]string, 0, len(ca.Signed))
wsError(resp, err) for k := range ca.Signed {
return keys = append(keys, k)
} }
if !found {
wsNotFound(resp) sort.Strings(keys)
resp.WriteJson(keys, restful.MIME_JSON)
return
}
kc := ca.Signed[name]
if kc == nil {
wsNotFound(req, resp)
return return
} }
resp.AddHeader("Content-Type", mime.CERT)
resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(clusterName+"_"+caName+"_"+url.PathEscape(name)+".crt"))
resp.Write(kc.Cert) resp.Write(kc.Cert)
} }
type SSHSignReq struct {
PubKey string
Principal string
Validity string
Options []string
}
func wsClusterSSHUserCAPubKey(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
pubkey, err := sshCAPubKey(clusterName)
if err != nil {
wsError(resp, err)
return
}
resp.Write(pubkey)
}
func wsClusterSSHUserCASign(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
signReq := SSHSignReq{}
err := req.ReadEntity(&signReq)
if err != nil {
wsError(resp, err)
return
}
now := time.Now().Truncate(time.Second)
notBefore, notAfter, err := parseCertDurationRange(signReq.Validity, now)
if err != nil {
wsError(resp, fmt.Errorf("invalid validity: %w", err))
return
}
const sshTimestamp = "20060102150405Z"
validity := notBefore.Format(sshTimestamp) + ":"
if notAfter.IsZero() {
validity += "forever"
} else {
validity += notAfter.Format(sshTimestamp)
}
log.Printf("sign ssh public key, validity %s -> %s", signReq.Validity, validity)
cert, err := sshCASign(clusterName, []byte(signReq.PubKey), signReq.Principal, validity, signReq.Options...)
if err != nil {
wsError(resp, err)
return
}
resp.Write(cert)
}
type KubeSignReq struct {
CSR string
User string
Group string
Validity string
}
func wsClusterKubeCASign(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
signReq := KubeSignReq{}
err := req.ReadEntity(&signReq)
if err != nil {
wsError(resp, err)
return
}
now := time.Now().Truncate(time.Second)
notBefore, notAfter, err := parseCertDurationRange(signReq.Validity, now)
if err != nil {
wsError(resp, fmt.Errorf("invalid validity: %w", err))
return
}
var names []csr.Name
if signReq.Group != "" {
names = []csr.Name{{O: signReq.Group}}
}
ca, err := getUsableClusterCA(clusterName, "cluster")
if err != nil {
wsError(resp, fmt.Errorf("get cluster CA failed: %w", err))
return
}
caSigner, err := ca.Signer(&config.Signing{
Default: &config.SigningProfile{
Usage: []string{"client auth"},
Expiry: notAfter.Sub(now),
},
})
if err != nil {
wsError(resp, err)
return
}
csr := signer.SignRequest{
Request: signReq.CSR,
Subject: &signer.Subject{
CN: signReq.User,
Names: names,
},
NotBefore: notBefore,
NotAfter: notAfter,
}
cert, err := caSigner.Sign(csr)
if err != nil {
wsError(resp, err)
return
}
resp.Write(cert)
}
func parseCertDurationRange(d string, now time.Time) (notBefore, notAfter time.Time, err error) {
if d == "" {
return
}
d1, d2, ok := strings.Cut(d, ":")
if ok {
notBefore, err = parseCertDuration(d1, now)
if err != nil {
return
}
notAfter, err = parseCertDuration(d2, now)
} else {
notAfter, err = parseCertDuration(d, now)
}
if err != nil {
return
}
if notBefore.IsZero() {
notBefore = now.Add(-5 * time.Minute)
}
return
}
var durRegex = regexp.MustCompile("^([+-]?)([0-9]+)([yMdwhms])")
func parseCertDuration(d string, now time.Time) (t time.Time, err error) {
if d == "" {
return
}
direction := 1
t = now
for d != "" {
match := durRegex.FindStringSubmatch(d)
if match == nil {
t = time.Time{}
err = errors.New("invalid duration: " + strconv.Quote(d))
return
}
d = d[len(match[0]):]
switch match[1] {
case "+":
direction = 1
case "-":
direction = -1
}
qty, _ := strconv.Atoi(match[2])
unit := match[3]
switch unit {
case "y":
t = t.AddDate(qty*direction, 0, 0)
case "M":
t = t.AddDate(0, qty*direction, 0)
case "d":
t = t.AddDate(0, 0, qty*direction)
case "w":
t = t.AddDate(0, 0, 7*qty*direction)
case "h":
t = t.Add(time.Duration(qty*direction) * time.Hour)
case "m":
t = t.Add(time.Duration(qty*direction) * time.Minute)
case "s":
t = t.Add(time.Duration(qty*direction) * time.Second)
}
}
return
}

View File

@ -3,6 +3,7 @@ package main
import ( import (
"compress/gzip" "compress/gzip"
"io" "io"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
@ -17,14 +18,11 @@ func wsUploadConfig(req *restful.Request, resp *restful.Response) {
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
return
} }
resp.WriteEntity(true)
} }
func writeNewConfig(reader io.Reader) (err error) { func writeNewConfig(reader io.Reader) (err error) {
out, err := os.CreateTemp(*dataDir, ".config-upload") out, err := ioutil.TempFile(*dataDir, ".config-upload")
if err != nil { if err != nil {
return return
} }
@ -40,22 +38,13 @@ func writeNewConfig(reader io.Reader) (err error) {
cfgPath := configFilePath() cfgPath := configFilePath()
in, err := os.Open(cfgPath) in, err := os.Open(cfgPath)
if err != nil { if err == nil {
if os.IsNotExist(err) {
// nothing to backup
} else {
return // real error
}
} else {
err = backupCurrentConfig(in) err = backupCurrentConfig(in)
if err != nil { } else if !os.IsNotExist(err) {
return return
} }
}
err = os.Rename(out.Name(), cfgPath) err = os.Rename(out.Name(), cfgPath)
updateState()
return return
} }

View File

@ -1,151 +0,0 @@
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(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 {
wsNotFound(resp)
return
}
cow.Map(&v.Downloads)
if len(newAssets) == 0 {
delete(v.Downloads, token)
} else {
spec.Assets = newAssets
v.Downloads[token] = spec
}
})
if !found {
wsNotFound(resp)
return
}
log.Printf("download via token: %s %q asset %q", spec.Kind, spec.Name, asset)
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(resp)
return
}
switch asset {
case "addons":
setHeader(".yaml")
resp.Write([]byte(cluster.Addons))
default:
wsNotFound(resp)
}
case "host":
host := hostOrTemplate(cfg, spec.Name)
if host == nil {
wsNotFound(resp)
return
}
switch asset {
case "config", "bootstrap-config":
setHeader(".yaml")
default:
setHeader("")
}
renderHost(resp.ResponseWriter, req.Request, asset, host, cfg)
default:
wsNotFound(resp)
}
}

View File

@ -13,24 +13,21 @@ import (
"novit.tech/direktil/local-server/pkg/mime" "novit.tech/direktil/local-server/pkg/mime"
) )
var ( var trustXFF = flag.Bool("trust-xff", true, "Trust the X-Forwarded-For header")
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 { type wsHost struct {
prefix string
hostDoc string hostDoc string
getHost func(req *restful.Request) (hostName string, err error) getHost func(req *restful.Request) string
} }
func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBuilder)) { func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBuilder)) {
b := func(what string) *restful.RouteBuilder { b := func(what string) *restful.RouteBuilder {
return rws.GET("/" + what).To(ws.render) return rws.GET(ws.prefix + "/" + what).To(ws.render)
} }
for _, rb := range []*restful.RouteBuilder{ for _, rb := range []*restful.RouteBuilder{
rws.GET("").To(ws.get). rws.GET(ws.prefix).To(ws.get).
Produces(mime.JSON).
Doc("Get the "+ws.hostDoc+"'s details"). Doc("Get the "+ws.hostDoc+"'s details").
Returns(200, "OK", localconfig.Host{}), Returns(200, "OK", localconfig.Host{}),
@ -47,30 +44,13 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
Produces(mime.DISK). Produces(mime.DISK).
Doc("Get the " + ws.hostDoc + "'s boot disk image"), Doc("Get the " + ws.hostDoc + "'s boot disk image"),
// - raw + compressed
b("boot.img.gz"). b("boot.img.gz").
Produces(mime.DISK + "+gzip"). Produces(mime.DISK + "+gzip").
Doc("Get the " + ws.hostDoc + "'s boot disk image, gzip compressed"), Doc("Get the " + ws.hostDoc + "'s boot disk image (gzip compressed)"),
b("boot.img.lz4"). b("boot.img.lz4").
Produces(mime.DISK + "+lz4"). Produces(mime.DISK + "+lz4").
Doc("Get the " + ws.hostDoc + "'s boot disk image, lz4 compressed"), Doc("Get the " + ws.hostDoc + "'s boot disk image (lz4 compressed)"),
// - other formats
b("boot.qcow2").
Produces(mime.DISK + "+qcow2").
Doc("Get the " + ws.hostDoc + "'s boot disk image, QCOW2 (KVM, Xen)"),
b("boot.qed").
Produces(mime.DISK + "+qed").
Doc("Get the " + ws.hostDoc + "'s boot disk image, QED (KVM)"),
b("boot.vmdk").
Produces(mime.DISK + "+vdi").
Doc("Get the " + ws.hostDoc + "'s boot disk image, VDI (VirtualBox)"),
b("boot.qcow2").
Produces(mime.DISK + "+vpc").
Doc("Get the " + ws.hostDoc + "'s boot disk image, VHD (Hyper-V)"),
b("boot.vmdk").
Produces(mime.DISK + "+vmdk").
Doc("Get the " + ws.hostDoc + "'s boot disk image, VMDK (VMware)"),
// metal/local HDD upgrades // metal/local HDD upgrades
b("boot.tar"). b("boot.tar").
@ -91,20 +71,25 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
Produces(mime.IPXE). Produces(mime.IPXE).
Doc("Get the " + ws.hostDoc + "'s IPXE code (for netboot)"), Doc("Get the " + ws.hostDoc + "'s IPXE code (for netboot)"),
// boot support
b("kernel"). b("kernel").
Produces(mime.OCTET). Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s kernel (ie: for netboot)"), Doc("Get the " + ws.hostDoc + "'s kernel (ie: for netboot)"),
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 // - bootstrap config
b("bootstrap-config"). b("bootstrap-config").
Produces(mime.YAML). Produces(mime.YAML).
Doc("Get the " + ws.hostDoc + "'s bootstrap configuration"), Doc("Get the " + ws.hostDoc + "'s bootstrap configuration"),
b("bootstrap-config.json"). b("bootstrap-config.json").
Doc("Get the " + ws.hostDoc + "'s bootstrap configuration (as 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 // - bootstrap
b("bootstrap.tar"). b("bootstrap.tar").
Produces(mime.TAR). Produces(mime.TAR).
@ -115,33 +100,29 @@ func (ws wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBu
} }
} }
func (ws wsHost) host(req *restful.Request, resp *restful.Response) (host *localconfig.Host, cfg *localconfig.Config) { func (ws *wsHost) host(req *restful.Request, resp *restful.Response) (host *localconfig.Host, cfg *localconfig.Config) {
hostname, err := ws.getHost(req) hostname := ws.getHost(req)
if err != nil {
wsError(resp, err)
return
}
if hostname == "" { if hostname == "" {
wsNotFound(resp) wsNotFound(req, resp)
return return
} }
cfg, err = readConfig() cfg, err := readConfig()
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
return return
} }
host = hostOrTemplate(cfg, hostname) host = cfg.Host(hostname)
if host == nil { if host == nil {
wsNotFound(resp) log.Print("no host named ", hostname)
wsNotFound(req, resp)
return return
} }
return return
} }
func (ws wsHost) get(req *restful.Request, resp *restful.Response) { func (ws *wsHost) get(req *restful.Request, resp *restful.Response) {
host, _ := ws.host(req, resp) host, _ := ws.host(req, resp)
if host == nil { if host == nil {
return return
@ -150,7 +131,7 @@ func (ws wsHost) get(req *restful.Request, resp *restful.Response) {
resp.WriteEntity(host) resp.WriteEntity(host)
} }
func (ws wsHost) render(req *restful.Request, resp *restful.Response) { func (ws *wsHost) render(req *restful.Request, resp *restful.Response) {
host, cfg := ws.host(req, resp) host, cfg := ws.host(req, resp)
if host == nil { if host == nil {
return return
@ -172,6 +153,7 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
switch what { switch what {
case "config": case "config":
err = renderConfig(w, r, ctx, false) err = renderConfig(w, r, ctx, false)
case "config.json": case "config.json":
err = renderConfig(w, r, ctx, true) err = renderConfig(w, r, ctx, true)
@ -180,27 +162,10 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "kernel": case "kernel":
err = renderKernel(w, r, ctx) err = renderKernel(w, r, ctx)
case "initrd": case "initrd":
err = renderCtx(w, r, ctx, what, buildInitrd) 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": case "boot.iso":
err = renderCtx(w, r, ctx, what, buildBootISO) err = renderCtx(w, r, ctx, what, buildBootISO)
@ -209,11 +174,24 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "boot-efi.tar": case "boot-efi.tar":
err = renderCtx(w, r, ctx, what, buildBootEFITar) err = renderCtx(w, r, ctx, what, buildBootEFITar)
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)
// boot v2 // boot v2
case "bootstrap-config": case "bootstrap-config":
err = renderBootstrapConfig(w, r, ctx, false) err = renderBootstrapConfig(w, r, ctx, false)
case "bootstrap-config.json": case "bootstrap-config.json":
err = renderBootstrapConfig(w, r, ctx, true) 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)
default: default:
http.NotFound(w, r) http.NotFound(w, r)

View File

@ -1,117 +0,0 @@
package main
import (
"log"
"net/netip"
"github.com/emicklei/go-restful"
"novit.tech/direktil/pkg/localconfig"
)
var hostsFromTemplate = KVSecrets[HostFromTemplate]{"hosts-from-template"}
type HostFromTemplate struct {
Template string
IP string
}
func (hft HostFromTemplate) ClusterName(cfg *localconfig.Config) string {
for _, ht := range cfg.HostTemplates {
if ht.Name == hft.Template {
return ht.ClusterName
}
}
return ""
}
func hostOrTemplate(cfg *localconfig.Config, name string) (host *localconfig.Host) {
host = cfg.Host(name)
if host != nil {
log.Print("no host named ", name)
return
}
hft, found, err := hostsFromTemplate.Get(name)
if err != nil {
log.Print("failed to read store: ", err)
return
}
if !found {
log.Print("no host from template named ", name)
return
}
ht := cfg.HostTemplate(hft.Template)
if ht == nil {
log.Print("no host template named ", name)
return
}
host = &localconfig.Host{}
*host = *ht
host.Name = name
host.IPs = []string{hft.IP}
return
}
func wsHostsFromTemplateList(req *restful.Request, resp *restful.Response) {
hostsFromTemplate.WsList(resp, "")
}
func wsHostsFromTemplateSet(req *restful.Request, resp *restful.Response) {
name := req.PathParameter("name")
cfg, err := readConfig()
if err != nil {
wsError(resp, err)
return
}
v := HostFromTemplate{}
if err := req.ReadEntity(&v); err != nil {
wsBadRequest(resp, err.Error())
return
}
if v.Template == "" {
wsBadRequest(resp, "template is required")
return
}
if v.IP == "" {
wsBadRequest(resp, "ip is required")
return
}
if _, err := netip.ParseAddr(v.IP); err != nil {
wsBadRequest(resp, "bad IP: "+err.Error())
return
}
found := false
for _, ht := range cfg.HostTemplates {
if ht.Name != v.Template {
continue
}
found = true
break
}
if !found {
wsBadRequest(resp, "no host template with this name")
return
}
if err := hostsFromTemplate.Put(name, v); err != nil {
wsError(resp, err)
return
}
updateState()
}
func wsHostsFromTemplateDelete(req *restful.Request, resp *restful.Response) {
name := req.PathParameter("name")
hostsFromTemplate.WsDel(req, resp, name)
updateState()
}

View File

@ -1,195 +0,0 @@
package main
import (
"archive/tar"
"bytes"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
restful "github.com/emicklei/go-restful"
"m.cluseau.fr/go/httperr"
"novit.tech/direktil/local-server/secretstore"
)
type NamedPassphrase struct {
Name string
Passphrase []byte
}
func wsUnlockStore(req *restful.Request, resp *restful.Response) {
np := NamedPassphrase{}
err := req.ReadEntity(&np)
if err != nil {
resp.WriteError(http.StatusBadRequest, err)
return
}
defer secretstore.Memzero(np.Passphrase)
if secStore.IsNew() {
if len(np.Name) == 0 {
wsBadRequest(resp, "no name given")
return
}
}
if len(np.Passphrase) == 0 {
wsBadRequest(resp, "no passphrase given")
return
}
if secStore.Unlocked() {
if secStore.HasKey(np.Passphrase) {
resp.WriteEntity(adminToken)
} else {
wsError(resp, ErrUnauthorized)
}
return
}
if err := unlockSecretStore(np.Name, np.Passphrase); err.Any() {
err.WriteJSON(resp.ResponseWriter)
return
}
resp.WriteEntity(adminToken)
}
func wsStoreDownload(req *restful.Request, resp *restful.Response) {
token := req.QueryParameter("token")
if token != wState.Get().Store.DownloadToken {
wsError(resp, ErrInvalidToken)
return
}
buf := new(bytes.Buffer)
arch := tar.NewWriter(buf)
root := os.DirFS(secStoreRoot())
err := fs.WalkDir(root, ".", func(path string, d fs.DirEntry, readErr error) (err error) {
if readErr != nil {
err = readErr
return
}
if path == "." {
return
}
fi, err := d.Info()
if err != nil {
return
}
hdr, err := tar.FileInfoHeader(fi, "")
if err != nil {
return
}
hdr.Name = path
hdr.Uid = 0
hdr.Gid = 0
err = arch.WriteHeader(hdr)
if err != nil {
return
}
if fi.IsDir() {
return
}
f, err := root.Open(path)
if err != nil {
return
}
defer f.Close()
io.Copy(arch, f)
return
})
if err != nil {
wsError(resp, err)
return
}
err = arch.Close()
if err != nil {
wsError(resp, err)
return
}
buf.WriteTo(resp)
}
func wsStoreUpload(req *restful.Request, resp *restful.Response) {
if !secStore.IsNew() {
wsError(resp, httperr.BadRequest("store is not new"))
return
}
buf := new(bytes.Buffer)
_, err := io.Copy(buf, req.Request.Body)
if err != nil {
wsError(resp, err)
return
}
arch := tar.NewReader(buf)
root := secStoreRoot()
for {
hdr, err := arch.Next()
if err == io.EOF {
err = nil
break
} else if err != nil {
wsError(resp, err)
return
}
log.Print(hdr.Name)
fullPath := filepath.Join(root, hdr.Name)
switch {
case hdr.FileInfo().IsDir():
err = os.MkdirAll(fullPath, 0700)
if err != nil {
wsError(resp, err)
return
}
default:
content, err := io.ReadAll(io.LimitReader(arch, hdr.Size))
if err != nil {
wsError(resp, err)
return
}
err = os.WriteFile(fullPath, content, 0600)
if err != nil {
wsError(resp, err)
return
}
}
}
if err != nil {
wsError(resp, err)
return
}
openSecretStore()
resp.WriteEntity(map[string]any{"ok": true})
}

View File

@ -1,80 +0,0 @@
package main
import (
"strconv"
"strings"
restful "github.com/emicklei/go-restful"
"novit.tech/direktil/local-server/secretstore"
)
func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
np := NamedPassphrase{}
err := req.ReadEntity(&np)
if err != nil {
wsBadRequest(resp, err.Error())
return
}
np.Name = strings.TrimSpace(np.Name)
if len(np.Name) == 0 {
wsBadRequest(resp, "no name given")
return
}
if len(np.Passphrase) == 0 {
wsBadRequest(resp, "no passphrase given")
return
}
for _, k := range secStore.Keys {
if k.Name == np.Name {
wsBadRequest(resp, "there's already a passphrase named "+strconv.Quote(np.Name))
return
}
}
secStore.AddKey(np.Name, np.Passphrase)
defer updateState()
err = secStore.SaveTo(secKeysStorePath())
if err != nil {
wsError(resp, err)
return
}
}
func wsStoreDelKey(req *restful.Request, resp *restful.Response) {
name := ""
err := req.ReadEntity(&name)
if err != nil {
wsBadRequest(resp, err.Error())
return
}
newKeys := make([]secretstore.KeyEntry, 0, len(secStore.Keys))
for _, k := range secStore.Keys {
if k.Name == name {
continue
}
newKeys = append(newKeys, k)
}
if len(newKeys) == 0 {
wsBadRequest(resp, "can't remove the last key from the store")
return
}
secStore.Keys = newKeys
defer updateState()
err = secStore.SaveTo(secKeysStorePath())
if err != nil {
wsError(resp, err)
return
}
}

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
@ -9,9 +8,7 @@ import (
"strings" "strings"
"text/template" "text/template"
cfsslconfig "github.com/cloudflare/cfssl/config"
"github.com/emicklei/go-restful" "github.com/emicklei/go-restful"
"m.cluseau.fr/go/httperr"
"novit.tech/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
@ -19,131 +16,66 @@ import (
) )
func registerWS(rest *restful.Container) { func registerWS(rest *restful.Container) {
// public-level APIs
{
ws := &restful.WebService{}
ws.
Path("/public").
Produces(mime.JSON).
Consumes(mime.JSON).
Route(ws.POST("/unlock-store").To(wsUnlockStore).
Reads(NamedPassphrase{}).
Writes("").
Doc("Try to unlock the store")).
Route(ws.GET("/store.tar").To(wsStoreDownload).
Produces(mime.TAR).
Param(ws.QueryParameter("token", "the download token")).
Doc("Fetch the encrypted store")).
Route(ws.POST("/store.tar").To(wsStoreUpload).
Consumes(mime.TAR).
Doc("Upload an existing 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{}
Filter(requireSecStore). ws.Filter(adminAuth).
Filter(adminAuth). HeaderParameter("Authorization", "Admin bearer token")
Param(restful.HeaderParameter("Authorization", "Admin bearer token").Required(true)).
Produces(mime.JSON)
// - store management
ws.Route(ws.POST("/store/add-key").To(wsStoreAddKey).
Consumes(mime.JSON).Reads(NamedPassphrase{}).
Doc("Add an unlock key to the store"))
ws.Route(ws.POST("/store/delete-key").To(wsStoreDelKey).
Consumes(mime.JSON).Reads("").
Doc("Remove an unlock key to the store (by its name)"))
// - downloads
ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload).
Consumes(mime.JSON).Reads(DownloadSpec{}).
Produces(mime.JSON).
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).
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")) Doc("Upload a new current configuration, archiving the previous one"))
// - clusters API // - clusters API
ws.Route(ws.GET("/clusters").To(wsListClusters). ws.Route(ws.GET("/clusters").To(wsListClusters).
Doc("List clusters")) Doc("List clusters"))
ws.Route(ws.GET("/hosts-from-template").To(wsHostsFromTemplateList). ws.Route(ws.GET("/clusters/{cluster-name}").To(wsCluster).
Doc("List host template instances")) Doc("Get cluster details"))
ws.Route(ws.POST("/hosts-from-template/{name}").To(wsHostsFromTemplateSet).
Reads(HostFromTemplate{}).
Doc("Create or update a host template instance"))
ws.Route(ws.DELETE("/hosts-from-template/{name}").To(wsHostsFromTemplateDelete).
Reads(HostFromTemplate{}).
Doc("Delete a host template instance"))
const ( ws.Route(ws.GET("/clusters/{cluster-name}/addons").To(wsClusterAddons).
GET = http.MethodGet
PUT = http.MethodPut
POST = http.MethodPost
)
cluster := func(method, subPath string) *restful.RouteBuilder {
return ws.Method(method).Path("/clusters/{cluster-name}" + subPath).
Param(ws.PathParameter("cluster-name", "name of the cluster"))
}
for _, builder := range []*restful.RouteBuilder{
cluster(GET, "").To(wsCluster).
Doc("Get cluster details"),
cluster(GET, "/addons").To(wsClusterAddons).
Produces(mime.YAML). Produces(mime.YAML).
Doc("Get cluster addons"). Doc("Get cluster addons").
Returns(http.StatusOK, "OK", nil). Returns(http.StatusOK, "OK", nil).
Returns(http.StatusNotFound, "The cluster does not exists or does not have addons defined", nil), Returns(http.StatusNotFound, "The cluster does not exists or does not have addons defined", nil))
cluster(GET, "/tokens").To(wsClusterTokens). ws.Route(ws.GET("/clusters/{cluster-name}/bootstrap-pods").To(wsClusterBootstrapPods).
Doc("List cluster's tokens"), Produces(mime.YAML).
cluster(GET, "/tokens/{token-name}").To(wsClusterToken). Doc("Get cluster bootstrap pods YAML definitions").
Doc("Get cluster's token"), Returns(http.StatusOK, "OK", nil).
Returns(http.StatusNotFound, "The cluster does not exists or does not have bootstrap pods defined", nil))
cluster(GET, "/passwords").To(wsClusterPasswords). ws.Route(ws.GET("/clusters/{cluster-name}/passwords").To(wsClusterPasswords).
Doc("List cluster's passwords"), Doc("List cluster's passwords"))
cluster(GET, "/passwords/{password-name}").To(wsClusterPassword). ws.Route(ws.GET("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterPassword).
Doc("Get cluster's password"), Doc("Get cluster's password"))
cluster(PUT, "/passwords/{password-name}").To(wsClusterSetPassword). ws.Route(ws.PUT("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterSetPassword).
Doc("Set cluster's password"), Doc("Set cluster's password"))
cluster(GET, "/CAs").To(wsClusterCAs). ws.Route(ws.GET("/clusters/{cluster-name}/ca").To(wsClusterCAs).
Doc("Get cluster CAs"), Doc("Get cluster CAs"))
cluster(GET, "/CAs/{ca-name}/certificate").To(wsClusterCACert). ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/certificate").To(wsClusterCACert).
Produces(mime.CACERT). Produces(mime.CACERT).
Doc("Get cluster CA's certificate"), Doc("Get cluster CA's certificate"))
cluster(GET, "/CAs/{ca-name}/signed").To(wsClusterSignedCert). ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/signed").To(wsClusterSignedCert).
Produces(mime.CERT). Produces(mime.CERT).
Param(ws.QueryParameter("name", "signed reference name").Required(true)). Param(ws.QueryParameter("name", "signed reference name").Required(true)).
Doc("Get cluster's certificate signed by the CA"), Doc("Get cluster's certificate signed by the CA"))
cluster(GET, "/ssh/user-ca").To(wsClusterSSHUserCAPubKey). ws.Route(ws.GET("/clusters/{cluster-name}/tokens/{token-name}").To(wsClusterToken).
Produces(mime.OCTET). Doc("Get cluster's token"))
Doc("User CA public key for this cluster"),
cluster(POST, "/ssh/user-ca/sign").To(wsClusterSSHUserCASign).
Produces(mime.OCTET).
Doc("Sign a user's SSH public key for this cluster"),
cluster(POST, "/kube/sign").To(wsClusterKubeCASign).
Produces(mime.OCTET).
Doc("Sign a user's public key for this cluster's Kubernetes API server"),
} {
ws.Route(builder)
}
ws.Route(ws.GET("/hosts").To(wsListHosts). ws.Route(ws.GET("/hosts").To(wsListHosts).
Doc("List hosts")) Doc("List hosts"))
(&wsHost{
prefix: "/hosts/{host-name}",
hostDoc: "given host",
getHost: func(req *restful.Request) string {
return req.PathParameter("host-name")
},
}).register(ws, func(rb *restful.RouteBuilder) {
})
ws.Route(ws.GET("/ssh-acls").To(wsSSH_ACL_List)) 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.GET("/ssh-acls/{acl-name}").To(wsSSH_ACL_Get))
ws.Route(ws.PUT("/ssh-acls/{acl-name}").To(wsSSH_ACL_Set)) ws.Route(ws.PUT("/ssh-acls/{acl-name}").To(wsSSH_ACL_Set))
@ -151,28 +83,10 @@ func registerWS(rest *restful.Container) {
rest.Add(ws) rest.Add(ws)
// Hosts API // Hosts API
ws = (&restful.WebService{}). ws = &restful.WebService{}
Filter(requireSecStore). ws.Path("/me")
Filter(adminAuth). ws.Filter(hostsAuth).
Path("/hosts/{host-name}"). HeaderParameter("Authorization", "Host or admin bearer token")
Param(ws.HeaderParameter("Authorization", "Host or admin bearer token"))
(&wsHost{
hostDoc: "given host",
getHost: func(req *restful.Request) (string, error) {
return req.PathParameter("host-name"), nil
},
}).register(ws, func(rb *restful.RouteBuilder) {
rb.Param(ws.PathParameter("host-name", "host's name"))
})
rest.Add(ws)
// Detected host API
ws = (&restful.WebService{}).
Filter(requireSecStore).
Path("/me").
Param(ws.HeaderParameter("Authorization", "Host or admin bearer token"))
(&wsHost{ (&wsHost{
hostDoc: "detected host", hostDoc: "detected host",
@ -181,51 +95,10 @@ func registerWS(rest *restful.Container) {
rb.Notes("In this case, the host is detected from the remote IP") rb.Notes("In this case, the host is detected from the remote IP")
}) })
// Hosts by token API
ws = (&restful.WebService{}).
Filter(requireSecStore).
Path("/hosts-by-token/{host-token}").
Param(ws.PathParameter("host-token", "host's download token"))
(&wsHost{
hostDoc: "token's host",
getHost: func(req *restful.Request) (host string, err error) {
reqToken := req.PathParameter("host-token")
data, err := hostDownloadTokens.Data()
if err != nil {
return
}
for h, token := range data {
if token == reqToken {
host = h
return
}
}
return
},
}).register(ws, func(rb *restful.RouteBuilder) {
rb.Notes("In this case, the host is detected from the token")
})
rest.Add(ws) rest.Add(ws)
} }
func requireSecStore(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) { func detectHost(req *restful.Request) string {
if !secStore.Unlocked() {
wsError(resp, ErrStoreLocked)
return
}
chain.ProcessFilter(req, resp)
}
func detectHost(req *restful.Request) (hostName string, err error) {
if !*allowDetectedHost {
return
}
r := req.Request r := req.Request
remoteAddr := r.RemoteAddr remoteAddr := r.RemoteAddr
@ -243,17 +116,17 @@ func detectHost(req *restful.Request) (hostName string, err error) {
cfg, err := readConfig() cfg, err := readConfig()
if err != nil { if err != nil {
return return ""
} }
host := cfg.HostByIP(hostIP) host := cfg.HostByIP(hostIP)
if host == nil { if host == nil {
log.Print("no host found for IP ", hostIP) log.Print("no host found for IP ", hostIP)
return return ""
} }
return host.Name, nil return host.Name
} }
func wsReadConfig(resp *restful.Response) *localconfig.Config { func wsReadConfig(resp *restful.Response) *localconfig.Config {
@ -267,28 +140,19 @@ func wsReadConfig(resp *restful.Response) *localconfig.Config {
return cfg return cfg
} }
func wsNotFound(resp *restful.Response) { func wsNotFound(req *restful.Request, resp *restful.Response) {
wsError(resp, ErrNotFound) http.NotFound(resp.ResponseWriter, req.Request)
}
func wsBadRequest(resp *restful.Response, err string) {
httperr.New(http.StatusBadRequest, errors.New(err)).WriteJSON(resp.ResponseWriter)
} }
func wsError(resp *restful.Response, err error) { func wsError(resp *restful.Response, err error) {
log.Output(2, fmt.Sprint("request failed: ", err)) log.Output(2, fmt.Sprint("request failed: ", err))
resp.WriteErrorString(
switch err := err.(type) { http.StatusInternalServerError,
case httperr.Error: http.StatusText(http.StatusInternalServerError))
err.WriteJSON(resp.ResponseWriter)
default:
httperr.Internal(err).WriteJSON(resp.ResponseWriter)
}
} }
func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) { func wsRender(resp *restful.Response, tmplStr string, value interface{}) {
tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr) tmpl, err := template.New("wsRender").Funcs(templateFuncs).Parse(tmplStr)
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
return return

View File

@ -1 +0,0 @@
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/

167
go.mod
View File

@ -1,81 +1,146 @@
module novit.tech/direktil/local-server module novit.tech/direktil/local-server
go 1.24.0 go 1.18
toolchain go1.24.4
require ( require (
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 v1.6.5 github.com/cloudflare/cfssl v1.6.1
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.0
github.com/emicklei/go-restful v2.16.0+incompatible github.com/emicklei/go-restful v2.15.0+incompatible
github.com/emicklei/go-restful-openapi v1.4.1 github.com/emicklei/go-restful-openapi v1.4.1
github.com/go-git/go-git/v5 v5.16.2
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.3 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.6.1+incompatible github.com/pierrec/lz4 v2.6.1+incompatible
github.com/sergeymakinen/go-crypt v1.0.1
golang.org/x/crypto v0.39.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.4.0 gopkg.in/yaml.v2 v2.4.0
k8s.io/apimachinery v0.33.2 k8s.io/apimachinery v0.23.5
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766 novit.tech/direktil/pkg v0.0.0-20220331140020-b11c53b36ae8
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1
) )
replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1 replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1
require ( require (
dario.cat/mergo v1.0.2 // indirect bitbucket.org/creachadair/shell v0.0.7 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect cloud.google.com/go/compute v1.5.0 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/cavaliergopher/cpio v1.0.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/cloudflare/circl v1.6.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/benbjohnson/clock v1.3.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/speakeasy v0.1.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe // indirect
github.com/cncf/xds/go v0.0.0-20220314180256-7f1daf1720fc // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.7 // indirect
github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect
github.com/frankban/quicktest v1.5.0 // indirect github.com/frankban/quicktest v1.5.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/fullstorydev/grpcurl v1.8.6 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.21.1 // indirect
github.com/go-openapi/spec v0.21.0 // indirect github.com/gobuffalo/envy v1.10.1 // indirect
github.com/go-openapi/swag v0.23.1 // indirect github.com/gobuffalo/packd v1.0.1 // 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/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/certificate-transparency-go v1.3.2 // indirect github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/certificate-transparency-go v1.1.2 // indirect
github.com/google/trillian v1.4.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // 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/jmoiron/sqlx v1.4.0 // indirect github.com/jhump/protoreflect v1.12.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect github.com/jmoiron/sqlx v1.3.4 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/jonboulle/clockwork v0.2.3 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.1.0 // indirect
github.com/kisielk/sqlstruct v0.0.0-20210630145711-dae28ed37023 // indirect github.com/kisielk/sqlstruct v0.0.0-20210630145711-dae28ed37023 // indirect
github.com/mailru/easyjson v0.9.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // 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.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/prometheus/client_golang v1.12.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect github.com/prometheus/client_model v0.2.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect github.com/prometheus/common v0.33.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/soheilhy/cmux v0.1.5 // indirect
github.com/spf13/cobra v1.4.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/src-d/gcfg v1.4.0 // indirect github.com/src-d/gcfg v1.4.0 // indirect
github.com/weppos/publicsuffix-go v0.40.3-0.20250617082559-9b2e24a9e482 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/urfave/cli v1.22.5 // indirect
github.com/zmap/zcrypto v0.0.0-20250627161936-38850a079d72 // indirect github.com/weppos/publicsuffix-go v0.15.1-0.20220329081811-9a40b608a236 // indirect
github.com/zmap/zlint/v3 v3.5.0 // indirect github.com/xanzy/ssh-agent v0.3.1 // indirect
golang.org/x/mod v0.25.0 // indirect github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
golang.org/x/net v0.41.0 // indirect github.com/zmap/zcrypto v0.0.0-20220402174210-599ec18ecbac // indirect
golang.org/x/sys v0.33.0 // indirect github.com/zmap/zlint/v3 v3.1.0 // indirect
golang.org/x/text v0.26.0 // indirect go.etcd.io/bbolt v1.3.6 // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect go.etcd.io/etcd/api/v3 v3.5.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.2 // indirect
go.etcd.io/etcd/client/v2 v2.305.2 // indirect
go.etcd.io/etcd/client/v3 v3.5.2 // indirect
go.etcd.io/etcd/etcdctl/v3 v3.5.2 // indirect
go.etcd.io/etcd/etcdutl/v3 v3.5.2 // indirect
go.etcd.io/etcd/pkg/v3 v3.5.2 // indirect
go.etcd.io/etcd/raft/v3 v3.5.2 // indirect
go.etcd.io/etcd/server/v3 v3.5.2 // indirect
go.etcd.io/etcd/tests/v3 v3.5.2 // indirect
go.etcd.io/etcd/v3 v3.5.2 // indirect
go.opentelemetry.io/contrib v0.20.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.20.0 // indirect
go.opentelemetry.io/otel v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp v0.20.0 // indirect
go.opentelemetry.io/otel/metric v0.20.0 // indirect
go.opentelemetry.io/otel/sdk v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/export/metric v0.20.0 // indirect
go.opentelemetry.io/otel/sdk/metric v0.20.0 // indirect
go.opentelemetry.io/otel/trace v0.20.0 // indirect
go.opentelemetry.io/proto/otlp v0.7.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 // indirect
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect
golang.org/x/tools v0.1.10 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7 // indirect
google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // 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/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
k8s.io/klog/v2 v2.130.1 // indirect sigs.k8s.io/yaml v1.3.0 // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
) )

1671
go.sum

File diff suppressed because it is too large Load Diff

8
govc.env Normal file
View File

@ -0,0 +1,8 @@
export GOVC_DATACENTER=
export GOVC_PASSWORD=
export GOVC_URL=
export GOVC_USERNAME=
export GOVC_INSECURE=1
export GOVC_DATASTORE=
export NOVIT_VM_FOLDER=
export NOVIT_ISO_FOLDER=

View File

@ -1,3 +0,0 @@
#! /bin/sh
set -ex
go build -o dist/ -trimpath -ldflags "-X main.Version=${GIT_TAG:-$(git describe --always --dirty)}" $*

View File

@ -1,8 +0,0 @@
#! /bin/bash
set -ex
case "$1" in
commit) tag=$(git describe --always --dirty) ;;
"") tag=latest ;;
*) tag=$1 ;;
esac
docker build -t novit.tech/direktil/local-server:$tag .

View File

@ -1,4 +0,0 @@
#! /bin/sh
set -ex
go install -trimpath -ldflags "-X main.Version=$(git describe --always --dirty)" \
./cmd/dkl-dir2config

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

View File

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

View File

@ -1,37 +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 {
& > * {
display: inline-block;
margin-right: 1ex;
margin-bottom: 1ex;
padding: 0.5ex;
border: 1px solid;
border-radius: 1ex;
cursor: pointer;
}
& > .selected {
color: blue;
}
}
.download-links a {
margin-right: 1ex;
}
@media (prefers-color-scheme: dark) {
.downloads > .selected,
.view-links > .selected {
color: #31b0fa;
}
}

View File

@ -1,139 +0,0 @@
<!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>server <code>{{ serverVersion || '-----' }}</code></span>
<span>ui <code>{{ uiHash || '-----' }}</code></span>
<span :class="publicState ? 'green' : 'red'">&#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>
<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="text" 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/>
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" />
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
<input type="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>
<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="state.HostTemplates && state.HostTemplates.length">
<h3>Hosts from template</h3>
<form @submit="hostFromTemplateAdd" action="">
<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="">
<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>

View File

@ -1,101 +0,0 @@
import Downloads from './Downloads.js';
import GetCopy from './GetCopy.js';
export default {
components: { Downloads, GetCopy },
props: [ 'cluster', 'token', 'state' ],
data() {
return {
signReqValidity: "1d",
sshSignReq: {
PubKey: "",
Principal: "root",
},
sshUserCert: null,
kubeSignReq: {
CSR: "",
User: "anonymous",
Group: "",
},
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) => resp.blob())
.then((cert) => { this.sshUserCert = URL.createObjectURL(cert) })
.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) => resp.blob())
.then((cert) => { this.kubeUserCert = URL.createObjectURL(cert) })
.catch((e) => { alert('failed to sign: '+e); })
},
},
template: `
<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>
<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>
`
}

View File

@ -1,72 +0,0 @@
export default {
props: [ 'kind', 'name', 'token', 'state' ],
data() {
return { createDisabled: false, selectedAssets: {} }
},
computed: {
availableAssets() {
return {
cluster: ['addons'],
host: [
"kernel",
"initrd",
"bootstrap.tar",
"boot.img.lz4",
"boot.img.gz",
"boot.qcow2",
"boot.vmdk",
"boot.img",
"boot.iso",
"boot.tar",
"boot-efi.tar",
"config",
"bootstrap-config",
"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, '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]" />&nbsp;{{ asset }}</label>
{{" "}}
</template>
</p>
<p><button :disabled="createDisabled || assets.length==0" @click="createToken">Create links</button></p>
<template v-if="downloads.length">
<h4>Active links</h4>
<p class="download-links"><template v-for="d in downloads"><a :href="d.url" download>{{ d.name }}</a>{{" "}}</template></p>
</template>`
}

View File

@ -1,32 +0,0 @@
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>&nbsp;<a href="#" class="copy" @click="fetchAndCopy()">&#x1F5D0;</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)
},
},
}

View File

@ -1,17 +0,0 @@
import Downloads from './Downloads.js';
export default {
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" />
`
}

View File

@ -1,263 +0,0 @@
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: {},
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.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: {
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() {
this.apiPost('/store/add-key', this.namedPassphrase(), (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() {
this.apiPost('/configs', this.$refs.configUpload.files[0], (v) => {}, "text/vnd.yaml")
},
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') {
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 (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')

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,162 +0,0 @@
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], a[href]:visited, button.link {
border: none;
color: #31b0fa;
}
.red { color: #c00; }
textarea, input[type=text] {
background: #111;
color: #ddd;
border: dotted 1pt;
border-top-color: #805300;
border-left-color: #805300;
border-bottom-color: orange;
border-right-color: orange;
}
}
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; }

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"io/ioutil"
"log" "log"
"net" "net"
"os" "os"
@ -24,9 +25,11 @@ var (
type Config struct { type Config struct {
Hosts []*Host Hosts []*Host
Groups []*Group
Clusters []*Cluster Clusters []*Cluster
Configs []*Template Configs []*Template
StaticPods map[string][]*Template `yaml:"static_pods"` StaticPods []*Template `yaml:"static_pods"`
BootstrapPods map[string][]*Template `yaml:"bootstrap_pods"`
Addons map[string][]*Template Addons map[string][]*Template
SSLConfig string `yaml:"ssl_config"` SSLConfig string `yaml:"ssl_config"`
CertRequests []*CertRequest `yaml:"cert_requests"` CertRequests []*CertRequest `yaml:"cert_requests"`
@ -41,7 +44,7 @@ func FromBytes(data []byte) (*Config, error) {
} }
func FromFile(path string) (*Config, error) { func FromFile(path string) (*Config, error) {
ba, err := os.ReadFile(path) ba, err := ioutil.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -86,6 +89,15 @@ func (c *Config) HostByMAC(mac string) *Host {
return nil return nil
} }
func (c *Config) Group(name string) *Group {
for _, group := range c.Groups {
if group.Name == name {
return group
}
}
return nil
}
func (c *Config) Cluster(name string) *Cluster { func (c *Config) Cluster(name string) *Cluster {
for _, cluster := range c.Clusters { for _, cluster := range c.Clusters {
if cluster.Name == name { if cluster.Name == name {
@ -104,6 +116,15 @@ func (c *Config) ConfigTemplate(name string) *Template {
return nil return nil
} }
func (c *Config) StaticPodsTemplate(name string) *Template {
for _, s := range c.StaticPods {
if s.Name == name {
return s
}
}
return nil
}
func (c *Config) CSR(name string) *CertRequest { func (c *Config) CSR(name string) *CertRequest {
for _, s := range c.CertRequests { for _, s := range c.CertRequests {
if s.Name == name { if s.Name == name {
@ -119,25 +140,23 @@ func (c *Config) SaveTo(path string) error {
return err return err
} }
return os.WriteFile(path, ba, 0600) return ioutil.WriteFile(path, ba, 0600)
} }
type Template struct { type Template struct {
Name string Name string
Template string Template string
parsedTemplate *template.Template
} }
func (t *Template) Execute(contextName, elementName string, wr io.Writer, data interface{}, extraFuncs map[string]interface{}) error { func (t *Template) Execute(contextName, elementName string, wr io.Writer, data interface{}, extraFuncs map[string]interface{}) error {
if t.parsedTemplate == nil {
var templateFuncs = map[string]interface{}{ var templateFuncs = map[string]interface{}{
"indent": func(indent, s string) (indented string) { "indent": func(indent, s string) (indented string) {
indented = indent + strings.Replace(s, "\n", "\n"+indent, -1) indented = indent + strings.Replace(s, "\n", "\n"+indent, -1)
return return
}, },
"yaml": func(v any) (s string, err error) {
ba, err := yaml.Marshal(v)
s = string(ba)
return
},
} }
for name, f := range extraFuncs { for name, f := range extraFuncs {
@ -150,6 +169,8 @@ func (t *Template) Execute(contextName, elementName string, wr io.Writer, data i
if err != nil { if err != nil {
return err return err
} }
t.parsedTemplate = tmpl
}
if *templateDetailsDir != "" { if *templateDetailsDir != "" {
templateID++ templateID++
@ -160,7 +181,7 @@ func (t *Template) Execute(contextName, elementName string, wr io.Writer, data i
base += string(filepath.Separator) base += string(filepath.Separator)
log.Print("writing template details: ", base, "{in,data,out}") log.Print("writing template details: ", base, "{in,data,out}")
if err := os.WriteFile(base+"in", []byte(t.Template), 0600); err != nil { if err := ioutil.WriteFile(base+"in", []byte(t.Template), 0600); err != nil {
return err return err
} }
@ -169,7 +190,7 @@ func (t *Template) Execute(contextName, elementName string, wr io.Writer, data i
return err return err
} }
if err := os.WriteFile(base+"data", yamlBytes, 0600); err != nil { if err := ioutil.WriteFile(base+"data", yamlBytes, 0600); err != nil {
return err return err
} }
@ -183,37 +204,41 @@ func (t *Template) Execute(contextName, elementName string, wr io.Writer, data i
wr = io.MultiWriter(wr, out) wr = io.MultiWriter(wr, out)
} }
return tmpl.Execute(wr, data) return t.parsedTemplate.Execute(wr, data)
} }
// Host represents a host served by this server. // Host represents a host served by this server.
type Host struct { type Host struct {
WithRev WithRev
Template bool `json:",omitempty"`
Name string Name string
Labels map[string]string `json:",omitempty"` Labels map[string]string
Annotations map[string]string `json:",omitempty"` Annotations map[string]string
MAC string `json:",omitempty"` MAC string
IP string IP string
IPs []string `json:",omitempty"` IPs []string
Cluster string Cluster string
Group string Group string
Vars Vars
}
Net string // Group represents a group of hosts and provides their configuration.
IPFrom map[string]string `json:",omitempty" yaml:"ip_from"` type Group struct {
WithRev
IPXE string `json:",omitempty"` Name string
Labels map[string]string
Annotations map[string]string
Master bool
IPXE string
Kernel string Kernel string
Initrd string Initrd string
BootstrapConfig string `yaml:"bootstrap_config"` BootstrapConfig string `yaml:"bootstrap_config"`
Config string Config string
Versions map[string]string
StaticPods string `yaml:"static_pods"` StaticPods string `yaml:"static_pods"`
Versions map[string]string
Vars Vars Vars Vars
} }
@ -229,12 +254,12 @@ type Cluster struct {
Annotations map[string]string Annotations map[string]string
Domain string Domain string
Addons []string Addons string
BootstrapPods string `yaml:"bootstrap_pods"`
Subnets struct { Subnets struct {
Services string Services string
Pods string Pods string
} }
Vars Vars Vars Vars
} }
@ -249,7 +274,7 @@ func (c *Cluster) DNSSvcIP() net.IP {
func (c *Cluster) NthSvcIP(n byte) net.IP { func (c *Cluster) NthSvcIP(n byte) net.IP {
_, cidr, err := net.ParseCIDR(c.Subnets.Services) _, cidr, err := net.ParseCIDR(c.Subnets.Services)
if err != nil { if err != nil {
panic(fmt.Errorf("invalid services CIDR: %v", err)) panic(fmt.Errorf("Invalid services CIDR: %v", err))
} }
ip := cidr.IP ip := cidr.IP

View File

@ -3,6 +3,7 @@ package clustersconfig
import ( import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@ -14,38 +15,40 @@ import (
// Debug enables debug logs from this package. // Debug enables debug logs from this package.
var Debug = false var Debug = false
func FromDir( func FromDir(dirPath, defaultsPath string) (*Config, error) {
read func(path string) ([]byte, error), if Debug {
assemble func(path string) ([]byte, error), log.Printf("loading config from dir %s (defaults from %s)", dirPath, defaultsPath)
listBase func(path string) ([]string, error),
listMerged func(path string) ([]string, error),
) (*Config, error) {
load := func(dir, name string, out any) (err error) {
ba, err := assemble(filepath.Join(dir, name))
if err != nil {
return
} }
err = yaml.UnmarshalStrict(ba, out)
defaults, err := NewDefaults(defaultsPath)
if err != nil { if err != nil {
return return nil, fmt.Errorf("failed to load defaults: %v", err)
}
store := &dirStore{dirPath}
load := func(dir, name string, out Rev) error {
ba, err := store.Get(path.Join(dir, name))
if err != nil {
return fmt.Errorf("failed to load %s/%s from dir: %v", dir, name, err)
}
if err = defaults.Load(dir, ".yaml", out, ba); err != nil {
return fmt.Errorf("failed to enrich %s/%s from defaults: %v", dir, name, err)
} }
return nil return nil
} }
config := &Config{ config := &Config{
Addons: make(map[string][]*Template), Addons: make(map[string][]*Template),
StaticPods: make(map[string][]*Template), BootstrapPods: make(map[string][]*Template),
} }
// load clusters // load clusters
names, err := listBase("clusters") names, err := store.List("clusters")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list clusters: %v", err) return nil, fmt.Errorf("failed to list clusters: %v", err)
} }
for _, name := range names { for _, name := range names {
name, _ = strings.CutSuffix(name, ".yaml")
cluster := &Cluster{Name: name} cluster := &Cluster{Name: name}
if err := load("clusters", name, cluster); err != nil { if err := load("clusters", name, cluster); err != nil {
return nil, err return nil, err
@ -54,14 +57,103 @@ func FromDir(
config.Clusters = append(config.Clusters, cluster) config.Clusters = append(config.Clusters, cluster)
} }
// load groups
names, err = store.List("groups")
if err != nil {
return nil, fmt.Errorf("failed to list groups: %v", err)
}
read := func(rev, filePath string) (data []byte, fromDefaults bool, err error) {
data, err = store.Get(filePath)
if err != nil {
err = fmt.Errorf("faild to read %s: %v", filePath, err)
return
}
if data != nil {
return // ok
}
if len(rev) == 0 {
err = fmt.Errorf("entry not found: %s", filePath)
return
}
data, err = defaults.ReadAll(rev, filePath+".yaml")
if err != nil {
err = fmt.Errorf("failed to read %s:%s: %v", rev, filePath, err)
return
}
fromDefaults = true
return
}
template := func(rev, dir, name string, templates *[]*Template) (ref string, err error) {
ref = name
if len(name) == 0 {
return
}
ba, fromDefaults, err := read(rev, path.Join(dir, name))
if err != nil {
return
}
if fromDefaults {
ref = rev + ":" + name
}
if !hasTemplate(ref, *templates) {
if Debug {
log.Printf("new template in %s: %s", dir, ref)
}
*templates = append(*templates, &Template{
Name: ref,
Template: string(ba),
})
}
return
}
for _, name := range names {
group := &Group{Name: name}
if err := load("groups", name, group); err != nil {
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)
if err != nil {
return nil, fmt.Errorf("failed to load config for group %q: %v", name, err)
}
if Debug {
log.Printf("group %q: config=%q static_pods=%q",
group.Name, group.Config, group.StaticPods)
}
group.StaticPods, err = template(group.Rev(), "static-pods", group.StaticPods, &config.StaticPods)
if err != nil {
return nil, fmt.Errorf("failed to load static pods for group %q: %v", name, err)
}
config.Groups = append(config.Groups, group)
}
// load hosts // load hosts
names, err = listBase("hosts") names, err = store.List("hosts")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list hosts: %v", err) return nil, fmt.Errorf("failed to list hosts: %v", err)
} }
for _, name := range names { for _, name := range names {
name, _ = strings.CutSuffix(name, ".yaml")
o := &Host{Name: name} o := &Host{Name: name}
if err := load("hosts", name, o); err != nil { if err := load("hosts", name, o); err != nil {
return nil, err return nil, err
@ -71,20 +163,28 @@ func FromDir(
} }
// load config templates // load config templates
loadTemplates := func(dir string, templates *[]*Template) error { loadTemplates := func(rev, dir string, templates *[]*Template) error {
names, err := listMerged(dir) names, err := store.List(dir)
if err != nil { if err != nil {
return fmt.Errorf("failed to list %s: %v", dir, err) return fmt.Errorf("failed to list %s: %v", dir, err)
} }
for _, fullName := range names { if len(rev) != 0 {
name, _ := strings.CutSuffix(fullName, ".yaml") var defaultsNames []string
defaultsNames, err = defaults.List(rev, dir)
if err != nil {
return fmt.Errorf("failed to list %s:%s: %v", rev, dir, err)
}
names = append(names, defaultsNames...)
}
for _, name := range names {
if hasTemplate(name, *templates) { if hasTemplate(name, *templates) {
continue continue
} }
ba, err := read(path.Join(dir, fullName)) ba, _, err := read(rev, path.Join(dir, name))
if err != nil { if err != nil {
return err return err
} }
@ -98,57 +198,53 @@ func FromDir(
return nil return nil
} }
loadTemplates("configs", &config.Configs)
// cluster addons // cluster addons
for _, cluster := range config.Clusters { for _, cluster := range config.Clusters {
addonSets := cluster.Addons addonSet := cluster.Addons
if len(addonSets) == 0 { if len(addonSet) == 0 {
continue continue
} }
for _, addonSet := range addonSets {
if _, ok := config.Addons[addonSet]; ok { if _, ok := config.Addons[addonSet]; ok {
continue continue
} }
templates := make([]*Template, 0) templates := make([]*Template, 0)
if err = loadTemplates(path.Join("addons", addonSet), &templates); err != nil { if err = loadTemplates(cluster.Rev(), path.Join("addons", addonSet), &templates); err != nil {
return nil, err return nil, err
} }
config.Addons[addonSet] = templates config.Addons[addonSet] = templates
} }
}
// cluster static pods // cluster bootstrap pods
for _, host := range config.Hosts { for _, cluster := range config.Clusters {
bpSet := host.StaticPods bpSet := cluster.BootstrapPods
if bpSet == "" { if bpSet == "" {
continue continue
} }
if _, ok := config.StaticPods[bpSet]; ok { if _, ok := config.BootstrapPods[bpSet]; ok {
continue continue
} }
templates := make([]*Template, 0) templates := make([]*Template, 0)
if err = loadTemplates(path.Join("static-pods", bpSet), &templates); err != nil { if err = loadTemplates(cluster.Rev(), path.Join("bootstrap-pods", bpSet), &templates); err != nil {
return nil, err return nil, err
} }
config.StaticPods[bpSet] = templates config.BootstrapPods[bpSet] = templates
} }
// load SSL configuration // load SSL configuration
if ba, err := read("ssl-config.json"); err == nil { if ba, err := ioutil.ReadFile(filepath.Join(dirPath, "ssl-config.json")); err == nil {
config.SSLConfig = string(ba) config.SSLConfig = string(ba)
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return nil, err return nil, err
} }
if ba, err := read("cert-requests.yaml"); err == nil { if ba, err := ioutil.ReadFile(filepath.Join(dirPath, "cert-requests.yaml")); err == nil {
reqs := make([]*CertRequest, 0) reqs := make([]*CertRequest, 0)
if err = yaml.Unmarshal(ba, &reqs); err != nil { if err = yaml.Unmarshal(ba, &reqs); err != nil {
return nil, err return nil, err

View File

@ -1,7 +1,6 @@
package mime package mime
const ( const (
JSON = "application/json"
YAML = "text/vnd.yaml" YAML = "text/vnd.yaml"
TAR = "application/tar" TAR = "application/tar"
DISK = "application/x-diskimage" DISK = "application/x-diskimage"

View File

@ -11,7 +11,7 @@ func FromUTF8(data []byte) (res []byte) {
res = make([]byte, (len(data)+1)*2) res = make([]byte, (len(data)+1)*2)
res = res[:2] res = res[0:2]
endian.PutUint16(res, 0xfeff) endian.PutUint16(res, 0xfeff)
for len(data) > 0 { for len(data) > 0 {
@ -21,7 +21,7 @@ func FromUTF8(data []byte) (res []byte) {
} }
slen := len(res) slen := len(res)
res = res[:slen+2] res = res[0 : slen+2]
endian.PutUint16(res[slen:], uint16(r)) endian.PutUint16(res[slen:], uint16(r))
data = data[size:] data = data[size:]
} }

View File

@ -1,30 +0,0 @@
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
}

View File

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

View File

@ -1,68 +0,0 @@
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
}

View File

@ -1,278 +0,0 @@
package secretstore
import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/sha512"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"strconv"
"syscall"
"golang.org/x/crypto/argon2"
)
type Store struct {
Salt [aes.BlockSize]byte
Keys []KeyEntry
unlocked bool
key [32]byte
}
type KeyEntry struct {
Name string
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(name string, passphrase []byte) (err error) {
err = randRead(s.key[:])
if err != nil {
return
}
err = randRead(s.Salt[:])
if err != nil {
return
}
s.AddKey(name, passphrase)
s.unlocked = true
return
}
var jsonFormatHdr = []byte("{json}")
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 file's start (json header or start of salt)
readFull(s.Salt[:len(jsonFormatHdr)])
if err != nil {
return
}
if !bytes.Equal(s.Salt[:len(jsonFormatHdr)], jsonFormatHdr) {
// old key file
// finish reading the salt
readFull(s.Salt[len(jsonFormatHdr):])
if err != nil {
return
}
// read the (encrypted) keys
s.Keys = make([]KeyEntry, 0)
for {
k := KeyEntry{Name: "key-" + strconv.Itoa(len(s.Keys))}
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)
}
}
err = json.NewDecoder(in).Decode(s)
return
}
func (s *Store) WriteTo(out io.Writer) (n int64, err error) {
_, err = out.Write(jsonFormatHdr)
if err != nil {
return
}
err = json.NewEncoder(out).Encode(s)
return
}
var ErrNoSuchKey = errors.New("no such key")
func (s *Store) HasKey(passphrase []byte) bool {
key, hash := s.keyPairFromPassword(passphrase)
defer memzero(key[:])
for _, k := range s.Keys {
if k.Hash == hash {
return true
}
}
return false
}
func (s *Store) Unlock(passphrase []byte) (ok bool) {
key, hash := s.keyPairFromPassword(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(name string, passphrase []byte) {
key, hash := s.keyPairFromPassword(passphrase)
memzero(passphrase)
defer memzero(key[:])
k := KeyEntry{Name: name, 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[:])
}

27
upload-vmware.sh Normal file
View File

@ -0,0 +1,27 @@
set -e
dir=/var/lib/direktil/
PATH=$PATH:$dir
cd $dir
if [ ! -f govc.env ]; then
echo ERROR: govc.env file not found in dir $dir ; exit 1
fi
source govc.env
if [ $# != 2 ]; then
echo "Usage: $0 <VM_NAME> <NOVIT_HOST>" ; exit 1
fi
if [[ -z $NOVIT_VM_FOLDER || -z $NOVIT_ISO_FOLDER ]]; then
echo "ERROR: All GOVC env vars (including NOVIT_VM_FOLDER and NOVIT_ISO_FOLDER) must be provided" ; exit 1
fi
VM=$1
HOST=$2
govc vm.power -off "$NOVIT_VM_FOLDER/$VM" || true
sleep 5
curl localhost:7606/hosts/$HOST/boot.iso | govc datastore.upload - "$NOVIT_ISO_FOLDER/$VM.iso"
sleep 5
govc vm.power -on "$NOVIT_VM_FOLDER/$VM"