Compare commits

..

29 Commits

Author SHA1 Message Date
6197369e04 go bump 2023-03-22 20:45:28 +01:00
d950bc6996 go mod tidy 2023-03-07 14:46:47 +01:00
18dc85d6fb missing notfound 2023-02-21 10:39:57 +01:00
26953cf703 secrets migration 2023-02-15 08:49:34 +01:00
1f03315897 log fix 2023-02-13 18:34:45 +01:00
5a6c0fa3d8 rework not found 2023-02-13 18:07:10 +01:00
4acdf88785 misc fixes 2023-02-13 17:31:37 +01:00
bde41c9859 host download tokens 2023-02-13 17:08:17 +01:00
1e3ac9a0fb store download & add key 2023-02-13 13:21:45 +01:00
1672b901d4 cosmestic 2023-02-12 18:59:14 +01:00
11f3c953e2 migration to new secrets nearly complete 2023-02-12 15:18:42 +01:00
3bc20e95cc secrets migration & restitution 2023-02-12 11:58:26 +01:00
1aefc5d2b7 go.mod: go 1.20 2023-02-09 08:58:48 +01:00
5c432e3b42 go mod update 2023-02-07 21:31:02 +01:00
b6c714fac7 downloads API, UI 2023-02-07 21:29:19 +01:00
e44303eab9 go mod update 2023-02-02 17:08:56 +01:00
2a9295e8e8 bump initrdv2 2023-02-02 00:32:38 +01:00
52ffbe9727 go mod update 2023-01-27 12:01:56 +01:00
811a3bddfd renew: don't use Renew, just create a new cert 2023-01-27 06:42:39 +01:00
227c341f6b renew: hande more error cases 2023-01-27 06:25:51 +01:00
153c37b591 secrets: more verbose errors 2023-01-27 06:13:05 +01:00
4ff85eaeb3 remove bootstrap pods from cluster, and render in hosts' context 2023-01-16 18:17:11 +01:00
76e02c6f31 allow configuration of grub-support version 2022-12-22 00:28:54 +01:00
93b32eb52a dockerfile: go 1.19.0 2022-08-05 16:06:36 +02:00
0fcd219268 boot-v2.iso: initial commit 2022-07-21 16:03:39 +02:00
18d3c42fc7 boot efi 2022-05-31 11:57:21 +02:00
645c617956 migrate boot.tar to initrd-v2 2022-05-31 09:52:43 +02:00
dacfc8c6ce upgrade deps 2022-04-28 10:01:21 +02:00
16a0ff0823 bootv2 support 2022-04-28 03:33:19 +02:00
79 changed files with 20913 additions and 13635 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
tmp

4
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -1,76 +0,0 @@
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
var (
testDir = "testdata"
goldenFile = "config.yaml.golden"
outFile = "config.yaml"
binName = "dkl-dir2config"
)
/*
Build and run the code with default parameters and testdata
*/
func TestMain(m *testing.M) {
fmt.Println("Building...")
if runtime.GOOS == "windows" {
binName += ".exe"
}
build := exec.Command("go", "build", "-o", filepath.Join(testDir, binName))
if err := build.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Cannot build : %v", err)
os.Exit(1)
}
fmt.Println("Running Tests...")
result := m.Run()
fmt.Println("Cleaning Up ... ")
os.Remove(binName)
os.Exit(result)
}
func TestRunMain(t *testing.T) {
err := os.Chdir(testDir)
if err != nil {
t.Fatal(err)
}
t.Run("RunWithNoArgument", func(t *testing.T) {
cmd := exec.Command("./" + binName)
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
})
t.Run("CompareOutputs", func(t *testing.T) {
cmd := exec.Command("./"+binName, "-out", outFile)
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
output, err := os.ReadFile(outFile)
if err != nil {
t.Fatal(err)
}
expected, err := os.ReadFile(goldenFile)
if err != nil {
t.Fatal(err)
}
if ret := bytes.Compare(output, expected); ret != 0 {
t.Fatalf("Output (%v) of length %d is different than expected (%v) of length %d", outFile, len(output), goldenFile, len(expected))
}
})
}

View File

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

View File

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

View File

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

View File

@ -1,39 +0,0 @@
- name: etcd-server
ca: etcd
profile: server
per_host: true
template: |
{"CN":"{{.host.name}}","hosts":["127.0.0.1","{{.host.ip}}"],"key":{"algo":"ecdsa","size":256}}
- name: etcd-peer
ca: etcd
profile: peer
per_host: true
template: |
{"CN":"{{.host.name}}","hosts":["127.0.0.1","{{.host.ip}}"],"key":{"algo":"ecdsa","size":256}}
- name: etcd-client
ca: etcd
profile: client
template: |
{"CN":"client","hosts":["*"],"key":{"algo":"ecdsa","size":256}}
- name: apiserver
ca: cluster
profile: server
per_host: true
template: |
{"CN":"{{.host.name}}","hosts":[
"kubernetes", "kubernetes.default", "kubernetes.default.svc.{{.cluster.domain}}","{{.host.name}}",
"127.0.0.1","{{.cluster.kubernetes_svc_ip}}","{{.vars.public_vip}}",
{{- if .vars.apiserver_vip }}"{{.vars.apiserver_vip}}",{{ end }}
"{{.host.ip}}"
],"key":{"algo":"ecdsa","size":521}}
- name: cluster-client
ca: cluster
profile: client
template: |
{"CN":"client","hosts":["*"],"key":{"algo":"ecdsa","size":256}}
- name: kubelet-client
ca: cluster
profile: client
template: |
{"CN":"kubelet-client","names":[{"O":"system:masters"}],"hosts":["*"],"key":{"algo":"ecdsa","size":256}}

View File

@ -1 +0,0 @@
from: v1.19:test

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Submodule cmd/dkl-dir2config/testdata/defaults deleted from be8c8592fb

View File

@ -1 +0,0 @@
from: v1.19:master

View File

@ -1,3 +0,0 @@
ip: 172.16.0.1
cluster: test
group: test-master

View File

@ -1,3 +0,0 @@
ip: 172.16.0.2
cluster: test
group: test-master

View File

@ -1,3 +0,0 @@
ip: 172.16.0.3
cluster: test
group: test-master

View File

@ -1,34 +0,0 @@
{
"signing": {
"default": {
"expiry": "43800h"
},
"profiles": {
"server": {
"expiry": "43800h",
"usages": [
"signing",
"key encipherment",
"server auth"
]
},
"client": {
"expiry": "43800h",
"usages": [
"signing",
"key encipherment",
"client auth"
]
},
"peer": {
"expiry": "43800h",
"usages": [
"signing",
"key encipherment",
"server auth",
"client auth"
]
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,13 +12,15 @@ var (
) )
func casCleaner() { func casCleaner() {
for { for range time.Tick(*cacheCleanDelay) {
if !wPublicState.Get().Store.Open {
continue
}
err := cleanCAS() err := cleanCAS()
if err != nil { if err != nil {
log.Print("warn: couldn't clean cache: ", err) log.Print("warn: couldn't clean cache: ", err)
} }
time.Sleep(*cacheCleanDelay)
} }
} }

View File

@ -6,26 +6,44 @@ import (
"log" "log"
"path" "path"
cfsslconfig "github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr" "github.com/cloudflare/cfssl/csr"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.nc/direktil/pkg/config"
"novit.tech/direktil/pkg/config"
) )
var templateFuncs = map[string]interface{}{ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
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)
}
return map[string]any{
"password": func(cluster, name string) (password string, err error) { "password": func(cluster, name string) (password string, err error) {
password = secretData.Password(cluster, name) password, _, err = clusterPasswords.Get(cluster + "/" + name)
if err != nil {
return
}
if len(password) == 0 { if len(password) == 0 {
err = fmt.Errorf("password %q not defined for cluster %q", name, cluster) err = fmt.Errorf("password %q not defined for cluster %q", name, cluster)
} }
return return
}, },
"token": func(cluster, name string) (s string, err error) { "token": getOrCreateClusterToken,
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 := secretData.CA(cluster, name) ca, err := getUsableClusterCA(cluster, name)
if err != nil { if err != nil {
return return
} }
@ -35,7 +53,7 @@ var templateFuncs = map[string]interface{}{
}, },
"ca_crt": func(cluster, name string) (s string, err error) { "ca_crt": func(cluster, name string) (s string, err error) {
ca, err := secretData.CA(cluster, name) ca, err := getUsableClusterCA(cluster, name)
if err != nil { if err != nil {
return return
} }
@ -45,7 +63,7 @@ var templateFuncs = map[string]interface{}{
}, },
"ca_dir": func(cluster, name string) (s string, err error) { "ca_dir": func(cluster, name string) (s string, err error) {
ca, err := secretData.CA(cluster, name) ca, err := getUsableClusterCA(cluster, name)
if err != nil { if err != nil {
return return
} }
@ -87,7 +105,7 @@ var templateFuncs = map[string]interface{}{
}, },
"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 := secretData.CA(cluster, caName) ca, err := getUsableClusterCA(cluster, caName)
if err != nil { if err != nil {
return return
} }
@ -115,47 +133,7 @@ var templateFuncs = map[string]interface{}{
}, },
}) })
}, },
"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.NewBasicKeyRequest(),
}
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

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

View File

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

View File

@ -0,0 +1,14 @@
package main
import (
"net/http"
"m.cluseau.fr/go/httperr"
)
var (
ErrNotFound = httperr.StdStatus(http.StatusNotFound)
ErrUnauthorized = httperr.StdStatus(http.StatusUnauthorized)
ErrForbidden = httperr.StdStatus(http.StatusForbidden)
ErrInvalidToken = httperr.NewStd(1000, http.StatusForbidden, "invalid token")
)

View File

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

View File

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

View File

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

View File

@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"path"
"path/filepath" "path/filepath"
"text/template" "text/template"
@ -15,15 +17,17 @@ import (
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
"novit.nc/direktil/pkg/config" "novit.tech/direktil/pkg/config"
"novit.nc/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
bsconfig "novit.tech/direktil/pkg/bootstrapconfig"
) )
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition") var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
type renderContext struct { type renderContext struct {
Host *localconfig.Host Host *localconfig.Host
SSLConfig string SSLConfig *cfsslconfig.Config
// Linux kernel extra cmdline // Linux kernel extra cmdline
CmdLine string `yaml:"-"` CmdLine string `yaml:"-"`
@ -59,12 +63,7 @@ func renderCtx(w http.ResponseWriter, r *http.Request, ctx *renderContext, what
return nil return nil
} }
var prevSSLConfig = "-" func sslConfigFromLocalConfig(cfg *localconfig.Config) (sslCfg *cfsslconfig.Config, err error) {
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 {
@ -73,25 +72,53 @@ func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *ren
return return
} }
} }
return
}
err = loadSecretData(sslCfg) func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) {
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
} }
func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) { func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
ba, err = ctx.render(ctx.Host.Config)
if err != nil {
return
}
cfg = &config.Config{}
if err = yaml.Unmarshal(ba, cfg); err != nil {
return
}
return
}
func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, err error) {
ba, err = ctx.render(ctx.Host.BootstrapConfig)
if err != nil {
return
}
cfg = &bsconfig.Config{}
if err = yaml.Unmarshal(ba, cfg); err != nil {
return
}
return
}
func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
tmpl, err := template.New(ctx.Host.Name + "/config"). tmpl, err := template.New(ctx.Host.Name + "/config").
Funcs(templateFuncs). Funcs(ctx.TemplateFuncs()).
Parse(ctx.Host.Config) Parse(templateText)
if err != nil { if err != nil {
return return
@ -102,21 +129,7 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
return return
} }
if secretData.Changed() {
err = secretData.Save()
if err != nil {
return
}
}
ba = buf.Bytes() ba = buf.Bytes()
cfg = &config.Config{}
if err = yaml.Unmarshal(buf.Bytes(), cfg); err != nil {
return
}
return return
} }
@ -157,3 +170,69 @@ func asMap(v interface{}) map[string]interface{} {
return result return result
} }
func (ctx *renderContext) TemplateFuncs() map[string]any {
funcs := templateFuncs(ctx.SSLConfig)
for name, method := range map[string]any{
"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 {
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)
},
"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

@ -0,0 +1,332 @@
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(http.StatusConflict, 1, "store already unlocked")
ErrInvalidPassphrase = httperr.NewStd(http.StatusBadRequest, 2, "invalid passphrase")
)
func unlockSecretStore(passphrase []byte) (err httperr.Error) {
unlockMutex.Lock()
defer unlockMutex.Unlock()
if secStore.Unlocked() {
return ErrStoreAlreadyUnlocked
}
if secStore.IsNew() {
err := secStore.Init(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
}
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]) 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
}
}

View File

@ -0,0 +1,88 @@
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,46 +1,20 @@
package main package main
import ( import (
"crypto"
"crypto/rand"
"crypto/x509"
"encoding/base32"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io/ioutil" "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
} }
@ -51,13 +25,6 @@ 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
@ -68,21 +35,18 @@ func secretDataPath() string {
return filepath.Join(*dataDir, "secret-data.json") return filepath.Join(*dataDir, "secret-data.json")
} }
func loadSecretData(config *config.Config) (err error) { func loadSecretData(config *config.Config) (sd *SecretData, 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 := ioutil.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
@ -92,209 +56,6 @@ func loadSecretData(config *config.Config) (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.BasicKeyRequest{
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
} }
@ -313,104 +74,3 @@ 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

@ -15,34 +15,16 @@ import (
"os/exec" "os/exec"
) )
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 (sd *SecretData) SSHKeyPairs(cluster, host string) (pairs []SSHKeyPair, err error) { func getSSHKeyPairs(host string) (pairs []SSHKeyPair, err error) {
cs := sd.cluster(cluster) pairs, _, err = sshHostKeys.Get(host)
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
@ -59,17 +41,30 @@ genLoop:
} }
} }
didGenerate = true err = func() (err error) {
outFile, err := ioutil.TempFile("/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
out, err = exec.Command("ssh-keygen", cmd := exec.Command("ssh-keygen",
"-N", "", "-N", "",
"-C", "root@"+host, "-C", "root@"+host,
"-f", outPath, "-f", outPath,
"-t", keyType).CombinedOutput() "-t", keyType)
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
@ -80,25 +75,31 @@ genLoop:
return return
} }
os.Remove(outPath)
pubKey, err = ioutil.ReadFile(outPath + ".pub") 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 {
cs.SSHKeyPairs[host] = pairs err = sshHostKeys.Put(host, pairs)
err = sd.Save() if err != nil {
return
}
} }
return return

View File

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

View File

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

View File

@ -0,0 +1,178 @@
package main
import (
"crypto"
"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) 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)
}
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 = checkCertUsable(kc.Cert)
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

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

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

View File

@ -19,7 +19,7 @@ import (
) )
var ( var (
upstreamURL = flag.String("upstream", "https://direktil.novit.nc/dist", "Upstream server for dist elements") upstreamURL = flag.String("upstream", "https://dkl.novit.io/dist", "Upstream server for dist elements")
) )
func (ctx *renderContext) distFetch(path ...string) (outPath string, err error) { func (ctx *renderContext) distFetch(path ...string) (outPath string, err error) {

View File

@ -24,7 +24,7 @@ func tokenAuth(req *restful.Request, resp *restful.Response, chain *restful.Filt
} }
} }
resp.WriteErrorString(401, "401: Not Authorized") wsError(resp, ErrUnauthorized)
return return
} }
@ -33,8 +33,12 @@ func getToken(req *restful.Request) string {
token := req.HeaderParameter("Authorization") token := req.HeaderParameter("Authorization")
if token == "" {
return req.QueryParameter("token")
}
if !strings.HasPrefix(token, bearerPrefix) { if !strings.HasPrefix(token, bearerPrefix) {
return "" return token
} }
return token[len(bearerPrefix):] return token[len(bearerPrefix):]

View File

@ -0,0 +1,83 @@
package main
import (
"fmt"
"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)
err = ca.RenewCert()
if err != nil {
err = fmt.Errorf("renew: %w", err)
}
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

@ -0,0 +1,30 @@
package main
import (
restful "github.com/emicklei/go-restful"
)
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

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

@ -2,13 +2,22 @@ package main
import ( import (
"log" "log"
"sort" "net/url"
"strconv"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.nc/direktil/pkg/localconfig" "novit.tech/direktil/local-server/pkg/mime"
"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 {
@ -33,7 +42,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(req, resp) wsNotFound(resp)
return return
} }
@ -57,152 +66,58 @@ 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(req, resp) wsNotFound(resp)
return return
} }
wsRender(resp, cluster.Addons, cluster) cfg := wsReadConfig(resp)
} if cfg == nil {
func wsClusterPasswords(req *restful.Request, resp *restful.Response) {
cluster := wsReadCluster(req, resp)
if cluster == nil {
return return
} }
resp.WriteEntity(secretData.Passwords(cluster.Name)) sslCfg, err := sslConfigFromLocalConfig(cfg)
}
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
} }
resp.WriteEntity(token) wsRender(resp, sslCfg, cluster.Addons, cluster)
}
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) {
cs := secretData.clusters[req.PathParameter("cluster-name")] clusterName := req.PathParameter("cluster-name")
if cs == nil { caName := req.PathParameter("ca-name")
wsNotFound(req, resp)
return ca, found, err := clusterCAs.Get(clusterName + "/" + caName)
} if err != nil {
wsError(resp, err)
ca := cs.CAs[req.PathParameter("ca-name")] return
if ca == nil { }
wsNotFound(req, resp) if !found {
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) {
cs := secretData.clusters[req.PathParameter("cluster-name")] clusterName := req.PathParameter("cluster-name")
if cs == nil { caName := req.PathParameter("ca-name")
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")
if name == "" { kc, found, err := clusterCASignedKeys.Get(clusterName + "/" + caName + "/" + name)
keys := make([]string, 0, len(ca.Signed)) if err != nil {
for k := range ca.Signed { wsError(resp, err)
keys = append(keys, k) return
} }
if !found {
sort.Strings(keys) wsNotFound(resp)
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)
} }

View File

@ -18,7 +18,10 @@ 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) {
@ -38,13 +41,22 @@ 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)
} else if !os.IsNotExist(err) { if err != nil {
return return
} }
}
err = os.Rename(out.Name(), cfgPath) err = os.Rename(out.Name(), cfgPath)
updateState()
return return
} }

View File

@ -0,0 +1,151 @@
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 := cfg.Host(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

@ -8,8 +8,9 @@ import (
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.nc/direktil/local-server/pkg/mime" "novit.tech/direktil/pkg/localconfig"
"novit.nc/direktil/pkg/localconfig"
"novit.tech/direktil/local-server/pkg/mime"
) )
var trustXFF = flag.Bool("trust-xff", true, "Trust the X-Forwarded-For header") var trustXFF = flag.Bool("trust-xff", true, "Trust the X-Forwarded-For header")
@ -17,7 +18,7 @@ var trustXFF = flag.Bool("trust-xff", true, "Trust the X-Forwarded-For header")
type wsHost struct { type wsHost struct {
prefix string prefix string
hostDoc string hostDoc string
getHost func(req *restful.Request) string getHost func(req *restful.Request) (hostName string, err error)
} }
func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBuilder)) { func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteBuilder)) {
@ -55,6 +56,9 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
b("boot.tar"). b("boot.tar").
Produces(mime.TAR). Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"), Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
b("boot-efi.tar").
Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
// read-only ISO support // read-only ISO support
b("boot.iso"). b("boot.iso").
@ -74,6 +78,26 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
b("initrd"). b("initrd").
Produces(mime.OCTET). Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s initial RAM disk (ie: for netboot)"), Doc("Get the " + ws.hostDoc + "'s initial RAM disk (ie: for netboot)"),
// boot v2
// - bootstrap config
b("bootstrap-config").
Produces(mime.YAML).
Doc("Get the " + ws.hostDoc + "'s bootstrap configuration"),
b("bootstrap-config.json").
Doc("Get the " + ws.hostDoc + "'s bootstrap configuration (as JSON)"),
// - initrd
b("initrd-v2").
Produces(mime.OCTET).
Doc("Get the " + ws.hostDoc + "'s initial RAM disk (v2)"),
// - bootstrap
b("bootstrap.tar").
Produces(mime.TAR).
Doc("Get the " + ws.hostDoc + "'s bootstrap seed archive"),
b("boot-v2.iso").
Produces(mime.ISO).
Param(cmdlineParam).
Doc("Get the " + ws.hostDoc + "'s boot CD-ROM image (v2)"),
} { } {
alterRB(rb) alterRB(rb)
rws.Route(rb) rws.Route(rb)
@ -81,13 +105,17 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
} }
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 := ws.getHost(req) hostname, err := ws.getHost(req)
if err != nil {
wsError(resp, err)
return
}
if hostname == "" { if hostname == "" {
wsNotFound(req, resp) wsNotFound(resp)
return return
} }
cfg, err := readConfig() cfg, err = readConfig()
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
return return
@ -96,7 +124,7 @@ func (ws *wsHost) host(req *restful.Request, resp *restful.Response) (host *loca
host = cfg.Host(hostname) host = cfg.Host(hostname)
if host == nil { if host == nil {
log.Print("no host named ", hostname) log.Print("no host named ", hostname)
wsNotFound(req, resp) wsNotFound(resp)
return return
} }
return return
@ -151,6 +179,8 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "boot.tar": case "boot.tar":
err = renderCtx(w, r, ctx, what, buildBootTar) err = renderCtx(w, r, ctx, what, buildBootTar)
case "boot-efi.tar":
err = renderCtx(w, r, ctx, what, buildBootEFITar)
case "boot.img": case "boot.img":
err = renderCtx(w, r, ctx, what, buildBootImg) err = renderCtx(w, r, ctx, what, buildBootImg)
@ -161,6 +191,18 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
case "boot.img.lz4": case "boot.img.lz4":
err = renderCtx(w, r, ctx, what, buildBootImgLZ4) err = renderCtx(w, r, ctx, what, buildBootImgLZ4)
// boot v2
case "bootstrap-config":
err = renderBootstrapConfig(w, r, ctx, false)
case "bootstrap-config.json":
err = renderBootstrapConfig(w, r, ctx, true)
case "initrd-v2":
err = renderCtx(w, r, ctx, what, buildInitrdV2)
case "bootstrap.tar":
err = renderCtx(w, r, ctx, what, buildBootstrap)
case "boot-v2.iso":
err = renderCtx(w, r, ctx, what, buildBootISOv2)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }

View File

@ -0,0 +1,98 @@
package main
import (
"archive/tar"
"bytes"
"io"
"io/fs"
"net/http"
"os"
restful "github.com/emicklei/go-restful"
)
func wsUnlockStore(req *restful.Request, resp *restful.Response) {
var passphrase string
err := req.ReadEntity(&passphrase)
if err != nil {
resp.WriteError(http.StatusBadRequest, err)
return
}
if err := unlockSecretStore([]byte(passphrase)); err.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)
}

View File

@ -0,0 +1,27 @@
package main
import (
restful "github.com/emicklei/go-restful"
)
func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
var passphrase string
err := req.ReadEntity(&passphrase)
if err != nil {
wsBadRequest(resp, err.Error())
return
}
if len(passphrase) == 0 {
wsBadRequest(resp, "no passphrase given")
return
}
secStore.AddKey([]byte(passphrase))
err = secStore.SaveTo(secKeysStorePath())
if err != nil {
wsError(resp, err)
return
}
}

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
@ -8,60 +9,111 @@ 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.nc/direktil/local-server/pkg/mime" "novit.tech/direktil/pkg/localconfig"
"novit.nc/direktil/pkg/localconfig"
"novit.tech/direktil/local-server/pkg/mime"
) )
func registerWS(rest *restful.Container) { func registerWS(rest *restful.Container) {
// public-level APIs
{
ws := &restful.WebService{}
ws.
Path("/public").
Produces(mime.JSON).
Consumes(mime.JSON).
Route(ws.POST("/unlock-store").To(wsUnlockStore).
Reads("").
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.GET("/downloads/{token}/{asset}").To(wsDownload).
Param(ws.PathParameter("token", "the download token")).
Param(ws.PathParameter("asset", "the requested asset")).
Doc("Fetch an asset via a download token"))
rest.Add(ws)
}
// Admin-level APIs // Admin-level APIs
ws := &restful.WebService{} ws := &restful.WebService{}
ws.Filter(adminAuth). ws.
HeaderParameter("Authorization", "Admin bearer token") Filter(adminAuth).
Param(ws.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("").
Doc("Add an unlock key to the store"))
// - 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("/clusters/{cluster-name}").To(wsCluster). const (
Doc("Get cluster details")) GET = http.MethodGet
PUT = http.MethodPut
)
ws.Route(ws.GET("/clusters/{cluster-name}/addons").To(wsClusterAddons). 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),
ws.Route(ws.GET("/clusters/{cluster-name}/bootstrap-pods").To(wsClusterBootstrapPods). cluster(GET, "/tokens").To(wsClusterTokens).
Produces(mime.YAML). Doc("List cluster's tokens"),
Doc("Get cluster bootstrap pods YAML definitions"). cluster(GET, "/tokens/{token-name}").To(wsClusterToken).
Returns(http.StatusOK, "OK", nil). Doc("Get cluster's token"),
Returns(http.StatusNotFound, "The cluster does not exists or does not have bootstrap pods defined", nil))
ws.Route(ws.GET("/clusters/{cluster-name}/passwords").To(wsClusterPasswords). cluster(GET, "/passwords").To(wsClusterPasswords).
Doc("List cluster's passwords")) Doc("List cluster's passwords"),
ws.Route(ws.GET("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterPassword). cluster(GET, "/passwords/{password-name}").To(wsClusterPassword).
Doc("Get cluster's password")) Doc("Get cluster's password"),
ws.Route(ws.PUT("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterSetPassword). cluster(PUT, "/passwords/{password-name}").To(wsClusterSetPassword).
Doc("Set cluster's password")) Doc("Set cluster's password"),
ws.Route(ws.GET("/clusters/{cluster-name}/ca").To(wsClusterCAs). cluster(GET, "/CAs").To(wsClusterCAs).
Doc("Get cluster CAs")) Doc("Get cluster CAs"),
ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/certificate").To(wsClusterCACert). cluster(GET, "/CAs/{ca-name}/certificate").To(wsClusterCACert).
Produces(mime.CACERT). Produces(mime.CACERT).
Doc("Get cluster CA's certificate")) Doc("Get cluster CA's certificate"),
ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/signed").To(wsClusterSignedCert). cluster(GET, "/CAs/{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"),
} {
ws.Route(ws.GET("/clusters/{cluster-name}/tokens/{token-name}").To(wsClusterToken). ws.Route(builder)
Doc("Get cluster's token")) }
ws.Route(ws.GET("/hosts").To(wsListHosts). ws.Route(ws.GET("/hosts").To(wsListHosts).
Doc("List hosts")) Doc("List hosts"))
@ -69,10 +121,11 @@ func registerWS(rest *restful.Container) {
(&wsHost{ (&wsHost{
prefix: "/hosts/{host-name}", prefix: "/hosts/{host-name}",
hostDoc: "given host", hostDoc: "given host",
getHost: func(req *restful.Request) string { getHost: func(req *restful.Request) (string, error) {
return req.PathParameter("host-name") return req.PathParameter("host-name"), nil
}, },
}).register(ws, func(rb *restful.RouteBuilder) { }).register(ws, func(rb *restful.RouteBuilder) {
rb.Param(ws.PathParameter("host-name", "host's name"))
}) })
ws.Route(ws.GET("/ssh-acls").To(wsSSH_ACL_List)) ws.Route(ws.GET("/ssh-acls").To(wsSSH_ACL_List))
@ -83,9 +136,10 @@ func registerWS(rest *restful.Container) {
// Hosts API // Hosts API
ws = &restful.WebService{} ws = &restful.WebService{}
ws.Path("/me") ws.Produces(mime.JSON).
ws.Filter(hostsAuth). Path("/me").
HeaderParameter("Authorization", "Host or admin bearer token") Filter(hostsAuth).
Param(ws.HeaderParameter("Authorization", "Host or admin bearer token"))
(&wsHost{ (&wsHost{
hostDoc: "detected host", hostDoc: "detected host",
@ -94,10 +148,37 @@ 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{}
ws.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 detectHost(req *restful.Request) string { func detectHost(req *restful.Request) (hostName string, err error) {
r := req.Request r := req.Request
remoteAddr := r.RemoteAddr remoteAddr := r.RemoteAddr
@ -115,17 +196,17 @@ func detectHost(req *restful.Request) string {
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 return host.Name, nil
} }
func wsReadConfig(resp *restful.Response) *localconfig.Config { func wsReadConfig(resp *restful.Response) *localconfig.Config {
@ -139,19 +220,28 @@ func wsReadConfig(resp *restful.Response) *localconfig.Config {
return cfg return cfg
} }
func wsNotFound(req *restful.Request, resp *restful.Response) { func wsNotFound(resp *restful.Response) {
http.NotFound(resp.ResponseWriter, req.Request) wsError(resp, ErrNotFound)
}
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(
http.StatusInternalServerError, switch err := err.(type) {
http.StatusText(http.StatusInternalServerError)) case httperr.Error:
err.WriteJSON(resp.ResponseWriter)
default:
httperr.Internal(err).WriteJSON(resp.ResponseWriter)
}
} }
func wsRender(resp *restful.Response, tmplStr string, value interface{}) { func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) {
tmpl, err := template.New("wsRender").Funcs(templateFuncs).Parse(tmplStr) tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr)
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)
return return

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

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

88
go.mod
View File

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

1263
go.sum

File diff suppressed because it is too large Load Diff

BIN
html/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

6
html/html.go Normal file
View File

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

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

@ -0,0 +1,41 @@
.downloads {
display: flex;
align-content: stretch;
}
.downloads > * {
margin-left: 6pt;
}
.downloads > *:first-child {
margin-left: 0;
}
.downloads > div {
display: flex;
flex-flow: column;
max-height: 100pt;
overflow: auto;
}
.cluster {
max-width: 50%;
}
#store-infos {
display: flex;
flex-flow: row wrap;
align-content: center;
justify-content: flex-start;
border-bottom: dashed 1pt;
margin-bottom: 1ex;
}
#store-infos > * {
display: block;
font-size: medium;
padding: 2pt 1ex;
margin: 0 0 0 1ex;
}
#store-infos > *:first-child {
margin-left: 0;
}

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

@ -0,0 +1,98 @@
<!doctype html>
<html>
<head>
<title>Direktil Local Server</title>
<style>
@import url('./style.css');
@import url('./app.css');
</style>
<script src="js/jsonpatch.min.js" crossorigin="anonymous"></script>
<script src="js/app.js" type="module" defer></script>
<body>
<div id="app">
<header>
<div id="logo">
<img src="/favicon.ico" />
<span>Direktil Local Server</span>
</div>
<div class="utils">
<span id="login-hdr" v-if="session.token">
Logged in
<button class="link" @click="copyText(session.token)">&#x1F5D0;</button>
</span>
<span id="uiHash">ui <code>{{ uiHash || '-----' }}</code></span>
<span class="green" v-if="publicState">&#x1F5F2;</span>
<span class="red" v-else >&#x1F5F2;</span>
</div>
</header>
<div class="error" v-if="error">
<button class="btn-close" @click="error=null">&times;</button>
<div class="code" v-if="error.code">{{ error.code }}</div>
<div class="message">{{ error.message }}</div>
</div>
<template v-if="!publicState">
<p>Not connected.</p>
</template>
<template v-else-if="publicState.Store.New">
<p>Store is new.</p>
<form @submit="unlockStore" action="/public/unlock-store">
<input type="password" v-model="forms.store.pass1" name="passphrase" required />
<input type="password" v-model="forms.store.pass2" required />
<input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
</form>
</template>
<template v-else-if="!publicState.Store.Open">
<p>Store is not open.</p>
<form @submit="unlockStore" action="/public/unlock-store">
<input type="password" name="passphrase" v-model="forms.store.pass1" required />
<input type="submit" value="unlock" :disabled="!forms.store.pass1" />
</form>
</template>
<template v-else-if="!state">
<p v-if="!session.token">Not logged in.</p>
<p v-else>Invalid token</p>
<form @submit="setToken">
<input type="password" v-model="forms.setToken" required />
<input type="submit" value="set token"/>
</form>
</template>
<template v-else>
<div id="store-infos">
<h2>Store</h2>
<a :href="'/public/store.tar?token='+state.Store.DownloadToken" target="_blank">download</a>
<form @submit="storeAddKey" action="/store/add-key">
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required />
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required />
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
</form>
</div>
<div v-if="state.Clusters" id="clusters">
<h2>Clusters</h2>
<div class="sheets">
<Cluster v-for="c in state.Clusters" :cluster="c" :token="session.token" :state="state" />
</div>
</div>
<div v-if="state.Hosts" id="hosts">
<h2>Hosts</h2>
<div class="sheets">
<Host v-for="h in state.Hosts" :host="h" :token="session.token" :state="state" />
</div>
</div>
<pre v-if="false">{{ state }}</pre>
</template>
</div>
</body>
</html>

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

@ -0,0 +1,35 @@
import Downloads from './Downloads.js';
import GetCopy from './GetCopy.js';
export default {
components: { Downloads, GetCopy },
props: [ 'cluster', 'token', 'state' ],
template: `
<div class="cluster">
<div class="title">Cluster {{ cluster.Name }}</div>
<div class="section">Tokens</div>
<section class="links">
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
</section>
<div class="section">Passwords</div>
<section class="links">
<GetCopy v-for="n in cluster.Passwords" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/passwords/'+n" />
</section>
<div class="section">Downloads</div>
<section class="downloads">
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
</section>
<div class="section">CAs</div>
<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>
</div>
`
}

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

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

32
html/ui/js/GetCopy.js Normal file
View File

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

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

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

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

@ -0,0 +1,167 @@
import { createApp } from './vue.esm-browser.js';
import Cluster from './Cluster.js';
import Host from './Host.js';
createApp({
components: { Cluster, Host },
data() {
return {
forms: {
store: { },
},
session: {},
error: null,
publicState: null,
uiHash: null,
watchingState: false,
state: null,
}
},
mounted() {
this.session = JSON.parse(sessionStorage.state || "{}")
this.watchPublicState()
},
watch: {
session: {
deep: true,
handler(v) {
sessionStorage.state = JSON.stringify(v)
if (v.token && !this.watchingState) {
this.watchState()
this.watchingState = true
}
}
},
publicState: {
deep: true,
handler(v) {
if (v) {
if (this.uiHash && v.UIHash != this.uiHash) {
console.log("reloading")
location.reload()
} else {
this.uiHash = v.UIHash
}
}
},
}
},
methods: {
copyText(text) {
event.preventDefault()
window.navigator.clipboard.writeText(text)
},
setToken() {
event.preventDefault()
this.session.token = this.forms.setToken
this.forms.setToken = null
},
storeAddKey() {
this.apiPost('/store/add-key', this.forms.store.pass1, (v) => {
this.forms.store = {}
})
},
unlockStore() {
this.apiPost('/public/unlock-store', this.forms.store.pass1, (v) => {
this.forms.store = {}
if (v) {
this.session.token = v
if (!this.watchingState) {
this.watchState()
this.watchingState = true
}
}
})
},
apiPost(action, data, onload) {
event.preventDefault()
if (data === undefined) {
throw("action " + action + ": no data")
}
/* TODO
fetch(action, {
method: 'POST',
body: JSON.stringify(data),
})
.then((response) => response.json())
.then((result) => onload)
// */
var xhr = new XMLHttpRequest()
xhr.responseType = 'json'
// TODO spinner, pending aciton notification, or something
xhr.onerror = () => {
// this.actionResults.splice(idx, 1, {...item, done: true, failed: true })
}
xhr.onload = (r) => {
if (xhr.status != 200) {
this.error = xhr.response
return
}
// this.actionResults.splice(idx, 1, {...item, done: true, resp: xhr.responseText})
this.error = null
if (onload) {
onload(xhr.response)
}
}
xhr.open("POST", action)
xhr.setRequestHeader('Accept', 'application/json')
xhr.setRequestHeader('Content-Type', 'application/json')
if (this.session.token) {
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
}
xhr.send(JSON.stringify(data))
},
download(url) {
event.target.target = '_blank'
event.target.href = this.downloadLink(url)
},
downloadLink(url) {
// TODO once-shot download link
return url + '?token=' + this.session.token
},
watchPublicState() {
this.watchStream('publicState', '/public-state')
},
watchState() {
this.watchStream('state', '/state', true)
},
watchStream(field, path, withToken) {
let evtSrc = new EventSource(path + (withToken ? '?token='+this.session.token : ''));
evtSrc.onmessage = (e) => {
let update = JSON.parse(e.data)
console.log("watch "+path+":", update)
if (update.err) {
console.log("watch error from server:", err)
}
if (update.set) {
this[field] = update.set
}
if (update.p) { // patch
new jsonpatch.JSONPatch(update.p, true).apply(this[field])
}
}
evtSrc.onerror = (e) => {
// console.log("event source " + path + " error:", e)
if (evtSrc) evtSrc.close()
this[field] = null
window.setTimeout(() => { this.watchStream(field, path, withToken) }, 1000)
}
},
}
}).mount('#app')

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

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

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

2
install Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
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"

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

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

30
secretstore/io.go Normal file
View File

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

7
secretstore/mem.go Normal file
View File

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

68
secretstore/reader.go Normal file
View File

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

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

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