From 3bc20e95cc1b22dbc64e47f88b4d30ab18c26817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Cluseau?= Date: Sun, 12 Feb 2023 11:58:26 +0100 Subject: [PATCH] secrets migration & restitution --- Dockerfile | 2 +- cmd/dkl-local-server/httperr.go | 9 ++ cmd/dkl-local-server/secret-store.go | 158 ++++++++++++++++++- cmd/dkl-local-server/secrets-migrate.go | 75 +++++++++ cmd/dkl-local-server/state.go | 43 ++++- cmd/dkl-local-server/ws-cluster-cas.go | 35 ++++ cmd/dkl-local-server/ws-cluster-passwords.go | 30 ++++ cmd/dkl-local-server/ws-cluster-tokens.go | 19 +++ cmd/dkl-local-server/ws-clusters.go | 98 +----------- cmd/dkl-local-server/ws.go | 68 ++++---- html/ui/js/Cluster.js | 23 ++- html/ui/js/GetCopy.js | 21 +++ html/ui/style.css | 22 ++- 13 files changed, 473 insertions(+), 130 deletions(-) create mode 100644 cmd/dkl-local-server/httperr.go create mode 100644 cmd/dkl-local-server/secrets-migrate.go create mode 100644 cmd/dkl-local-server/ws-cluster-cas.go create mode 100644 cmd/dkl-local-server/ws-cluster-passwords.go create mode 100644 cmd/dkl-local-server/ws-cluster-tokens.go create mode 100644 html/ui/js/GetCopy.js diff --git a/Dockerfile b/Dockerfile index 7b916ea..fd02cb3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # ------------------------------------------------------------------------ -from mcluseau/golang-builder:1.19.4 as build +from mcluseau/golang-builder:1.20.0 as build # ------------------------------------------------------------------------ from debian:stretch diff --git a/cmd/dkl-local-server/httperr.go b/cmd/dkl-local-server/httperr.go new file mode 100644 index 0000000..277b1f6 --- /dev/null +++ b/cmd/dkl-local-server/httperr.go @@ -0,0 +1,9 @@ +package main + +import ( + "net/http" + + "m.cluseau.fr/go/httperr" +) + +var ErrNotFound = httperr.NewStd(404, http.StatusNotFound, "not found") diff --git a/cmd/dkl-local-server/secret-store.go b/cmd/dkl-local-server/secret-store.go index 6b7ed74..3ed6b86 100644 --- a/cmd/dkl-local-server/secret-store.go +++ b/cmd/dkl-local-server/secret-store.go @@ -8,9 +8,13 @@ import ( "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" ) @@ -126,6 +130,7 @@ func unlockSecretStore(passphrase []byte) *httperr.Error { }) go updateState() + go migrateSecrets() return nil } @@ -147,7 +152,13 @@ func readSecret(name string, value any) (err error) { } func writeSecret(name string, value any) (err error) { - f, err := os.Create(secStorePath(name + ".data.new")) + 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 } @@ -167,5 +178,148 @@ func writeSecret(name string, value any) (err error) { return } - return os.Rename(f.Name(), secStorePath(name+".data")) + 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 { + httperr.New(http.StatusInternalServerError, err).WriteJSON(resp.ResponseWriter) + return + } + + resp.WriteEntity(keys) +} + +func (s KVSecrets[T]) WsGet(resp *restful.Response, key string) { + keys, found, err := s.Get(key) + if err != nil { + httperr.New(http.StatusInternalServerError, err).WriteJSON(resp.ResponseWriter) + return + } + + if !found { + ErrNotFound.WriteJSON(resp.ResponseWriter) + 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 { + httperr.New(http.StatusBadRequest, err).WriteJSON(resp.ResponseWriter) + return + } + + err = s.Put(key, *v) + if err != nil { + httperr.New(http.StatusInternalServerError, err).WriteJSON(resp.ResponseWriter) + return + } } diff --git a/cmd/dkl-local-server/secrets-migrate.go b/cmd/dkl-local-server/secrets-migrate.go new file mode 100644 index 0000000..aabf079 --- /dev/null +++ b/cmd/dkl-local-server/secrets-migrate.go @@ -0,0 +1,75 @@ +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 + } + } + + if err := loadSecretData(sslCfg); 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 { + clusterCASignedKeys.Put(clusterName+"/"+caName+"/"+signedName, *signed) + } + } + + // TODO + } +} diff --git a/cmd/dkl-local-server/state.go b/cmd/dkl-local-server/state.go index bd8a764..487ddbe 100644 --- a/cmd/dkl-local-server/state.go +++ b/cmd/dkl-local-server/state.go @@ -28,11 +28,11 @@ type State struct { } type ClusterState struct { - Name string - Addons bool - // TODO CAs - // TODO passwords - // TODO tokens + Name string + Addons bool + Passwords []string + Tokens []string + CAs []CAState } type HostState struct { @@ -41,6 +41,11 @@ type HostState struct { IPs []string } +type CAState struct { + Name string + Signed []string +} + var wState = watchable.New[State]() func init() { @@ -68,6 +73,34 @@ func updateState() { 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) } diff --git a/cmd/dkl-local-server/ws-cluster-cas.go b/cmd/dkl-local-server/ws-cluster-cas.go new file mode 100644 index 0000000..08ca6f9 --- /dev/null +++ b/cmd/dkl-local-server/ws-cluster-cas.go @@ -0,0 +1,35 @@ +package main + +import ( + 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) +} + +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) +} diff --git a/cmd/dkl-local-server/ws-cluster-passwords.go b/cmd/dkl-local-server/ws-cluster-passwords.go new file mode 100644 index 0000000..8b81ca6 --- /dev/null +++ b/cmd/dkl-local-server/ws-cluster-passwords.go @@ -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) +} diff --git a/cmd/dkl-local-server/ws-cluster-tokens.go b/cmd/dkl-local-server/ws-cluster-tokens.go new file mode 100644 index 0000000..1354c68 --- /dev/null +++ b/cmd/dkl-local-server/ws-cluster-tokens.go @@ -0,0 +1,19 @@ +package main + +import ( + restful "github.com/emicklei/go-restful" +) + +var clusterTokens = newClusterSecretKV[string]("tokens") + +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) +} diff --git a/cmd/dkl-local-server/ws-clusters.go b/cmd/dkl-local-server/ws-clusters.go index 39a3beb..391d750 100644 --- a/cmd/dkl-local-server/ws-clusters.go +++ b/cmd/dkl-local-server/ws-clusters.go @@ -9,6 +9,13 @@ import ( "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 { @@ -64,97 +71,6 @@ func wsClusterAddons(req *restful.Request, resp *restful.Response) { wsRender(resp, cluster.Addons, cluster) } -func wsClusterPasswords(req *restful.Request, resp *restful.Response) { - cluster := wsReadCluster(req, resp) - if cluster == 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) - 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) -} - func wsClusterCACert(req *restful.Request, resp *restful.Response) { cs := secretData.clusters[req.PathParameter("cluster-name")] if cs == nil { diff --git a/cmd/dkl-local-server/ws.go b/cmd/dkl-local-server/ws.go index 8223a82..d681e09 100644 --- a/cmd/dkl-local-server/ws.go +++ b/cmd/dkl-local-server/ws.go @@ -53,40 +53,50 @@ func registerWS(rest *restful.Container) { 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")) diff --git a/html/ui/js/Cluster.js b/html/ui/js/Cluster.js index 342f7d1..b956b11 100644 --- a/html/ui/js/Cluster.js +++ b/html/ui/js/Cluster.js @@ -1,16 +1,37 @@ import Downloads from './Downloads.js'; +import GetCopy from './GetCopy.js'; export default { - components: { Downloads }, + components: { Downloads, GetCopy }, props: [ 'cluster', 'token', 'state' ], template: `
Cluster {{ cluster.Name }}
+
Tokens
+ +
Passwords
+
Downloads
+
CAs
+
+ {{ ca.Name }}: + + +
` } diff --git a/html/ui/js/GetCopy.js b/html/ui/js/GetCopy.js new file mode 100644 index 0000000..b2a6bd5 --- /dev/null +++ b/html/ui/js/GetCopy.js @@ -0,0 +1,21 @@ +export default { + props: [ 'name', 'href', 'token' ], + data() { return {showCopied: false} }, + template: `
copied!
{{name}} 🗐
`, + methods: { + fetchAndCopy() { + event.preventDefault() + + fetch(this.href, { + method: 'GET', + headers: { 'Authorization': 'Bearer ' + this.token }, + }).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((e) => { console.log("failed to get value:", e); alert('failed to get value') }) + }, + }, +} diff --git a/html/ui/style.css b/html/ui/style.css index eb14e76..c040380 100644 --- a/html/ui/style.css +++ b/html/ui/style.css @@ -45,7 +45,7 @@ th, tr:last-child > td { background: #333; color: #eee; } - a[href], button.link { + a[href], a[href]:visited, button.link { border: none; color: #31b0fa; } @@ -125,3 +125,23 @@ header .utils > * { margin: 2pt 6pt 6pt 6pt; } +.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; + } +}