store download & add key
This commit is contained in:
		| @ -12,13 +12,15 @@ var ( | ||||
| ) | ||||
|  | ||||
| func casCleaner() { | ||||
| 	for { | ||||
| 	for range time.Tick(*cacheCleanDelay) { | ||||
| 		if !wPublicState.Get().Store.Open { | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		err := cleanCAS() | ||||
| 		if err != nil { | ||||
| 			log.Print("warn: couldn't clean cache: ", err) | ||||
| 		} | ||||
|  | ||||
| 		time.Sleep(*cacheCleanDelay) | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -6,4 +6,7 @@ import ( | ||||
| 	"m.cluseau.fr/go/httperr" | ||||
| ) | ||||
|  | ||||
| var ErrNotFound = httperr.NewStd(404, http.StatusNotFound, "not found") | ||||
| var ( | ||||
| 	ErrNotFound     = httperr.NewStd(404, http.StatusNotFound, "not found") | ||||
| 	ErrInvalidToken = httperr.NewStd(403, http.StatusForbidden, "invalid token") | ||||
| ) | ||||
|  | ||||
| @ -53,7 +53,7 @@ func main() { | ||||
| 		if autoUnlock != "" { | ||||
| 			log.Printf("auto-unlocking the store") | ||||
| 			err := unlockSecretStore([]byte(autoUnlock)) | ||||
| 			if err != nil { | ||||
| 			if err.Any() { | ||||
| 				log.Fatal(err) | ||||
| 			} | ||||
|  | ||||
|  | ||||
| @ -1,14 +1,12 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| 	"encoding/base32" | ||||
| 	"encoding/json" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
|     "sort" | ||||
| 	"sort" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
|  | ||||
| @ -20,7 +18,8 @@ import ( | ||||
|  | ||||
| var secStore *secretstore.Store | ||||
|  | ||||
| func secStorePath(name string) string { return filepath.Join(*dataDir, "secrets", name) } | ||||
| func secStoreRoot() string            { return filepath.Join(*dataDir, "secrets") } | ||||
| func secStorePath(name string) string { return filepath.Join(secStoreRoot(), name) } | ||||
| func secKeysStorePath() string        { return secStorePath(".keys") } | ||||
|  | ||||
| func openSecretStore() { | ||||
| @ -64,7 +63,7 @@ var ( | ||||
| 	ErrInvalidPassphrase    = httperr.NewStd(http.StatusBadRequest, 2, "invalid passphrase") | ||||
| ) | ||||
|  | ||||
| func unlockSecretStore(passphrase []byte) *httperr.Error { | ||||
| func unlockSecretStore(passphrase []byte) (err httperr.Error) { | ||||
| 	unlockMutex.Lock() | ||||
| 	defer unlockMutex.Unlock() | ||||
|  | ||||
| @ -75,7 +74,7 @@ func unlockSecretStore(passphrase []byte) *httperr.Error { | ||||
| 	if secStore.IsNew() { | ||||
| 		err := secStore.Init(passphrase) | ||||
| 		if err != nil { | ||||
| 			return httperr.New(http.StatusInternalServerError, err) | ||||
| 			return httperr.Internal(err) | ||||
| 		} | ||||
|  | ||||
| 		err = secStore.SaveTo(secKeysStorePath()) | ||||
| @ -83,7 +82,7 @@ func unlockSecretStore(passphrase []byte) *httperr.Error { | ||||
| 			log.Print("secret store save error: ", err) | ||||
| 			secStore.Close() | ||||
|  | ||||
| 			return httperr.New(http.StatusInternalServerError, err) | ||||
| 			return httperr.Internal(err) | ||||
| 		} | ||||
|  | ||||
| 	} else { | ||||
| @ -98,25 +97,21 @@ func unlockSecretStore(passphrase []byte) *httperr.Error { | ||||
| 			log.Print("failed to read admin token: ", err) | ||||
| 			secStore.Close() | ||||
|  | ||||
| 			return httperr.New(http.StatusInternalServerError, err) | ||||
| 			return httperr.Internal(err) | ||||
| 		} | ||||
|  | ||||
| 		randBytes := make([]byte, 32) | ||||
| 		_, err := rand.Read(randBytes) | ||||
| 		token, err = newToken(32) | ||||
| 		if err != nil { | ||||
| 			log.Print("rand read error: ", err) | ||||
| 			secStore.Close() | ||||
|  | ||||
| 			return httperr.New(http.StatusInternalServerError, err) | ||||
| 			return httperr.Internal(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) | ||||
| 			return httperr.Internal(err) | ||||
| 		} | ||||
|  | ||||
| 		log.Print("wrote new admin token") | ||||
| @ -124,6 +119,18 @@ func unlockSecretStore(passphrase []byte) *httperr.Error { | ||||
|  | ||||
| 	*adminToken = token | ||||
|  | ||||
| 	{ | ||||
| 		token, err := newToken(16) | ||||
| 		if err != nil { | ||||
| 			secStore.Close() | ||||
| 			return httperr.Internal(err) | ||||
| 		} | ||||
|  | ||||
| 		wState.Change(func(v *State) { | ||||
| 			v.Store.DownloadToken = token | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	wPublicState.Change(func(v *PublicState) { | ||||
| 		v.Store.New = false | ||||
| 		v.Store.Open = true | ||||
| @ -132,7 +139,7 @@ func unlockSecretStore(passphrase []byte) *httperr.Error { | ||||
| 	go updateState() | ||||
| 	go migrateSecrets() | ||||
|  | ||||
| 	return nil | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func readSecret(name string, value any) (err error) { | ||||
| @ -179,12 +186,12 @@ func writeSecret(name string, value any) (err error) { | ||||
| 	} | ||||
|  | ||||
| 	err = os.Rename(f.Name(), secStorePath(name+".data")) | ||||
|     if err != nil { | ||||
|     return | ||||
|     } | ||||
| 	if err != nil { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
|     go updateState() | ||||
|     return | ||||
| 	go updateState() | ||||
| 	return | ||||
| } | ||||
|  | ||||
| var secL sync.Mutex | ||||
| @ -255,7 +262,7 @@ func (s KVSecrets[T]) Keys(prefix string) (keys []string, err error) { | ||||
| 		keys = append(keys, k[len(prefix):]) | ||||
| 	} | ||||
|  | ||||
|     sort.Strings(keys) | ||||
| 	sort.Strings(keys) | ||||
|  | ||||
| 	return | ||||
| } | ||||
|  | ||||
| @ -20,6 +20,10 @@ var wPublicState = watchable.New[PublicState]() | ||||
| type State struct { | ||||
| 	HasConfig bool | ||||
|  | ||||
| 	Store struct { | ||||
| 		DownloadToken string | ||||
| 	} | ||||
|  | ||||
| 	Clusters []ClusterState | ||||
| 	Hosts    []HostState | ||||
| 	Config   *localconfig.Config | ||||
|  | ||||
							
								
								
									
										24
									
								
								cmd/dkl-local-server/token.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								cmd/dkl-local-server/token.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| 	"encoding/base32" | ||||
| 	"log" | ||||
| 	"net/http" | ||||
|  | ||||
| 	"m.cluseau.fr/go/httperr" | ||||
| ) | ||||
|  | ||||
| func newToken(sizeInBytes int) (token string, err error) { | ||||
| 	randBytes := make([]byte, sizeInBytes) | ||||
|  | ||||
| 	_, err = rand.Read(randBytes) | ||||
| 	if err != nil { | ||||
| 		log.Print("rand read error: ", err) | ||||
| 		err = httperr.New(http.StatusInternalServerError, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randBytes) | ||||
| 	return | ||||
| } | ||||
| @ -1,7 +1,12 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"archive/tar" | ||||
| 	"bytes" | ||||
| 	"io" | ||||
| 	"io/fs" | ||||
| 	"net/http" | ||||
| 	"os" | ||||
|  | ||||
| 	restful "github.com/emicklei/go-restful" | ||||
| ) | ||||
| @ -14,10 +19,80 @@ func wsUnlockStore(req *restful.Request, resp *restful.Response) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := unlockSecretStore([]byte(passphrase)); err != nil { | ||||
| 	if err := unlockSecretStore([]byte(passphrase)); err.Any() { | ||||
| 		err.WriteJSON(resp.ResponseWriter) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp.WriteEntity(*adminToken) | ||||
| } | ||||
|  | ||||
| func wsStoreDownload(req *restful.Request, resp *restful.Response) { | ||||
| 	token := req.QueryParameter("token") | ||||
| 	if token != wState.Get().Store.DownloadToken { | ||||
| 		wsError(resp, ErrInvalidToken) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	buf := new(bytes.Buffer) | ||||
| 	arch := tar.NewWriter(buf) | ||||
|  | ||||
| 	root := os.DirFS(secStoreRoot()) | ||||
|  | ||||
| 	err := fs.WalkDir(root, ".", func(path string, d fs.DirEntry, readErr error) (err error) { | ||||
| 		if readErr != nil { | ||||
| 			err = readErr | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if path == "." { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		fi, err := d.Info() | ||||
| 		if err != nil { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		hdr, err := tar.FileInfoHeader(fi, "") | ||||
| 		if err != nil { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		hdr.Name = path | ||||
| 		hdr.Uid = 0 | ||||
| 		hdr.Gid = 0 | ||||
|  | ||||
| 		err = arch.WriteHeader(hdr) | ||||
| 		if err != nil { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		if fi.IsDir() { | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		f, err := root.Open(path) | ||||
| 		if err != nil { | ||||
| 			return | ||||
| 		} | ||||
| 		defer f.Close() | ||||
|  | ||||
| 		io.Copy(arch, f) | ||||
|  | ||||
| 		return | ||||
| 	}) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		wsError(resp, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	err = arch.Close() | ||||
| 	if err != nil { | ||||
| 		wsError(resp, err) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	buf.WriteTo(resp) | ||||
| } | ||||
|  | ||||
							
								
								
									
										27
									
								
								cmd/dkl-local-server/ws-store.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								cmd/dkl-local-server/ws-store.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	restful "github.com/emicklei/go-restful" | ||||
| ) | ||||
|  | ||||
| func wsStoreAddKey(req *restful.Request, resp *restful.Response) { | ||||
| 	var passphrase string | ||||
|  | ||||
| 	err := req.ReadEntity(&passphrase) | ||||
| 	if err != nil { | ||||
| 		wsBadRequest(resp, err.Error()) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if len(passphrase) == 0 { | ||||
| 		wsBadRequest(resp, "no passphrase given") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	secStore.AddKey([]byte(passphrase)) | ||||
| 	err = secStore.SaveTo(secKeysStorePath()) | ||||
| 	if err != nil { | ||||
| 		wsError(resp, err) | ||||
| 		return | ||||
| 	} | ||||
| } | ||||
| @ -1,6 +1,7 @@ | ||||
| package main | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"log" | ||||
| 	"net" | ||||
| @ -10,6 +11,7 @@ import ( | ||||
|  | ||||
| 	cfsslconfig "github.com/cloudflare/cfssl/config" | ||||
| 	"github.com/emicklei/go-restful" | ||||
| 	"m.cluseau.fr/go/httperr" | ||||
|  | ||||
| 	"novit.tech/direktil/pkg/localconfig" | ||||
|  | ||||
| @ -28,6 +30,10 @@ func registerWS(rest *restful.Container) { | ||||
| 				Reads(""). | ||||
| 				Writes(""). | ||||
| 				Doc("Try to unlock the store")). | ||||
| 			Route(ws.GET("/store.tar").To(wsStoreDownload). | ||||
| 				Produces(mime.TAR). | ||||
| 				Param(ws.QueryParameter("token", "the download token")). | ||||
| 				Doc("Fetch the encrypted store")). | ||||
| 			Route(ws.GET("/downloads/{token}/{asset}").To(wsDownload). | ||||
| 				Param(ws.PathParameter("token", "the download token")). | ||||
| 				Param(ws.PathParameter("asset", "the requested asset")). | ||||
| @ -42,12 +48,21 @@ func registerWS(rest *restful.Container) { | ||||
| 		Filter(adminAuth). | ||||
| 		HeaderParameter("Authorization", "Admin bearer token") | ||||
|  | ||||
| 	// - store management | ||||
| 	ws.Route(ws.POST("/store/add-key").To(wsStoreAddKey). | ||||
| 		Consumes("application/json").Reads(""). | ||||
| 		Produces("application/json"). | ||||
| 		Doc("Add an unlock key to the store")) | ||||
|  | ||||
| 	// - downloads | ||||
| 	ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload). | ||||
| 		Consumes("application/json").Reads(DownloadSpec{}). | ||||
| 		Produces("application/json"). | ||||
| 		Doc("Create a download token for the given download")) | ||||
|  | ||||
| 	// - configs API | ||||
| 	ws.Route(ws.POST("/configs").To(wsUploadConfig). | ||||
| 		Consumes(mime.YAML). | ||||
| 		Doc("Upload a new current configuration, archiving the previous one")) | ||||
|  | ||||
| 	// - clusters API | ||||
| @ -180,11 +195,20 @@ func wsNotFound(req *restful.Request, resp *restful.Response) { | ||||
| 	http.NotFound(resp.ResponseWriter, req.Request) | ||||
| } | ||||
|  | ||||
| func wsBadRequest(resp *restful.Response, err string) { | ||||
| 	httperr.New(http.StatusBadRequest, errors.New(err)).WriteJSON(resp.ResponseWriter) | ||||
| } | ||||
|  | ||||
| func wsError(resp *restful.Response, err error) { | ||||
| 	log.Output(2, fmt.Sprint("request failed: ", err)) | ||||
| 	resp.WriteErrorString( | ||||
| 		http.StatusInternalServerError, | ||||
| 		http.StatusText(http.StatusInternalServerError)) | ||||
|  | ||||
| 	switch err := err.(type) { | ||||
| 	case httperr.Error: | ||||
| 		err.WriteJSON(resp.ResponseWriter) | ||||
|  | ||||
| 	default: | ||||
| 		httperr.Internal(err).WriteJSON(resp.ResponseWriter) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user