feat(env): initialize /opt/ infrastructure framework, config definitions, and git repo
This commit is contained in:
commit
3ec860b67d
8 changed files with 400 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
29
config.yaml
Normal file
29
config.yaml
Normal file
|
|
@ -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
|
||||||
40
src/cmd/setupcheck/main.go
Normal file
40
src/cmd/setupcheck/main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/core/config/config.go
Normal file
90
src/core/config/config.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
94
src/core/db/db.go
Normal file
94
src/core/db/db.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
20
src/go.mod
Normal file
20
src/go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
30
src/go.sum
Normal file
30
src/go.sum
Normal file
|
|
@ -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=
|
||||||
71
src/main.go
Normal file
71
src/main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue