downloads API, UI
This commit is contained in:
parent
e44303eab9
commit
b6c714fac7
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1,5 @@
|
||||
*.sw[po]
|
||||
modd-local.conf
|
||||
/tmp
|
||||
/go.work
|
||||
/dist
|
||||
|
@ -26,11 +26,32 @@ func authorizeToken(r *http.Request, token string) bool {
|
||||
}
|
||||
|
||||
reqToken := r.Header.Get("Authorization")
|
||||
|
||||
if reqToken != "" {
|
||||
return reqToken == "Bearer "+token
|
||||
}
|
||||
|
||||
return r.URL.Query().Get("token") == token
|
||||
}
|
||||
|
||||
func forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("denied access to %s from %s", r.RequestURI, r.RemoteAddr)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}
|
||||
|
||||
func requireToken(token string, handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if !authorizeToken(req, token) {
|
||||
forbidden(w, req)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
func requireAdmin(handler http.Handler) http.Handler {
|
||||
return requireToken(*adminToken, handler)
|
||||
}
|
||||
|
||||
func requireHosts(handler http.Handler) http.Handler {
|
||||
return requireToken(*hostsToken, handler)
|
||||
}
|
||||
|
@ -4,13 +4,16 @@ import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
swaggerui "github.com/mcluseau/go-swagger-ui"
|
||||
"m.cluseau.fr/go/watchable/streamsse"
|
||||
|
||||
"novit.tech/direktil/pkg/cas"
|
||||
|
||||
dlshtml "novit.tech/direktil/local-server/html"
|
||||
"novit.tech/direktil/local-server/pkg/apiutils"
|
||||
)
|
||||
|
||||
@ -24,6 +27,8 @@ var (
|
||||
certFile = flag.String("tls-cert", etcDir+"/server.crt", "Server TLS certificate")
|
||||
keyFile = flag.String("tls-key", etcDir+"/server.key", "Server TLS key")
|
||||
|
||||
autoUnlock = flag.String("auto-unlock", "", "Auto-unlock store (testing only!)")
|
||||
|
||||
casStore cas.Store
|
||||
)
|
||||
|
||||
@ -36,6 +41,28 @@ func main() {
|
||||
log.Fatal("no listen address given")
|
||||
}
|
||||
|
||||
computeUIHash()
|
||||
|
||||
openSecretStore()
|
||||
|
||||
{
|
||||
autoUnlock := *autoUnlock
|
||||
if autoUnlock == "" {
|
||||
autoUnlock = os.Getenv("DLS_AUTO_UNLOCK")
|
||||
}
|
||||
if autoUnlock != "" {
|
||||
log.Printf("auto-unlocking the store")
|
||||
err := unlockSecretStore([]byte(autoUnlock))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
log.Print("store auto-unlocked, admin token is ", *adminToken)
|
||||
}
|
||||
|
||||
os.Setenv("DLS_AUTO_UNLOCK", "")
|
||||
}
|
||||
|
||||
casStore = cas.NewDir(filepath.Join(*dataDir, "cache"))
|
||||
go casCleaner()
|
||||
|
||||
@ -45,6 +72,13 @@ func main() {
|
||||
|
||||
swaggerui.HandleAt("/swagger-ui/")
|
||||
|
||||
staticHandler := http.FileServer(http.FS(dlshtml.FS))
|
||||
http.Handle("/favicon.ico", staticHandler)
|
||||
http.Handle("/ui/", staticHandler)
|
||||
|
||||
http.Handle("/public-state", streamsse.StreamHandler(wPublicState))
|
||||
http.Handle("/state", requireAdmin(streamsse.StreamHandler(wState)))
|
||||
|
||||
if *address != "" {
|
||||
log.Print("HTTP listening on ", *address)
|
||||
go log.Fatal(http.ListenAndServe(*address, nil))
|
||||
|
171
cmd/dkl-local-server/secret-store.go
Normal file
171
cmd/dkl-local-server/secret-store.go
Normal file
@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"m.cluseau.fr/go/httperr"
|
||||
"novit.tech/direktil/local-server/secretstore"
|
||||
)
|
||||
|
||||
var secStore *secretstore.Store
|
||||
|
||||
func secStorePath(name string) string { return filepath.Join(*dataDir, "secrets", name) }
|
||||
func secKeysStorePath() string { return secStorePath(".keys") }
|
||||
|
||||
func openSecretStore() {
|
||||
var err error
|
||||
|
||||
keysPath := secKeysStorePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Dir(keysPath)), 0755); err != nil {
|
||||
log.Fatal("failed to create dirs: ", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(keysPath), 0700); err != nil {
|
||||
log.Fatal("failed to secret store dir: ", err)
|
||||
}
|
||||
|
||||
secStore, err = secretstore.Open(keysPath)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
wPublicState.Change(func(v *PublicState) {
|
||||
v.Store.New = false
|
||||
v.Store.Open = false
|
||||
})
|
||||
|
||||
case os.IsNotExist(err):
|
||||
secStore = secretstore.New()
|
||||
wPublicState.Change(func(v *PublicState) {
|
||||
v.Store.New = true
|
||||
v.Store.Open = false
|
||||
})
|
||||
|
||||
default:
|
||||
log.Fatal("failed to open keys store: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
unlockMutex = sync.Mutex{}
|
||||
|
||||
ErrStoreAlreadyUnlocked = httperr.NewStd(http.StatusConflict, 1, "store already unlocked")
|
||||
ErrInvalidPassphrase = httperr.NewStd(http.StatusBadRequest, 2, "invalid passphrase")
|
||||
)
|
||||
|
||||
func unlockSecretStore(passphrase []byte) *httperr.Error {
|
||||
unlockMutex.Lock()
|
||||
defer unlockMutex.Unlock()
|
||||
|
||||
if secStore.Unlocked() {
|
||||
return ErrStoreAlreadyUnlocked
|
||||
}
|
||||
|
||||
if secStore.IsNew() {
|
||||
err := secStore.Init(passphrase)
|
||||
if err != nil {
|
||||
return httperr.New(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
err = secStore.SaveTo(secKeysStorePath())
|
||||
if err != nil {
|
||||
log.Print("secret store save error: ", err)
|
||||
secStore.Close()
|
||||
|
||||
return httperr.New(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
} else {
|
||||
if !secStore.Unlock([]byte(passphrase)) {
|
||||
return ErrInvalidPassphrase
|
||||
}
|
||||
}
|
||||
|
||||
token := ""
|
||||
if err := readSecret("admin-token", &token); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Print("failed to read admin token: ", err)
|
||||
secStore.Close()
|
||||
|
||||
return httperr.New(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
randBytes := make([]byte, 32)
|
||||
_, err := rand.Read(randBytes)
|
||||
if err != nil {
|
||||
log.Print("rand read error: ", err)
|
||||
secStore.Close()
|
||||
|
||||
return httperr.New(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randBytes)
|
||||
err = writeSecret("admin-token", token)
|
||||
if err != nil {
|
||||
log.Print("write error: ", err)
|
||||
secStore.Close()
|
||||
|
||||
return httperr.New(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
log.Print("wrote new admin token")
|
||||
}
|
||||
|
||||
*adminToken = token
|
||||
|
||||
wPublicState.Change(func(v *PublicState) {
|
||||
v.Store.New = false
|
||||
v.Store.Open = true
|
||||
})
|
||||
|
||||
go updateState()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSecret(name string, value any) (err error) {
|
||||
f, err := os.Open(secStorePath(name + ".data"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
in, err := secStore.NewReader(f)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return json.NewDecoder(in).Decode(value)
|
||||
}
|
||||
|
||||
func writeSecret(name string, value any) (err error) {
|
||||
f, err := os.Create(secStorePath(name + ".data.new"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
defer f.Close()
|
||||
|
||||
out, err := secStore.NewWriter(f)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return json.NewEncoder(out).Encode(value)
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return os.Rename(f.Name(), secStorePath(name+".data"))
|
||||
}
|
92
cmd/dkl-local-server/state.go
Normal file
92
cmd/dkl-local-server/state.go
Normal file
@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"m.cluseau.fr/go/watchable"
|
||||
"novit.tech/direktil/pkg/localconfig"
|
||||
)
|
||||
|
||||
type PublicState struct {
|
||||
UIHash string
|
||||
Store struct {
|
||||
New bool
|
||||
Open bool
|
||||
}
|
||||
}
|
||||
|
||||
var wPublicState = watchable.New[PublicState]()
|
||||
|
||||
type State struct {
|
||||
HasConfig bool
|
||||
|
||||
Clusters []ClusterState
|
||||
Hosts []HostState
|
||||
Config *localconfig.Config
|
||||
|
||||
Downloads map[string]DownloadSpec
|
||||
}
|
||||
|
||||
type ClusterState struct {
|
||||
Name string
|
||||
Addons bool
|
||||
// TODO CAs
|
||||
// TODO passwords
|
||||
// TODO tokens
|
||||
}
|
||||
|
||||
type HostState struct {
|
||||
Name string
|
||||
Cluster string
|
||||
IPs []string
|
||||
}
|
||||
|
||||
var wState = watchable.New[State]()
|
||||
|
||||
func init() {
|
||||
wState.Set(State{Downloads: map[string]DownloadSpec{}})
|
||||
}
|
||||
|
||||
func updateState() {
|
||||
log.Print("updating state")
|
||||
|
||||
cfg, err := readConfig()
|
||||
if err != nil {
|
||||
wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil })
|
||||
return
|
||||
}
|
||||
|
||||
if secStore.IsNew() || !secStore.Unlocked() {
|
||||
wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil })
|
||||
return
|
||||
}
|
||||
|
||||
// remove heavy data
|
||||
clusters := make([]ClusterState, 0, len(cfg.Clusters))
|
||||
for _, cluster := range cfg.Clusters {
|
||||
c := ClusterState{
|
||||
Name: cluster.Name,
|
||||
Addons: len(cluster.Addons) != 0,
|
||||
}
|
||||
clusters = append(clusters, c)
|
||||
}
|
||||
|
||||
hosts := make([]HostState, 0, len(cfg.Hosts))
|
||||
for _, host := range cfg.Hosts {
|
||||
h := HostState{
|
||||
Name: host.Name,
|
||||
Cluster: host.ClusterName,
|
||||
IPs: host.IPs,
|
||||
}
|
||||
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
|
||||
// done
|
||||
wState.Change(func(v *State) {
|
||||
v.HasConfig = true
|
||||
//v.Config = cfg
|
||||
v.Clusters = clusters
|
||||
v.Hosts = hosts
|
||||
})
|
||||
}
|
45
cmd/dkl-local-server/ui.go
Normal file
45
cmd/dkl-local-server/ui.go
Normal file
@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/cespare/xxhash"
|
||||
dlshtml "novit.tech/direktil/local-server/html"
|
||||
)
|
||||
|
||||
func computeUIHash() {
|
||||
xxh := xxhash.New()
|
||||
|
||||
err := fs.WalkDir(dlshtml.FS, "ui", func(path string, entry fs.DirEntry, walkErr error) (err error) {
|
||||
if walkErr != nil {
|
||||
err = walkErr
|
||||
return
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := dlshtml.FS.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
io.Copy(xxh, f)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatal("failed to hash UI: ", err)
|
||||
}
|
||||
|
||||
h := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(xxh.Sum(nil)))[:5]
|
||||
log.Printf("UI hash: %s", h)
|
||||
wPublicState.Change(func(v *PublicState) { v.UIHash = h })
|
||||
}
|
@ -33,6 +33,10 @@ func getToken(req *restful.Request) string {
|
||||
|
||||
token := req.HeaderParameter("Authorization")
|
||||
|
||||
if token == "" {
|
||||
return req.QueryParameter("token")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(token, bearerPrefix) {
|
||||
return ""
|
||||
}
|
||||
|
@ -45,6 +45,8 @@ func writeNewConfig(reader io.Reader) (err error) {
|
||||
}
|
||||
|
||||
err = os.Rename(out.Name(), cfgPath)
|
||||
|
||||
updateState()
|
||||
return
|
||||
}
|
||||
|
||||
|
150
cmd/dkl-local-server/ws-downloads.go
Normal file
150
cmd/dkl-local-server/ws-downloads.go
Normal file
@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
"m.cluseau.fr/go/cow"
|
||||
)
|
||||
|
||||
type DownloadSpec struct {
|
||||
Kind string
|
||||
Name string
|
||||
Assets []string
|
||||
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
func wsAuthorizeDownload(req *restful.Request, resp *restful.Response) {
|
||||
var spec DownloadSpec
|
||||
|
||||
if err := req.ReadEntity(&spec); err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
|
||||
if spec.Kind == "" || spec.Name == "" || len(spec.Assets) == 0 {
|
||||
resp.WriteErrorString(http.StatusBadRequest, "missing data")
|
||||
return
|
||||
}
|
||||
|
||||
randBytes := make([]byte, 32)
|
||||
_, err := rand.Read(randBytes)
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
|
||||
token := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randBytes)
|
||||
|
||||
spec.createdAt = time.Now()
|
||||
|
||||
wState.Change(func(v *State) {
|
||||
cow.MapSet(&v.Downloads, token, spec)
|
||||
})
|
||||
|
||||
log.Printf("download token created for %s %q, assets %q", spec.Kind, spec.Name, spec.Assets)
|
||||
|
||||
resp.WriteAsJson(token)
|
||||
}
|
||||
|
||||
func wsDownload(req *restful.Request, resp *restful.Response) {
|
||||
token := req.PathParameter("token")
|
||||
asset := req.PathParameter("asset")
|
||||
|
||||
if token == "" || asset == "" {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
var spec DownloadSpec
|
||||
found := false
|
||||
wState.Change(func(v *State) {
|
||||
var ok bool
|
||||
spec, ok = v.Downloads[token]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
newAssets := make([]string, 0, len(spec.Assets))
|
||||
for _, a := range spec.Assets {
|
||||
if a == asset {
|
||||
found = true
|
||||
} else {
|
||||
newAssets = append(newAssets, a)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
cow.Map(&v.Downloads)
|
||||
|
||||
if len(newAssets) == 0 {
|
||||
delete(v.Downloads, token)
|
||||
} else {
|
||||
spec.Assets = newAssets
|
||||
v.Downloads[token] = spec
|
||||
}
|
||||
})
|
||||
|
||||
if !found {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("download via token %q", token)
|
||||
|
||||
cfg, err := readConfig()
|
||||
if err != nil {
|
||||
wsError(resp, err)
|
||||
return
|
||||
}
|
||||
|
||||
setHeader := func(ext string) {
|
||||
resp.AddHeader("Content-Disposition", "attachment; filename="+strconv.Quote(spec.Kind+"_"+spec.Name+"_"+asset+ext))
|
||||
}
|
||||
|
||||
switch spec.Kind {
|
||||
case "cluster":
|
||||
cluster := cfg.ClusterByName(spec.Name)
|
||||
if cluster == nil {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
switch asset {
|
||||
case "addons":
|
||||
setHeader(".yaml")
|
||||
resp.Write([]byte(cluster.Addons))
|
||||
|
||||
default:
|
||||
wsNotFound(req, resp)
|
||||
}
|
||||
|
||||
case "host":
|
||||
host := cfg.Host(spec.Name)
|
||||
if host == nil {
|
||||
wsNotFound(req, resp)
|
||||
return
|
||||
}
|
||||
|
||||
switch asset {
|
||||
case "config", "bootstrap-config":
|
||||
setHeader(".yaml")
|
||||
default:
|
||||
setHeader("")
|
||||
}
|
||||
|
||||
renderHost(resp.ResponseWriter, req.Request, asset, host, cfg)
|
||||
|
||||
default:
|
||||
wsNotFound(req, resp)
|
||||
}
|
||||
}
|
23
cmd/dkl-local-server/ws-public.go
Normal file
23
cmd/dkl-local-server/ws-public.go
Normal file
@ -0,0 +1,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
)
|
||||
|
||||
func wsUnlockStore(req *restful.Request, resp *restful.Response) {
|
||||
var passphrase string
|
||||
err := req.ReadEntity(&passphrase)
|
||||
if err != nil {
|
||||
resp.WriteError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := unlockSecretStore([]byte(passphrase)); err != nil {
|
||||
err.WriteJSON(resp.ResponseWriter)
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteEntity(*adminToken)
|
||||
}
|
@ -16,11 +16,35 @@ import (
|
||||
)
|
||||
|
||||
func registerWS(rest *restful.Container) {
|
||||
// public-level APIs
|
||||
{
|
||||
ws := &restful.WebService{}
|
||||
ws.
|
||||
Path("/public").
|
||||
Produces("application/json").
|
||||
Consumes("application/json").
|
||||
Route(ws.POST("/unlock-store").To(wsUnlockStore).
|
||||
Reads("").
|
||||
Writes("").
|
||||
Doc("Try to unlock the store")).
|
||||
Route(ws.GET("/downloads/{token}/{asset}").To(wsDownload).
|
||||
Param(ws.PathParameter("token", "the download token")).
|
||||
Param(ws.PathParameter("asset", "the requested asset")).
|
||||
Doc("Fetch an asset via a download token"))
|
||||
|
||||
rest.Add(ws)
|
||||
}
|
||||
|
||||
// Admin-level APIs
|
||||
ws := &restful.WebService{}
|
||||
ws.Filter(adminAuth).
|
||||
ws.
|
||||
Filter(adminAuth).
|
||||
HeaderParameter("Authorization", "Admin bearer token")
|
||||
|
||||
// - downloads
|
||||
ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload).
|
||||
Doc("Create a download token for the given download"))
|
||||
|
||||
// - configs API
|
||||
ws.Route(ws.POST("/configs").To(wsUploadConfig).
|
||||
Doc("Upload a new current configuration, archiving the previous one"))
|
||||
@ -84,6 +108,7 @@ func registerWS(rest *restful.Container) {
|
||||
|
||||
// Hosts API
|
||||
ws = &restful.WebService{}
|
||||
ws.Produces("application/json")
|
||||
ws.Path("/me")
|
||||
ws.Filter(hostsAuth).
|
||||
HeaderParameter("Authorization", "Host or admin bearer token")
|
||||
|
1
gen-api-js.sh
Executable file
1
gen-api-js.sh
Executable file
@ -0,0 +1 @@
|
||||
docker run --rm --net=host --user $(id -u) -v ${PWD}:/local swaggerapi/swagger-codegen-cli generate -i http://[::1]:7606/swagger.json -l javascript -o /local/js/api/
|
1
go.mod
1
go.mod
@ -66,4 +66,5 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.90.0 // indirect
|
||||
k8s.io/utils v0.0.0-20230115233650-391b47cb4029 // indirect
|
||||
m.cluseau.fr/go v0.0.0-20230205163051-2d3050134ad5 // indirect
|
||||
)
|
||||
|
2
go.sum
2
go.sum
@ -1301,6 +1301,8 @@ k8s.io/klog/v2 v2.90.0 h1:VkTxIV/FjRXn1fgNNcKGM8cfmL1Z33ZjXRTVxKCoF5M=
|
||||
k8s.io/klog/v2 v2.90.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
|
||||
k8s.io/utils v0.0.0-20230115233650-391b47cb4029 h1:L8zDtT4jrxj+TaQYD0k8KNlr556WaVQylDXswKmX+dE=
|
||||
k8s.io/utils v0.0.0-20230115233650-391b47cb4029/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
m.cluseau.fr/go v0.0.0-20230205163051-2d3050134ad5 h1:ss7MlpIRrkji6QhhRn4sTkOf44Y6f3roO+7kFXs0Q0U=
|
||||
m.cluseau.fr/go v0.0.0-20230205163051-2d3050134ad5/go.mod h1:1DYAavEumEsHasA2RbRUExaEodBB2NAYD+IcwmgVkRM=
|
||||
novit.nc/direktil/pkg v0.0.0-20220221171542-fd3ce3a1491b/go.mod h1:zwTVO6U0tXFEaga73megQIBK7yVIKZJVePaIh/UtdfU=
|
||||
novit.tech/direktil/pkg v0.0.0-20220331152412-40403eca850f h1:Ka7zFkP01l4TW9JSmQQzaYVOq0i+qf+JIWAfyoreoSk=
|
||||
novit.tech/direktil/pkg v0.0.0-20220331152412-40403eca850f/go.mod h1:2Mir5x1eT/e295WeFGzzXa4siunKX4z+rmNPfVsXS0k=
|
||||
|
2
go.work.sum
Normal file
2
go.work.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/jhump/protoreflect v1.12.0 h1:1NQ4FpWMgn3by/n1X0fbeKEUxP1wBt7+Oitpv01HR10=
|
BIN
html/favicon.ico
Normal file
BIN
html/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.7 KiB |
6
html/html.go
Normal file
6
html/html.go
Normal file
@ -0,0 +1,6 @@
|
||||
package dlshtml
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed favicon.ico ui
|
||||
var FS embed.FS
|
19
html/ui/app.css
Normal file
19
html/ui/app.css
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
.downloads {
|
||||
display: flex;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.downloads > * {
|
||||
margin-left: 6pt;
|
||||
}
|
||||
.downloads > *:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.downloads > div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
max-height: 100pt;
|
||||
overflow: auto;
|
||||
}
|
87
html/ui/index.html
Normal file
87
html/ui/index.html
Normal file
@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Direktil Local Server</title>
|
||||
<style>
|
||||
@import url('./style.css');
|
||||
@import url('./app.css');
|
||||
</style>
|
||||
<script src="js/jsonpatch.min.js" crossorigin="anonymous"></script>
|
||||
<script src="js/app.js" type="module" defer></script>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
<header>
|
||||
<div id="logo">
|
||||
<img src="/favicon.ico" />
|
||||
<span>Direktil Local Server</span>
|
||||
</div>
|
||||
<div class="utils">
|
||||
<span id="login-hdr" v-if="session.token">
|
||||
Logged in
|
||||
<button class="link" @click="copyText(session.token)">🗐</button>
|
||||
</span>
|
||||
|
||||
<span id="uiHash">ui <code>{{ uiHash || '-----' }}</code></span>
|
||||
|
||||
<span class="green" v-if="publicState">🗲</span>
|
||||
<span class="red" v-else >🗲</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
<button class="btn-close" @click="error=null">×</button>
|
||||
<div class="code" v-if="error.code">{{ error.code }}</div>
|
||||
<div class="message">{{ error.message }}</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!publicState">
|
||||
<p>Not connected.</p>
|
||||
</template>
|
||||
<template v-else-if="publicState.Store.New">
|
||||
<p>Store is new.</p>
|
||||
<form @submit="unlockStore" action="/public/unlock-store">
|
||||
<input type="password" v-model="forms.store.pass1" name="passphrase" />
|
||||
<input type="password" v-model="forms.store.pass2" />
|
||||
<input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
|
||||
</form>
|
||||
</template>
|
||||
<template v-else-if="!publicState.Store.Open">
|
||||
<p>Store is not open.</p>
|
||||
<form @submit="unlockStore" action="/public/unlock-store">
|
||||
<input type="password" name="passphrase" v-model="forms.store.pass1" />
|
||||
<input type="submit" value="unlock" :disabled="!forms.store.pass1" />
|
||||
</form>
|
||||
</template>
|
||||
<template v-else-if="!state">
|
||||
<p v-if="!session.token">Not logged in.</p>
|
||||
<p v-else>Invalid token</p>
|
||||
|
||||
<form @submit="setToken">
|
||||
<input type="password" v-model="forms.setToken" />
|
||||
<input type="submit" value="set token"/>
|
||||
</form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="state.Clusters" id="clusters">
|
||||
<h2>Clusters</h2>
|
||||
|
||||
<div class="sheets">
|
||||
<Cluster v-for="c in state.Clusters" :cluster="c" :token="session.token" :state="state" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="state.Hosts" id="hosts">
|
||||
<h2>Hosts</h2>
|
||||
|
||||
<div class="sheets">
|
||||
<Host v-for="h in state.Hosts" :host="h" :token="session.token" :state="state" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre v-if="false">{{ state }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
16
html/ui/js/Cluster.js
Normal file
16
html/ui/js/Cluster.js
Normal file
@ -0,0 +1,16 @@
|
||||
|
||||
import Downloads from './Downloads.js';
|
||||
|
||||
export default {
|
||||
components: { Downloads },
|
||||
props: [ 'cluster', 'token', 'state' ],
|
||||
template: `
|
||||
<div class="cluster">
|
||||
<div class="title">Cluster {{ cluster.Name }}</div>
|
||||
<div class="section">Downloads</div>
|
||||
<section class="downloads">
|
||||
<Downloads :token="token" :state="state" kind="cluster" :name="cluster.Name" />
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
}
|
66
html/ui/js/Downloads.js
Normal file
66
html/ui/js/Downloads.js
Normal file
@ -0,0 +1,66 @@
|
||||
|
||||
export default {
|
||||
props: [ 'kind', 'name', 'token', 'state' ],
|
||||
data() {
|
||||
return { createDisabled: false, selectedAssets: {} }
|
||||
},
|
||||
computed: {
|
||||
availableAssets() {
|
||||
return {
|
||||
cluster: ['addons'],
|
||||
host: [
|
||||
"kernel",
|
||||
"initrd-v2",
|
||||
"bootstrap.tar",
|
||||
"boot-v2.iso",
|
||||
"config",
|
||||
"boot.iso",
|
||||
"boot.tar",
|
||||
"boot-efi.tar",
|
||||
"boot.img",
|
||||
"boot.img.gz",
|
||||
"boot.img.lz4",
|
||||
"bootstrap-config",
|
||||
"initrd",
|
||||
"ipxe",
|
||||
],
|
||||
}[this.kind]
|
||||
},
|
||||
downloads() {
|
||||
let ret = []
|
||||
Object.entries(this.state.Downloads)
|
||||
.filter(e => { let d=e[1]; return d.Kind == this.kind && d.Name == this.name })
|
||||
.forEach(e => {
|
||||
let token= e[0], d = e[1]
|
||||
d.Assets.forEach(asset => {
|
||||
ret.push({name: asset, url: '/public/downloads/'+token+'/'+asset})
|
||||
})
|
||||
})
|
||||
return ret
|
||||
},
|
||||
assets() {
|
||||
return this.availableAssets.filter(a => this.selectedAssets[a])
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
createToken() {
|
||||
event.preventDefault()
|
||||
this.createDisabled = true
|
||||
|
||||
fetch('/authorize-download', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({Kind: this.kind, Name: this.name, Assets: this.assets}),
|
||||
headers: { 'Authorization': 'Bearer ' + this.token },
|
||||
}).then((resp) => resp.json())
|
||||
.then((token) => { this.selectedAssets = {}; this.createDisabled = false })
|
||||
.catch((e) => { alert('failed to create link'); this.createDisabled = false })
|
||||
},
|
||||
},
|
||||
template: `<div class="downloads">
|
||||
<div class="options">
|
||||
<span v-for="asset in availableAssets"><label><input type="checkbox" v-model="selectedAssets[asset]" /> {{ asset }}</label></span>
|
||||
</div>
|
||||
<button :disabled="createDisabled || assets.length==0" @click="createToken">+</button>
|
||||
<div><a v-for="d in downloads" target="_blank" :href="d.url">{{ d.name }}</a></div>
|
||||
</div>`
|
||||
}
|
21
html/ui/js/Host.js
Normal file
21
html/ui/js/Host.js
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
import Downloads from './Downloads.js';
|
||||
|
||||
export default {
|
||||
components: { Downloads },
|
||||
props: [ 'host', 'token', 'state' ],
|
||||
template: `
|
||||
<div class="host">
|
||||
<div class="title">Host {{ host.Name }}</div>
|
||||
<section>
|
||||
<template v-for="ip in host.IPs">
|
||||
{{ ip }}
|
||||
</template>
|
||||
</section>
|
||||
<div class="section">Downloads</div>
|
||||
<section>
|
||||
<Downloads :token="token" :state="state" kind="host" :name="host.Name" />
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
}
|
162
html/ui/js/app.js
Normal file
162
html/ui/js/app.js
Normal file
@ -0,0 +1,162 @@
|
||||
|
||||
import { createApp } from './vue.esm-browser.js';
|
||||
|
||||
import Cluster from './Cluster.js';
|
||||
import Host from './Host.js';
|
||||
|
||||
createApp({
|
||||
components: { Cluster, Host },
|
||||
data() {
|
||||
return {
|
||||
forms: {
|
||||
store: { },
|
||||
},
|
||||
session: {},
|
||||
error: null,
|
||||
publicState: null,
|
||||
uiHash: null,
|
||||
watchingState: false,
|
||||
state: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.session = JSON.parse(sessionStorage.state || "{}")
|
||||
this.watchPublicState()
|
||||
},
|
||||
watch: {
|
||||
session: {
|
||||
deep: true,
|
||||
handler(v) {
|
||||
sessionStorage.state = JSON.stringify(v)
|
||||
|
||||
if (v.token && !this.watchingState) {
|
||||
this.watchState()
|
||||
this.watchingState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
publicState: {
|
||||
deep: true,
|
||||
handler(v) {
|
||||
if (v) {
|
||||
if (this.uiHash && v.UIHash != this.uiHash) {
|
||||
console.log("reloading")
|
||||
location.reload()
|
||||
} else {
|
||||
this.uiHash = v.UIHash
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
copyText(text) {
|
||||
event.preventDefault()
|
||||
window.navigator.clipboard.writeText(text)
|
||||
},
|
||||
setToken() {
|
||||
event.preventDefault()
|
||||
this.session.token = this.forms.setToken
|
||||
this.forms.setToken = null
|
||||
},
|
||||
unlockStore() {
|
||||
this.apiPost('/public/unlock-store', this.forms.store.pass1, (v) => {
|
||||
this.forms.store = {}
|
||||
|
||||
if (v) {
|
||||
this.session.token = v
|
||||
if (!this.watchingState) {
|
||||
this.watchState()
|
||||
this.watchingState = true
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
apiPost(action, data, onload) {
|
||||
event.preventDefault()
|
||||
|
||||
if (data === undefined) {
|
||||
throw("action " + action + ": no data")
|
||||
}
|
||||
|
||||
/* TODO
|
||||
fetch(action, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((result) => onload)
|
||||
// */
|
||||
|
||||
var xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.responseType = 'json'
|
||||
// TODO spinner, pending aciton notification, or something
|
||||
xhr.onerror = () => {
|
||||
// this.actionResults.splice(idx, 1, {...item, done: true, failed: true })
|
||||
}
|
||||
xhr.onload = (r) => {
|
||||
if (xhr.status != 200) {
|
||||
this.error = xhr.response
|
||||
return
|
||||
}
|
||||
// this.actionResults.splice(idx, 1, {...item, done: true, resp: xhr.responseText})
|
||||
this.error = null
|
||||
if (onload) {
|
||||
onload(xhr.response)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.open("POST", action)
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
if (this.session.token) {
|
||||
xhr.setRequestHeader('Authorization', 'Bearer '+this.session.token)
|
||||
}
|
||||
xhr.send(JSON.stringify(data))
|
||||
},
|
||||
download(url) {
|
||||
event.target.target = '_blank'
|
||||
event.target.href = this.downloadLink(url)
|
||||
},
|
||||
downloadLink(url) {
|
||||
// TODO once-shot download link
|
||||
return url + '?token=' + this.session.token
|
||||
},
|
||||
watchPublicState() {
|
||||
this.watchStream('publicState', '/public-state')
|
||||
},
|
||||
watchState() {
|
||||
this.watchStream('state', '/state', true)
|
||||
},
|
||||
watchStream(field, path, withToken) {
|
||||
let evtSrc = new EventSource(path + (withToken ? '?token='+this.session.token : ''));
|
||||
evtSrc.onmessage = (e) => {
|
||||
let update = JSON.parse(e.data)
|
||||
|
||||
console.log("watch "+path+":", update)
|
||||
|
||||
if (update.err) {
|
||||
console.log("watch error from server:", err)
|
||||
}
|
||||
if (update.set) {
|
||||
this[field] = update.set
|
||||
}
|
||||
if (update.p) { // patch
|
||||
new jsonpatch.JSONPatch(update.p, true).apply(this[field])
|
||||
}
|
||||
}
|
||||
evtSrc.onerror = (e) => {
|
||||
// console.log("event source " + path + " error:", e)
|
||||
if (evtSrc) evtSrc.close()
|
||||
|
||||
this[field] = null
|
||||
|
||||
window.setTimeout(() => { this.watchStream(field, path, withToken) }, 1000)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
}).mount('#app')
|
||||
|
36
html/ui/js/jsonpatch.min.js
vendored
Normal file
36
html/ui/js/jsonpatch.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
16172
html/ui/js/vue.esm-browser.js
Normal file
16172
html/ui/js/vue.esm-browser.js
Normal file
File diff suppressed because it is too large
Load Diff
127
html/ui/style.css
Normal file
127
html/ui/style.css
Normal file
@ -0,0 +1,127 @@
|
||||
body {
|
||||
background: white;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
a[href], a[href]:visited, button.link {
|
||||
border: none;
|
||||
color: blue;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
border-left: dotted 1pt;
|
||||
border-right: dotted 1pt;
|
||||
border-bottom: dotted 1pt;
|
||||
padding: 2pt 4pt;
|
||||
}
|
||||
tr:first-child > th {
|
||||
border-top: dotted 1pt;
|
||||
}
|
||||
th, tr:last-child > td {
|
||||
border-bottom: solid 1pt;
|
||||
}
|
||||
|
||||
.flat > * { margin-left: 1ex; }
|
||||
.flat > *:first-child { margin-left: 0; }
|
||||
|
||||
.green { color: green; }
|
||||
.red { color: red; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: black;
|
||||
color: orange;
|
||||
}
|
||||
button, input[type=submit] {
|
||||
background: #333;
|
||||
color: #eee;
|
||||
}
|
||||
a[href], button.link {
|
||||
border: none;
|
||||
color: #31b0fa;
|
||||
}
|
||||
|
||||
.red { color: #c00; }
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 2pt solid;
|
||||
margin: 0 0 1em 0;
|
||||
padding: 1ex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
#logo > img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
header .utils > * {
|
||||
margin-left: 1ex;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: rgba(255,0,0,0.2);
|
||||
border: 1pt solid red;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.error .btn-close,
|
||||
.error .code {
|
||||
background: #600;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
align-self: stretch;
|
||||
padding: 1ex 1em;
|
||||
}
|
||||
.error .code {
|
||||
order: 1;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.error .message {
|
||||
order: 2;
|
||||
padding: 1ex 2em;
|
||||
}
|
||||
.error .btn-close {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.sheets {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.sheets > div {
|
||||
margin: 0 1ex;
|
||||
border: 1pt solid;
|
||||
border-radius: 6pt;
|
||||
}
|
||||
.sheets .title {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
padding: 2pt 6pt;
|
||||
background: rgba(127,127,127,0.5);
|
||||
}
|
||||
.sheets .section {
|
||||
padding: 2pt 6pt 2pt 6pt;
|
||||
font-weight: bold;
|
||||
border-top: 1px dotted;
|
||||
}
|
||||
.sheets section {
|
||||
margin: 2pt 6pt 6pt 6pt;
|
||||
}
|
||||
|
18
modd.conf
18
modd.conf
@ -1,10 +1,22 @@
|
||||
**/*.go go.mod go.sum Dockerfile {
|
||||
modd.conf {}
|
||||
|
||||
**/*.go go.mod go.sum {
|
||||
prep: go test ./...
|
||||
prep: go install -trimpath ./cmd/...
|
||||
prep: docker build --build-arg GOPROXY=$GOPROXY -t dls .
|
||||
prep: mkdir -p dist
|
||||
prep: go build -o dist/ -trimpath ./...
|
||||
#prep: docker build --build-arg GOPROXY=$GOPROXY -t dls .
|
||||
#daemon +sigterm: /var/lib/direktil/test-run
|
||||
}
|
||||
|
||||
html/**/* {
|
||||
prep: go build -o dist/ -trimpath ./cmd/dkl-local-server
|
||||
}
|
||||
|
||||
dist/dkl-local-server {
|
||||
prep: mkdir -p tmp
|
||||
daemon +sigterm: dist/dkl-local-server -data tmp -auto-unlock test
|
||||
}
|
||||
|
||||
**/*.proto !dist/**/* {
|
||||
prep: for mod in @mods; do protoc -I ./ --go_out=plugins=grpc,paths=source_relative:. $mod; done
|
||||
}
|
||||
|
30
secretstore/io.go
Normal file
30
secretstore/io.go
Normal file
@ -0,0 +1,30 @@
|
||||
package secretstore
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func readFull(in io.Reader, ba []byte) (err error) {
|
||||
_, err = io.ReadFull(in, ba)
|
||||
return
|
||||
}
|
||||
|
||||
func read[T any](in io.Reader) (v T, err error) {
|
||||
err = binary.Read(in, binary.BigEndian, &v)
|
||||
return
|
||||
}
|
||||
|
||||
var readSize = read[uint16]
|
||||
|
||||
func randRead(ba []byte) (err error) {
|
||||
err = readFull(rand.Reader, ba)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("failed to read random bytes: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
7
secretstore/mem.go
Normal file
7
secretstore/mem.go
Normal file
@ -0,0 +1,7 @@
|
||||
package secretstore
|
||||
|
||||
func memzero(ba []byte) {
|
||||
for i := range ba {
|
||||
ba[i] = 0
|
||||
}
|
||||
}
|
68
secretstore/reader.go
Normal file
68
secretstore/reader.go
Normal file
@ -0,0 +1,68 @@
|
||||
package secretstore
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"io"
|
||||
)
|
||||
|
||||
func (s *Store) NewReader(reader io.Reader) (r io.Reader, err error) {
|
||||
iv := [aes.BlockSize]byte{}
|
||||
|
||||
err = readFull(reader, iv[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
r = storeReader{reader, s.NewDecrypter(iv)}
|
||||
return
|
||||
}
|
||||
|
||||
type storeReader struct {
|
||||
reader io.Reader
|
||||
decrypter cipher.Stream
|
||||
}
|
||||
|
||||
func (r storeReader) Read(ba []byte) (n int, err error) {
|
||||
n, err = r.reader.Read(ba)
|
||||
|
||||
if n > 0 {
|
||||
r.decrypter.XORKeyStream(ba[:n], ba[:n])
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) NewWriter(writer io.Writer) (r io.Writer, err error) {
|
||||
iv := [aes.BlockSize]byte{}
|
||||
|
||||
if err = randRead(iv[:]); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = writer.Write(iv[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
r = storeWriter{writer, s.NewEncrypter(iv)}
|
||||
return
|
||||
}
|
||||
|
||||
type storeWriter struct {
|
||||
writer io.Writer
|
||||
encrypter cipher.Stream
|
||||
}
|
||||
|
||||
func (r storeWriter) Write(ba []byte) (n int, err error) {
|
||||
if len(ba) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
encBA := make([]byte, len(ba))
|
||||
r.encrypter.XORKeyStream(encBA, ba)
|
||||
|
||||
n, err = r.writer.Write(encBA)
|
||||
|
||||
return
|
||||
}
|
263
secretstore/secret-store.go
Normal file
263
secretstore/secret-store.go
Normal file
@ -0,0 +1,263 @@
|
||||
package secretstore
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
unlocked bool
|
||||
key [32]byte
|
||||
salt [aes.BlockSize]byte
|
||||
keys []keyEntry
|
||||
}
|
||||
|
||||
type keyEntry struct {
|
||||
hash [64]byte
|
||||
encKey [32]byte
|
||||
}
|
||||
|
||||
func New() (s *Store) {
|
||||
s = &Store{}
|
||||
syscall.Mlock(s.key[:])
|
||||
return
|
||||
}
|
||||
|
||||
func Open(path string) (s *Store, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
s = New()
|
||||
_, err = s.ReadFrom(bufio.NewReader(f))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) SaveTo(path string) (err error) {
|
||||
f, err := os.OpenFile(path, syscall.O_CREAT|syscall.O_TRUNC|syscall.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
out := bufio.NewWriter(f)
|
||||
|
||||
_, err = s.WriteTo(out)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = out.Flush()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
memzero(s.key[:])
|
||||
syscall.Munlock(s.key[:])
|
||||
s.unlocked = false
|
||||
}
|
||||
|
||||
func (s *Store) IsNew() bool {
|
||||
return len(s.keys) == 0
|
||||
}
|
||||
|
||||
func (s *Store) Unlocked() bool {
|
||||
return s.unlocked
|
||||
}
|
||||
|
||||
func (s *Store) Init(passphrase []byte) (err error) {
|
||||
err = randRead(s.key[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = randRead(s.salt[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.AddKey(passphrase)
|
||||
|
||||
s.unlocked = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) ReadFrom(in io.Reader) (n int64, err error) {
|
||||
memzero(s.key[:])
|
||||
s.unlocked = false
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
log.Output(2, fmt.Sprintf("failed after %d bytes", n))
|
||||
}
|
||||
}()
|
||||
|
||||
readFull := func(ba []byte) {
|
||||
var nr int
|
||||
nr, err = io.ReadFull(in, ba)
|
||||
n += int64(nr)
|
||||
}
|
||||
|
||||
// read the salt
|
||||
readFull(s.salt[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// read the (encrypted) keys
|
||||
s.keys = make([]keyEntry, 0)
|
||||
for {
|
||||
k := keyEntry{}
|
||||
readFull(k.hash[:])
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
readFull(k.encKey[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.keys = append(s.keys, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) WriteTo(out io.Writer) (n int64, err error) {
|
||||
write := func(ba []byte) {
|
||||
var nr int
|
||||
nr, err = out.Write(ba)
|
||||
n += int64(nr)
|
||||
}
|
||||
|
||||
write(s.salt[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, k := range s.keys {
|
||||
write(k.hash[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
write(k.encKey[:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var ErrNoSuchKey = errors.New("no such key")
|
||||
|
||||
func (s *Store) Unlock(passphrase []byte) (ok bool) {
|
||||
key, hash := s.keyPairFromPassword(passphrase)
|
||||
memzero(passphrase)
|
||||
defer memzero(key[:])
|
||||
|
||||
var idx = -1
|
||||
for i := range s.keys {
|
||||
if hash == s.keys[i].hash {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if idx == -1 {
|
||||
return
|
||||
}
|
||||
|
||||
s.decryptTo(s.key[:], s.keys[idx].encKey[:], &key)
|
||||
|
||||
s.unlocked = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Store) AddKey(passphrase []byte) {
|
||||
key, hash := s.keyPairFromPassword(passphrase)
|
||||
memzero(passphrase)
|
||||
|
||||
defer memzero(key[:])
|
||||
|
||||
k := keyEntry{hash: hash}
|
||||
|
||||
encKey := s.encrypt(s.key[:], &key)
|
||||
copy(k.encKey[:], encKey)
|
||||
|
||||
s.keys = append(s.keys, k)
|
||||
}
|
||||
|
||||
func (s *Store) keyPairFromPassword(password []byte) (key [32]byte, hash [64]byte) {
|
||||
keySlice := argon2.IDKey(password, s.salt[:], 1, 64*1024, 4, 32)
|
||||
|
||||
copy(key[:], keySlice)
|
||||
memzero(keySlice)
|
||||
|
||||
hash = sha512.Sum512(key[:])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) NewEncrypter(iv [aes.BlockSize]byte) cipher.Stream {
|
||||
if !s.unlocked {
|
||||
panic("not unlocked")
|
||||
}
|
||||
return newEncrypter(iv, &s.key)
|
||||
}
|
||||
|
||||
func (s *Store) NewDecrypter(iv [aes.BlockSize]byte) cipher.Stream {
|
||||
if !s.unlocked {
|
||||
panic("not unlocked")
|
||||
}
|
||||
return newDecrypter(iv, &s.key)
|
||||
}
|
||||
|
||||
func (s *Store) encrypt(src []byte, key *[32]byte) (dst []byte) {
|
||||
dst = make([]byte, len(src))
|
||||
newEncrypter(s.salt, key).XORKeyStream(dst, src)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) decryptTo(dst []byte, src []byte, key *[32]byte) {
|
||||
newDecrypter(s.salt, key).XORKeyStream(dst, src)
|
||||
}
|
||||
|
||||
func newEncrypter(iv [aes.BlockSize]byte, key *[32]byte) cipher.Stream {
|
||||
c, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to init AES: %w", err))
|
||||
}
|
||||
|
||||
return cipher.NewCFBEncrypter(c, iv[:])
|
||||
}
|
||||
|
||||
func newDecrypter(iv [aes.BlockSize]byte, key *[32]byte) cipher.Stream {
|
||||
c, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to init AES: %w", err))
|
||||
}
|
||||
|
||||
return cipher.NewCFBDecrypter(c, iv[:])
|
||||
}
|
Loading…
Reference in New Issue
Block a user