Add users and network settings tabs with admin protections.
List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type encryptedBlob struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func loadEncryptedJSON(dir string, fileName string, key []byte, destination any) error {
|
||||
path := filepath.Join(dir, fileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", fileName, err)
|
||||
}
|
||||
|
||||
var blob encryptedBlob
|
||||
if err := json.Unmarshal(payload, &blob); err != nil {
|
||||
return fmt.Errorf("parse %s envelope: %w", fileName, err)
|
||||
}
|
||||
|
||||
nonce, err := base64.StdEncoding.DecodeString(blob.Nonce)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode nonce: %w", err)
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(blob.Ciphertext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode ciphertext: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := open(nonce, ciphertext, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(plaintext, destination); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", fileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveEncryptedJSON(dir string, fileName string, key []byte, value any) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plaintext, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", fileName, 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 %s envelope: %w", fileName, err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, fileName)
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", fileName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ListenHost returns the configured listen IP for binding, defaulting to 0.0.0.0.
|
||||
func ListenHost(dir string) string {
|
||||
payload, err := LoadSettings(dir)
|
||||
if err != nil {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
network := EffectiveNetwork(payload.Network)
|
||||
host := network.ListenAddress
|
||||
if host == "" {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
if host != "0.0.0.0" && host != "::" && host != "localhost" && net.ParseIP(host) == nil {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// SettingsPath returns the path to settings.json in dir.
|
||||
func SettingsPath(dir string) string {
|
||||
return filepath.Join(dir, SettingsFileName)
|
||||
}
|
||||
@@ -12,8 +12,10 @@ const (
|
||||
ConfigDirEnvVar = "CLUSTERCANVAS_CONFIG_DIR"
|
||||
ConfigKeyEnvVar = "CLUSTERCANVAS_CONFIG_KEY"
|
||||
|
||||
SettingsFileName = "settings.json"
|
||||
SecretsFileName = "secrets.enc"
|
||||
SettingsFileName = "settings.json"
|
||||
SecretsFileName = "secrets.enc"
|
||||
PasswordsFileName = "passwords.enc"
|
||||
SessionsFileName = "sessions.enc"
|
||||
)
|
||||
|
||||
// ResolveDir returns the config directory using precedence:
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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)
|
||||
@@ -24,67 +17,78 @@ func KeyFromEnv() ([]byte, error) {
|
||||
|
||||
// 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)
|
||||
if err := loadEncryptedJSON(dir, SecretsFileName, key, &secrets); err != nil {
|
||||
return Secrets{}, 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
|
||||
return saveEncryptedJSON(dir, SecretsFileName, key, secrets)
|
||||
}
|
||||
|
||||
// LoadPasswords decrypts passwords.enc from dir using key.
|
||||
func LoadPasswords(dir string, key []byte) (PasswordStore, error) {
|
||||
var store PasswordStore
|
||||
if err := loadEncryptedJSON(dir, PasswordsFileName, key, &store); err != nil {
|
||||
return PasswordStore{}, err
|
||||
}
|
||||
if store.Users == nil {
|
||||
store.Users = []UserCredential{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadPasswordsOrEmpty returns an empty store when passwords.enc is missing.
|
||||
func LoadPasswordsOrEmpty(dir string, key []byte) (PasswordStore, error) {
|
||||
store, err := LoadPasswords(dir, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return PasswordStore{Users: []UserCredential{}}, nil
|
||||
}
|
||||
return PasswordStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SavePasswords encrypts the password store and writes passwords.enc.
|
||||
func SavePasswords(dir string, store PasswordStore, key []byte) error {
|
||||
if store.Users == nil {
|
||||
store.Users = []UserCredential{}
|
||||
}
|
||||
return saveEncryptedJSON(dir, PasswordsFileName, key, store)
|
||||
}
|
||||
|
||||
// LoadSessions decrypts sessions.enc from dir using key.
|
||||
func LoadSessions(dir string, key []byte) (SessionStore, error) {
|
||||
var store SessionStore
|
||||
if err := loadEncryptedJSON(dir, SessionsFileName, key, &store); err != nil {
|
||||
return SessionStore{}, err
|
||||
}
|
||||
if store.Sessions == nil {
|
||||
store.Sessions = []SessionRecord{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadSessionsOrEmpty returns an empty store when sessions.enc is missing.
|
||||
func LoadSessionsOrEmpty(dir string, key []byte) (SessionStore, error) {
|
||||
store, err := LoadSessions(dir, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return SessionStore{Sessions: []SessionRecord{}}, nil
|
||||
}
|
||||
return SessionStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveSessions encrypts the session store and writes sessions.enc.
|
||||
func SaveSessions(dir string, store SessionStore, key []byte) error {
|
||||
if store.Sessions == nil {
|
||||
store.Sessions = []SessionRecord{}
|
||||
}
|
||||
return saveEncryptedJSON(dir, SessionsFileName, key, store)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsSetupCompleted reports whether first-run setup has finished.
|
||||
func IsSetupCompleted(dir string) (bool, error) {
|
||||
payload, err := LoadSettings(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return payload.SetupCompleted, nil
|
||||
}
|
||||
|
||||
// EffectiveNetwork returns network settings with defaults applied.
|
||||
func EffectiveNetwork(network NetworkSettings) NetworkSettings {
|
||||
defaults := DefaultNetworkSettings()
|
||||
if network.ListenAddress == "" {
|
||||
network.ListenAddress = defaults.ListenAddress
|
||||
}
|
||||
network.PublicHostname = strings.TrimSpace(network.PublicHostname)
|
||||
if network.AccessMode == "" {
|
||||
network.AccessMode = defaults.AccessMode
|
||||
}
|
||||
if network.Rules == nil {
|
||||
network.Rules = []string{}
|
||||
}
|
||||
return network
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package settings
|
||||
|
||||
import "time"
|
||||
|
||||
// GroupScopeKind identifies what a group scopes to.
|
||||
type GroupScopeKind string
|
||||
|
||||
@@ -8,6 +10,10 @@ const (
|
||||
GroupScopeGroup GroupScopeKind = "group"
|
||||
)
|
||||
|
||||
// AdministratorsGroupName is the built-in admin group created at setup.
|
||||
// At least one user must remain in this group; the group itself cannot be deleted.
|
||||
const AdministratorsGroupName = "Administrators"
|
||||
|
||||
// Group configures a named permission set for a given scope target.
|
||||
//
|
||||
// Groups are persisted in settings.json and later used by authz middleware.
|
||||
@@ -38,14 +44,75 @@ func DefaultSecuritySettings() SecuritySettings {
|
||||
}
|
||||
}
|
||||
|
||||
// AccessMode controls IP allow/deny lists.
|
||||
type AccessMode string
|
||||
|
||||
const (
|
||||
AccessModeOpen AccessMode = "open"
|
||||
AccessModeWhitelist AccessMode = "whitelist"
|
||||
AccessModeBlacklist AccessMode = "blacklist"
|
||||
)
|
||||
|
||||
// NetworkSettings holds listen bind and access-control configuration.
|
||||
type NetworkSettings struct {
|
||||
ListenAddress string `json:"listen_address"`
|
||||
PublicHostname string `json:"public_hostname"`
|
||||
AccessMode AccessMode `json:"access_mode"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
|
||||
// DefaultNetworkSettings returns built-in network defaults.
|
||||
func DefaultNetworkSettings() NetworkSettings {
|
||||
return NetworkSettings{
|
||||
ListenAddress: "0.0.0.0",
|
||||
PublicHostname: "",
|
||||
AccessMode: AccessModeOpen,
|
||||
Rules: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Settings holds plain (non-secret) base/CLI configuration.
|
||||
type Settings struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
Groups []Group `json:"groups"`
|
||||
Security SecuritySettings `json:"security"`
|
||||
SetupCompleted bool `json:"setup_completed"`
|
||||
LogLevel string `json:"log_level"`
|
||||
Groups []Group `json:"groups"`
|
||||
Security SecuritySettings `json:"security"`
|
||||
Network NetworkSettings `json:"network"`
|
||||
}
|
||||
|
||||
// Secrets holds sensitive configuration encrypted at rest.
|
||||
type Secrets struct {
|
||||
// Placeholder until Proxmox/API secrets exist.
|
||||
}
|
||||
|
||||
// UserCredential is one account stored in passwords.enc.
|
||||
type UserCredential struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
TOTPSecret string `json:"totp_secret"`
|
||||
TOTPConfirmed bool `json:"totp_confirmed"`
|
||||
Enabled bool `json:"enabled"`
|
||||
GroupNames []string `json:"group_names"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
PasswordChangedAt time.Time `json:"password_changed_at"`
|
||||
}
|
||||
|
||||
// PasswordStore is the plaintext JSON payload inside passwords.enc.
|
||||
type PasswordStore struct {
|
||||
Users []UserCredential `json:"users"`
|
||||
}
|
||||
|
||||
// SessionRecord is one server-side session stored in sessions.enc.
|
||||
type SessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// SessionStore is the plaintext JSON payload inside sessions.enc.
|
||||
type SessionStore struct {
|
||||
Sessions []SessionRecord `json:"sessions"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user