Compare commits
12 Commits
2af7ff85c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 06a87a6d07 | |||
| d37c4c2f13 | |||
| 629bb21f12 | |||
| 6d9499ebb1 | |||
| 4ab136be68 | |||
| 0dbab431a0 | |||
| 183099f7da | |||
| 47f695a8cd | |||
| f57aa581f3 | |||
| 63a206f1ac | |||
| f2b23df97b | |||
| 3085dac359 |
@ -51,7 +51,7 @@ func buildBootISO(out io.Writer, ctx *renderContext) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// create a tag file
|
// create a tag file
|
||||||
bootstrapBytes, _, err := ctx.BootstrapConfig()
|
bootstrapBytes, err := ctx.BootstrapConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto"
|
"crypto"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@ -12,26 +11,20 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
yaml "gopkg.in/yaml.v2"
|
|
||||||
|
|
||||||
"novit.tech/direktil/pkg/cpiocat"
|
"novit.tech/direktil/pkg/cpiocat"
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderBootstrapConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) {
|
func renderBootstrapConfig(w http.ResponseWriter, ctx *renderContext) (err error) {
|
||||||
log.Printf("sending bootstrap config for %q", ctx.Host.Name)
|
log.Printf("sending bootstrap config for %q", ctx.Host.Name)
|
||||||
|
|
||||||
_, cfg, err := ctx.BootstrapConfig()
|
ba, err := ctx.BootstrapConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if asJson {
|
_, err = w.Write(ba)
|
||||||
err = json.NewEncoder(w).Encode(cfg)
|
return
|
||||||
} else {
|
|
||||||
err = yaml.NewEncoder(w).Encode(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
||||||
@ -58,8 +51,10 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
|||||||
for _, layer := range cfg.Layers {
|
for _, layer := range cfg.Layers {
|
||||||
switch layer {
|
switch layer {
|
||||||
case "modules":
|
case "modules":
|
||||||
|
|
||||||
layerVersion := ctx.Host.Versions[layer]
|
layerVersion := ctx.Host.Versions[layer]
|
||||||
|
if layerVersion == "" {
|
||||||
|
layerVersion = ctx.Host.Kernel
|
||||||
|
}
|
||||||
modulesPath, err := distFetch("layers", layer, layerVersion)
|
modulesPath, err := distFetch("layers", layer, layerVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -70,7 +65,7 @@ func buildInitrd(out io.Writer, ctx *renderContext) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// config
|
// config
|
||||||
cfgBytes, _, err := ctx.BootstrapConfig()
|
cfgBytes, err := ctx.BootstrapConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,7 +117,12 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s = string(ca.Cert)
|
extra, err := caExtraCerts(cluster, name)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s = string(ca.Cert) + extra
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -127,13 +132,18 @@ func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extra, err := caExtraCerts(cluster, name)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
dir := "/etc/tls-ca/" + name
|
dir := "/etc/tls-ca/" + name
|
||||||
|
|
||||||
return asYaml([]config.FileDef{
|
return asYaml([]config.FileDef{
|
||||||
{
|
{
|
||||||
Path: path.Join(dir, "ca.crt"),
|
Path: path.Join(dir, "ca.crt"),
|
||||||
Mode: 0644,
|
Mode: 0644,
|
||||||
Content: string(ca.Cert),
|
Content: string(ca.Cert) + extra,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: path.Join(dir, "ca.key"),
|
Path: path.Join(dir, "ca.key"),
|
||||||
|
|||||||
@ -4,14 +4,12 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
yaml "gopkg.in/yaml.v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func renderConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, asJson bool) (err error) {
|
func renderConfig(w http.ResponseWriter, _ *http.Request, ctx *renderContext, asJson bool) (err error) {
|
||||||
log.Printf("sending config for %q", ctx.Host.Name)
|
log.Printf("sending config for %q", ctx.Host.Name)
|
||||||
|
|
||||||
_, cfg, err := ctx.Config()
|
cfgBytes, cfg, err := ctx.Config()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -19,7 +17,7 @@ func renderConfig(w http.ResponseWriter, r *http.Request, ctx *renderContext, as
|
|||||||
if asJson {
|
if asJson {
|
||||||
err = json.NewEncoder(w).Encode(cfg)
|
err = json.NewEncoder(w).Encode(cfg)
|
||||||
} else {
|
} else {
|
||||||
err = yaml.NewEncoder(w).Encode(cfg)
|
_, err = w.Write(cfgBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -25,8 +26,6 @@ import (
|
|||||||
|
|
||||||
"novit.tech/direktil/pkg/config"
|
"novit.tech/direktil/pkg/config"
|
||||||
"novit.tech/direktil/pkg/localconfig"
|
"novit.tech/direktil/pkg/localconfig"
|
||||||
|
|
||||||
bsconfig "novit.tech/direktil/pkg/bootstrapconfig"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
|
var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addition")
|
||||||
@ -101,6 +100,8 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// log.Print("rendered config:\n", string(ba))
|
||||||
|
|
||||||
cfg = &config.Config{}
|
cfg = &config.Config{}
|
||||||
if err = yaml.Unmarshal(ba, cfg); err != nil {
|
if err = yaml.Unmarshal(ba, cfg); err != nil {
|
||||||
return
|
return
|
||||||
@ -109,19 +110,8 @@ func (ctx *renderContext) Config() (ba []byte, cfg *config.Config, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, err error) {
|
func (ctx *renderContext) BootstrapConfig() (ba []byte, err error) {
|
||||||
ba, err = ctx.render(ctx.Host.BootstrapConfig)
|
return ctx.render(ctx.Host.BootstrapConfig)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg = &bsconfig.Config{}
|
|
||||||
if err = yaml.Unmarshal(ba, cfg); err != nil {
|
|
||||||
log.Print("invalid bootstrap config yaml:\n", string(ba))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
|
func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
|
||||||
@ -184,6 +174,10 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for name, method := range map[string]any{
|
for name, method := range map[string]any{
|
||||||
|
"base64": func(input string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(input))
|
||||||
|
},
|
||||||
|
|
||||||
"host_ip": func() (s string) {
|
"host_ip": func() (s string) {
|
||||||
return ctx.Host.IPs[0]
|
return ctx.Host.IPs[0]
|
||||||
},
|
},
|
||||||
@ -236,6 +230,10 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
|||||||
Content: string(userCA),
|
Content: string(userCA),
|
||||||
}})
|
}})
|
||||||
},
|
},
|
||||||
|
"ssh_user_ca_pub": func(cluster string) (s string, err error) {
|
||||||
|
userCA, err := sshCAPubKey(cluster)
|
||||||
|
return string(userCA), err
|
||||||
|
},
|
||||||
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
|
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
|
||||||
if host == "" {
|
if host == "" {
|
||||||
host = ctx.Host.Name
|
host = ctx.Host.Name
|
||||||
@ -270,6 +268,20 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
|||||||
|
|
||||||
return asYaml(files)
|
return asYaml(files)
|
||||||
},
|
},
|
||||||
|
"ssh_host_key": func(type_ string) (s string, err error) {
|
||||||
|
pair, err := ctx.sshHostKeyPair(type_)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return pair.Private, nil
|
||||||
|
},
|
||||||
|
"ssh_host_pubkey": func(type_ string) (s string, err error) {
|
||||||
|
pair, err := ctx.sshHostKeyPair(type_)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return pair.Public, nil
|
||||||
|
},
|
||||||
"host_download_token": func() (token string, err error) {
|
"host_download_token": func() (token string, err error) {
|
||||||
key := ctx.Host.Name
|
key := ctx.Host.Name
|
||||||
token, found, err := hostDownloadTokens.Get(key)
|
token, found, err := hostDownloadTokens.Get(key)
|
||||||
@ -337,3 +349,19 @@ func (ctx *renderContext) TemplateFuncs() map[string]any {
|
|||||||
|
|
||||||
return funcs
|
return funcs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ctx *renderContext) sshHostKeyPair(type_ string) (kp SSHKeyPair, err error) {
|
||||||
|
pairs, err := getSSHKeyPairs(ctx.Host.Name)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if pair.Type == type_ {
|
||||||
|
return pair, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fmt.Errorf("no key pair with type %q", type_)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|||||||
@ -79,6 +79,17 @@ func getUsableClusterCA(cluster, name string) (ca CA, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func caExtraCerts(cluster, name string) (extra string, err error) {
|
||||||
|
cfg, err := readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.ExtraCaCerts != nil {
|
||||||
|
extra = cfg.ExtraCaCerts[cluster+"/"+name]
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var clusterCASignedKeys = newClusterSecretKV[KeyCert]("CA-signed-keys")
|
var clusterCASignedKeys = newClusterSecretKV[KeyCert]("CA-signed-keys")
|
||||||
|
|
||||||
func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) {
|
func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) {
|
||||||
|
|||||||
@ -211,9 +211,7 @@ func renderHost(w http.ResponseWriter, r *http.Request, what string, host *local
|
|||||||
|
|
||||||
// boot v2
|
// boot v2
|
||||||
case "bootstrap-config":
|
case "bootstrap-config":
|
||||||
err = renderBootstrapConfig(w, r, ctx, false)
|
err = renderBootstrapConfig(w, ctx)
|
||||||
case "bootstrap-config.json":
|
|
||||||
err = renderBootstrapConfig(w, r, ctx, true)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
restful "github.com/emicklei/go-restful"
|
|
||||||
yaml "gopkg.in/yaml.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SSH_ACL struct {
|
|
||||||
Keys []string
|
|
||||||
Clusters []string
|
|
||||||
Groups []string
|
|
||||||
Hosts []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadSSH_ACLs() (acls []SSH_ACL, err error) {
|
|
||||||
f, err := os.Open(filepath.Join(*dataDir, "ssh-acls.yaml"))
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
err = yaml.NewDecoder(f).Decode(&acls)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func wsSSH_ACL_List(req *restful.Request, resp *restful.Response) {
|
|
||||||
// TODO
|
|
||||||
http.NotFound(resp.ResponseWriter, req.Request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func wsSSH_ACL_Get(req *restful.Request, resp *restful.Response) {
|
|
||||||
// TODO
|
|
||||||
http.NotFound(resp.ResponseWriter, req.Request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func wsSSH_ACL_Set(req *restful.Request, resp *restful.Response) {
|
|
||||||
// TODO
|
|
||||||
http.NotFound(resp.ResponseWriter, req.Request)
|
|
||||||
}
|
|
||||||
@ -4,9 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
cfsslconfig "github.com/cloudflare/cfssl/config"
|
cfsslconfig "github.com/cloudflare/cfssl/config"
|
||||||
@ -152,10 +150,6 @@ func registerWS(rest *restful.Container) {
|
|||||||
ws.Route(ws.GET("/hosts").To(wsListHosts).
|
ws.Route(ws.GET("/hosts").To(wsListHosts).
|
||||||
Doc("List hosts"))
|
Doc("List hosts"))
|
||||||
|
|
||||||
ws.Route(ws.GET("/ssh-acls").To(wsSSH_ACL_List))
|
|
||||||
ws.Route(ws.GET("/ssh-acls/{acl-name}").To(wsSSH_ACL_Get))
|
|
||||||
ws.Route(ws.PUT("/ssh-acls/{acl-name}").To(wsSSH_ACL_Set))
|
|
||||||
|
|
||||||
rest.Add(ws)
|
rest.Add(ws)
|
||||||
|
|
||||||
// Hosts API
|
// Hosts API
|
||||||
@ -176,19 +170,6 @@ func registerWS(rest *restful.Container) {
|
|||||||
|
|
||||||
rest.Add(ws)
|
rest.Add(ws)
|
||||||
|
|
||||||
// Detected host API
|
|
||||||
ws = (&restful.WebService{}).
|
|
||||||
Filter(requireSecStore).
|
|
||||||
Path("/me").
|
|
||||||
Param(ws.HeaderParameter("Authorization", "Host or admin bearer token"))
|
|
||||||
|
|
||||||
(&wsHost{
|
|
||||||
hostDoc: "detected host",
|
|
||||||
getHost: detectHost,
|
|
||||||
}).register(ws, func(rb *restful.RouteBuilder) {
|
|
||||||
rb.Notes("In this case, the host is detected from the remote IP")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Hosts by token API
|
// Hosts by token API
|
||||||
ws = (&restful.WebService{}).
|
ws = (&restful.WebService{}).
|
||||||
Filter(requireSecStore).
|
Filter(requireSecStore).
|
||||||
@ -229,41 +210,6 @@ func requireSecStore(req *restful.Request, resp *restful.Response, chain *restfu
|
|||||||
chain.ProcessFilter(req, resp)
|
chain.ProcessFilter(req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectHost(req *restful.Request) (hostName string, err error) {
|
|
||||||
if !*allowDetectedHost {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r := req.Request
|
|
||||||
remoteAddr := r.RemoteAddr
|
|
||||||
|
|
||||||
if *trustXFF {
|
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
||||||
remoteAddr = strings.Split(xff, ",")[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hostIP, _, err := net.SplitHostPort(remoteAddr)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
hostIP = remoteAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := readConfig()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host := cfg.HostByIP(hostIP)
|
|
||||||
|
|
||||||
if host == nil {
|
|
||||||
log.Print("no host found for IP ", hostIP)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return host.Name, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func wsReadConfig(resp *restful.Response) *localconfig.Config {
|
func wsReadConfig(resp *restful.Response) *localconfig.Config {
|
||||||
cfg, err := readConfig()
|
cfg, err := readConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -25,7 +25,7 @@ require (
|
|||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
k8s.io/apimachinery v0.33.2
|
k8s.io/apimachinery v0.33.2
|
||||||
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766
|
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766
|
||||||
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1
|
novit.tech/direktil/pkg v0.0.0-20260221072850-b72bed72bb51
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1
|
replace github.com/zmap/zlint/v3 => github.com/zmap/zlint/v3 v3.3.1
|
||||||
|
|||||||
6
go.sum
6
go.sum
@ -346,5 +346,7 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8
|
|||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766 h1:JRzMBDbUwrTTGDJaJSH0ap4vRL0Q9CN1bG8a6n49eaQ=
|
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766 h1:JRzMBDbUwrTTGDJaJSH0ap4vRL0Q9CN1bG8a6n49eaQ=
|
||||||
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766/go.mod h1:BMv3aOSYpupuiiG3Ch3ND88aB5CfAks3YZuRLE8j1ls=
|
m.cluseau.fr/go v0.0.0-20230809064045-12c5a121c766/go.mod h1:BMv3aOSYpupuiiG3Ch3ND88aB5CfAks3YZuRLE8j1ls=
|
||||||
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1 h1:hKj9qhbTAoTxYIj6KaMLJp9I+bvZfkSM/QwK8Bd496o=
|
novit.tech/direktil/pkg v0.0.0-20260210141740-4d5661fa8ecd h1:proGf8Cid9tzJzoRbqQHGGpZZKTpUDFwOREbjYrCbkM=
|
||||||
novit.tech/direktil/pkg v0.0.0-20250706092353-d857af8032a1/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
|
novit.tech/direktil/pkg v0.0.0-20260210141740-4d5661fa8ecd/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
|
||||||
|
novit.tech/direktil/pkg v0.0.0-20260221072850-b72bed72bb51 h1:NBcpvWcTBMzFos0pkuLsbVCQ+mHf8KqNOdVywMX6FFk=
|
||||||
|
novit.tech/direktil/pkg v0.0.0-20260221072850-b72bed72bb51/go.mod h1:zjezU6tELE880oYHs/WAauGBupKIEQQ7KqWTB69RW10=
|
||||||
|
|||||||
@ -2,5 +2,5 @@ package dlshtml
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed favicon.ico ui
|
//go:embed ui
|
||||||
var FS embed.FS
|
var FS embed.FS
|
||||||
|
|||||||
131
html/ui/Cluster-703dcdca97841304.js
Normal file
131
html/ui/Cluster-703dcdca97841304.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
const Cluster = {
|
||||||
|
components: { Downloads, GetCopy },
|
||||||
|
props: [ 'cluster', 'token', 'state' ],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
signReqValidity: "1d",
|
||||||
|
sshSignReq: {
|
||||||
|
PubKey: "",
|
||||||
|
Principal: "root",
|
||||||
|
},
|
||||||
|
sshUserCert: null,
|
||||||
|
kubeSignReq: {
|
||||||
|
CSR: "",
|
||||||
|
User: "",
|
||||||
|
Group: "system:masters",
|
||||||
|
},
|
||||||
|
kubeUserCert: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
sshCASign() {
|
||||||
|
event.preventDefault();
|
||||||
|
fetch(`/clusters/${this.cluster.Name}/ssh/user-ca/sign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...this.sshSignReq, Validity: this.signReqValidity }),
|
||||||
|
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
|
||||||
|
}).then((resp) => {
|
||||||
|
if (resp.ok) {
|
||||||
|
resp.blob().then((cert) => { this.sshUserCert = URL.createObjectURL(cert) })
|
||||||
|
} else {
|
||||||
|
resp.json().then((resp) => alert('failed to sign: '+resp.message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => { alert('failed to sign: '+e); })
|
||||||
|
},
|
||||||
|
kubeCASign() {
|
||||||
|
event.preventDefault();
|
||||||
|
fetch(`/clusters/${this.cluster.Name}/kube/sign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...this.kubeSignReq, Validity: this.signReqValidity }),
|
||||||
|
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
|
||||||
|
}).then((resp) => {
|
||||||
|
if (resp.ok) {
|
||||||
|
resp.blob().then((cert) => { this.kubeUserCert = URL.createObjectURL(cert) })
|
||||||
|
} else {
|
||||||
|
resp.json().then((resp) => alert('failed to sign: '+resp.message))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => { alert('failed to sign: '+e); })
|
||||||
|
},
|
||||||
|
readFile(e, onload) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) { return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => { onload(reader.result) };
|
||||||
|
reader.onerror = () => { alert("error reading file"); };
|
||||||
|
reader.readAsText(file);
|
||||||
|
},
|
||||||
|
loadPubKey(e) {
|
||||||
|
this.readFile(e, (v) => {
|
||||||
|
this.sshSignReq.PubKey = v;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadCSR(e) {
|
||||||
|
this.readFile(e, (v) => {
|
||||||
|
this.kubeSignReq.CSR = v;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<h3>Access</h3>
|
||||||
|
|
||||||
|
<p>Allow cluster access from a public key</p>
|
||||||
|
|
||||||
|
<h4>Grant SSH access</h4>
|
||||||
|
|
||||||
|
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
|
||||||
|
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
|
||||||
|
<p>Public key (OpenSSH format):<br/>
|
||||||
|
<span class="text-and-file"><textarea v-model="sshSignReq.PubKey" style="height:3lh"></textarea>
|
||||||
|
<input type="file" accept=".pub" @change="loadPubKey" /></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><button @click="sshCASign">Sign SSH access</button>
|
||||||
|
<template v-if="sshUserCert">
|
||||||
|
=> <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Grant Kubernetes API access</h4>
|
||||||
|
|
||||||
|
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
|
||||||
|
<p>User: <input type="text" v-model="kubeSignReq.User"/> (by default, from the CSR)</p>
|
||||||
|
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
|
||||||
|
<p>Certificate signing request (PEM format):<br/>
|
||||||
|
<span class="text-and-file"><textarea v-model="kubeSignReq.CSR" style="height:7lh;"></textarea>
|
||||||
|
<input type="file" accept=".csr" @change="loadCSR" /></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><button @click="kubeCASign">Sign Kubernetes API access</button>
|
||||||
|
<template v-if="kubeUserCert">
|
||||||
|
=> <a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Tokens</h3>
|
||||||
|
<section class="links">
|
||||||
|
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h3>Passwords</h3>
|
||||||
|
<section class="links">
|
||||||
|
<GetCopy v-for="n in cluster.Passwords" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/passwords/'+n" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h3>Downloads</h3>
|
||||||
|
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
|
||||||
|
|
||||||
|
<h3>CAs</h3>
|
||||||
|
<table><tr><th>Name</th><th>Certificate</th><th>Signed certificates</th></tr>
|
||||||
|
<tr v-for="ca in cluster.CAs">
|
||||||
|
<td>{{ ca.Name }}</td>
|
||||||
|
<td><GetCopy :token="token" name="cert" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/certificate'" /></td>
|
||||||
|
<td><template v-for="signed in ca.Signed">
|
||||||
|
{{" "}}
|
||||||
|
<GetCopy :token="token" :name="signed" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/signed?name='+signed" />
|
||||||
|
</template></td>
|
||||||
|
</tr></table>
|
||||||
|
|
||||||
|
`
|
||||||
|
}
|
||||||
70
html/ui/Downloads-c9374f19f52c46d.js
Normal file
70
html/ui/Downloads-c9374f19f52c46d.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const Downloads = {
|
||||||
|
props: [ 'kind', 'name', 'token', 'state' ],
|
||||||
|
data() {
|
||||||
|
return { createDisabled: false, selectedAssets: {} }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
availableAssets() {
|
||||||
|
return {
|
||||||
|
cluster: ['addons'],
|
||||||
|
host: [
|
||||||
|
"kernel",
|
||||||
|
"initrd",
|
||||||
|
"bootstrap.tar",
|
||||||
|
"boot.img.lz4",
|
||||||
|
"boot.img.gz",
|
||||||
|
"boot.qcow2",
|
||||||
|
"boot.vmdk",
|
||||||
|
"boot.img",
|
||||||
|
"boot.iso",
|
||||||
|
"boot.tar",
|
||||||
|
"boot-efi.tar",
|
||||||
|
"config",
|
||||||
|
"bootstrap-config",
|
||||||
|
"ipxe",
|
||||||
|
],
|
||||||
|
}[this.kind]
|
||||||
|
},
|
||||||
|
downloads() {
|
||||||
|
return Object.entries(this.state.Downloads)
|
||||||
|
.filter(e => { let d=e[1]; return d.Kind == this.kind && d.Name == this.name })
|
||||||
|
.map(e => {
|
||||||
|
const token= e[0];
|
||||||
|
return {
|
||||||
|
text: token.substring(0, 5) + '...',
|
||||||
|
url: '/public/downloads/'+token+"/",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
assets() {
|
||||||
|
return this.availableAssets.filter(a => this.selectedAssets[a])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
createToken() {
|
||||||
|
event.preventDefault()
|
||||||
|
this.createDisabled = true
|
||||||
|
|
||||||
|
fetch('/authorize-download', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({Kind: this.kind, Name: this.name, Assets: this.assets}),
|
||||||
|
headers: { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' },
|
||||||
|
}).then((resp) => resp.json())
|
||||||
|
.then((token) => { this.selectedAssets = {}; this.createDisabled = false })
|
||||||
|
.catch((e) => { alert('failed to create link'); this.createDisabled = false })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<h4>Available assets</h4>
|
||||||
|
<p class="downloads">
|
||||||
|
<template v-for="asset in availableAssets">
|
||||||
|
<label :class="{selected: selectedAssets[asset]}"><input type="checkbox" v-model="selectedAssets[asset]" /> {{ asset }}</label>
|
||||||
|
{{" "}}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<p><button :disabled="createDisabled || assets.length==0" @click="createToken">Create link</button></p>
|
||||||
|
<template v-if="downloads.length">
|
||||||
|
<h4>Active links</h4>
|
||||||
|
<p class="download-links"><template v-for="d in downloads"><a :href="d.url" target="_blank">{{ d.text }}</a>{{" "}}</template></p>
|
||||||
|
</template>`
|
||||||
|
}
|
||||||
32
html/ui/GetCopy-7e3c9678f9647d40.js
Normal file
32
html/ui/GetCopy-7e3c9678f9647d40.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const GetCopy = {
|
||||||
|
props: [ 'name', 'href', 'token' ],
|
||||||
|
data() { return {showCopied: false} },
|
||||||
|
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a> <a href="#" class="copy" @click="fetchAndCopy()">🗐</a></span>`,
|
||||||
|
methods: {
|
||||||
|
fetch() {
|
||||||
|
event.preventDefault()
|
||||||
|
return fetch(this.href, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + this.token },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleFetchError(e) {
|
||||||
|
console.log("failed to get value:", e)
|
||||||
|
alert('failed to get value')
|
||||||
|
},
|
||||||
|
fetchAndSave() {
|
||||||
|
this.fetch().then(resp => resp.blob()).then((value) => {
|
||||||
|
window.open(URL.createObjectURL(value), "_blank")
|
||||||
|
}).catch(this.handleFetchError)
|
||||||
|
},
|
||||||
|
fetchAndCopy() {
|
||||||
|
this.fetch()
|
||||||
|
.then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
|
||||||
|
.then((value) => {
|
||||||
|
window.navigator.clipboard.writeText(value)
|
||||||
|
this.showCopied = true
|
||||||
|
setTimeout(() => { this.showCopied = false }, 1000)
|
||||||
|
}).catch(this.handleFetchError)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
14
html/ui/Host-61916516a854adff.js
Normal file
14
html/ui/Host-61916516a854adff.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const Host = {
|
||||||
|
components: { Downloads },
|
||||||
|
props: [ 'host', 'token', 'state' ],
|
||||||
|
template: `
|
||||||
|
<p>Cluster: {{ host.Cluster }}<template v-if="host.Template"> ({{ host.Template }})</template></p>
|
||||||
|
<p>IPs:
|
||||||
|
<code v-for="ip in host.IPs">
|
||||||
|
{{ ip }}{{" "}}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<h3>Downloads</h3>
|
||||||
|
<Downloads :token="token" :state="state" kind="host" :name="host.Name" />
|
||||||
|
`
|
||||||
|
}
|
||||||
267
html/ui/app-7f4645e84eaae7ce.js
Normal file
267
html/ui/app-7f4645e84eaae7ce.js
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
|
||||||
|
import { createApp } from './vue.esm-browser.js';
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
components: { Cluster, Host },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
forms: {
|
||||||
|
store: {},
|
||||||
|
storeUpload: {},
|
||||||
|
delKey: {},
|
||||||
|
hostFromTemplate: {},
|
||||||
|
hostFromTemplateDel: "",
|
||||||
|
},
|
||||||
|
view: "",
|
||||||
|
viewFilter: "",
|
||||||
|
session: {},
|
||||||
|
error: null,
|
||||||
|
publicState: null,
|
||||||
|
serverVersion: null,
|
||||||
|
uiHash: null,
|
||||||
|
watchingState: false,
|
||||||
|
state: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.session = JSON.parse(sessionStorage.state || "{}")
|
||||||
|
this.watchPublicState()
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
session: {
|
||||||
|
deep: true,
|
||||||
|
handler(v) {
|
||||||
|
sessionStorage.state = JSON.stringify(v)
|
||||||
|
|
||||||
|
if (v.token && !this.watchingState) {
|
||||||
|
this.watchState()
|
||||||
|
this.watchingState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
publicState: {
|
||||||
|
deep: true,
|
||||||
|
handler(v) {
|
||||||
|
if (v) {
|
||||||
|
this.serverVersion = v.ServerVersion
|
||||||
|
if (this.uiHash && v.UIHash != this.uiHash) {
|
||||||
|
console.log("reloading")
|
||||||
|
location.reload()
|
||||||
|
} else {
|
||||||
|
this.uiHash = v.UIHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
views() {
|
||||||
|
var views = [{type: "actions", name: "admin", title: "Admin actions"}];
|
||||||
|
|
||||||
|
(this.state.Clusters||[]).forEach((c) => views.push({type: "cluster", name: c.Name, title: `Cluster ${c.Name}`}));
|
||||||
|
(this.state.Hosts ||[]).forEach((c) => views.push({type: "host", name: c.Name, title: `Host ${c.Name}`}));
|
||||||
|
|
||||||
|
return views.filter((v) => v.type != "host" || v.name.includes(this.viewFilter));
|
||||||
|
},
|
||||||
|
viewObj() {
|
||||||
|
if (this.view) {
|
||||||
|
if (this.view.type == "cluster") {
|
||||||
|
return this.state.Clusters.find((c) => c.Name == this.view.name);
|
||||||
|
}
|
||||||
|
if (this.view.type == "host") {
|
||||||
|
return this.state.Hosts.find((h) => h.Name == this.view.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
hostsFromTemplate() {
|
||||||
|
return (this.state.Hosts||[]).filter((h) => h.Template);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
any(array) {
|
||||||
|
return array && array.length != 0;
|
||||||
|
},
|
||||||
|
copyText(text) {
|
||||||
|
event.preventDefault()
|
||||||
|
window.navigator.clipboard.writeText(text)
|
||||||
|
},
|
||||||
|
setToken() {
|
||||||
|
event.preventDefault()
|
||||||
|
this.session.token = this.forms.setToken
|
||||||
|
this.forms.setToken = null
|
||||||
|
},
|
||||||
|
uploadStore() {
|
||||||
|
event.preventDefault()
|
||||||
|
this.apiPost('/public/store.tar', this.$refs.storeUpload.files[0], (v) => {
|
||||||
|
this.forms.store = {}
|
||||||
|
}, "application/tar")
|
||||||
|
},
|
||||||
|
namedPassphrase(name, passphrase) {
|
||||||
|
return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)}
|
||||||
|
},
|
||||||
|
storeAddKey() {
|
||||||
|
this.apiPost('/store/add-key', this.namedPassphrase(), (v) => {
|
||||||
|
this.forms.store = {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
storeDelKey() {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
let name = this.forms.delKey.name
|
||||||
|
|
||||||
|
if (!confirm("Remove key named "+JSON.stringify(name)+"?")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.apiPost('/store/delete-key', name , (v) => {
|
||||||
|
this.forms.delKey = {}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
unlockStore() {
|
||||||
|
this.apiPost('/public/unlock-store', this.namedPassphrase(), (v) => {
|
||||||
|
this.forms.store = {}
|
||||||
|
|
||||||
|
if (v) {
|
||||||
|
this.session.token = v
|
||||||
|
if (!this.watchingState) {
|
||||||
|
this.watchState()
|
||||||
|
this.watchingState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
uploadConfig() {
|
||||||
|
const file = this.$refs.configUpload.files[0];
|
||||||
|
let contentType = "text/vnd.yaml";
|
||||||
|
if (file.name.endsWith(".json")) {
|
||||||
|
contentType = "application/json";
|
||||||
|
}
|
||||||
|
this.apiPost('/configs', file, (v) => {}, contentType, true)
|
||||||
|
},
|
||||||
|
hostFromTemplateAdd() {
|
||||||
|
let v = this.forms.hostFromTemplate;
|
||||||
|
this.apiPost('/hosts-from-template/'+v.name, v, (v) => { this.forms.hostFromTemplate = {} });
|
||||||
|
},
|
||||||
|
hostFromTemplateDel() {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
let v = this.forms.hostFromTemplateDel;
|
||||||
|
if (!confirm("delete host template instance "+v+"?")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.apiDelete('/hosts-from-template/'+v, (v) => { this.forms.hostFromTemplateDel = "" });
|
||||||
|
},
|
||||||
|
apiPost(action, data, onload, contentType = 'application/json', raw = false) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (data === undefined) {
|
||||||
|
throw("action " + action + ": no data")
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO
|
||||||
|
fetch(action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((result) => onload)
|
||||||
|
// */
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest()
|
||||||
|
|
||||||
|
xhr.responseType = 'json'
|
||||||
|
// TODO spinner, pending action notification, or something
|
||||||
|
xhr.onerror = () => {
|
||||||
|
// this.actionResults.splice(idx, 1, {...item, done: true, failed: true })
|
||||||
|
}
|
||||||
|
xhr.onload = (r) => {
|
||||||
|
if (xhr.status != 200) {
|
||||||
|
this.error = xhr.response
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// this.actionResults.splice(idx, 1, {...item, done: true, resp: xhr.responseText})
|
||||||
|
this.error = null
|
||||||
|
if (onload) {
|
||||||
|
onload(xhr.response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.open("POST", action)
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json')
|
||||||
|
xhr.setRequestHeader('Content-Type', contentType)
|
||||||
|
if (this.session.token) {
|
||||||
|
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raw && contentType == "application/json") {
|
||||||
|
xhr.send(JSON.stringify(data))
|
||||||
|
} else {
|
||||||
|
xhr.send(data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apiDelete(action, data, onload) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest()
|
||||||
|
xhr.onload = (r) => {
|
||||||
|
if (xhr.status != 200) {
|
||||||
|
this.error = xhr.response
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.error = null
|
||||||
|
if (onload) {
|
||||||
|
onload(xhr.response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.open("DELETE", action)
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json')
|
||||||
|
if (this.session.token) {
|
||||||
|
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||||
|
}
|
||||||
|
xhr.send()
|
||||||
|
},
|
||||||
|
download(url) {
|
||||||
|
event.target.target = '_blank'
|
||||||
|
event.target.href = this.downloadLink(url)
|
||||||
|
},
|
||||||
|
downloadLink(url) {
|
||||||
|
// TODO once-shot download link
|
||||||
|
return url + '?token=' + this.session.token
|
||||||
|
},
|
||||||
|
watchPublicState() {
|
||||||
|
this.watchStream('publicState', '/public-state')
|
||||||
|
},
|
||||||
|
watchState() {
|
||||||
|
this.watchStream('state', '/state', true)
|
||||||
|
},
|
||||||
|
watchStream(field, path, withToken) {
|
||||||
|
let evtSrc = new EventSource(path + (withToken ? '?token='+this.session.token : ''));
|
||||||
|
evtSrc.onmessage = (e) => {
|
||||||
|
let update = JSON.parse(e.data)
|
||||||
|
|
||||||
|
console.log("watch "+path+":", update)
|
||||||
|
|
||||||
|
if (update.err) {
|
||||||
|
console.log("watch error from server:", err)
|
||||||
|
}
|
||||||
|
if (update.set) {
|
||||||
|
this[field] = update.set
|
||||||
|
}
|
||||||
|
if (update.p) { // patch
|
||||||
|
new jsonpatch.JSONPatch(update.p, true).apply(this[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evtSrc.onerror = (e) => {
|
||||||
|
// console.log("event source " + path + " error:", e)
|
||||||
|
if (evtSrc) evtSrc.close()
|
||||||
|
|
||||||
|
this[field] = null
|
||||||
|
|
||||||
|
window.setTimeout(() => { this.watchStream(field, path, withToken) }, 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
}).mount('#app');
|
||||||
@ -27,3 +27,16 @@
|
|||||||
color: var(--link);
|
color: var(--link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-and-file {
|
||||||
|
position:relative;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 64em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
position:absolute;
|
||||||
|
bottom:0;right:0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
@ -2,18 +2,25 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Direktil Local Server</title>
|
<title>Direktil Local Server</title>
|
||||||
<style>
|
<base href="/ui/" />
|
||||||
@import url('./style.css');
|
|
||||||
@import url('./app.css');
|
<link rel="icon" href="favicon.ico" />
|
||||||
</style>
|
|
||||||
<script src="js/jsonpatch.min.js" crossorigin="anonymous"></script>
|
|
||||||
<script src="js/app.js" type="module" defer></script>
|
|
||||||
|
<style>@import url('/ui/style.css');@import url('/ui/app.css');</style>
|
||||||
|
<script src="/ui/jsonpatch.min-942279a1c4209cc2.js" integrity="sha384-GcPrkRS12jtrElEkbJcrZ8asvvb9s3mc+CUq9UW/8bL4L0bpkmh9M+oFnWN6qLq2"></script>
|
||||||
|
<script src="/ui/app-7f4645e84eaae7ce.js" defer integrity="sha384-31uiQnXVSPLDq61o+chfyKRkSdkmzv023M7KafOo+0xRw5AM70BUFyYO6yo0kPiZ" type="module"></script>
|
||||||
|
<script src="/ui/Downloads-c9374f19f52c46d.js" integrity="sha384-WQIkCSxlUkvu4jFIWwS3bESMaazGwwnBn9dyvB7nItz3L8BmusRMsJACzfJa7sC4"></script>
|
||||||
|
<script src="/ui/GetCopy-7e3c9678f9647d40.js" integrity="sha384-LzxUXylxE/t25HyTch8y2qvKcehvP2nqCo37swIBGEKZZUzHVJVQrS5UJDWNskTA"></script>
|
||||||
|
<script src="/ui/Cluster-703dcdca97841304.js" integrity="sha384-ifGpq/GB1nDfqczm5clTRtcfnc9UMkT+SptMyS3UG9Di3xoKORtOGGjmK5RnJx+v"></script>
|
||||||
|
<script src="/ui/Host-61916516a854adff.js" integrity="sha384-/wh3KrC0sb4MT7ekO2U84rswxI42WSH/0jB4dbDaaGaGhX60xTEZHFsdQAf7UgTG"></script>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<header>
|
||||||
<div id="logo">
|
<div id="logo">
|
||||||
<img src="/favicon.ico" />
|
<img src="favicon.ico" />
|
||||||
<span>Direktil Local Server</span>
|
<span>Direktil Local Server</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="utils">
|
<div class="utils">
|
||||||
@ -134,6 +141,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
24
ui/Trunk.toml
Normal file
24
ui/Trunk.toml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
[build]
|
||||||
|
public_url = "/ui"
|
||||||
|
dist = "../html/ui"
|
||||||
|
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/public-state"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/state"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/public"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/store"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/authorize-download"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/sign-download-set"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/configs"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/clusters"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/hosts-from-template"
|
||||||
|
[[proxy]]
|
||||||
|
backend = "http://localhost:7606/hosts"
|
||||||
42
ui/app.css
Normal file
42
ui/app.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
.view-links > span {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 1ex;
|
||||||
|
margin-bottom: 1ex;
|
||||||
|
padding: 0.5ex;
|
||||||
|
border: 1pt solid;
|
||||||
|
border-radius: 1ex;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads, .download-links {
|
||||||
|
& > * {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 1ex;
|
||||||
|
margin-bottom: 1ex;
|
||||||
|
padding: 0.5ex;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 1ex;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads, .view-links {
|
||||||
|
& > .selected {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-and-file {
|
||||||
|
position:relative;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 64em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
position:absolute;
|
||||||
|
bottom:0;right:0;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ui/favicon.ico
Normal file
BIN
ui/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
145
ui/index.html
Normal file
145
ui/index.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Direktil Local Server</title>
|
||||||
|
<base data-trunk-public-url />
|
||||||
|
<link data-trunk rel="copy-file" href="favicon.ico" />
|
||||||
|
<link rel="icon" href="favicon.ico" />
|
||||||
|
<link data-trunk rel="copy-file" href="js/vue.esm-browser.js" />
|
||||||
|
<link data-trunk rel="copy-file" href="style.css" />
|
||||||
|
<link data-trunk rel="copy-file" href="app.css" />
|
||||||
|
<style>@import url('/ui/style.css');@import url('/ui/app.css');</style>
|
||||||
|
<script data-trunk src="js/jsonpatch.min.js"></script>
|
||||||
|
<script data-trunk src="js/app.js" type="module" defer></script>
|
||||||
|
<script data-trunk src="js/Downloads.js"></script>
|
||||||
|
<script data-trunk src="js/GetCopy.js"></script>
|
||||||
|
<script data-trunk src="js/Cluster.js"></script>
|
||||||
|
<script data-trunk src="js/Host.js"></script>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<header>
|
||||||
|
<div id="logo">
|
||||||
|
<img src="favicon.ico" />
|
||||||
|
<span>Direktil Local Server</span>
|
||||||
|
</div>
|
||||||
|
<div class="utils">
|
||||||
|
<span id="login-hdr" v-if="session.token">
|
||||||
|
Logged in
|
||||||
|
<button class="link" @click="copyText(session.token)">🗐</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>server <code>{{ serverVersion || '-----' }}</code></span>
|
||||||
|
<span>ui <code>{{ uiHash || '-----' }}</code></span>
|
||||||
|
|
||||||
|
<span :class="publicState ? 'green' : 'red'">🗲</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="error" v-if="error">
|
||||||
|
<button class="btn-close" @click="error=null">×</button>
|
||||||
|
<div class="code" v-if="error.code">{{ error.code }}</div>
|
||||||
|
<div class="message">{{ error.message }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!publicState">
|
||||||
|
<p>Not connected.</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="publicState.Store.New">
|
||||||
|
<p>Store is new.</p>
|
||||||
|
<p>Option 1: initialize a new store</p>
|
||||||
|
<form @submit="unlockStore">
|
||||||
|
<input type="text" v-model="forms.store.name" name="name" placeholder="Name" /><br/>
|
||||||
|
<input type="password" v-model="forms.store.pass1" name="passphrase" required placeholder="Passphrase" />
|
||||||
|
<input type="password" v-model="forms.store.pass2" required placeholder="Passphrase confirmation" />
|
||||||
|
<input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
|
||||||
|
</form>
|
||||||
|
<p>Option 2: upload a previously downloaded store</p>
|
||||||
|
<form @submit="uploadStore">
|
||||||
|
<input type="file" ref="storeUpload" />
|
||||||
|
<input type="submit" value="upload" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="!publicState.Store.Open">
|
||||||
|
<p>Store is not open.</p>
|
||||||
|
<form @submit="unlockStore">
|
||||||
|
<input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" />
|
||||||
|
<input type="submit" value="unlock" :disabled="!forms.store.pass1" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="!state">
|
||||||
|
<p v-if="!session.token">Not logged in.</p>
|
||||||
|
<p v-else>Invalid token</p>
|
||||||
|
|
||||||
|
<form @submit="unlockStore">
|
||||||
|
<input type="password" v-model="forms.store.pass1" required placeholder="Passphrase" />
|
||||||
|
<input type="submit" value="log in"/>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div style="float:right;"><input type="search" placeholder="Filter" v-model="viewFilter"/></div>
|
||||||
|
<p class="view-links"><span v-for="v in views" @click="view = v" :class="{selected: view.type==v.type && view.name==v.name}">{{v.title}}</span></p>
|
||||||
|
|
||||||
|
<h2 v-if="view">{{view.title}}</h2>
|
||||||
|
|
||||||
|
<div v-if="view.type == 'cluster'" id="clusters">
|
||||||
|
<Cluster :cluster="viewObj" :token="session.token" :state="state" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="view.type == 'host'" id="hosts">
|
||||||
|
<Host :host="viewObj" :token="session.token" :state="state" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="view.type == 'actions' && view.name == 'admin'">
|
||||||
|
<h3>Config</h3>
|
||||||
|
<form @submit="uploadConfig">
|
||||||
|
<input type="file" ref="configUpload" required />
|
||||||
|
<input type="submit" value="upload config" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h3>Store</h3>
|
||||||
|
<p><a :href="'/public/store.tar?token='+state.Store.DownloadToken" target="_blank">Download</a></p>
|
||||||
|
<form @submit="storeAddKey" action="/store/add-key">
|
||||||
|
<p>Add an unlock phrase:</p>
|
||||||
|
<input type="text" v-model="forms.store.name" name="name" required placeholder="Name" /><br/>
|
||||||
|
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" />
|
||||||
|
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
|
||||||
|
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
|
||||||
|
</form>
|
||||||
|
<form @submit="storeDelKey" action="/store/delete-key">
|
||||||
|
<p>Remove an unlock phrase:</p>
|
||||||
|
<input type="text" v-model="forms.delKey.name" name="name" required placeholder="Name" />
|
||||||
|
<input type="submit" value="remove unlock phrase" />
|
||||||
|
|
||||||
|
<p v-if="state.Store.KeyNames">Available names:
|
||||||
|
<template v-for="k,i in state.Store.KeyNames">{{i?", ":""}}<code @click="forms.delKey.name=k">{{k}}</code></template>.</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template v-if="any(state.HostTemplates) || any(hostsFromTemplate)">
|
||||||
|
<h3>Hosts from template</h3>
|
||||||
|
<form @submit="hostFromTemplateAdd" action="" v-if="any(state.HostTemplates)">
|
||||||
|
<p>Add a host from template instance:</p>
|
||||||
|
<input type="text" v-model="forms.hostFromTemplate.name" required placeholder="Name" />
|
||||||
|
<select v-model="forms.hostFromTemplate.Template" required>
|
||||||
|
<option v-for="name in state.HostTemplates" :value="name">{{name}}</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" v-model="forms.hostFromTemplate.IP" required placeholder="IP" />
|
||||||
|
<input type="submit" value="add instance" />
|
||||||
|
</form>
|
||||||
|
<form @submit="hostFromTemplateDel" action="" v-if="any(hostsFromTemplate)">
|
||||||
|
<p>Remove a host from template instance:</p>
|
||||||
|
<select v-model="forms.hostFromTemplateDel" required>
|
||||||
|
<option v-for="h in hostsFromTemplate" :value="h.Name">{{h.Name}}</option>
|
||||||
|
</select>
|
||||||
|
<input type="submit" value="delete instance" :disabled="!forms.hostFromTemplateDel" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,8 +1,4 @@
|
|||||||
|
const Cluster = {
|
||||||
import Downloads from './Downloads.js';
|
|
||||||
import GetCopy from './GetCopy.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { Downloads, GetCopy },
|
components: { Downloads, GetCopy },
|
||||||
props: [ 'cluster', 'token', 'state' ],
|
props: [ 'cluster', 'token', 'state' ],
|
||||||
data() {
|
data() {
|
||||||
@ -52,8 +48,61 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((e) => { alert('failed to sign: '+e); })
|
.catch((e) => { alert('failed to sign: '+e); })
|
||||||
},
|
},
|
||||||
|
readFile(e, onload) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) { return; }
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => { onload(reader.result) };
|
||||||
|
reader.onerror = () => { alert("error reading file"); };
|
||||||
|
reader.readAsText(file);
|
||||||
|
},
|
||||||
|
loadPubKey(e) {
|
||||||
|
this.readFile(e, (v) => {
|
||||||
|
this.sshSignReq.PubKey = v;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadCSR(e) {
|
||||||
|
this.readFile(e, (v) => {
|
||||||
|
this.kubeSignReq.CSR = v;
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
template: `
|
template: `
|
||||||
|
<h3>Access</h3>
|
||||||
|
|
||||||
|
<p>Allow cluster access from a public key</p>
|
||||||
|
|
||||||
|
<h4>Grant SSH access</h4>
|
||||||
|
|
||||||
|
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
|
||||||
|
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
|
||||||
|
<p>Public key (OpenSSH format):<br/>
|
||||||
|
<span class="text-and-file"><textarea v-model="sshSignReq.PubKey" style="height:3lh"></textarea>
|
||||||
|
<input type="file" accept=".pub" @change="loadPubKey" /></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><button @click="sshCASign">Sign SSH access</button>
|
||||||
|
<template v-if="sshUserCert">
|
||||||
|
=> <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>Grant Kubernetes API access</h4>
|
||||||
|
|
||||||
|
<p>Validity: <input type="text" v-model="signReqValidity"/> <small>time range, ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</small></p>
|
||||||
|
<p>User: <input type="text" v-model="kubeSignReq.User"/> (by default, from the CSR)</p>
|
||||||
|
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
|
||||||
|
<p>Certificate signing request (PEM format):<br/>
|
||||||
|
<span class="text-and-file"><textarea v-model="kubeSignReq.CSR" style="height:7lh;"></textarea>
|
||||||
|
<input type="file" accept=".csr" @change="loadCSR" /></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><button @click="kubeCASign">Sign Kubernetes API access</button>
|
||||||
|
<template v-if="kubeUserCert">
|
||||||
|
=> <a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3>Tokens</h3>
|
<h3>Tokens</h3>
|
||||||
<section class="links">
|
<section class="links">
|
||||||
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
|
<GetCopy v-for="n in cluster.Tokens" :token="token" :name="n" :href="'/clusters/'+cluster.Name+'/tokens/'+n" />
|
||||||
@ -78,36 +127,5 @@ export default {
|
|||||||
</template></td>
|
</template></td>
|
||||||
</tr></table>
|
</tr></table>
|
||||||
|
|
||||||
<h3>Access</h3>
|
|
||||||
|
|
||||||
<p>Allow cluster access from a public key</p>
|
|
||||||
<p>Certificate time validity: <input type="text" v-model="signReqValidity"/> <small>ie: -5m:1w, 5m, 1M, 1y, 1d-1s, etc.</p>
|
|
||||||
|
|
||||||
<h4>Grant SSH access</h4>
|
|
||||||
|
|
||||||
<p>Public key (OpenSSH format):<br/>
|
|
||||||
<textarea v-model="sshSignReq.PubKey" style="width:64em;height:2lh"></textarea>
|
|
||||||
</p>
|
|
||||||
<p>User: <input type="text" v-model="sshSignReq.Principal"/></p>
|
|
||||||
|
|
||||||
<p><button @click="sshCASign">Sign SSH access (validity: {{signReqValidity}})</button>
|
|
||||||
<template v-if="sshUserCert">
|
|
||||||
=> <a :href="sshUserCert" download="ssh-cert.pub">Get certificate</a>
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4>Grant Kubernetes API access</h4>
|
|
||||||
|
|
||||||
<p>Certificate signing request (PEM format):<br/>
|
|
||||||
<textarea v-model="kubeSignReq.CSR" style="width:64em;height:7lh;"></textarea>
|
|
||||||
</p>
|
|
||||||
<p>User: <input type="text" v-model="kubeSignReq.User"/></p>
|
|
||||||
<p>Group: <input type="text" v-model="kubeSignReq.Group"/></p>
|
|
||||||
|
|
||||||
<p><button @click="kubeCASign">Sign Kubernetes API access (validity: {{signReqValidity}})</button>
|
|
||||||
<template v-if="kubeUserCert">
|
|
||||||
=> <a :href="kubeUserCert" download="kube-cert.pub">Get certificate</a>
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
|
const Downloads = {
|
||||||
export default {
|
|
||||||
props: [ 'kind', 'name', 'token', 'state' ],
|
props: [ 'kind', 'name', 'token', 'state' ],
|
||||||
data() {
|
data() {
|
||||||
return { createDisabled: false, selectedAssets: {} }
|
return { createDisabled: false, selectedAssets: {} }
|
||||||
@ -1,4 +1,4 @@
|
|||||||
export default {
|
const GetCopy = {
|
||||||
props: [ 'name', 'href', 'token' ],
|
props: [ 'name', 'href', 'token' ],
|
||||||
data() { return {showCopied: false} },
|
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>`,
|
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a> <a href="#" class="copy" @click="fetchAndCopy()">🗐</a></span>`,
|
||||||
@ -1,7 +1,4 @@
|
|||||||
|
const Host = {
|
||||||
import Downloads from './Downloads.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: { Downloads },
|
components: { Downloads },
|
||||||
props: [ 'host', 'token', 'state' ],
|
props: [ 'host', 'token', 'state' ],
|
||||||
template: `
|
template: `
|
||||||
@ -1,9 +1,6 @@
|
|||||||
|
|
||||||
import { createApp } from './vue.esm-browser.js';
|
import { createApp } from './vue.esm-browser.js';
|
||||||
|
|
||||||
import Cluster from './Cluster.js';
|
|
||||||
import Host from './Host.js';
|
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
components: { Cluster, Host },
|
components: { Cluster, Host },
|
||||||
data() {
|
data() {
|
||||||
@ -136,7 +133,12 @@ createApp({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
uploadConfig() {
|
uploadConfig() {
|
||||||
this.apiPost('/configs', this.$refs.configUpload.files[0], (v) => {}, "text/vnd.yaml")
|
const file = this.$refs.configUpload.files[0];
|
||||||
|
let contentType = "text/vnd.yaml";
|
||||||
|
if (file.name.endsWith(".json")) {
|
||||||
|
contentType = "application/json";
|
||||||
|
}
|
||||||
|
this.apiPost('/configs', file, (v) => {}, contentType, true)
|
||||||
},
|
},
|
||||||
hostFromTemplateAdd() {
|
hostFromTemplateAdd() {
|
||||||
let v = this.forms.hostFromTemplate;
|
let v = this.forms.hostFromTemplate;
|
||||||
@ -151,7 +153,7 @@ createApp({
|
|||||||
}
|
}
|
||||||
this.apiDelete('/hosts-from-template/'+v, (v) => { this.forms.hostFromTemplateDel = "" });
|
this.apiDelete('/hosts-from-template/'+v, (v) => { this.forms.hostFromTemplateDel = "" });
|
||||||
},
|
},
|
||||||
apiPost(action, data, onload, contentType = 'application/json') {
|
apiPost(action, data, onload, contentType = 'application/json', raw = false) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (data === undefined) {
|
if (data === undefined) {
|
||||||
@ -193,7 +195,7 @@ createApp({
|
|||||||
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType == "application/json") {
|
if (!raw && contentType == "application/json") {
|
||||||
xhr.send(JSON.stringify(data))
|
xhr.send(JSON.stringify(data))
|
||||||
} else {
|
} else {
|
||||||
xhr.send(data)
|
xhr.send(data)
|
||||||
@ -262,5 +264,4 @@ createApp({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
}).mount('#app')
|
}).mount('#app');
|
||||||
|
|
||||||
36
ui/js/jsonpatch.min.js
vendored
Normal file
36
ui/js/jsonpatch.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
16172
ui/js/vue.esm-browser.js
Normal file
16172
ui/js/vue.esm-browser.js
Normal file
File diff suppressed because it is too large
Load Diff
195
ui/style.css
Normal file
195
ui/style.css
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #eee;
|
||||||
|
--color: black;
|
||||||
|
--bevel-dark: darkgray;
|
||||||
|
--bevel-light: lightgray;
|
||||||
|
--link: blue;
|
||||||
|
--input-bg: #ddd;
|
||||||
|
--input-text: white;
|
||||||
|
--btn-bg: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: black;
|
||||||
|
--color: orange;
|
||||||
|
--bevel-dark: #402900;
|
||||||
|
--bevel-light: #805300;
|
||||||
|
--link: #31b0fa;
|
||||||
|
--input-bg: #111;
|
||||||
|
--input-text: #ddd;
|
||||||
|
--btn-bg: #222;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a[href], a[href]:visited, button.link {
|
||||||
|
border: none;
|
||||||
|
color: var(--link);
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border-left: dotted 1pt;
|
||||||
|
border-right: dotted 1pt;
|
||||||
|
border-bottom: dotted 1pt;
|
||||||
|
padding: 2pt 4pt;
|
||||||
|
}
|
||||||
|
tr:first-child > th {
|
||||||
|
border-top: dotted 1pt;
|
||||||
|
}
|
||||||
|
th, tr:last-child > td {
|
||||||
|
border-bottom: solid 1pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat > * { margin-left: 1ex; }
|
||||||
|
.flat > *:first-child { margin-left: 0; }
|
||||||
|
|
||||||
|
.green { color: green; }
|
||||||
|
.red { color: red; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.red { color: #c00; }
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea, select, input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--input-text);
|
||||||
|
border: solid 1pt;
|
||||||
|
border-color: var(--bevel-light);
|
||||||
|
border-top-color: var(--bevel-dark);
|
||||||
|
border-left-color: var(--bevel-dark);
|
||||||
|
margin: 1pt;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: solid 1pt var(--color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input[type=button], input[type=submit], ::file-selector-button {
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--color);
|
||||||
|
border: solid 2pt;
|
||||||
|
border-color: var(--bevel-dark);
|
||||||
|
border-top-color: var(--bevel-light);
|
||||||
|
border-left-color: var(--bevel-light);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bevel-dark);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background: var(--bevel-dark);
|
||||||
|
border-color: var(--bevel-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 2pt solid;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
padding: 1ex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
#logo > img {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
header .utils > * {
|
||||||
|
margin-left: 1ex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
background: rgba(255,0,0,0.2);
|
||||||
|
border: 1pt solid red;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.error .btn-close,
|
||||||
|
.error .code {
|
||||||
|
background: #600;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 1ex 1em;
|
||||||
|
}
|
||||||
|
.error .code {
|
||||||
|
order: 1;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error .message {
|
||||||
|
order: 2;
|
||||||
|
padding: 1ex 2em;
|
||||||
|
}
|
||||||
|
.error .btn-close {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheets {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.sheets > div {
|
||||||
|
margin: 0 1ex;
|
||||||
|
border: 1pt solid;
|
||||||
|
border-radius: 6pt;
|
||||||
|
}
|
||||||
|
.sheets .title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: large;
|
||||||
|
padding: 2pt 6pt;
|
||||||
|
background: rgba(127,127,127,0.5);
|
||||||
|
}
|
||||||
|
.sheets .section {
|
||||||
|
padding: 2pt 6pt 2pt 6pt;
|
||||||
|
font-weight: bold;
|
||||||
|
border-top: 1px dotted;
|
||||||
|
}
|
||||||
|
.sheets section {
|
||||||
|
margin: 2pt 6pt 6pt 6pt;
|
||||||
|
}
|
||||||
|
.sheets > *:last-child > table:last-child > tr:last-child > td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.notif > div:first-child {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 100%; height: 100%;
|
||||||
|
background: white;
|
||||||
|
opacity: 75%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links > * { margin-left: 1ex; }
|
||||||
|
.links > *:first-child { margin-left: 0; }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.notif > div:first-child {
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy { font-size: small; }
|
||||||
Reference in New Issue
Block a user