commit 3ec860b67d637abc14a78436c902172798cc84cb Author: cclohmar Date: Sun Jun 14 12:56:33 2026 +0000 feat(env): initialize /opt/ infrastructure framework, config definitions, and git repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9647572 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Binaries +/bin/ +/data/*.db +/data/*.db-wal +/data/*.db-shm + +# OS files +.DS_Store +Thumbs.db + +# Editor/IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env +.env.local + +# Setup check binary +src/setupcheck +src/setupcheck.exe + +# Templ generated +**/*_templ.go diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..1785585 --- /dev/null +++ b/config.yaml @@ -0,0 +1,29 @@ +# Next Workspace (NextWks) Configuration +# Path: /opt/nextwks/config.yaml + +server: + host: "0.0.0.0" + port: 8080 + +admin: + secret_token: "CHANGE_ME_ADMIN_SECRET_TOKEN" + +database: + type: "sqlite" + path: "/opt/nextwks/data/nextwks.db" + +authelia: + host: "http://127.0.0.1:9091" + config_path: "/opt/authelia/config/configuration.yml" + users_db_path: "/opt/authelia/data/users_database.yml" + +smtp: + host: "" + port: 587 + username: "" + password: "" + from: "noreply@nextwks.local" + +session: + secret: "CHANGE_ME_SESSION_SECRET" + expiry_minutes: 60 diff --git a/src/cmd/setupcheck/main.go b/src/cmd/setupcheck/main.go new file mode 100644 index 0000000..4ef9472 --- /dev/null +++ b/src/cmd/setupcheck/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" +) + +func main() { + checks := []struct { + path string + purpose string + mustExist bool + }{ + {"/opt/nextwks/config.yaml", "NextWks configuration", true}, + {"/opt/nextwks/bin", "Binary output directory", true}, + {"/opt/nextwks/data", "Data directory", true}, + {"/opt/authelia/config/configuration.yml", "Authelia mock configuration", true}, + } + + allPassed := true + for _, c := range checks { + _, err := os.Stat(c.path) + if c.mustExist && os.IsNotExist(err) { + fmt.Printf("❌ MISSING: %s (%s)\n", c.path, c.purpose) + allPassed = false + } else if c.mustExist && err != nil { + fmt.Printf("❌ ERROR: %s - %v\n", c.path, err) + allPassed = false + } else { + fmt.Printf("✅ OK: %s (%s)\n", c.path, c.purpose) + } + } + + if allPassed { + fmt.Println("\n✅ All system paths verified!") + } else { + fmt.Println("\n❌ Some paths are missing or have errors") + os.Exit(1) + } +} diff --git a/src/core/config/config.go b/src/core/config/config.go new file mode 100644 index 0000000..2d81ecb --- /dev/null +++ b/src/core/config/config.go @@ -0,0 +1,90 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config represents the full NextWks configuration. +type Config struct { + Server ServerConfig `yaml:"server"` + Admin AdminConfig `yaml:"admin"` + Database DatabaseConfig `yaml:"database"` + Authelia AutheliaConfig `yaml:"authelia"` + SMTP SMTPConfig `yaml:"smtp"` + Session SessionConfig `yaml:"session"` +} + +type ServerConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` +} + +type AdminConfig struct { + SecretToken string `yaml:"secret_token"` +} + +type DatabaseConfig struct { + Type string `yaml:"type"` + Path string `yaml:"path"` +} + +type AutheliaConfig struct { + Host string `yaml:"host"` + ConfigPath string `yaml:"config_path"` + UsersDBPath string `yaml:"users_db_path"` +} + +type SMTPConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + From string `yaml:"from"` +} + +type SessionConfig struct { + Secret string `yaml:"secret"` + ExpiryMinutes int `yaml:"expiry_minutes"` +} + +// Load reads and parses the YAML configuration file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config file: %w", err) + } + + return &cfg, nil +} + +// AutheliaSessionSecret extracts the session.secret from Authelia's configuration. +func AutheliaSessionSecret(cfgPath string) (string, error) { + data, err := os.ReadFile(cfgPath) + if err != nil { + return "", fmt.Errorf("read authelia config: %w", err) + } + + var autheliaCfg struct { + Session struct { + Secret string `yaml:"secret"` + } `yaml:"session"` + } + + if err := yaml.Unmarshal(data, &autheliaCfg); err != nil { + return "", fmt.Errorf("parse authelia config: %w", err) + } + + if autheliaCfg.Session.Secret == "" { + return "", fmt.Errorf("authelia session.secret not found in %s", cfgPath) + } + + return autheliaCfg.Session.Secret, nil +} diff --git a/src/core/db/db.go b/src/core/db/db.go new file mode 100644 index 0000000..34b1d91 --- /dev/null +++ b/src/core/db/db.go @@ -0,0 +1,94 @@ +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// Database wraps the SQLite connection and provides migration helpers. +type Database struct { + DB *sql.DB +} + +// Initialize opens (or creates) the SQLite database at the given path. +func Initialize(dbPath string) (*Database, error) { + // Ensure the data directory exists + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create data directory: %w", err) + } + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + // Enable WAL mode for better concurrency + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + return nil, fmt.Errorf("enable WAL mode: %w", err) + } + + // Enable foreign keys + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + + return &Database{DB: db}, nil +} + +// Migrate runs automatic schema migrations on startup. +func (d *Database) Migrate() error { + migrations := []string{ + `sessions`, + `audit_logs`, + } + + // Verify all required tables exist + for _, table := range migrations { + if err := d.ensureTable(table); err != nil { + return fmt.Errorf("ensure table %s: %w", table, err) + } + } + + return nil +} + +func (d *Database) ensureTable(name string) error { + switch name { + case "sessions": + _, err := d.DB.Exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL + ) + `) + return err + + case "audit_logs": + _, err := d.DB.Exec(` + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + actor TEXT NOT NULL, + target TEXT, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + return err + } + + return fmt.Errorf("unknown table: %s", name) +} + +// Close cleanly shuts down the database connection. +func (d *Database) Close() error { + return d.DB.Close() +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..fa2cac1 --- /dev/null +++ b/src/go.mod @@ -0,0 +1,20 @@ +module git.lohmar.co.uk/lexton-it/NextWks + +go 1.25.0 + +require ( + github.com/a-h/templ v0.3.1020 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-chi/chi/v5 v5.3.0 // indirect + github.com/go-chi/cors v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..69b32bd --- /dev/null +++ b/src/go.sum @@ -0,0 +1,30 @@ +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..f1cc396 --- /dev/null +++ b/src/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "git.lohmar.co.uk/lexton-it/NextWks/core/config" + "git.lohmar.co.uk/lexton-it/NextWks/core/db" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + logger.Info("starting Next Workspace (NextWks)") + + // Load configuration + cfg, err := config.Load("/opt/nextwks/config.yaml") + if err != nil { + logger.Error("failed to load config", "error", err) + os.Exit(1) + } + + // Initialize database + database, err := db.Initialize(cfg.Database.Path) + if err != nil { + logger.Error("failed to initialize database", "error", err) + os.Exit(1) + } + defer database.Close() + + // Run schema migrations + if err := database.Migrate(); err != nil { + logger.Error("failed to run migrations", "error", err) + os.Exit(1) + } + logger.Info("database initialized and migrated") + + // Setup HTTP router + mux := http.NewServeMux() + + // Health check + mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + + // Start server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + server := &http.Server{ + Addr: addr, + Handler: mux, + } + + // Graceful shutdown + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + logger.Info("shutting down server...") + server.Close() + }() + + logger.Info("server listening", "address", addr) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + logger.Error("server error", "error", err) + os.Exit(1) + } +}