Base webui and backend additions
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -11,9 +12,23 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/api"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configDirFlag := flag.String(
|
||||
"configdir",
|
||||
"",
|
||||
"config directory (default /etc/ClusterCanvas; overrides CLUSTERCANVAS_CONFIG_DIR)",
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
configDir, err := settings.ResolveDir(*configDirFlag)
|
||||
if err != nil {
|
||||
log.Fatalf("config dir: %v", err)
|
||||
}
|
||||
log.Printf("config directory: %s", configDir)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Version = "0.1.0"
|
||||
const Version = "0.0.1"
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
func seal(plaintext []byte, key []byte) (nonce []byte, ciphertext []byte, err error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
|
||||
nonce = make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, nil, fmt.Errorf("nonce: %w", err)
|
||||
}
|
||||
|
||||
ciphertext = gcm.Seal(nil, nonce, plaintext, nil)
|
||||
return nonce, ciphertext, nil
|
||||
}
|
||||
|
||||
func open(nonce []byte, ciphertext []byte, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gcm: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func decodeKey(base64Key string) ([]byte, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(base64Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode key: %w", err)
|
||||
}
|
||||
if length := len(key); length != 16 && length != 24 && length != 32 {
|
||||
return nil, fmt.Errorf("key must be 16, 24, or 32 bytes after base64 decode, got %d", length)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultConfigDir = "/etc/ClusterCanvas"
|
||||
ConfigDirEnvVar = "CLUSTERCANVAS_CONFIG_DIR"
|
||||
ConfigKeyEnvVar = "CLUSTERCANVAS_CONFIG_KEY"
|
||||
|
||||
SettingsFileName = "settings.json"
|
||||
SecretsFileName = "secrets.enc"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
// 1. flagValue (-configdir), if non-empty
|
||||
// 2. CLUSTERCANVAS_CONFIG_DIR, if set
|
||||
// 3. /etc/ClusterCanvas
|
||||
//
|
||||
// Leading ~/ is expanded to the user home directory.
|
||||
func ResolveDir(flagValue string) (string, error) {
|
||||
candidate := strings.TrimSpace(flagValue)
|
||||
if candidate == "" {
|
||||
candidate = strings.TrimSpace(os.Getenv(ConfigDirEnvVar))
|
||||
}
|
||||
if candidate == "" {
|
||||
candidate = DefaultConfigDir
|
||||
}
|
||||
|
||||
expanded, err := expandHome(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(expanded), nil
|
||||
}
|
||||
|
||||
func expandHome(path string) (string, error) {
|
||||
if path == "~" {
|
||||
homeDirectory, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("expand home directory: %w", err)
|
||||
}
|
||||
return homeDirectory, nil
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
homeDirectory, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("expand home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDirectory, path[2:]), nil
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func ensureConfigDir(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create config dir %q: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type encryptedBlob struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
// KeyFromEnv reads and decodes CLUSTERCANVAS_CONFIG_KEY (base64, 16/24/32 bytes).
|
||||
func KeyFromEnv() ([]byte, error) {
|
||||
rawKey := os.Getenv(ConfigKeyEnvVar)
|
||||
if rawKey == "" {
|
||||
return nil, fmt.Errorf("%s is not set", ConfigKeyEnvVar)
|
||||
}
|
||||
return decodeKey(rawKey)
|
||||
}
|
||||
|
||||
// LoadSecrets decrypts secrets.enc from dir using key.
|
||||
func LoadSecrets(dir string, key []byte) (Secrets, error) {
|
||||
path := filepath.Join(dir, SecretsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Secrets{}, fmt.Errorf("read secrets: %w", err)
|
||||
}
|
||||
|
||||
var blob encryptedBlob
|
||||
if err := json.Unmarshal(payload, &blob); err != nil {
|
||||
return Secrets{}, fmt.Errorf("parse secrets envelope: %w", err)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
|
||||
if err != nil {
|
||||
return Secrets{}, fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
|
||||
if err != nil {
|
||||
return Secrets{}, fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := open(nonce, ciphertext, key)
|
||||
if err != nil {
|
||||
return Secrets{}, err
|
||||
}
|
||||
|
||||
var secrets Secrets
|
||||
if err := json.Unmarshal(plaintext, &secrets); err != nil {
|
||||
return Secrets{}, fmt.Errorf("parse secrets: %w", err)
|
||||
}
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// SaveSecrets encrypts secrets and writes secrets.enc to dir with mode 0600.
|
||||
func SaveSecrets(dir string, secrets Secrets, key []byte) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext, err := json.Marshal(secrets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets: %w", err)
|
||||
}
|
||||
|
||||
nonce, ciphertext, err := seal(plaintext, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blob := encryptedBlob{
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}
|
||||
payload, err := json.MarshalIndent(blob, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets envelope: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, SecretsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write secrets: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadSettings reads and parses settings.json from dir.
|
||||
func LoadSettings(dir string) (Settings, error) {
|
||||
path := filepath.Join(dir, SettingsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("read settings: %w", err)
|
||||
}
|
||||
|
||||
var settings Settings
|
||||
if err := json.Unmarshal(payload, &settings); err != nil {
|
||||
return Settings{}, fmt.Errorf("parse settings: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// SaveSettings writes settings.json to dir with mode 0640.
|
||||
func SaveSettings(dir string, settings Settings) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode settings: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, SettingsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveDirDefault(t *testing.T) {
|
||||
t.Setenv(ConfigDirEnvVar, "")
|
||||
|
||||
resolved, err := ResolveDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
if resolved != DefaultConfigDir {
|
||||
t.Fatalf("got %q, want %q", resolved, DefaultConfigDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirFlagOverridesEnv(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
flagDir := filepath.Join(tempDir, "from-flag")
|
||||
envDir := filepath.Join(tempDir, "from-env")
|
||||
t.Setenv(ConfigDirEnvVar, envDir)
|
||||
|
||||
resolved, err := ResolveDir(flagDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
if resolved != flagDir {
|
||||
t.Fatalf("got %q, want flag dir %q", resolved, flagDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirEnvOverridesDefault(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
envDir := filepath.Join(tempDir, "from-env")
|
||||
t.Setenv(ConfigDirEnvVar, envDir)
|
||||
|
||||
resolved, err := ResolveDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
if resolved != envDir {
|
||||
t.Fatalf("got %q, want env dir %q", resolved, envDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirExpandsTilde(t *testing.T) {
|
||||
homeDirectory, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatalf("UserHomeDir: %v", err)
|
||||
}
|
||||
t.Setenv(ConfigDirEnvVar, "")
|
||||
|
||||
resolved, err := ResolveDir("~/myconfigs")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(homeDirectory, "myconfigs")
|
||||
if resolved != want {
|
||||
t.Fatalf("got %q, want %q", resolved, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
original := Settings{LogLevel: "debug"}
|
||||
|
||||
if err := SaveSettings(dir, original); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSettings(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSettings: %v", err)
|
||||
}
|
||||
if loaded.LogLevel != original.LogLevel {
|
||||
t.Fatalf("got log_level %q, want %q", loaded.LogLevel, original.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func testKey(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
key := make([]byte, 32)
|
||||
for index := range key {
|
||||
key[index] = byte(index + 1)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func TestSecretsRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key := testKey(t)
|
||||
original := Secrets{}
|
||||
|
||||
if err := SaveSecrets(dir, original, key); err != nil {
|
||||
t.Fatalf("SaveSecrets: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadSecrets(dir, key)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSecrets: %v", err)
|
||||
}
|
||||
_ = loaded
|
||||
}
|
||||
|
||||
func TestSecretsWrongKeyFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
goodKey := testKey(t)
|
||||
if err := SaveSecrets(dir, Secrets{}, goodKey); err != nil {
|
||||
t.Fatalf("SaveSecrets: %v", err)
|
||||
}
|
||||
|
||||
badKey := make([]byte, 32)
|
||||
for index := range badKey {
|
||||
badKey[index] = 0xff
|
||||
}
|
||||
|
||||
if _, err := LoadSecrets(dir, badKey); err == nil {
|
||||
t.Fatal("expected decrypt failure with wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnvMissing(t *testing.T) {
|
||||
t.Setenv(ConfigKeyEnvVar, "")
|
||||
if _, err := KeyFromEnv(); err == nil {
|
||||
t.Fatal("expected error when key env is unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnvValid(t *testing.T) {
|
||||
key := testKey(t)
|
||||
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString(key))
|
||||
|
||||
decoded, err := KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
if len(decoded) != 32 {
|
||||
t.Fatalf("got key length %d, want 32", len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFromEnvInvalidLength(t *testing.T) {
|
||||
t.Setenv(ConfigKeyEnvVar, base64.StdEncoding.EncodeToString([]byte("short")))
|
||||
if _, err := KeyFromEnv(); err == nil {
|
||||
t.Fatal("expected error for invalid key length")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package settings
|
||||
|
||||
// Settings holds plain (non-secret) base/CLI configuration.
|
||||
type Settings struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
}
|
||||
|
||||
// Secrets holds sensitive configuration encrypted at rest.
|
||||
type Secrets struct {
|
||||
// Placeholder until Proxmox/API secrets exist.
|
||||
}
|
||||
Reference in New Issue
Block a user