Compare commits
29 Commits
testing
...
6197369e04
Author | SHA1 | Date | |
---|---|---|---|
6197369e04 | |||
d950bc6996 | |||
18dc85d6fb | |||
26953cf703 | |||
1f03315897 | |||
5a6c0fa3d8 | |||
4acdf88785 | |||
bde41c9859 | |||
1e3ac9a0fb | |||
1672b901d4 | |||
11f3c953e2 | |||
3bc20e95cc | |||
1aefc5d2b7 | |||
5c432e3b42 | |||
b6c714fac7 | |||
e44303eab9 | |||
2a9295e8e8 | |||
52ffbe9727 | |||
811a3bddfd | |||
227c341f6b | |||
153c37b591 | |||
4ff85eaeb3 | |||
76e02c6f31 | |||
93b32eb52a | |||
0fcd219268 | |||
18d3c42fc7 | |||
645c617956 | |||
dacfc8c6ce | |||
16a0ff0823 |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@ -0,0 +1 @@
|
||||
tmp
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,2 +1,6 @@
|
||||
*.sw[po]
|
||||
modd-local.conf
|
||||
/tmp
|
||||
/dist
|
||||
/go.work
|
||||
/go.work.sum
|
||||
|
@ -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
|
||||
@ -7,7 +7,7 @@ entrypoint ["/bin/dkl-local-server"]
|
||||
|
||||
env _uncache 1
|
||||
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
|
||||
|
||||
run yes |apt-get install -y grub2 grub-pc-bin grub-efi-amd64-bin \
|
||||
|
@ -6,9 +6,10 @@ import (
|
||||
"os"
|
||||
|
||||
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 (
|
||||
@ -42,9 +43,8 @@ func main() {
|
||||
// ----------------------------------------------------------------------
|
||||
for _, cluster := range src.Clusters {
|
||||
dst.Clusters = append(dst.Clusters, &localconfig.Cluster{
|
||||
Name: cluster.Name,
|
||||
Addons: renderAddons(cluster),
|
||||
BootstrapPods: renderBootstrapPodsDS(cluster),
|
||||
Name: cluster.Name,
|
||||
Addons: renderAddons(cluster),
|
||||
})
|
||||
}
|
||||
|
||||
@ -92,7 +92,8 @@ func main() {
|
||||
Initrd: ctx.Group.Initrd,
|
||||
Versions: ctx.Group.Versions,
|
||||
|
||||
Config: ctx.Config(),
|
||||
BootstrapConfig: ctx.BootstrapConfig(),
|
||||
Config: ctx.Config(),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -3,13 +3,10 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path"
|
||||
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
|
||||
"novit.nc/direktil/local-server/pkg/clustersconfig"
|
||||
"novit.tech/direktil/local-server/pkg/clustersconfig"
|
||||
)
|
||||
|
||||
func clusterFuncs(clusterSpec *clustersconfig.Cluster) map[string]interface{} {
|
||||
@ -117,106 +114,3 @@ type namePod struct {
|
||||
Name string
|
||||
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()
|
||||
}
|
||||
|
@ -5,26 +5,31 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"path"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
|
||||
"novit.nc/direktil/local-server/pkg/clustersconfig"
|
||||
"novit.nc/direktil/pkg/config"
|
||||
"novit.tech/direktil/pkg/config"
|
||||
|
||||
"novit.tech/direktil/local-server/pkg/clustersconfig"
|
||||
)
|
||||
|
||||
type renderContext struct {
|
||||
Labels map[string]string
|
||||
Annotations map[string]string
|
||||
|
||||
Host *clustersconfig.Host
|
||||
Group *clustersconfig.Group
|
||||
Cluster *clustersconfig.Cluster
|
||||
Vars map[string]interface{}
|
||||
ConfigTemplate *clustersconfig.Template
|
||||
StaticPodsTemplate *clustersconfig.Template
|
||||
Host *clustersconfig.Host
|
||||
Group *clustersconfig.Group
|
||||
Cluster *clustersconfig.Cluster
|
||||
Vars map[string]interface{}
|
||||
|
||||
BootstrapConfigTemplate *clustersconfig.Template
|
||||
ConfigTemplate *clustersconfig.Template
|
||||
StaticPodsTemplate *clustersconfig.Template
|
||||
|
||||
clusterConfig *clustersconfig.Config
|
||||
}
|
||||
@ -56,12 +61,14 @@ func newRenderContext(host *clustersconfig.Host, cfg *clustersconfig.Config) (ct
|
||||
Labels: mergeLabels(cluster.Labels, group.Labels, host.Labels),
|
||||
Annotations: mergeLabels(cluster.Annotations, group.Annotations, host.Annotations),
|
||||
|
||||
Host: host,
|
||||
Group: group,
|
||||
Cluster: cluster,
|
||||
Vars: vars,
|
||||
ConfigTemplate: cfg.ConfigTemplate(group.Config),
|
||||
StaticPodsTemplate: cfg.StaticPodsTemplate(group.StaticPods),
|
||||
Host: host,
|
||||
Group: group,
|
||||
Cluster: cluster,
|
||||
Vars: vars,
|
||||
|
||||
BootstrapConfigTemplate: cfg.ConfigTemplate(group.BootstrapConfig),
|
||||
ConfigTemplate: cfg.ConfigTemplate(group.Config),
|
||||
StaticPodsTemplate: cfg.StaticPodsTemplate(group.StaticPods),
|
||||
|
||||
clusterConfig: cfg,
|
||||
}, nil
|
||||
@ -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 {
|
||||
if ctx.ConfigTemplate == nil {
|
||||
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()
|
||||
|
||||
ctxMap := ctx.asMap()
|
||||
@ -174,7 +197,7 @@ func (ctx *renderContext) Config() string {
|
||||
}
|
||||
|
||||
extraFuncs["bootstrap_pods_files"] = func(dir string) (string, error) {
|
||||
namePods := renderBootstrapPods(ctx.Cluster)
|
||||
namePods := ctx.renderBootstrapPods()
|
||||
|
||||
defs := make([]config.FileDef, 0)
|
||||
|
||||
@ -202,12 +225,9 @@ func (ctx *renderContext) Config() string {
|
||||
return hex.EncodeToString(ba[:])
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(make([]byte, 0, 4096))
|
||||
if err := ctx.ConfigTemplate.Execute(ctxName, "config", buf, ctxMap, extraFuncs); err != nil {
|
||||
if err := configTemplate.Execute(ctxName, "config", buf, ctxMap, extraFuncs); err != nil {
|
||||
log.Fatalf("failed to render config %q for host %q: %v", ctx.Group.Config, ctx.Host.Name, err)
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
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) {
|
||||
return fmt.Sprintf("{{ ssh_host_keys %q %q %q}}",
|
||||
dir, cluster, ctx.Host.Name)
|
||||
return fmt.Sprintf("{{ ssh_host_keys %q %q \"\"}}",
|
||||
dir, cluster)
|
||||
},
|
||||
"host_download_token": func() (s string) {
|
||||
return "{{ host_download_token }}"
|
||||
},
|
||||
|
||||
"hosts_of_group": func() (hosts []interface{}) {
|
||||
|
77
cmd/dkl-dir2config/render-host.go
Normal file
77
cmd/dkl-dir2config/render-host.go
Normal 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()
|
||||
}
|
@ -26,11 +26,32 @@ func authorizeToken(r *http.Request, token string) bool {
|
||||
}
|
||||
|
||||
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) {
|
||||
log.Printf("denied access to %s from %s", r.RequestURI, r.RemoteAddr)
|
||||
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)
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
@ -56,8 +57,10 @@ func buildBootImgGZ(out io.Writer, ctx *renderContext) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
var grubSupportVersion = flag.String("grub-support", "1.0.1", "GRUB support version")
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
@ -8,8 +8,12 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/cespare/xxhash"
|
||||
)
|
||||
|
||||
// deprecated
|
||||
func buildBootISO(out io.Writer, ctx *renderContext) error {
|
||||
tempDir, err := ioutil.TempDir("/tmp", "iso-")
|
||||
if err != nil {
|
||||
@ -189,3 +193,159 @@ menuentry "Direktil" {
|
||||
|
||||
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()
|
||||
}
|
||||
|
@ -2,11 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"fmt"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"novit.tech/direktil/local-server/pkg/utf16"
|
||||
)
|
||||
|
||||
func rmTempFile(f *os.File) {
|
||||
@ -21,7 +23,7 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
|
||||
defer arch.Close()
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@ -29,70 +31,95 @@ func buildBootTar(out io.Writer, ctx *renderContext) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// config
|
||||
cfgBytes, cfg, err := ctx.Config()
|
||||
// kernel
|
||||
kernelPath, err := ctx.distFetch("kernels", ctx.Host.Kernel)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
archAdd("config.yaml", cfgBytes)
|
||||
|
||||
// add "current" elements
|
||||
type distCopy struct {
|
||||
Src []string
|
||||
Dst string
|
||||
kernelBytes, err := ioutil.ReadFile(kernelPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// kernel and initrd
|
||||
copies := []distCopy{
|
||||
{Src: []string{"kernels", ctx.Host.Kernel}, Dst: "current/vmlinuz"},
|
||||
{Src: []string{"initrd", ctx.Host.Initrd}, Dst: "current/initrd"},
|
||||
err = archAdd("current/vmlinuz", kernelBytes)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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"),
|
||||
})
|
||||
// initrd
|
||||
initrd := new(bytes.Buffer)
|
||||
err = buildInitrdV2(initrd, ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, copy := range copies {
|
||||
outPath, err := ctx.distFetch(copy.Src...)
|
||||
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: copy.Dst,
|
||||
Size: stat.Size(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(arch, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = archAdd("current/initrd", initrd.Bytes())
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
143
cmd/dkl-local-server/bootv2.go
Normal file
143
cmd/dkl-local-server/bootv2.go
Normal 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
|
||||
}
|
@ -12,13 +12,15 @@ var (
|
||||
)
|
||||
|
||||
func casCleaner() {
|
||||
for {
|
||||
for range time.Tick(*cacheCleanDelay) {
|
||||
if !wPublicState.Get().Store.Open {
|
||||
continue
|
||||
}
|
||||
|
||||
err := cleanCAS()
|
||||
if err != nil {
|
||||
log.Print("warn: couldn't clean cache: ", err)
|
||||
}
|
||||
|
||||
time.Sleep(*cacheCleanDelay)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,156 +6,134 @@ import (
|
||||
"log"
|
||||
"path"
|
||||
|
||||
cfsslconfig "github.com/cloudflare/cfssl/config"
|
||||
"github.com/cloudflare/cfssl/csr"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
"novit.nc/direktil/pkg/config"
|
||||
|
||||
"novit.tech/direktil/pkg/config"
|
||||
)
|
||||
|
||||
var templateFuncs = map[string]interface{}{
|
||||
"password": func(cluster, name string) (password string, err error) {
|
||||
password = secretData.Password(cluster, name)
|
||||
if len(password) == 0 {
|
||||
err = fmt.Errorf("password %q not defined for cluster %q", name, cluster)
|
||||
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(),
|
||||
}
|
||||
return
|
||||
},
|
||||
|
||||
"token": func(cluster, name string) (s string, err error) {
|
||||
return secretData.Token(cluster, name)
|
||||
},
|
||||
|
||||
"ca_key": func(cluster, name string) (s string, err error) {
|
||||
ca, err := secretData.CA(cluster, name)
|
||||
err = json.Unmarshal([]byte(reqJson), certReq)
|
||||
if err != nil {
|
||||
log.Print("CSR unmarshal failed on: ", reqJson)
|
||||
return
|
||||
}
|
||||
|
||||
s = string(ca.Key)
|
||||
return
|
||||
},
|
||||
return getUsableKeyCert(cluster, caName, name, profile, label, certReq, sslCfg)
|
||||
}
|
||||
|
||||
"ca_crt": func(cluster, name string) (s string, err error) {
|
||||
ca, err := secretData.CA(cluster, name)
|
||||
if err != nil {
|
||||
return map[string]any{
|
||||
"password": func(cluster, name string) (password string, err error) {
|
||||
password, _, err = clusterPasswords.Get(cluster + "/" + name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(password) == 0 {
|
||||
err = fmt.Errorf("password %q not defined for cluster %q", name, cluster)
|
||||
}
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
s = string(ca.Cert)
|
||||
return
|
||||
},
|
||||
"token": getOrCreateClusterToken,
|
||||
|
||||
"ca_dir": func(cluster, name string) (s string, err error) {
|
||||
ca, err := secretData.CA(cluster, name)
|
||||
if err != nil {
|
||||
"ca_key": func(cluster, name string) (s string, err error) {
|
||||
ca, err := getUsableClusterCA(cluster, name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s = string(ca.Key)
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
dir := "/etc/tls-ca/" + name
|
||||
"ca_crt": func(cluster, name string) (s string, err error) {
|
||||
ca, err := getUsableClusterCA(cluster, name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "ca.key"),
|
||||
Mode: 0600,
|
||||
Content: string(ca.Key),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
"tls_key": func(cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
s = string(ca.Cert)
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
s = string(kc.Key)
|
||||
return
|
||||
},
|
||||
"ca_dir": func(cluster, name string) (s string, err error) {
|
||||
ca, err := getUsableClusterCA(cluster, name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
"tls_crt": func(cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
dir := "/etc/tls-ca/" + name
|
||||
|
||||
s = string(kc.Cert)
|
||||
return
|
||||
},
|
||||
|
||||
"tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
ca, err := secretData.CA(cluster, caName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(kc.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.key"),
|
||||
Mode: 0600,
|
||||
Content: string(kc.Key),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
"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{
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: basePath,
|
||||
Mode: 0600,
|
||||
Content: pair.Private,
|
||||
},
|
||||
{
|
||||
Path: basePath + ".pub",
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: pair.Public,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
}...)
|
||||
}
|
||||
{
|
||||
Path: path.Join(dir, "ca.key"),
|
||||
Mode: 0600,
|
||||
Content: string(ca.Key),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
return asYaml(files)
|
||||
},
|
||||
}
|
||||
"tls_key": func(cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
func getKeyCert(cluster, caName, name, profile, label, reqJson string) (kc *KeyCert, err error) {
|
||||
certReq := &csr.CertificateRequest{
|
||||
KeyRequest: csr.NewBasicKeyRequest(),
|
||||
s = string(kc.Key)
|
||||
return
|
||||
},
|
||||
|
||||
"tls_crt": func(cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s = string(kc.Cert)
|
||||
return
|
||||
},
|
||||
|
||||
"tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) {
|
||||
ca, err := getUsableClusterCA(cluster, caName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
kc, err := getKeyCert(cluster, caName, name, profile, label, reqJson)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return asYaml([]config.FileDef{
|
||||
{
|
||||
Path: path.Join(dir, "ca.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(ca.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.crt"),
|
||||
Mode: 0644,
|
||||
Content: string(kc.Cert),
|
||||
},
|
||||
{
|
||||
Path: path.Join(dir, "tls.key"),
|
||||
Mode: 0600,
|
||||
Content: string(kc.Key),
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -4,7 +4,7 @@ import (
|
||||
"flag"
|
||||
"path/filepath"
|
||||
|
||||
"novit.nc/direktil/pkg/localconfig"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
)
|
||||
|
||||
var (
|
||||
|
3
cmd/dkl-local-server/host-download-tokens.go
Normal file
3
cmd/dkl-local-server/host-download-tokens.go
Normal file
@ -0,0 +1,3 @@
|
||||
package main
|
||||
|
||||
var hostDownloadTokens = KVSecrets[string]{"hosts/download-tokens"}
|
14
cmd/dkl-local-server/httperr.go
Normal file
14
cmd/dkl-local-server/httperr.go
Normal 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")
|
||||
)
|
@ -8,7 +8,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
cpio "github.com/cavaliercoder/go-cpio"
|
||||
cpio "github.com/cavaliergopher/cpio"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
@ -58,7 +58,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) error {
|
||||
} {
|
||||
archive.WriteHeader(&cpio.Header{
|
||||
Name: dir,
|
||||
Mode: 0600 | cpio.ModeDir,
|
||||
Mode: cpio.FileMode(0600 | os.ModeDir),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
@ -4,13 +4,17 @@ import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
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 (
|
||||
@ -23,6 +27,8 @@ var (
|
||||
certFile = flag.String("tls-cert", etcDir+"/server.crt", "Server TLS certificate")
|
||||
keyFile = flag.String("tls-key", etcDir+"/server.key", "Server TLS key")
|
||||
|
||||
autoUnlock = flag.String("auto-unlock", "", "Auto-unlock store (testing only!)")
|
||||
|
||||
casStore cas.Store
|
||||
)
|
||||
|
||||
@ -35,6 +41,28 @@ func main() {
|
||||
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"))
|
||||
go casCleaner()
|
||||
|
||||
@ -44,6 +72,13 @@ func main() {
|
||||
|
||||
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 != "" {
|
||||
log.Print("HTTP listening on ", *address)
|
||||
go log.Fatal(http.ListenAndServe(*address, nil))
|
||||
|
@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"text/template"
|
||||
|
||||
@ -15,15 +17,17 @@ import (
|
||||
restful "github.com/emicklei/go-restful"
|
||||
yaml "gopkg.in/yaml.v2"
|
||||
|
||||
"novit.nc/direktil/pkg/config"
|
||||
"novit.nc/direktil/pkg/localconfig"
|
||||
"novit.tech/direktil/pkg/config"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
|
||||
bsconfig "novit.tech/direktil/pkg/bootstrapconfig"
|
||||
)
|
||||
|
||||
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
|
||||
|
||||
type renderContext struct {
|
||||
Host *localconfig.Host
|
||||
SSLConfig string
|
||||
SSLConfig *cfsslconfig.Config
|
||||
|
||||
// Linux kernel extra cmdline
|
||||
CmdLine string `yaml:"-"`
|
||||
@ -59,39 +63,62 @@ func renderCtx(w http.ResponseWriter, r *http.Request, ctx *renderContext, what
|
||||
return nil
|
||||
}
|
||||
|
||||
var prevSSLConfig = "-"
|
||||
|
||||
func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) {
|
||||
if prevSSLConfig != cfg.SSLConfig {
|
||||
var sslCfg *cfsslconfig.Config
|
||||
|
||||
if len(cfg.SSLConfig) == 0 {
|
||||
sslCfg = &cfsslconfig.Config{}
|
||||
} else {
|
||||
sslCfg, err = cfsslconfig.LoadConfig([]byte(cfg.SSLConfig))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = loadSecretData(sslCfg)
|
||||
func sslConfigFromLocalConfig(cfg *localconfig.Config) (sslCfg *cfsslconfig.Config, err error) {
|
||||
if len(cfg.SSLConfig) == 0 {
|
||||
sslCfg = &cfsslconfig.Config{}
|
||||
} else {
|
||||
sslCfg, err = cfsslconfig.LoadConfig([]byte(cfg.SSLConfig))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
prevSSLConfig = cfg.SSLConfig
|
||||
func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) {
|
||||
sslCfg, err := sslConfigFromLocalConfig(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return &renderContext{
|
||||
SSLConfig: cfg.SSLConfig,
|
||||
Host: host,
|
||||
SSLConfig: sslCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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").
|
||||
Funcs(templateFuncs).
|
||||
Parse(ctx.Host.Config)
|
||||
Funcs(ctx.TemplateFuncs()).
|
||||
Parse(templateText)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
@ -102,21 +129,7 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
if secretData.Changed() {
|
||||
err = secretData.Save()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ba = buf.Bytes()
|
||||
|
||||
cfg = &config.Config{}
|
||||
|
||||
if err = yaml.Unmarshal(buf.Bytes(), cfg); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@ -157,3 +170,69 @@ func asMap(v interface{}) map[string]interface{} {
|
||||
|
||||
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
|
||||
}
|
||||
|
332
cmd/dkl-local-server/secret-store.go
Normal file
332
cmd/dkl-local-server/secret-store.go
Normal 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
|
||||
}
|
||||
}
|
88
cmd/dkl-local-server/secrets-migrate.go
Normal file
88
cmd/dkl-local-server/secrets-migrate.go
Normal 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)
|
||||
}
|
||||
}
|
@ -1,46 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cespare/xxhash"
|
||||
"github.com/cloudflare/cfssl/certinfo"
|
||||
"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/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 {
|
||||
l sync.Mutex
|
||||
|
||||
prevHash uint64
|
||||
|
||||
clusters map[string]*ClusterSecrets
|
||||
changed bool
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
@ -51,13 +25,6 @@ type ClusterSecrets struct {
|
||||
SSHKeyPairs map[string][]SSHKeyPair
|
||||
}
|
||||
|
||||
type CA struct {
|
||||
Key []byte
|
||||
Cert []byte
|
||||
|
||||
Signed map[string]*KeyCert
|
||||
}
|
||||
|
||||
type KeyCert struct {
|
||||
Key []byte
|
||||
Cert []byte
|
||||
@ -68,21 +35,18 @@ func secretDataPath() string {
|
||||
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")
|
||||
|
||||
sd := &SecretData{
|
||||
sd = &SecretData{
|
||||
clusters: make(map[string]*ClusterSecrets),
|
||||
changed: false,
|
||||
config: config,
|
||||
}
|
||||
|
||||
ba, err := ioutil.ReadFile(secretDataPath())
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
sd.changed = true
|
||||
err = nil
|
||||
secretData = sd
|
||||
return
|
||||
}
|
||||
return
|
||||
@ -92,209 +56,6 @@ func loadSecretData(config *config.Config) (err error) {
|
||||
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
|
||||
}
|
||||
|
||||
@ -313,104 +74,3 @@ func checkCertUsable(certPEM []byte) error {
|
||||
|
||||
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)
|
||||
}
|
||||
|
@ -15,34 +15,16 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"}
|
||||
|
||||
type SSHKeyPair struct {
|
||||
Type string
|
||||
Public string
|
||||
Private string
|
||||
}
|
||||
|
||||
func (sd *SecretData) SSHKeyPairs(cluster, host string) (pairs []SSHKeyPair, err error) {
|
||||
cs := sd.cluster(cluster)
|
||||
|
||||
if cs.SSHKeyPairs == nil {
|
||||
cs.SSHKeyPairs = map[string][]SSHKeyPair{}
|
||||
}
|
||||
|
||||
outFile, err := ioutil.TempFile("/tmp", "dls-key.")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
outPath := outFile.Name()
|
||||
|
||||
removeTemp := func() {
|
||||
os.Remove(outPath)
|
||||
os.Remove(outPath + ".pub")
|
||||
}
|
||||
|
||||
defer removeTemp()
|
||||
|
||||
pairs = cs.SSHKeyPairs[host]
|
||||
func getSSHKeyPairs(host string) (pairs []SSHKeyPair, err error) {
|
||||
pairs, _, err = sshHostKeys.Get(host)
|
||||
|
||||
didGenerate := false
|
||||
|
||||
@ -59,46 +41,65 @@ genLoop:
|
||||
}
|
||||
}
|
||||
|
||||
didGenerate = true
|
||||
err = func() (err error) {
|
||||
outFile, err := ioutil.TempFile("/tmp", "dls-key.")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
removeTemp()
|
||||
outPath := outFile.Name()
|
||||
|
||||
var out, privKey, pubKey []byte
|
||||
removeTemp := func() {
|
||||
os.Remove(outPath)
|
||||
os.Remove(outPath + ".pub")
|
||||
}
|
||||
|
||||
removeTemp()
|
||||
defer removeTemp()
|
||||
|
||||
var out, privKey, pubKey []byte
|
||||
|
||||
cmd := exec.Command("ssh-keygen",
|
||||
"-N", "",
|
||||
"-C", "root@"+host,
|
||||
"-f", outPath,
|
||||
"-t", keyType)
|
||||
out, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ssh-keygen failed: %v: %s", err, string(out))
|
||||
return
|
||||
}
|
||||
|
||||
privKey, err = ioutil.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, err = ioutil.ReadFile(outPath + ".pub")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
pairs = append(pairs, SSHKeyPair{
|
||||
Type: keyType,
|
||||
Public: string(pubKey),
|
||||
Private: string(privKey),
|
||||
})
|
||||
didGenerate = true
|
||||
|
||||
out, err = exec.Command("ssh-keygen",
|
||||
"-N", "",
|
||||
"-C", "root@"+host,
|
||||
"-f", outPath,
|
||||
"-t", keyType).CombinedOutput()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ssh-keygen failed: %v: %s", err, string(out))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
privKey, err = ioutil.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
os.Remove(outPath)
|
||||
|
||||
pubKey, err = ioutil.ReadFile(outPath + ".pub")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
os.Remove(outPath + ".pub")
|
||||
|
||||
pairs = append(pairs, SSHKeyPair{
|
||||
Type: keyType,
|
||||
Public: string(pubKey),
|
||||
Private: string(privKey),
|
||||
})
|
||||
}
|
||||
|
||||
if didGenerate {
|
||||
cs.SSHKeyPairs[host] = pairs
|
||||
err = sd.Save()
|
||||
err = sshHostKeys.Put(host, pairs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
|
@ -2,16 +2,9 @@ package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func init() {
|
||||
DontSave = true
|
||||
}
|
||||
|
||||
func TestSSHKeyGet(t *testing.T) {
|
||||
sd := &SecretData{
|
||||
clusters: make(map[string]*ClusterSecrets),
|
||||
}
|
||||
|
||||
if _, err := sd.SSHKeyPairs("test", "host"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
// TODO needs fake secret store
|
||||
// if _, err := getSSHKeyPairs("host"); err != nil {
|
||||
// t.Error(err)
|
||||
// }
|
||||
}
|
||||
|
129
cmd/dkl-local-server/state.go
Normal file
129
cmd/dkl-local-server/state.go
Normal 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
|
||||
})
|
||||
}
|
178
cmd/dkl-local-server/tls-ca.go
Normal file
178
cmd/dkl-local-server/tls-ca.go
Normal 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
|
||||
}
|
24
cmd/dkl-local-server/token.go
Normal file
24
cmd/dkl-local-server/token.go
Normal 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
|
||||
}
|
45
cmd/dkl-local-server/ui.go
Normal file
45
cmd/dkl-local-server/ui.go
Normal 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 })
|
||||
}
|
@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
@ -33,8 +33,12 @@ func getToken(req *restful.Request) string {
|
||||
|
||||
token := req.HeaderParameter("Authorization")
|
||||
|
||||
if token == "" {
|
||||
return req.QueryParameter("token")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(token, bearerPrefix) {
|
||||
return ""
|
||||
return token
|
||||
}
|
||||
|
||||
return token[len(bearerPrefix):]
|
||||
|
83
cmd/dkl-local-server/ws-cluster-cas.go
Normal file
83
cmd/dkl-local-server/ws-cluster-cas.go
Normal 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)
|
||||
}
|
30
cmd/dkl-local-server/ws-cluster-passwords.go
Normal file
30
cmd/dkl-local-server/ws-cluster-passwords.go
Normal 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)
|
||||
}
|
43
cmd/dkl-local-server/ws-cluster-tokens.go
Normal file
43
cmd/dkl-local-server/ws-cluster-tokens.go
Normal 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)
|
||||
}
|
@ -2,13 +2,22 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sort"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
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) {
|
||||
cfg := wsReadConfig(resp)
|
||||
if cfg == nil {
|
||||
@ -33,7 +42,7 @@ func wsReadCluster(req *restful.Request, resp *restful.Response) (cluster *local
|
||||
|
||||
cluster = cfg.Cluster(clusterName)
|
||||
if cluster == nil {
|
||||
wsNotFound(req, resp)
|
||||
wsNotFound(resp)
|
||||
return
|
||||
}
|
||||
|
||||
@ -57,152 +66,58 @@ func wsClusterAddons(req *restful.Request, resp *restful.Response) {
|
||||
|
||||
if len(cluster.Addons) == 0 {
|
||||
log.Printf("cluster %q has no addons defined", cluster.Name)
|
||||
wsNotFound(req, resp)
|
||||
wsNotFound(resp)
|
||||
return
|
||||
}
|
||||
|
||||
wsRender(resp, cluster.Addons, cluster)
|
||||
}
|
||||
|
||||
func wsClusterPasswords(req *restful.Request, resp *restful.Response) {
|
||||
cluster := wsReadCluster(req, resp)
|
||||
if cluster == nil {
|
||||
cfg := wsReadConfig(resp)
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteEntity(secretData.Passwords(cluster.Name))
|
||||
}
|
||||
|
||||
func wsClusterPassword(req *restful.Request, resp *restful.Response) {
|
||||
cluster := wsReadCluster(req, resp)
|
||||
if cluster == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := req.PathParameter("password-name")
|
||||
|
||||
resp.WriteEntity(secretData.Password(cluster.Name, name))
|
||||
}
|
||||
|
||||
func wsClusterSetPassword(req *restful.Request, resp *restful.Response) {
|
||||
cluster := wsReadCluster(req, resp)
|
||||
if cluster == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := req.PathParameter("password-name")
|
||||
|
||||
var password string
|
||||
if err := req.ReadEntity(&password); err != nil {
|
||||
wsError(resp, err) // FIXME this is a BadRequest
|
||||
return
|
||||
}
|
||||
|
||||
secretData.SetPassword(cluster.Name, name, password)
|
||||
|
||||
if err := secretData.Save(); err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func wsClusterToken(req *restful.Request, resp *restful.Response) {
|
||||
cluster := wsReadCluster(req, resp)
|
||||
if cluster == nil {
|
||||
return
|
||||
}
|
||||
|
||||
name := req.PathParameter("token-name")
|
||||
|
||||
token, err := secretData.Token(cluster.Name, name)
|
||||
sslCfg, err := sslConfigFromLocalConfig(cfg)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteEntity(token)
|
||||
}
|
||||
|
||||
func wsClusterBootstrapPods(req *restful.Request, resp *restful.Response) {
|
||||
cluster := wsReadCluster(req, resp)
|
||||
if cluster == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(cluster.BootstrapPods) == 0 {
|
||||
log.Printf("cluster %q has no bootstrap pods defined", cluster.Name)
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
wsRender(resp, cluster.BootstrapPods, cluster)
|
||||
}
|
||||
|
||||
func wsClusterCAs(req *restful.Request, resp *restful.Response) {
|
||||
cs := secretData.clusters[req.PathParameter("cluster-name")]
|
||||
if cs == nil {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(cs.CAs))
|
||||
for k := range cs.CAs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
resp.WriteJson(keys, restful.MIME_JSON)
|
||||
wsRender(resp, sslCfg, cluster.Addons, cluster)
|
||||
}
|
||||
|
||||
func wsClusterCACert(req *restful.Request, resp *restful.Response) {
|
||||
cs := secretData.clusters[req.PathParameter("cluster-name")]
|
||||
if cs == nil {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ca := cs.CAs[req.PathParameter("ca-name")]
|
||||
if ca == nil {
|
||||
wsNotFound(req, resp)
|
||||
clusterName := req.PathParameter("cluster-name")
|
||||
caName := req.PathParameter("ca-name")
|
||||
|
||||
ca, found, err := clusterCAs.Get(clusterName + "/" + caName)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
wsNotFound(resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Header().Set("Content-Type", mime.CERT)
|
||||
resp.Write(ca.Cert)
|
||||
}
|
||||
|
||||
func wsClusterSignedCert(req *restful.Request, resp *restful.Response) {
|
||||
cs := secretData.clusters[req.PathParameter("cluster-name")]
|
||||
if cs == nil {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ca := cs.CAs[req.PathParameter("ca-name")]
|
||||
if ca == nil {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
clusterName := req.PathParameter("cluster-name")
|
||||
caName := req.PathParameter("ca-name")
|
||||
name := req.QueryParameter("name")
|
||||
|
||||
if name == "" {
|
||||
keys := make([]string, 0, len(ca.Signed))
|
||||
for k := range ca.Signed {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
resp.WriteJson(keys, restful.MIME_JSON)
|
||||
return
|
||||
}
|
||||
|
||||
kc := ca.Signed[name]
|
||||
if kc == nil {
|
||||
wsNotFound(req, resp)
|
||||
kc, found, err := clusterCASignedKeys.Get(clusterName + "/" + caName + "/" + name)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
wsNotFound(resp)
|
||||
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)
|
||||
}
|
||||
|
@ -18,7 +18,10 @@ func wsUploadConfig(req *restful.Request, resp *restful.Response) {
|
||||
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteEntity(true)
|
||||
}
|
||||
|
||||
func writeNewConfig(reader io.Reader) (err error) {
|
||||
@ -38,13 +41,22 @@ func writeNewConfig(reader io.Reader) (err error) {
|
||||
cfgPath := configFilePath()
|
||||
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)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = os.Rename(out.Name(), cfgPath)
|
||||
|
||||
updateState()
|
||||
return
|
||||
}
|
||||
|
||||
|
151
cmd/dkl-local-server/ws-downloads.go
Normal file
151
cmd/dkl-local-server/ws-downloads.go
Normal 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)
|
||||
}
|
||||
}
|
@ -8,8 +8,9 @@ import (
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
|
||||
"novit.nc/direktil/local-server/pkg/mime"
|
||||
"novit.nc/direktil/pkg/localconfig"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
|
||||
"novit.tech/direktil/local-server/pkg/mime"
|
||||
)
|
||||
|
||||
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 {
|
||||
prefix 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)) {
|
||||
@ -55,6 +56,9 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
|
||||
b("boot.tar").
|
||||
Produces(mime.TAR).
|
||||
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
|
||||
b("boot-efi.tar").
|
||||
Produces(mime.TAR).
|
||||
Doc("Get the " + ws.hostDoc + "'s /boot archive (ie: for metal upgrades)"),
|
||||
|
||||
// read-only ISO support
|
||||
b("boot.iso").
|
||||
@ -74,6 +78,26 @@ func (ws *wsHost) register(rws *restful.WebService, alterRB func(*restful.RouteB
|
||||
b("initrd").
|
||||
Produces(mime.OCTET).
|
||||
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)
|
||||
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) {
|
||||
hostname := ws.getHost(req)
|
||||
hostname, err := ws.getHost(req)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
if hostname == "" {
|
||||
wsNotFound(req, resp)
|
||||
wsNotFound(resp)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := readConfig()
|
||||
cfg, err = readConfig()
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
@ -96,7 +124,7 @@ func (ws *wsHost) host(req *restful.Request, resp *restful.Response) (host *loca
|
||||
host = cfg.Host(hostname)
|
||||
if host == nil {
|
||||
log.Print("no host named ", hostname)
|
||||
wsNotFound(req, resp)
|
||||
wsNotFound(resp)
|
||||
return
|
||||
}
|
||||
return
|
||||
@ -151,6 +179,8 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
|
||||
|
||||
case "boot.tar":
|
||||
err = renderCtx(w, r, ctx, what, buildBootTar)
|
||||
case "boot-efi.tar":
|
||||
err = renderCtx(w, r, ctx, what, buildBootEFITar)
|
||||
|
||||
case "boot.img":
|
||||
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":
|
||||
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:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
98
cmd/dkl-local-server/ws-public.go
Normal file
98
cmd/dkl-local-server/ws-public.go
Normal 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)
|
||||
}
|
27
cmd/dkl-local-server/ws-store.go
Normal file
27
cmd/dkl-local-server/ws-store.go
Normal 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
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@ -8,60 +9,111 @@ import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
cfsslconfig "github.com/cloudflare/cfssl/config"
|
||||
"github.com/emicklei/go-restful"
|
||||
"m.cluseau.fr/go/httperr"
|
||||
|
||||
"novit.nc/direktil/local-server/pkg/mime"
|
||||
"novit.nc/direktil/pkg/localconfig"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
|
||||
"novit.tech/direktil/local-server/pkg/mime"
|
||||
)
|
||||
|
||||
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
|
||||
ws := &restful.WebService{}
|
||||
ws.Filter(adminAuth).
|
||||
HeaderParameter("Authorization", "Admin bearer token")
|
||||
ws.
|
||||
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
|
||||
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"))
|
||||
|
||||
// - clusters API
|
||||
ws.Route(ws.GET("/clusters").To(wsListClusters).
|
||||
Doc("List clusters"))
|
||||
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}").To(wsCluster).
|
||||
Doc("Get cluster details"))
|
||||
const (
|
||||
GET = http.MethodGet
|
||||
PUT = http.MethodPut
|
||||
)
|
||||
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/addons").To(wsClusterAddons).
|
||||
Produces(mime.YAML).
|
||||
Doc("Get cluster addons").
|
||||
Returns(http.StatusOK, "OK", nil).
|
||||
Returns(http.StatusNotFound, "The cluster does not exists or does not have addons defined", nil))
|
||||
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"))
|
||||
}
|
||||
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/bootstrap-pods").To(wsClusterBootstrapPods).
|
||||
Produces(mime.YAML).
|
||||
Doc("Get cluster bootstrap pods YAML definitions").
|
||||
Returns(http.StatusOK, "OK", nil).
|
||||
Returns(http.StatusNotFound, "The cluster does not exists or does not have bootstrap pods defined", nil))
|
||||
for _, builder := range []*restful.RouteBuilder{
|
||||
cluster(GET, "").To(wsCluster).
|
||||
Doc("Get cluster details"),
|
||||
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/passwords").To(wsClusterPasswords).
|
||||
Doc("List cluster's passwords"))
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterPassword).
|
||||
Doc("Get cluster's password"))
|
||||
ws.Route(ws.PUT("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterSetPassword).
|
||||
Doc("Set cluster's password"))
|
||||
cluster(GET, "/addons").To(wsClusterAddons).
|
||||
Produces(mime.YAML).
|
||||
Doc("Get cluster addons").
|
||||
Returns(http.StatusOK, "OK", nil).
|
||||
Returns(http.StatusNotFound, "The cluster does not exists or does not have addons defined", nil),
|
||||
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/ca").To(wsClusterCAs).
|
||||
Doc("Get cluster CAs"))
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/certificate").To(wsClusterCACert).
|
||||
Produces(mime.CACERT).
|
||||
Doc("Get cluster CA's certificate"))
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/signed").To(wsClusterSignedCert).
|
||||
Produces(mime.CERT).
|
||||
Param(ws.QueryParameter("name", "signed reference name").Required(true)).
|
||||
Doc("Get cluster's certificate signed by the CA"))
|
||||
cluster(GET, "/tokens").To(wsClusterTokens).
|
||||
Doc("List cluster's tokens"),
|
||||
cluster(GET, "/tokens/{token-name}").To(wsClusterToken).
|
||||
Doc("Get cluster's token"),
|
||||
|
||||
ws.Route(ws.GET("/clusters/{cluster-name}/tokens/{token-name}").To(wsClusterToken).
|
||||
Doc("Get cluster's token"))
|
||||
cluster(GET, "/passwords").To(wsClusterPasswords).
|
||||
Doc("List cluster's passwords"),
|
||||
cluster(GET, "/passwords/{password-name}").To(wsClusterPassword).
|
||||
Doc("Get cluster's password"),
|
||||
cluster(PUT, "/passwords/{password-name}").To(wsClusterSetPassword).
|
||||
Doc("Set cluster's password"),
|
||||
|
||||
cluster(GET, "/CAs").To(wsClusterCAs).
|
||||
Doc("Get cluster CAs"),
|
||||
cluster(GET, "/CAs/{ca-name}/certificate").To(wsClusterCACert).
|
||||
Produces(mime.CACERT).
|
||||
Doc("Get cluster CA's certificate"),
|
||||
cluster(GET, "/CAs/{ca-name}/signed").To(wsClusterSignedCert).
|
||||
Produces(mime.CERT).
|
||||
Param(ws.QueryParameter("name", "signed reference name").Required(true)).
|
||||
Doc("Get cluster's certificate signed by the CA"),
|
||||
} {
|
||||
ws.Route(builder)
|
||||
}
|
||||
|
||||
ws.Route(ws.GET("/hosts").To(wsListHosts).
|
||||
Doc("List hosts"))
|
||||
@ -69,10 +121,11 @@ func registerWS(rest *restful.Container) {
|
||||
(&wsHost{
|
||||
prefix: "/hosts/{host-name}",
|
||||
hostDoc: "given host",
|
||||
getHost: func(req *restful.Request) string {
|
||||
return req.PathParameter("host-name")
|
||||
getHost: func(req *restful.Request) (string, error) {
|
||||
return req.PathParameter("host-name"), nil
|
||||
},
|
||||
}).register(ws, func(rb *restful.RouteBuilder) {
|
||||
rb.Param(ws.PathParameter("host-name", "host's name"))
|
||||
})
|
||||
|
||||
ws.Route(ws.GET("/ssh-acls").To(wsSSH_ACL_List))
|
||||
@ -83,9 +136,10 @@ func registerWS(rest *restful.Container) {
|
||||
|
||||
// Hosts API
|
||||
ws = &restful.WebService{}
|
||||
ws.Path("/me")
|
||||
ws.Filter(hostsAuth).
|
||||
HeaderParameter("Authorization", "Host or admin bearer token")
|
||||
ws.Produces(mime.JSON).
|
||||
Path("/me").
|
||||
Filter(hostsAuth).
|
||||
Param(ws.HeaderParameter("Authorization", "Host or admin bearer token"))
|
||||
|
||||
(&wsHost{
|
||||
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")
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
func detectHost(req *restful.Request) string {
|
||||
func detectHost(req *restful.Request) (hostName string, err error) {
|
||||
r := req.Request
|
||||
remoteAddr := r.RemoteAddr
|
||||
|
||||
@ -115,17 +196,17 @@ func detectHost(req *restful.Request) string {
|
||||
|
||||
cfg, err := readConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
return
|
||||
}
|
||||
|
||||
host := cfg.HostByIP(hostIP)
|
||||
|
||||
if host == nil {
|
||||
log.Print("no host found for IP ", hostIP)
|
||||
return ""
|
||||
return
|
||||
}
|
||||
|
||||
return host.Name
|
||||
return host.Name, nil
|
||||
}
|
||||
|
||||
func wsReadConfig(resp *restful.Response) *localconfig.Config {
|
||||
@ -139,19 +220,28 @@ func wsReadConfig(resp *restful.Response) *localconfig.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func wsNotFound(req *restful.Request, resp *restful.Response) {
|
||||
http.NotFound(resp.ResponseWriter, req.Request)
|
||||
func wsNotFound(resp *restful.Response) {
|
||||
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) {
|
||||
log.Output(2, fmt.Sprint("request failed: ", err))
|
||||
resp.WriteErrorString(
|
||||
http.StatusInternalServerError,
|
||||
http.StatusText(http.StatusInternalServerError))
|
||||
|
||||
switch err := err.(type) {
|
||||
case httperr.Error:
|
||||
err.WriteJSON(resp.ResponseWriter)
|
||||
|
||||
default:
|
||||
httperr.Internal(err).WriteJSON(resp.ResponseWriter)
|
||||
}
|
||||
}
|
||||
|
||||
func wsRender(resp *restful.Response, tmplStr string, value interface{}) {
|
||||
tmpl, err := template.New("wsRender").Funcs(templateFuncs).Parse(tmplStr)
|
||||
func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) {
|
||||
tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
|
1
gen-api-js.sh
Executable file
1
gen-api-js.sh
Executable 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
88
go.mod
@ -1,55 +1,71 @@
|
||||
module novit.nc/direktil/local-server
|
||||
module novit.tech/direktil/local-server
|
||||
|
||||
go 1.17
|
||||
go 1.20
|
||||
|
||||
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/cloudflare/cfssl v0.0.0-20181213083726-b94e044bb51e
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/emicklei/go-restful v2.10.0+incompatible
|
||||
github.com/emicklei/go-restful-openapi v1.2.0
|
||||
github.com/cloudflare/cfssl v1.6.3
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/emicklei/go-restful v2.16.0+incompatible
|
||||
github.com/emicklei/go-restful-openapi v1.4.1
|
||||
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/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-git.v4 v4.13.1
|
||||
gopkg.in/yaml.v2 v2.2.4
|
||||
k8s.io/apimachinery v0.0.0-20191203211716-adc6f4cd9e7d
|
||||
novit.nc/direktil/pkg v0.0.0-20191211161950-96b0448b84c2
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
k8s.io/apimachinery v0.26.2
|
||||
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 (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/emirpasic/gods v1.12.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/frankban/quicktest v1.5.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.3 // indirect
|
||||
github.com/go-openapi/spec v0.19.3 // indirect
|
||||
github.com/go-openapi/swag v0.19.5 // indirect
|
||||
github.com/gobuffalo/envy v1.7.1 // indirect
|
||||
github.com/gobuffalo/packd v0.3.0 // indirect
|
||||
github.com/go-logr/logr v1.2.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/spec v0.20.8 // indirect
|
||||
github.com/go-openapi/swag v0.22.3 // 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/golang/protobuf v1.3.2 // indirect
|
||||
github.com/google/certificate-transparency-go v1.0.21 // indirect
|
||||
github.com/google/go-cmp v0.3.1 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.4 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/joho/godotenv v1.3.0 // indirect
|
||||
github.com/json-iterator/go v1.1.8 // indirect
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
|
||||
github.com/mailru/easyjson v0.7.0 // indirect
|
||||
github.com/jmoiron/sqlx v1.3.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/josharian/intern v1.0.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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.0.0 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/src-d/gcfg v1.4.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.2.1 // indirect
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect
|
||||
golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 // indirect
|
||||
golang.org/x/sys v0.0.0-20191018095205-727590c5006e // indirect
|
||||
golang.org/x/text v0.3.2 // indirect
|
||||
github.com/weppos/publicsuffix-go v0.30.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/zmap/zcrypto v0.0.0-20230205235340-d51ce4775101 // indirect
|
||||
github.com/zmap/zlint/v3 v3.1.0 // 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/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.90.1 // indirect
|
||||
k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 // indirect
|
||||
)
|
||||
|
BIN
html/favicon.ico
Normal file
BIN
html/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
6
html/html.go
Normal file
6
html/html.go
Normal 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
41
html/ui/app.css
Normal 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
98
html/ui/index.html
Normal 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)">🗐</button>
|
||||
</span>
|
||||
|
||||
<span id="uiHash">ui <code>{{ uiHash || '-----' }}</code></span>
|
||||
|
||||
<span class="green" v-if="publicState">🗲</span>
|
||||
<span class="red" v-else >🗲</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
<button class="btn-close" @click="error=null">×</button>
|
||||
<div class="code" v-if="error.code">{{ error.code }}</div>
|
||||
<div class="message">{{ error.message }}</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!publicState">
|
||||
<p>Not connected.</p>
|
||||
</template>
|
||||
<template v-else-if="publicState.Store.New">
|
||||
<p>Store is new.</p>
|
||||
<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
35
html/ui/js/Cluster.js
Normal 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
66
html/ui/js/Downloads.js
Normal 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]" /> {{ 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
32
html/ui/js/GetCopy.js
Normal 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> <a href="#" class="copy" @click="fetchAndCopy()">🗐</a></span>`,
|
||||
methods: {
|
||||
fetch() {
|
||||
event.preventDefault()
|
||||
return fetch(this.href, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer ' + this.token },
|
||||
})
|
||||
},
|
||||
handleFetchError(e) {
|
||||
console.log("failed to get value:", e)
|
||||
alert('failed to get value')
|
||||
},
|
||||
fetchAndSave() {
|
||||
this.fetch().then(resp => resp.blob()).then((value) => {
|
||||
window.open(URL.createObjectURL(value), "_blank")
|
||||
}).catch(this.handleFetchError)
|
||||
},
|
||||
fetchAndCopy() {
|
||||
this.fetch()
|
||||
.then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
|
||||
.then((value) => {
|
||||
window.navigator.clipboard.writeText(value)
|
||||
this.showCopied = true
|
||||
setTimeout(() => { this.showCopied = false }, 1000)
|
||||
}).catch(this.handleFetchError)
|
||||
},
|
||||
},
|
||||
}
|
21
html/ui/js/Host.js
Normal file
21
html/ui/js/Host.js
Normal 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
167
html/ui/js/app.js
Normal 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
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
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
152
html/ui/style.css
Normal 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; }
|
18
modd.conf
18
modd.conf
@ -1,10 +1,22 @@
|
||||
**/*.go go.mod go.sum Dockerfile {
|
||||
modd.conf {}
|
||||
|
||||
**/*.go go.mod go.sum {
|
||||
prep: go test ./...
|
||||
prep: go install -trimpath ./cmd/...
|
||||
prep: docker build --build-arg GOPROXY=$GOPROXY -t dls .
|
||||
prep: mkdir -p dist
|
||||
prep: go build -o dist/ -trimpath ./...
|
||||
#prep: docker build --build-arg GOPROXY=$GOPROXY -t dls .
|
||||
#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/**/* {
|
||||
prep: for mod in @mods; do protoc -I ./ --go_out=plugins=grpc,paths=source_relative:. $mod; done
|
||||
}
|
||||
|
@ -231,14 +231,15 @@ type Group struct {
|
||||
Labels map[string]string
|
||||
Annotations map[string]string
|
||||
|
||||
Master bool
|
||||
IPXE string
|
||||
Kernel string
|
||||
Initrd string
|
||||
Config string
|
||||
StaticPods string `yaml:"static_pods"`
|
||||
Versions map[string]string
|
||||
Vars Vars
|
||||
Master bool
|
||||
IPXE string
|
||||
Kernel string
|
||||
Initrd string
|
||||
BootstrapConfig string `yaml:"bootstrap_config"`
|
||||
Config string
|
||||
StaticPods string `yaml:"static_pods"`
|
||||
Versions map[string]string
|
||||
Vars Vars
|
||||
}
|
||||
|
||||
// Vars store user-defined key-values
|
||||
|
@ -124,6 +124,11 @@ func FromDir(dirPath, defaultsPath string) (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
group.BootstrapConfig, err = template(group.Rev(), "configs", group.BootstrapConfig, &config.Configs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config for group %q: %v", name, err)
|
||||
}
|
||||
|
||||
group.Config, err = template(group.Rev(), "configs", group.Config, &config.Configs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config for group %q: %v", name, err)
|
||||
|
61
pkg/initrdconfig/config.go
Normal file
61
pkg/initrdconfig/config.go
Normal 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
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package mime
|
||||
|
||||
const (
|
||||
JSON = "application/json"
|
||||
YAML = "text/vnd.yaml"
|
||||
TAR = "application/tar"
|
||||
DISK = "application/x-diskimage"
|
||||
|
29
pkg/utf16/utf16.go
Normal file
29
pkg/utf16/utf16.go
Normal 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
30
secretstore/io.go
Normal 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
7
secretstore/mem.go
Normal file
@ -0,0 +1,7 @@
|
||||
package secretstore
|
||||
|
||||
func memzero(ba []byte) {
|
||||
for i := range ba {
|
||||
ba[i] = 0
|
||||
}
|
||||
}
|
68
secretstore/reader.go
Normal file
68
secretstore/reader.go
Normal 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
263
secretstore/secret-store.go
Normal 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[:])
|
||||
}
|
Reference in New Issue
Block a user