Compare commits

...

2 Commits

Author SHA1 Message Date
Mikaël Cluseau
3bc20e95cc secrets migration & restitution 2023-02-12 11:58:26 +01:00
Mikaël Cluseau
1aefc5d2b7 go.mod: go 1.20 2023-02-09 08:58:48 +01:00
14 changed files with 474 additions and 131 deletions

View File

@ -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 from debian:stretch

View File

@ -0,0 +1,9 @@
package main
import (
"net/http"
"m.cluseau.fr/go/httperr"
)
var ErrNotFound = httperr.NewStd(404, http.StatusNotFound, "not found")

View File

@ -8,9 +8,13 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings"
"sync" "sync"
restful "github.com/emicklei/go-restful"
"m.cluseau.fr/go/httperr" "m.cluseau.fr/go/httperr"
"novit.tech/direktil/local-server/secretstore" "novit.tech/direktil/local-server/secretstore"
) )
@ -126,6 +130,7 @@ func unlockSecretStore(passphrase []byte) *httperr.Error {
}) })
go updateState() go updateState()
go migrateSecrets()
return nil return nil
} }
@ -147,7 +152,13 @@ func readSecret(name string, value any) (err error) {
} }
func writeSecret(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 { if err != nil {
return return
} }
@ -167,5 +178,148 @@ func writeSecret(name string, value any) (err error) {
return 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
}
} }

View File

@ -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
}
}

View File

@ -30,9 +30,9 @@ type State struct {
type ClusterState struct { type ClusterState struct {
Name string Name string
Addons bool Addons bool
// TODO CAs Passwords []string
// TODO passwords Tokens []string
// TODO tokens CAs []CAState
} }
type HostState struct { type HostState struct {
@ -41,6 +41,11 @@ type HostState struct {
IPs []string IPs []string
} }
type CAState struct {
Name string
Signed []string
}
var wState = watchable.New[State]() var wState = watchable.New[State]()
func init() { func init() {
@ -68,6 +73,34 @@ func updateState() {
Name: cluster.Name, Name: cluster.Name,
Addons: len(cluster.Addons) != 0, 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) clusters = append(clusters, c)
} }

View File

@ -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)
}

View File

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

View File

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

View File

@ -9,6 +9,13 @@ import (
"novit.tech/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
) )
var clusterSecretKVs = []string{}
func newClusterSecretKV[T any](name string) KVSecrets[T] {
clusterSecretKVs = append(clusterSecretKVs, name)
return KVSecrets[T]{"clusters/"+name}
}
func wsListClusters(req *restful.Request, resp *restful.Response) { func wsListClusters(req *restful.Request, resp *restful.Response) {
cfg := wsReadConfig(resp) cfg := wsReadConfig(resp)
if cfg == nil { if cfg == nil {
@ -64,97 +71,6 @@ func wsClusterAddons(req *restful.Request, resp *restful.Response) {
wsRender(resp, cluster.Addons, cluster) 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) { func wsClusterCACert(req *restful.Request, resp *restful.Response) {
cs := secretData.clusters[req.PathParameter("cluster-name")] cs := secretData.clusters[req.PathParameter("cluster-name")]
if cs == nil { if cs == nil {

View File

@ -53,40 +53,50 @@ func registerWS(rest *restful.Container) {
ws.Route(ws.GET("/clusters").To(wsListClusters). ws.Route(ws.GET("/clusters").To(wsListClusters).
Doc("List clusters")) Doc("List clusters"))
ws.Route(ws.GET("/clusters/{cluster-name}").To(wsCluster). const (
Doc("Get cluster details")) GET = http.MethodGet
PUT = http.MethodPut
)
ws.Route(ws.GET("/clusters/{cluster-name}/addons").To(wsClusterAddons). cluster := func(method, subPath string) *restful.RouteBuilder {
return ws.Method(method).Path("/clusters/{cluster-name}" + subPath).
Param(ws.PathParameter("cluster-name", "name of the cluster"))
}
for _, builder := range []*restful.RouteBuilder{
cluster(GET, "").To(wsCluster).
Doc("Get cluster details"),
cluster(GET, "/addons").To(wsClusterAddons).
Produces(mime.YAML). Produces(mime.YAML).
Doc("Get cluster addons"). Doc("Get cluster addons").
Returns(http.StatusOK, "OK", nil). Returns(http.StatusOK, "OK", nil).
Returns(http.StatusNotFound, "The cluster does not exists or does not have addons defined", nil)) Returns(http.StatusNotFound, "The cluster does not exists or does not have addons defined", nil),
ws.Route(ws.GET("/clusters/{cluster-name}/bootstrap-pods").To(wsClusterBootstrapPods). cluster(GET, "/tokens").To(wsClusterTokens).
Produces(mime.YAML). Doc("List cluster's tokens"),
Doc("Get cluster bootstrap pods YAML definitions"). cluster(GET, "/tokens/{token-name}").To(wsClusterToken).
Returns(http.StatusOK, "OK", nil). Doc("Get cluster's token"),
Returns(http.StatusNotFound, "The cluster does not exists or does not have bootstrap pods defined", nil))
ws.Route(ws.GET("/clusters/{cluster-name}/passwords").To(wsClusterPasswords). cluster(GET, "/passwords").To(wsClusterPasswords).
Doc("List cluster's passwords")) Doc("List cluster's passwords"),
ws.Route(ws.GET("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterPassword). cluster(GET, "/passwords/{password-name}").To(wsClusterPassword).
Doc("Get cluster's password")) Doc("Get cluster's password"),
ws.Route(ws.PUT("/clusters/{cluster-name}/passwords/{password-name}").To(wsClusterSetPassword). cluster(PUT, "/passwords/{password-name}").To(wsClusterSetPassword).
Doc("Set cluster's password")) Doc("Set cluster's password"),
ws.Route(ws.GET("/clusters/{cluster-name}/ca").To(wsClusterCAs). cluster(GET, "/CAs").To(wsClusterCAs).
Doc("Get cluster CAs")) Doc("Get cluster CAs"),
ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/certificate").To(wsClusterCACert). cluster(GET, "/CAs/{ca-name}/certificate").To(wsClusterCACert).
Produces(mime.CACERT). Produces(mime.CACERT).
Doc("Get cluster CA's certificate")) Doc("Get cluster CA's certificate"),
ws.Route(ws.GET("/clusters/{cluster-name}/ca/{ca-name}/signed").To(wsClusterSignedCert). cluster(GET, "/CAs/{ca-name}/signed").To(wsClusterSignedCert).
Produces(mime.CERT). Produces(mime.CERT).
Param(ws.QueryParameter("name", "signed reference name").Required(true)). Param(ws.QueryParameter("name", "signed reference name").Required(true)).
Doc("Get cluster's certificate signed by the CA")) Doc("Get cluster's certificate signed by the CA"),
} {
ws.Route(ws.GET("/clusters/{cluster-name}/tokens/{token-name}").To(wsClusterToken). ws.Route(builder)
Doc("Get cluster's token")) }
ws.Route(ws.GET("/hosts").To(wsListHosts). ws.Route(ws.GET("/hosts").To(wsListHosts).
Doc("List hosts")) Doc("List hosts"))

2
go.mod
View File

@ -1,6 +1,6 @@
module novit.tech/direktil/local-server module novit.tech/direktil/local-server
go 1.19 go 1.20
require ( require (
github.com/cavaliergopher/cpio v1.0.1 github.com/cavaliergopher/cpio v1.0.1

View File

@ -1,16 +1,37 @@
import Downloads from './Downloads.js'; import Downloads from './Downloads.js';
import GetCopy from './GetCopy.js';
export default { export default {
components: { Downloads }, components: { Downloads, GetCopy },
props: [ 'cluster', 'token', 'state' ], props: [ 'cluster', 'token', 'state' ],
template: ` template: `
<div class="cluster"> <div class="cluster">
<div class="title">Cluster {{ cluster.Name }}</div> <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> <div class="section">Downloads</div>
<section class="downloads"> <section class="downloads">
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" /> <Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
</section> </section>
<div class="section">CAs</div>
<section v-for="ca in cluster.CAs">
{{ ca.Name }}:
<GetCopy :token="token" name="cert" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/certificate'" />
<template v-if="ca.Signed">
{{" "}}signed
<template v-for="signed in ca.Signed">
{{" "}}
<GetCopy :token="token" :name="signed" :href="'/clusters/'+cluster.Name+'/CAs/'+ca.Name+'/signed?name='+signed" />
</template>
</template>
</section>
</div> </div>
` `
} }

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

@ -0,0 +1,21 @@
export default {
props: [ 'name', 'href', 'token' ],
data() { return {showCopied: false} },
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndCopy()">{{name}}<small>&nbsp;&#x1F5D0;</small></a></span>`,
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') })
},
},
}

View File

@ -45,7 +45,7 @@ th, tr:last-child > td {
background: #333; background: #333;
color: #eee; color: #eee;
} }
a[href], button.link { a[href], a[href]:visited, button.link {
border: none; border: none;
color: #31b0fa; color: #31b0fa;
} }
@ -125,3 +125,23 @@ header .utils > * {
margin: 2pt 6pt 6pt 6pt; 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;
}
}