Base webui and backend additions

This commit is contained in:
2026-07-16 21:35:38 +02:00
parent d2b92401f1
commit 324367abd7
18 changed files with 1231 additions and 43 deletions
+15
View File
@@ -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"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
)
const Version = "0.1.0"
const Version = "0.0.1"
type healthResponse struct {
Status string `json:"status"`
+59
View File
@@ -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
}
+64
View File
@@ -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
}
+90
View File
@@ -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
}
+42
View File
@@ -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
}
+152
View File
@@ -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")
}
}
+11
View File
@@ -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.
}