feat(core): reshape app bootstrap and access control

This commit is contained in:
2026-06-20 17:21:49 -05:00
parent 61d316866c
commit 2fbe9c3b57
8 changed files with 199 additions and 105 deletions

View File

@@ -3,7 +3,7 @@ package main
import (
"log"
"github.com/spenc/maintainarr/internal/app"
"maintainarr/internal/app"
)
func main() {

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/spenc/maintainarr
module maintainarr
go 1.25.7

View File

@@ -9,13 +9,13 @@ import (
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/spenc/maintainarr/internal/config"
"github.com/spenc/maintainarr/internal/db"
"github.com/spenc/maintainarr/internal/handlers"
localmiddleware "github.com/spenc/maintainarr/internal/middleware"
"github.com/spenc/maintainarr/internal/models"
"github.com/spenc/maintainarr/internal/services"
"github.com/spenc/maintainarr/internal/views"
"maintainarr/internal/config"
"maintainarr/internal/db"
"maintainarr/internal/handlers"
localmiddleware "maintainarr/internal/middleware"
"maintainarr/internal/models"
"maintainarr/internal/services"
"maintainarr/internal/views"
)
type App struct {
@@ -53,14 +53,18 @@ func New() (*App, error) {
if err != nil {
return nil, err
}
if org.ThemeMode == "" {
if err := repo.UpdateOrganizationTheme(ctx, org.Theme, cfg.DefaultMode); err != nil {
return nil, err
}
org.ThemeMode = cfg.DefaultMode
}
if err := repo.SeedDefaults(ctx, org.ID); err != nil {
return nil, err
}
if cfg.BootstrapAdmin {
if err := bootstrapAdmin(ctx, repo, auth, org); err != nil {
return nil, err
}
if err := repo.EnsurePrimaryAdmin(ctx); err != nil {
return nil, err
}
handler := handlers.New(repo, auth, sessions, nodes, renderer, org, cfg.BaseURL)
@@ -85,16 +89,23 @@ func New() (*App, error) {
protected.Get("/setup-2fa", handler.SetupOTPPage)
protected.Post("/setup-2fa", handler.SetupOTP)
protected.Get("/dashboard", handler.Dashboard)
protected.Get("/dashboard/nodes", handler.DashboardNodes)
protected.Get("/nodes/{nodeID}", handler.NodeOverview)
protected.Get("/nodes/{nodeID}/stats", handler.NodeStatsAPI)
protected.Get("/nodes/{nodeID}/console", handler.NodeConsole)
protected.Get("/nodes/{nodeID}/console/ws", handler.NodeConsoleWebSocket)
protected.Get("/groups", handler.GroupsPage)
protected.Get("/automations", handler.AutomationsPage)
protected.Get("/settings", handler.SettingsPage)
protected.Post("/settings/theme", handler.UpdateTheme)
protected.Group(func(editor chi.Router) {
editor.Use(localmiddleware.RequireRole(models.RoleEditor))
editor.Post("/groups", handler.CreateGroup)
editor.Post("/nodes", handler.CreateNode)
editor.Post("/nodes/{nodeID}/actions/{action}", handler.NodeAction)
editor.Post("/nodes/{nodeID}/commands", handler.NodeQuickCommand)
editor.Post("/nodes/{nodeID}/delete", handler.DeleteNode)
editor.Post("/automations", handler.CreateAutomation)
})
})
@@ -120,30 +131,3 @@ func (a *App) Run() error {
fmt.Printf("Maintainarr listening on %s\n", a.config.Address)
return a.server.ListenAndServe()
}
func bootstrapAdmin(ctx context.Context, repo *services.Repository, auth *services.AuthService, org models.Organization) error {
count, err := repo.CountUsers(ctx)
if err != nil || count > 0 {
return err
}
password, err := auth.HashPassword("admin123!")
if err != nil {
return err
}
key, err := auth.NewOTP("admin@maintainarr.local")
if err != nil {
return err
}
return repo.CreateUser(ctx, &models.User{
Organization: org.ID,
Name: "Bootstrap Admin",
Email: "admin@maintainarr.local",
PasswordHash: password,
Role: models.RoleAdmin,
OTPSecret: key.Secret(),
OTPEnabled: false,
})
}

View File

@@ -6,28 +6,28 @@ import (
)
type Config struct {
Address string
DatabasePath string
SessionKey string
EncryptionKey string
OrgName string
BaseURL string
DefaultTheme string
RefreshCron string
BootstrapAdmin bool
Address string
DatabasePath string
SessionKey string
EncryptionKey string
OrgName string
BaseURL string
DefaultTheme string
DefaultMode string
RefreshCron string
}
func Load() Config {
return Config{
Address: env("MAINTAINARR_ADDR", ":8080"),
DatabasePath: env("MAINTAINARR_DB_PATH", filepath.Join("data", "maintainarr.db")),
SessionKey: env("MAINTAINARR_SESSION_KEY", "change-me-session-key-please"),
EncryptionKey: env("MAINTAINARR_ENCRYPTION_KEY", "change-me-encryption-key-32bytes"),
OrgName: env("MAINTAINARR_ORG_NAME", "Maintainarr"),
BaseURL: env("MAINTAINARR_BASE_URL", "http://localhost:8080"),
DefaultTheme: env("MAINTAINARR_THEME", "emerald"),
RefreshCron: env("MAINTAINARR_REFRESH_CRON", "@every 5m"),
BootstrapAdmin: env("MAINTAINARR_BOOTSTRAP_ADMIN", "true") == "true",
Address: env("MAINTAINARR_ADDR", ":8080"),
DatabasePath: env("MAINTAINARR_DB_PATH", filepath.Join("data", "maintainarr.db")),
SessionKey: env("MAINTAINARR_SESSION_KEY", "change-me-session-key-please"),
EncryptionKey: env("MAINTAINARR_ENCRYPTION_KEY", "change-me-encryption-key-32bytes"),
OrgName: env("MAINTAINARR_ORG_NAME", "Maintainarr"),
BaseURL: env("MAINTAINARR_BASE_URL", "http://localhost:8080"),
DefaultTheme: env("MAINTAINARR_THEME", "blue"),
DefaultMode: env("MAINTAINARR_THEME_MODE", "dark"),
RefreshCron: env("MAINTAINARR_REFRESH_CRON", "@every 5s"),
}
}

View File

@@ -45,6 +45,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
theme TEXT NOT NULL DEFAULT 'emerald',
theme_mode TEXT NOT NULL DEFAULT 'dark',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE TABLE IF NOT EXISTS users (
@@ -72,6 +73,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id INTEGER NOT NULL,
group_id INTEGER,
tag TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
distro TEXT NOT NULL,
hostname TEXT NOT NULL,
@@ -80,6 +82,12 @@ func migrate(ctx context.Context, database *sql.DB) error {
ssh_port INTEGER NOT NULL DEFAULT 22,
ssh_username TEXT NOT NULL DEFAULT '',
ssh_password TEXT NOT NULL DEFAULT '',
package_manager TEXT NOT NULL DEFAULT '',
architecture TEXT NOT NULL DEFAULT '',
kernel_version TEXT NOT NULL DEFAULT '',
cpu_model TEXT NOT NULL DEFAULT '',
memory_total_mb INTEGER NOT NULL DEFAULT 0,
disk_total_gb INTEGER NOT NULL DEFAULT 0,
cpu_usage REAL NOT NULL DEFAULT 0,
ram_usage REAL NOT NULL DEFAULT 0,
disk_usage REAL NOT NULL DEFAULT 0,
@@ -97,6 +105,7 @@ func migrate(ctx context.Context, database *sql.DB) error {
organization_id INTEGER NOT NULL,
node_id INTEGER,
group_id INTEGER,
tag TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
trigger_type TEXT NOT NULL,
schedule TEXT NOT NULL DEFAULT '',
@@ -131,5 +140,21 @@ func migrate(ctx context.Context, database *sql.DB) error {
}
}
alterStatements := []string{
`ALTER TABLE organizations ADD COLUMN theme_mode TEXT NOT NULL DEFAULT 'dark';`,
`ALTER TABLE nodes ADD COLUMN tag TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN package_manager TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN architecture TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN kernel_version TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN cpu_model TEXT NOT NULL DEFAULT '';`,
`ALTER TABLE nodes ADD COLUMN memory_total_mb INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE nodes ADD COLUMN disk_total_gb INTEGER NOT NULL DEFAULT 0;`,
`ALTER TABLE automation_jobs ADD COLUMN tag TEXT NOT NULL DEFAULT '';`,
}
for _, statement := range alterStatements {
_, _ = database.ExecContext(ctx, statement)
}
return nil
}

View File

@@ -4,8 +4,8 @@ import (
"context"
"net/http"
"github.com/spenc/maintainarr/internal/models"
"github.com/spenc/maintainarr/internal/services"
"maintainarr/internal/models"
"maintainarr/internal/services"
)
type contextKey string

View File

@@ -14,6 +14,7 @@ type Organization struct {
ID int64
Name string
Theme string
ThemeMode string
CreatedAt time.Time
}
@@ -42,6 +43,8 @@ type Node struct {
ID int64
OrganizationID int64
GroupID *int64
GroupName string
Tag string
Name string
Distro string
Hostname string
@@ -50,6 +53,12 @@ type Node struct {
SSHPort int
SSHUsername string
SSHPassword string
PackageManager string
Architecture string
KernelVersion string
CPUModel string
MemoryTotalMB int64
DiskTotalGB int64
CPUUsage float64
RAMUsage float64
DiskUsage float64
@@ -66,6 +75,9 @@ type AutomationJob struct {
OrganizationID int64
NodeID *int64
GroupID *int64
NodeName string
GroupName string
Tag string
Name string
TriggerType string
Schedule string
@@ -77,13 +89,16 @@ type AutomationJob struct {
}
type CommandRun struct {
ID int64
ID int64
JobID *int64
NodeID int64
JobName string
NodeName string
Action string
Status string
Output string
TriggeredBy *int64
StartedAt time.Time
FinishedAt *time.Time
DurationText string
}

View File

@@ -4,7 +4,7 @@ import (
"context"
"database/sql"
"github.com/spenc/maintainarr/internal/models"
"maintainarr/internal/models"
)
type Repository struct {
@@ -22,10 +22,10 @@ func (r *Repository) DB() *sql.DB {
func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string) (models.Organization, error) {
var organization models.Organization
err := r.db.QueryRowContext(ctx, `
SELECT id, name, theme, created_at
SELECT id, name, theme, theme_mode, created_at
FROM organizations
LIMIT 1
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.CreatedAt)
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt)
if err == nil {
return organization, nil
}
@@ -33,16 +33,36 @@ func (r *Repository) EnsureOrganization(ctx context.Context, name, theme string)
return organization, err
}
result, err := r.db.ExecContext(ctx, `INSERT INTO organizations (name, theme) VALUES (?, ?)`, name, theme)
result, err := r.db.ExecContext(ctx, `INSERT INTO organizations (name, theme, theme_mode) VALUES (?, ?, 'dark')`, name, theme)
if err != nil {
return organization, err
}
organization.ID, _ = result.LastInsertId()
organization.Name = name
organization.Theme = theme
organization.ThemeMode = "dark"
return organization, nil
}
func (r *Repository) GetOrganization(ctx context.Context) (models.Organization, error) {
var organization models.Organization
err := r.db.QueryRowContext(ctx, `
SELECT id, name, theme, theme_mode, created_at
FROM organizations
LIMIT 1
`).Scan(&organization.ID, &organization.Name, &organization.Theme, &organization.ThemeMode, &organization.CreatedAt)
return organization, err
}
func (r *Repository) UpdateOrganizationTheme(ctx context.Context, theme, themeMode string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE organizations
SET theme = ?, theme_mode = ?
WHERE id = (SELECT id FROM organizations LIMIT 1)
`, theme, themeMode)
return err
}
func (r *Repository) CountUsers(ctx context.Context) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
@@ -101,54 +121,104 @@ func (r *Repository) EnableUserOTP(ctx context.Context, id int64) error {
return err
}
func (r *Repository) SeedDefaults(ctx context.Context, orgID int64) error {
var groups int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vm_groups`).Scan(&groups); err != nil {
func (r *Repository) EnsurePrimaryAdmin(ctx context.Context) error {
var adminCount int
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM users
WHERE role = ?
`, models.RoleAdmin).Scan(&adminCount); err != nil {
return err
}
if groups == 0 {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO vm_groups (organization_id, name, description, color_token)
VALUES
(?, 'Core Infra', 'Hypervisors, DNS, auth, storage', 'primary'),
(?, 'Edge Fleet', 'Proxies, tunnel boxes, ingress points', 'accent'),
(?, 'Lab', 'Test nodes and staging sandboxes', 'warning')
`, orgID, orgID, orgID); err != nil {
return err
if adminCount > 0 {
return nil
}
var userID int64
err := r.db.QueryRowContext(ctx, `
SELECT id
FROM users
ORDER BY created_at ASC, id ASC
LIMIT 1
`).Scan(&userID)
if err == sql.ErrNoRows {
return nil
}
if err != nil {
return err
}
_, err = r.db.ExecContext(ctx, `
UPDATE users
SET role = ?
WHERE id = ?
`, models.RoleAdmin, userID)
return err
}
func (r *Repository) ListGroups(ctx context.Context, orgID int64) ([]models.VMGroup, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, organization_id, name, description, color_token, created_at
FROM vm_groups
WHERE organization_id = ?
ORDER BY name ASC
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []models.VMGroup
for rows.Next() {
var group models.VMGroup
if err := rows.Scan(&group.ID, &group.OrganizationID, &group.Name, &group.Description, &group.ColorToken, &group.CreatedAt); err != nil {
return nil, err
}
groups = append(groups, group)
}
var nodes int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM nodes`).Scan(&nodes); err != nil {
return err
}
if nodes == 0 {
_, err := r.db.ExecContext(ctx, `
INSERT INTO nodes (organization_id, name, distro, hostname, ip_address, mac_address, ssh_port, cpu_usage, ram_usage, disk_usage, uptime_seconds, auto_updates_enabled, notes)
VALUES
(?, 'Aurora', 'Ubuntu', 'aurora.local', '192.168.1.20', 'AA:BB:CC:DD:EE:01', 22, 34.5, 62.4, 40.1, 864000, 1, 'Primary app host'),
(?, 'Forge', 'Debian', 'forge.local', '192.168.1.25', 'AA:BB:CC:DD:EE:02', 22, 12.8, 44.9, 71.2, 421920, 1, 'Build runner'),
(?, 'Nimbus', 'Arch', 'nimbus.local', '192.168.1.33', 'AA:BB:CC:DD:EE:03', 22, 73.1, 80.6, 55.8, 128800, 0, 'Bleeding-edge sandbox')
`, orgID, orgID, orgID)
if err != nil {
return err
}
}
return groups, rows.Err()
}
var jobs int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM automation_jobs`).Scan(&jobs); err != nil {
return err
}
if jobs == 0 {
_, err := r.db.ExecContext(ctx, `
INSERT INTO automation_jobs (organization_id, node_id, name, trigger_type, schedule, command, enabled)
VALUES
(?, 1, 'Nightly apt upgrade', 'recurring', '0 3 * * *', 'sudo apt update && sudo apt upgrade -y && sudo apt autoremove -y', 1),
(?, 2, 'Weekly docker prune', 'recurring', '0 4 * * 0', 'docker system prune -af', 1),
(?, 3, 'High-load audit hook', 'triggered', '', 'journalctl -p 3 -n 200', 1)
`, orgID, orgID, orgID)
func (r *Repository) CreateGroup(ctx context.Context, group *models.VMGroup) error {
result, err := r.db.ExecContext(ctx, `
INSERT INTO vm_groups (organization_id, name, description, color_token)
VALUES (?, ?, ?, ?)
`, group.OrganizationID, group.Name, group.Description, group.ColorToken)
if err != nil {
return err
}
group.ID, _ = result.LastInsertId()
return nil
}
func (r *Repository) ListTags(ctx context.Context, orgID int64) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT tag
FROM nodes
WHERE organization_id = ? AND tag <> ''
ORDER BY tag ASC
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, err
}
tags = append(tags, tag)
}
return tags, rows.Err()
}
func (r *Repository) SeedDefaults(ctx context.Context, orgID int64) error {
_ = ctx
_ = orgID
return nil
}