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,15 +53,19 @@ 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 {
if err := repo.EnsurePrimaryAdmin(ctx); err != nil {
return nil, err
}
}
handler := handlers.New(repo, auth, sessions, nodes, renderer, org, cfg.BaseURL)
router := chi.NewRouter()
@@ -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

@@ -13,8 +13,8 @@ type Config struct {
OrgName string
BaseURL string
DefaultTheme string
DefaultMode string
RefreshCron string
BootstrapAdmin bool
}
func Load() Config {
@@ -25,9 +25,9 @@ func Load() Config {
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",
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
@@ -80,10 +92,13 @@ type CommandRun struct {
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 {
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 {
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 adminCount > 0 {
return nil
}
var nodes int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM nodes`).Scan(&nodes); err != nil {
return err
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 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
}
_, 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 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)
return groups, rows.Err()
}
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
}