downloads API, UI
This commit is contained in:
@ -26,11 +26,32 @@ func authorizeToken(r *http.Request, token string) bool {
|
||||
}
|
||||
|
||||
reqToken := r.Header.Get("Authorization")
|
||||
if reqToken != "" {
|
||||
return reqToken == "Bearer "+token
|
||||
}
|
||||
|
||||
return reqToken == "Bearer "+token
|
||||
return r.URL.Query().Get("token") == token
|
||||
}
|
||||
|
||||
func forbidden(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("denied access to %s from %s", r.RequestURI, r.RemoteAddr)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
}
|
||||
|
||||
func requireToken(token string, handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if !authorizeToken(req, token) {
|
||||
forbidden(w, req)
|
||||
return
|
||||
}
|
||||
handler.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
func requireAdmin(handler http.Handler) http.Handler {
|
||||
return requireToken(*adminToken, handler)
|
||||
}
|
||||
|
||||
func requireHosts(handler http.Handler) http.Handler {
|
||||
return requireToken(*hostsToken, handler)
|
||||
}
|
||||
|
@ -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")
|
||||
|
Reference in New Issue
Block a user