Experimental gui.

This commit is contained in:
2025-06-26 08:28:52 -07:00
parent 55b052256a
commit c855786e61
99 changed files with 11664 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config represents the main configuration structure
type Config struct {
Wildcloud struct {
Repository string `yaml:"repository" json:"repository"`
CurrentPhase string `yaml:"currentPhase" json:"currentPhase"`
CompletedPhases []string `yaml:"completedPhases" json:"completedPhases"`
} `yaml:"wildcloud" json:"wildcloud"`
Server struct {
Port int `yaml:"port" json:"port"`
Host string `yaml:"host" json:"host"`
} `yaml:"server" json:"server"`
Cloud struct {
Domain string `yaml:"domain" json:"domain"`
InternalDomain string `yaml:"internalDomain" json:"internalDomain"`
DNS struct {
IP string `yaml:"ip" json:"ip"`
} `yaml:"dns" json:"dns"`
Router struct {
IP string `yaml:"ip" json:"ip"`
} `yaml:"router" json:"router"`
DHCPRange string `yaml:"dhcpRange" json:"dhcpRange"`
Dnsmasq struct {
Interface string `yaml:"interface" json:"interface"`
} `yaml:"dnsmasq" json:"dnsmasq"`
} `yaml:"cloud" json:"cloud"`
Cluster struct {
EndpointIP string `yaml:"endpointIp" json:"endpointIp"`
Nodes struct {
Talos struct {
Version string `yaml:"version" json:"version"`
} `yaml:"talos" json:"talos"`
} `yaml:"nodes" json:"nodes"`
} `yaml:"cluster" json:"cluster"`
}
// Load loads configuration from the specified path
func Load(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
config := &Config{}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
// Set defaults
if config.Server.Port == 0 {
config.Server.Port = 5055
}
if config.Server.Host == "" {
config.Server.Host = "0.0.0.0"
}
return config, nil
}
// Save saves the configuration to the specified path
func Save(config *Config, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
// IsEmpty checks if the configuration is empty or uninitialized
func (c *Config) IsEmpty() bool {
if c == nil {
return true
}
// Check if any essential fields are empty
return c.Cloud.Domain == "" ||
c.Cloud.DNS.IP == "" ||
c.Cluster.Nodes.Talos.Version == ""
}

View File

@@ -0,0 +1,130 @@
package data
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
// Paths represents the data directory paths configuration
type Paths struct {
ConfigFile string
DataDir string
LogsDir string
AssetsDir string
DnsmasqConf string
}
// Manager handles data directory management
type Manager struct {
dataDir string
isDev bool
}
// NewManager creates a new data manager
func NewManager() *Manager {
return &Manager{}
}
// Initialize sets up the data directory structure
func (m *Manager) Initialize() error {
// Detect environment: development vs production
m.isDev = m.isDevelopmentMode()
var dataDir string
if m.isDev {
// Development mode: use .wildcloud in current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
dataDir = filepath.Join(cwd, ".wildcloud")
log.Printf("Running in development mode, using data directory: %s", dataDir)
} else {
// Production mode: use standard Linux directories
dataDir = "/var/lib/wild-cloud-central"
log.Printf("Running in production mode, using data directory: %s", dataDir)
}
m.dataDir = dataDir
// Create directory structure
paths := m.GetPaths()
// Create all necessary directories
for _, dir := range []string{paths.DataDir, paths.LogsDir, paths.AssetsDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
log.Printf("Data directory structure initialized at: %s", dataDir)
return nil
}
// isDevelopmentMode detects if we're running in development mode
func (m *Manager) isDevelopmentMode() bool {
// Check multiple indicators for development mode
// 1. Check if GO_ENV is set to development
if env := os.Getenv("GO_ENV"); env == "development" {
return true
}
// 2. Check if running as systemd service (has INVOCATION_ID)
if os.Getenv("INVOCATION_ID") != "" {
return false // Running under systemd
}
// 3. Check if running from a typical development location
if exe, err := os.Executable(); err == nil {
// If executable is in current directory or contains "wild-central" without being in /usr/bin
if strings.Contains(exe, "/usr/bin") || strings.Contains(exe, "/usr/local/bin") {
return false
}
if filepath.Base(exe) == "wild-central" && !strings.HasPrefix(exe, "/") {
return true
}
}
// 4. Check if we can write to /var/lib (if not, probably development)
if _, err := os.Stat("/var/lib"); err != nil {
return true
}
// 5. Default to development if uncertain
return true
}
// GetPaths returns the appropriate paths for the current environment
func (m *Manager) GetPaths() Paths {
if m.isDev {
return Paths{
ConfigFile: filepath.Join(m.dataDir, "config.yaml"),
DataDir: m.dataDir,
LogsDir: filepath.Join(m.dataDir, "logs"),
AssetsDir: filepath.Join(m.dataDir, "assets"),
DnsmasqConf: filepath.Join(m.dataDir, "dnsmasq.conf"),
}
} else {
return Paths{
ConfigFile: "/etc/wild-cloud-central/config.yaml",
DataDir: m.dataDir,
LogsDir: "/var/log/wild-cloud-central",
AssetsDir: "/var/www/html/wild-central",
DnsmasqConf: "/etc/dnsmasq.conf",
}
}
}
// GetDataDir returns the current data directory
func (m *Manager) GetDataDir() string {
return m.dataDir
}
// IsDevelopment returns true if running in development mode
func (m *Manager) IsDevelopment() bool {
return m.isDev
}

View File

@@ -0,0 +1,97 @@
package dnsmasq
import (
"fmt"
"log"
"os"
"os/exec"
"wild-cloud-central/internal/config"
)
// ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct{}
// NewConfigGenerator creates a new dnsmasq config generator
func NewConfigGenerator() *ConfigGenerator {
return &ConfigGenerator{}
}
// Generate creates a dnsmasq configuration from the app config
func (g *ConfigGenerator) Generate(cfg *config.Config) string {
template := `# Configuration file for dnsmasq.
# Basic Settings
interface=%s
listen-address=%s
domain-needed
bogus-priv
no-resolv
# DNS Local Resolution - Central server handles these domains authoritatively
local=/%s/
address=/%s/%s
local=/%s/
address=/%s/%s
server=1.1.1.1
server=8.8.8.8
# --- DHCP Settings ---
dhcp-range=%s,12h
dhcp-option=3,%s
dhcp-option=6,%s
# --- PXE Booting ---
enable-tftp
tftp-root=/var/ftpd
dhcp-match=set:efi-x86_64,option:client-arch,7
dhcp-boot=tag:efi-x86_64,ipxe.efi
dhcp-boot=tag:!efi-x86_64,undionly.kpxe
dhcp-match=set:efi-arm64,option:client-arch,11
dhcp-boot=tag:efi-arm64,ipxe-arm64.efi
dhcp-userclass=set:ipxe,iPXE
dhcp-boot=tag:ipxe,http://%s/boot.ipxe
log-queries
log-dhcp
`
return fmt.Sprintf(template,
cfg.Cloud.Dnsmasq.Interface,
cfg.Cloud.DNS.IP,
cfg.Cloud.Domain,
cfg.Cloud.Domain,
cfg.Cluster.EndpointIP,
cfg.Cloud.InternalDomain,
cfg.Cloud.InternalDomain,
cfg.Cluster.EndpointIP,
cfg.Cloud.DHCPRange,
cfg.Cloud.Router.IP,
cfg.Cloud.DNS.IP,
cfg.Cloud.DNS.IP,
)
}
// WriteConfig writes the dnsmasq configuration to the specified path
func (g *ConfigGenerator) WriteConfig(cfg *config.Config, configPath string) error {
configContent := g.Generate(cfg)
log.Printf("Writing dnsmasq config to: %s", configPath)
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("writing dnsmasq config: %w", err)
}
return nil
}
// RestartService restarts the dnsmasq service
func (g *ConfigGenerator) RestartService() error {
cmd := exec.Command("sudo", "/usr/bin/systemctl", "restart", "dnsmasq.service")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to restart dnsmasq: %w", err)
}
return nil
}

View File

@@ -0,0 +1,45 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
)
// GetDnsmasqConfigHandler handles requests to view the dnsmasq configuration
func (app *App) GetDnsmasqConfigHandler(w http.ResponseWriter, r *http.Request) {
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration available. Please configure the system first.", http.StatusPreconditionFailed)
return
}
config := app.DnsmasqManager.Generate(app.Config)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(config))
}
// RestartDnsmasqHandler handles requests to restart the dnsmasq service
func (app *App) RestartDnsmasqHandler(w http.ResponseWriter, r *http.Request) {
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration available. Please configure the system first.", http.StatusPreconditionFailed)
return
}
// Update dnsmasq config first
paths := app.DataManager.GetPaths()
if err := app.DnsmasqManager.WriteConfig(app.Config, paths.DnsmasqConf); err != nil {
log.Printf("Failed to update dnsmasq config: %v", err)
http.Error(w, "Failed to update dnsmasq config", http.StatusInternalServerError)
return
}
// Restart dnsmasq service
if err := app.DnsmasqManager.RestartService(); err != nil {
log.Printf("Failed to restart dnsmasq: %v", err)
http.Error(w, "Failed to restart dnsmasq service", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
}

View File

@@ -0,0 +1,263 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
"wild-cloud-central/internal/config"
"wild-cloud-central/internal/data"
"wild-cloud-central/internal/dnsmasq"
)
// App represents the application with its dependencies
type App struct {
Config *config.Config
StartTime time.Time
DataManager *data.Manager
DnsmasqManager *dnsmasq.ConfigGenerator
}
// NewApp creates a new application instance
func NewApp() *App {
return &App{
StartTime: time.Now(),
DataManager: data.NewManager(),
DnsmasqManager: dnsmasq.NewConfigGenerator(),
}
}
// HealthHandler handles health check requests
func (app *App) HealthHandler(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"status": "healthy",
"service": "wild-cloud-central",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// StatusHandler handles status requests for the UI
func (app *App) StatusHandler(w http.ResponseWriter, r *http.Request) {
uptime := time.Since(app.StartTime)
response := map[string]interface{}{
"status": "running",
"version": "1.0.0",
"uptime": uptime.String(),
"timestamp": time.Now().UnixMilli(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// GetConfigHandler handles configuration retrieval requests
func (app *App) GetConfigHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Always reload config from file on each request
paths := app.DataManager.GetPaths()
cfg, err := config.Load(paths.ConfigFile)
if err != nil {
log.Printf("Failed to load config from file: %v", err)
response := map[string]interface{}{
"configured": false,
"message": "No configuration found. Please POST a configuration to /api/v1/config to get started.",
}
json.NewEncoder(w).Encode(response)
return
}
// Update the cached config with fresh data
app.Config = cfg
// Check if config is empty/uninitialized
if cfg.IsEmpty() {
response := map[string]interface{}{
"configured": false,
"message": "Configuration is incomplete. Please complete the setup.",
}
json.NewEncoder(w).Encode(response)
return
}
response := map[string]interface{}{
"configured": true,
"config": cfg,
}
json.NewEncoder(w).Encode(response)
}
// CreateConfigHandler handles configuration creation requests
func (app *App) CreateConfigHandler(w http.ResponseWriter, r *http.Request) {
// Only allow config creation if no config exists
if app.Config != nil && !app.Config.IsEmpty() {
http.Error(w, "Configuration already exists. Use PUT to update.", http.StatusConflict)
return
}
var newConfig config.Config
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Set defaults
if newConfig.Server.Port == 0 {
newConfig.Server.Port = 5055
}
if newConfig.Server.Host == "" {
newConfig.Server.Host = "0.0.0.0"
}
app.Config = &newConfig
// Persist config to file
paths := app.DataManager.GetPaths()
if err := config.Save(app.Config, paths.ConfigFile); err != nil {
log.Printf("Failed to save config: %v", err)
http.Error(w, "Failed to save config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}
// UpdateConfigHandler handles configuration update requests
func (app *App) UpdateConfigHandler(w http.ResponseWriter, r *http.Request) {
// Check if config exists
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration exists. Use POST to create initial configuration.", http.StatusNotFound)
return
}
var newConfig config.Config
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
app.Config = &newConfig
// Persist config to file
paths := app.DataManager.GetPaths()
if err := config.Save(app.Config, paths.ConfigFile); err != nil {
log.Printf("Failed to save config: %v", err)
http.Error(w, "Failed to save config", http.StatusInternalServerError)
return
}
// Regenerate and apply dnsmasq config
if err := app.DnsmasqManager.WriteConfig(app.Config, paths.DnsmasqConf); err != nil {
log.Printf("Failed to update dnsmasq config: %v", err)
http.Error(w, "Failed to update dnsmasq config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "updated"})
}
// GetConfigYamlHandler handles raw YAML config file retrieval
func (app *App) GetConfigYamlHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
paths := app.DataManager.GetPaths()
// Read the raw config file
yamlContent, err := os.ReadFile(paths.ConfigFile)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Configuration file not found", http.StatusNotFound)
return
}
log.Printf("Failed to read config file: %v", err)
http.Error(w, "Failed to read configuration file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(yamlContent)
}
// UpdateConfigYamlHandler handles raw YAML config file updates
func (app *App) UpdateConfigYamlHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read the raw YAML content from request body
yamlContent, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Failed to read request body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
paths := app.DataManager.GetPaths()
// Write the raw YAML content to file
if err := os.WriteFile(paths.ConfigFile, yamlContent, 0644); err != nil {
log.Printf("Failed to write config file: %v", err)
http.Error(w, "Failed to write configuration file", http.StatusInternalServerError)
return
}
// Try to reload the config to validate it and update the in-memory config
newConfig, err := config.Load(paths.ConfigFile)
if err != nil {
log.Printf("Warning: Saved YAML config but failed to parse it: %v", err)
// File was written but parsing failed - this is a validation warning
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "saved_with_warnings",
"warning": "Configuration saved but contains validation errors: " + err.Error(),
}
json.NewEncoder(w).Encode(response)
return
}
// Update in-memory config if parsing succeeded
app.Config = newConfig
// Try to regenerate dnsmasq config if the new config is valid
if err := app.DnsmasqManager.WriteConfig(app.Config, paths.DnsmasqConf); err != nil {
log.Printf("Warning: Failed to update dnsmasq config: %v", err)
// Config was saved but dnsmasq update failed
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "saved_with_warnings",
"warning": "Configuration saved but failed to update dnsmasq config: " + err.Error(),
}
json.NewEncoder(w).Encode(response)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "updated"})
}
// CORSMiddleware adds CORS headers to responses
func (app *App) CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,138 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
)
// DownloadPXEAssetsHandler handles requests to download PXE boot assets
func (app *App) DownloadPXEAssetsHandler(w http.ResponseWriter, r *http.Request) {
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration available. Please configure the system first.", http.StatusPreconditionFailed)
return
}
if err := app.downloadTalosAssets(); err != nil {
log.Printf("Failed to download PXE assets: %v", err)
http.Error(w, "Failed to download PXE assets", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "downloaded"})
}
// downloadTalosAssets downloads Talos Linux PXE assets
func (app *App) downloadTalosAssets() error {
// Get assets directory from data paths
paths := app.DataManager.GetPaths()
assetsDir := filepath.Join(paths.AssetsDir, "talos")
log.Printf("Downloading Talos assets to: %s", assetsDir)
if err := os.MkdirAll(filepath.Join(assetsDir, "amd64"), 0755); err != nil {
return fmt.Errorf("creating assets directory: %w", err)
}
// Create Talos bare metal configuration (schematic format)
bareMetalConfig := `customization:
extraKernelArgs:
- net.ifnames=0
systemExtensions:
officialExtensions:
- siderolabs/gvisor
- siderolabs/intel-ucode`
// Create Talos schematic
var buf bytes.Buffer
buf.WriteString(bareMetalConfig)
resp, err := http.Post("https://factory.talos.dev/schematics", "text/yaml", &buf)
if err != nil {
return fmt.Errorf("creating Talos schematic: %w", err)
}
defer resp.Body.Close()
var schematic struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&schematic); err != nil {
return fmt.Errorf("decoding schematic response: %w", err)
}
log.Printf("Created Talos schematic with ID: %s", schematic.ID)
// Download kernel
kernelURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/kernel-amd64",
schematic.ID, app.Config.Cluster.Nodes.Talos.Version)
if err := downloadFile(kernelURL, filepath.Join(assetsDir, "amd64", "vmlinuz")); err != nil {
return fmt.Errorf("downloading kernel: %w", err)
}
// Download initramfs
initramfsURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/initramfs-amd64.xz",
schematic.ID, app.Config.Cluster.Nodes.Talos.Version)
if err := downloadFile(initramfsURL, filepath.Join(assetsDir, "amd64", "initramfs.xz")); err != nil {
return fmt.Errorf("downloading initramfs: %w", err)
}
// Create boot.ipxe file
bootScript := fmt.Sprintf(`#!ipxe
imgfree
kernel http://%s/amd64/vmlinuz talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512 selinux=1 net.ifnames=0
initrd http://%s/amd64/initramfs.xz
boot
`, app.Config.Cloud.DNS.IP, app.Config.Cloud.DNS.IP)
if err := os.WriteFile(filepath.Join(assetsDir, "boot.ipxe"), []byte(bootScript), 0644); err != nil {
return fmt.Errorf("writing boot script: %w", err)
}
// Download iPXE bootloaders
tftpDir := filepath.Join(paths.AssetsDir, "tftp")
if err := os.MkdirAll(tftpDir, 0755); err != nil {
return fmt.Errorf("creating tftp directory: %w", err)
}
bootloaders := map[string]string{
"http://boot.ipxe.org/ipxe.efi": filepath.Join(tftpDir, "ipxe.efi"),
"http://boot.ipxe.org/undionly.kpxe": filepath.Join(tftpDir, "undionly.kpxe"),
"http://boot.ipxe.org/arm64-efi/ipxe.efi": filepath.Join(tftpDir, "ipxe-arm64.efi"),
}
for url, path := range bootloaders {
if err := downloadFile(url, path); err != nil {
return fmt.Errorf("downloading %s: %w", url, err)
}
}
log.Printf("Successfully downloaded PXE assets")
return nil
}
// downloadFile downloads a file from a URL to a local path
func downloadFile(url, filepath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}