feat(core): reshape app bootstrap and access control
This commit is contained in:
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/spenc/maintainarr/internal/app"
|
||||
"maintainarr/internal/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
_, 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user