No One Can Stop Me Now

This commit is contained in:
DigiJ
2026-03-13 23:48:47 -07:00
parent 4d3570781e
commit 1a138a2bd0
428 changed files with 519668 additions and 259 deletions

View File

@@ -0,0 +1,361 @@
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// Client wraps the certbot CLI for Let's Encrypt ACME certificate management.
type Client struct {
Email string
Staging bool
Webroot string
AccountDir string
}
// CertInfo holds parsed certificate metadata.
type CertInfo struct {
Domain string `json:"domain"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
ChainPath string `json:"chain_path"`
ExpiresAt time.Time `json:"expires_at"`
Issuer string `json:"issuer"`
DaysLeft int `json:"days_left"`
}
// domainRegex validates domain names (basic RFC 1123 hostname check).
var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
// NewClient creates a new ACME client.
func NewClient(email string, staging bool, webroot, accountDir string) *Client {
return &Client{
Email: email,
Staging: staging,
Webroot: webroot,
AccountDir: accountDir,
}
}
// validateDomain checks that a domain name is syntactically valid before passing
// it to certbot. This prevents command injection and catches obvious typos.
func validateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain name is empty")
}
if len(domain) > 253 {
return fmt.Errorf("domain name too long: %d characters (max 253)", len(domain))
}
if !domainRegex.MatchString(domain) {
return fmt.Errorf("invalid domain name: %q", domain)
}
return nil
}
// Issue requests a new certificate from Let's Encrypt for the given domain
// using the webroot challenge method.
func (c *Client) Issue(domain string) (*CertInfo, error) {
if err := validateDomain(domain); err != nil {
return nil, fmt.Errorf("issue: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return nil, fmt.Errorf("issue: %w", err)
}
// Ensure webroot directory exists
if err := os.MkdirAll(c.Webroot, 0755); err != nil {
return nil, fmt.Errorf("issue: create webroot: %w", err)
}
args := []string{
"certonly", "--webroot",
"-w", c.Webroot,
"-d", domain,
"--non-interactive",
"--agree-tos",
"-m", c.Email,
}
if c.Staging {
args = append(args, "--staging")
}
cmd := exec.Command("certbot", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("certbot certonly failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return c.GetCertInfo(domain)
}
// Renew renews the certificate for a specific domain.
func (c *Client) Renew(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("renew: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("renew: %w", err)
}
cmd := exec.Command("certbot", "renew",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot renew failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// RenewAll renews all certificates managed by certbot that are due for renewal.
func (c *Client) RenewAll() (string, error) {
if err := c.EnsureCertbotInstalled(); err != nil {
return "", fmt.Errorf("renew all: %w", err)
}
cmd := exec.Command("certbot", "renew", "--non-interactive")
out, err := cmd.CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("certbot renew --all failed: %s: %w", strings.TrimSpace(output), err)
}
return output, nil
}
// Revoke revokes the certificate for a given domain.
func (c *Client) Revoke(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("revoke: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("revoke: %w", err)
}
cmd := exec.Command("certbot", "revoke",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot revoke failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// Delete removes a certificate and its renewal configuration from certbot.
func (c *Client) Delete(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("delete: %w", err)
}
cmd := exec.Command("certbot", "delete",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot delete failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// ListCerts scans /etc/letsencrypt/live/ and parses each certificate to return
// metadata including expiry dates and issuer information.
func (c *Client) ListCerts() ([]CertInfo, error) {
liveDir := "/etc/letsencrypt/live"
entries, err := os.ReadDir(liveDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No certs directory yet
}
return nil, fmt.Errorf("list certs: read live dir: %w", err)
}
var certs []CertInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
domain := entry.Name()
// Skip the README directory certbot sometimes creates
if domain == "README" {
continue
}
info, err := c.GetCertInfo(domain)
if err != nil {
// Log but skip certs we can't parse
continue
}
certs = append(certs, *info)
}
return certs, nil
}
// GetCertInfo reads and parses the X.509 certificate at the standard Let's
// Encrypt live path for a domain, returning structured metadata.
func (c *Client) GetCertInfo(domain string) (*CertInfo, error) {
if err := validateDomain(domain); err != nil {
return nil, fmt.Errorf("get cert info: %w", err)
}
liveDir := filepath.Join("/etc/letsencrypt/live", domain)
certPath := filepath.Join(liveDir, "fullchain.pem")
keyPath := filepath.Join(liveDir, "privkey.pem")
chainPath := filepath.Join(liveDir, "chain.pem")
data, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("get cert info: read cert: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("get cert info: no PEM block found in %s", certPath)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("get cert info: parse x509: %w", err)
}
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
return &CertInfo{
Domain: domain,
CertPath: certPath,
KeyPath: keyPath,
ChainPath: chainPath,
ExpiresAt: cert.NotAfter,
Issuer: cert.Issuer.CommonName,
DaysLeft: daysLeft,
}, nil
}
// EnsureCertbotInstalled checks whether certbot is available in PATH. If not,
// it attempts to install it via apt-get.
func (c *Client) EnsureCertbotInstalled() error {
if _, err := exec.LookPath("certbot"); err == nil {
return nil // Already installed
}
// Attempt to install via apt-get
cmd := exec.Command("apt-get", "update", "-qq")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get update failed: %s: %w", strings.TrimSpace(string(out)), err)
}
cmd = exec.Command("apt-get", "install", "-y", "-qq", "certbot")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get install certbot failed: %s: %w", strings.TrimSpace(string(out)), err)
}
// Verify installation succeeded
if _, err := exec.LookPath("certbot"); err != nil {
return fmt.Errorf("certbot still not found after installation attempt")
}
return nil
}
// GenerateSelfSigned creates a self-signed X.509 certificate and private key
// for testing or as a fallback when Let's Encrypt is unavailable.
func (c *Client) GenerateSelfSigned(domain, certPath, keyPath string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("generate self-signed: %w", err)
}
// Ensure output directories exist
if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil {
return fmt.Errorf("generate self-signed: create cert dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
return fmt.Errorf("generate self-signed: create key dir: %w", err)
}
// Generate ECDSA P-256 private key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate self-signed: generate key: %w", err)
}
// Build the certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("generate self-signed: serial number: %w", err)
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: domain,
Organization: []string{"Setec Security Labs"},
},
DNSNames: []string{domain},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Self-sign the certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil {
return fmt.Errorf("generate self-signed: create cert: %w", err)
}
// Write certificate PEM
certFile, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("generate self-signed: write cert: %w", err)
}
defer certFile.Close()
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return fmt.Errorf("generate self-signed: encode cert PEM: %w", err)
}
// Write private key PEM
keyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
return fmt.Errorf("generate self-signed: marshal key: %w", err)
}
keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("generate self-signed: write key: %w", err)
}
defer keyFile.Close()
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
return fmt.Errorf("generate self-signed: encode key PEM: %w", err)
}
return nil
}

View File

@@ -0,0 +1,146 @@
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
Nginx NginxConfig `yaml:"nginx"`
ACME ACMEConfig `yaml:"acme"`
Autarch AutarchConfig `yaml:"autarch"`
Float FloatConfig `yaml:"float"`
Backups BackupsConfig `yaml:"backups"`
Logging LoggingConfig `yaml:"logging"`
}
type ServerConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS bool `yaml:"tls"`
Cert string `yaml:"cert"`
Key string `yaml:"key"`
}
type DatabaseConfig struct {
Path string `yaml:"path"`
}
type NginxConfig struct {
SitesAvailable string `yaml:"sites_available"`
SitesEnabled string `yaml:"sites_enabled"`
Snippets string `yaml:"snippets"`
Webroot string `yaml:"webroot"`
CertbotWebroot string `yaml:"certbot_webroot"`
}
type ACMEConfig struct {
Email string `yaml:"email"`
Staging bool `yaml:"staging"`
AccountDir string `yaml:"account_dir"`
}
type AutarchConfig struct {
InstallDir string `yaml:"install_dir"`
GitRepo string `yaml:"git_repo"`
GitBranch string `yaml:"git_branch"`
WebPort int `yaml:"web_port"`
DNSPort int `yaml:"dns_port"`
}
type FloatConfig struct {
Enabled bool `yaml:"enabled"`
MaxSessions int `yaml:"max_sessions"`
SessionTTL string `yaml:"session_ttl"`
}
type BackupsConfig struct {
Dir string `yaml:"dir"`
MaxAgeDays int `yaml:"max_age_days"`
MaxCount int `yaml:"max_count"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
File string `yaml:"file"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxBackups int `yaml:"max_backups"`
}
func DefaultConfig() *Config {
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 9090,
TLS: true,
Cert: "/opt/setec-manager/data/acme/manager.crt",
Key: "/opt/setec-manager/data/acme/manager.key",
},
Database: DatabaseConfig{
Path: "/opt/setec-manager/data/setec.db",
},
Nginx: NginxConfig{
SitesAvailable: "/etc/nginx/sites-available",
SitesEnabled: "/etc/nginx/sites-enabled",
Snippets: "/etc/nginx/snippets",
Webroot: "/var/www",
CertbotWebroot: "/var/www/certbot",
},
ACME: ACMEConfig{
Email: "",
Staging: false,
AccountDir: "/opt/setec-manager/data/acme",
},
Autarch: AutarchConfig{
InstallDir: "/var/www/autarch",
GitRepo: "https://github.com/DigijEth/autarch.git",
GitBranch: "main",
WebPort: 8181,
DNSPort: 53,
},
Float: FloatConfig{
Enabled: false,
MaxSessions: 10,
SessionTTL: "24h",
},
Backups: BackupsConfig{
Dir: "/opt/setec-manager/data/backups",
MaxAgeDays: 30,
MaxCount: 50,
},
Logging: LoggingConfig{
Level: "info",
File: "/var/log/setec-manager.log",
MaxSizeMB: 100,
MaxBackups: 3,
},
}
}
func Load(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) Save(path string) error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}

View File

@@ -0,0 +1,46 @@
package db
import "time"
type Backup struct {
ID int64 `json:"id"`
SiteID *int64 `json:"site_id"`
BackupType string `json:"backup_type"`
FilePath string `json:"file_path"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
func (d *DB) CreateBackup(siteID *int64, backupType, filePath string, sizeBytes int64) (int64, error) {
result, err := d.conn.Exec(`INSERT INTO backups (site_id, backup_type, file_path, size_bytes)
VALUES (?, ?, ?, ?)`, siteID, backupType, filePath, sizeBytes)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) ListBackups() ([]Backup, error) {
rows, err := d.conn.Query(`SELECT id, site_id, backup_type, file_path, size_bytes, created_at
FROM backups ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var backups []Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.SiteID, &b.BackupType, &b.FilePath,
&b.SizeBytes, &b.CreatedAt); err != nil {
return nil, err
}
backups = append(backups, b)
}
return backups, rows.Err()
}
func (d *DB) DeleteBackup(id int64) error {
_, err := d.conn.Exec(`DELETE FROM backups WHERE id=?`, id)
return err
}

View File

@@ -0,0 +1,163 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
conn *sql.DB
}
func Open(path string) (*DB, error) {
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
conn, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
conn.SetMaxOpenConns(1) // SQLite single-writer
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func (d *DB) Close() error {
return d.conn.Close()
}
func (d *DB) Conn() *sql.DB {
return d.conn
}
func (d *DB) migrate() error {
migrations := []string{
migrateSites,
migrateSystemUsers,
migrateManagerUsers,
migrateDeployments,
migrateCronJobs,
migrateFirewallRules,
migrateFloatSessions,
migrateBackups,
migrateAuditLog,
}
for _, m := range migrations {
if _, err := d.conn.Exec(m); err != nil {
return err
}
}
return nil
}
const migrateSites = `CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
aliases TEXT DEFAULT '',
app_type TEXT NOT NULL DEFAULT 'static',
app_root TEXT NOT NULL,
app_port INTEGER DEFAULT 0,
app_entry TEXT DEFAULT '',
git_repo TEXT DEFAULT '',
git_branch TEXT DEFAULT 'main',
ssl_enabled BOOLEAN DEFAULT FALSE,
ssl_cert_path TEXT DEFAULT '',
ssl_key_path TEXT DEFAULT '',
ssl_auto BOOLEAN DEFAULT TRUE,
enabled BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateSystemUsers = `CREATE TABLE IF NOT EXISTS system_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
uid INTEGER,
home_dir TEXT,
shell TEXT DEFAULT '/bin/bash',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateManagerUsers = `CREATE TABLE IF NOT EXISTS manager_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'admin',
force_change BOOLEAN DEFAULT FALSE,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateDeployments = `CREATE TABLE IF NOT EXISTS deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
action TEXT NOT NULL,
status TEXT DEFAULT 'pending',
output TEXT DEFAULT '',
started_at DATETIME,
finished_at DATETIME
);`
const migrateCronJobs = `CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
job_type TEXT NOT NULL,
schedule TEXT NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
last_run DATETIME,
next_run DATETIME
);`
const migrateFirewallRules = `CREATE TABLE IF NOT EXISTS firewall_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT DEFAULT 'in',
protocol TEXT DEFAULT 'tcp',
port TEXT NOT NULL,
source TEXT DEFAULT 'any',
action TEXT DEFAULT 'allow',
comment TEXT DEFAULT '',
enabled BOOLEAN DEFAULT TRUE
);`
const migrateFloatSessions = `CREATE TABLE IF NOT EXISTS float_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES manager_users(id),
client_ip TEXT,
client_agent TEXT,
usb_bridge BOOLEAN DEFAULT FALSE,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_ping DATETIME,
expires_at DATETIME
);`
const migrateAuditLog = `CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip TEXT,
action TEXT NOT NULL,
detail TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const migrateBackups = `CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id),
backup_type TEXT DEFAULT 'site',
file_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`

View File

@@ -0,0 +1,60 @@
package db
import "time"
type Deployment struct {
ID int64 `json:"id"`
SiteID *int64 `json:"site_id"`
Action string `json:"action"`
Status string `json:"status"`
Output string `json:"output"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
func (d *DB) CreateDeployment(siteID *int64, action string) (int64, error) {
result, err := d.conn.Exec(`INSERT INTO deployments (site_id, action, status, started_at)
VALUES (?, ?, 'running', CURRENT_TIMESTAMP)`, siteID, action)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) FinishDeployment(id int64, status, output string) error {
_, err := d.conn.Exec(`UPDATE deployments SET status=?, output=?, finished_at=CURRENT_TIMESTAMP
WHERE id=?`, status, output, id)
return err
}
func (d *DB) ListDeployments(siteID *int64, limit int) ([]Deployment, error) {
var rows_query string
var args []interface{}
if siteID != nil {
rows_query = `SELECT id, site_id, action, status, output, started_at, finished_at
FROM deployments WHERE site_id=? ORDER BY id DESC LIMIT ?`
args = []interface{}{*siteID, limit}
} else {
rows_query = `SELECT id, site_id, action, status, output, started_at, finished_at
FROM deployments ORDER BY id DESC LIMIT ?`
args = []interface{}{limit}
}
rows, err := d.conn.Query(rows_query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var deps []Deployment
for rows.Next() {
var dep Deployment
if err := rows.Scan(&dep.ID, &dep.SiteID, &dep.Action, &dep.Status,
&dep.Output, &dep.StartedAt, &dep.FinishedAt); err != nil {
return nil, err
}
deps = append(deps, dep)
}
return deps, rows.Err()
}

View File

@@ -0,0 +1,70 @@
package db
import "time"
type FloatSession struct {
ID string `json:"id"`
UserID int64 `json:"user_id"`
ClientIP string `json:"client_ip"`
ClientAgent string `json:"client_agent"`
USBBridge bool `json:"usb_bridge"`
ConnectedAt time.Time `json:"connected_at"`
LastPing *time.Time `json:"last_ping"`
ExpiresAt time.Time `json:"expires_at"`
}
func (d *DB) CreateFloatSession(id string, userID int64, clientIP, agent string, expiresAt time.Time) error {
_, err := d.conn.Exec(`INSERT INTO float_sessions (id, user_id, client_ip, client_agent, expires_at)
VALUES (?, ?, ?, ?, ?)`, id, userID, clientIP, agent, expiresAt)
return err
}
func (d *DB) GetFloatSession(id string) (*FloatSession, error) {
var s FloatSession
err := d.conn.QueryRow(`SELECT id, user_id, client_ip, client_agent, usb_bridge,
connected_at, last_ping, expires_at FROM float_sessions WHERE id=?`, id).
Scan(&s.ID, &s.UserID, &s.ClientIP, &s.ClientAgent, &s.USBBridge,
&s.ConnectedAt, &s.LastPing, &s.ExpiresAt)
if err != nil {
return nil, err
}
return &s, nil
}
func (d *DB) ListFloatSessions() ([]FloatSession, error) {
rows, err := d.conn.Query(`SELECT id, user_id, client_ip, client_agent, usb_bridge,
connected_at, last_ping, expires_at FROM float_sessions ORDER BY connected_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []FloatSession
for rows.Next() {
var s FloatSession
if err := rows.Scan(&s.ID, &s.UserID, &s.ClientIP, &s.ClientAgent, &s.USBBridge,
&s.ConnectedAt, &s.LastPing, &s.ExpiresAt); err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, rows.Err()
}
func (d *DB) DeleteFloatSession(id string) error {
_, err := d.conn.Exec(`DELETE FROM float_sessions WHERE id=?`, id)
return err
}
func (d *DB) PingFloatSession(id string) error {
_, err := d.conn.Exec(`UPDATE float_sessions SET last_ping=CURRENT_TIMESTAMP WHERE id=?`, id)
return err
}
func (d *DB) CleanExpiredFloatSessions() (int64, error) {
result, err := d.conn.Exec(`DELETE FROM float_sessions WHERE expires_at < CURRENT_TIMESTAMP`)
if err != nil {
return 0, err
}
return result.RowsAffected()
}

View File

@@ -0,0 +1,107 @@
package db
import (
"database/sql"
"time"
)
type Site struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
Aliases string `json:"aliases"`
AppType string `json:"app_type"`
AppRoot string `json:"app_root"`
AppPort int `json:"app_port"`
AppEntry string `json:"app_entry"`
GitRepo string `json:"git_repo"`
GitBranch string `json:"git_branch"`
SSLEnabled bool `json:"ssl_enabled"`
SSLCertPath string `json:"ssl_cert_path"`
SSLKeyPath string `json:"ssl_key_path"`
SSLAuto bool `json:"ssl_auto"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (d *DB) ListSites() ([]Site, error) {
rows, err := d.conn.Query(`SELECT id, domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
ssl_auto, enabled, created_at, updated_at FROM sites ORDER BY domain`)
if err != nil {
return nil, err
}
defer rows.Close()
var sites []Site
for rows.Next() {
var s Site
if err := rows.Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
&s.CreatedAt, &s.UpdatedAt); err != nil {
return nil, err
}
sites = append(sites, s)
}
return sites, rows.Err()
}
func (d *DB) GetSite(id int64) (*Site, error) {
var s Site
err := d.conn.QueryRow(`SELECT id, domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
ssl_auto, enabled, created_at, updated_at FROM sites WHERE id = ?`, id).
Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
&s.CreatedAt, &s.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &s, err
}
func (d *DB) GetSiteByDomain(domain string) (*Site, error) {
var s Site
err := d.conn.QueryRow(`SELECT id, domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
ssl_auto, enabled, created_at, updated_at FROM sites WHERE domain = ?`, domain).
Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
&s.CreatedAt, &s.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &s, err
}
func (d *DB) CreateSite(s *Site) (int64, error) {
result, err := d.conn.Exec(`INSERT INTO sites (domain, aliases, app_type, app_root, app_port,
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path, ssl_auto, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.Domain, s.Aliases, s.AppType, s.AppRoot, s.AppPort,
s.AppEntry, s.GitRepo, s.GitBranch, s.SSLEnabled,
s.SSLCertPath, s.SSLKeyPath, s.SSLAuto, s.Enabled)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) UpdateSite(s *Site) error {
_, err := d.conn.Exec(`UPDATE sites SET domain=?, aliases=?, app_type=?, app_root=?,
app_port=?, app_entry=?, git_repo=?, git_branch=?, ssl_enabled=?,
ssl_cert_path=?, ssl_key_path=?, ssl_auto=?, enabled=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?`,
s.Domain, s.Aliases, s.AppType, s.AppRoot, s.AppPort,
s.AppEntry, s.GitRepo, s.GitBranch, s.SSLEnabled,
s.SSLCertPath, s.SSLKeyPath, s.SSLAuto, s.Enabled, s.ID)
return err
}
func (d *DB) DeleteSite(id int64) error {
_, err := d.conn.Exec(`DELETE FROM sites WHERE id=?`, id)
return err
}

View File

@@ -0,0 +1,124 @@
package db
import (
"database/sql"
"time"
"golang.org/x/crypto/bcrypt"
)
type ManagerUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"`
ForceChange bool `json:"force_change"`
LastLogin *time.Time `json:"last_login"`
CreatedAt time.Time `json:"created_at"`
}
func (d *DB) ListManagerUsers() ([]ManagerUser, error) {
rows, err := d.conn.Query(`SELECT id, username, password_hash, role, force_change,
last_login, created_at FROM manager_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []ManagerUser
for rows.Next() {
var u ManagerUser
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.ForceChange, &u.LastLogin, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func (d *DB) GetManagerUser(username string) (*ManagerUser, error) {
var u ManagerUser
err := d.conn.QueryRow(`SELECT id, username, password_hash, role, force_change,
last_login, created_at FROM manager_users WHERE username = ?`, username).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.ForceChange, &u.LastLogin, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (d *DB) GetManagerUserByID(id int64) (*ManagerUser, error) {
var u ManagerUser
err := d.conn.QueryRow(`SELECT id, username, password_hash, role, force_change,
last_login, created_at FROM manager_users WHERE id = ?`, id).
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.ForceChange, &u.LastLogin, &u.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
return &u, err
}
func (d *DB) CreateManagerUser(username, password, role string) (int64, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return 0, err
}
result, err := d.conn.Exec(`INSERT INTO manager_users (username, password_hash, role)
VALUES (?, ?, ?)`, username, string(hash), role)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (d *DB) UpdateManagerUserPassword(id int64, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = d.conn.Exec(`UPDATE manager_users SET password_hash=?, force_change=FALSE WHERE id=?`,
string(hash), id)
return err
}
func (d *DB) UpdateManagerUserRole(id int64, role string) error {
_, err := d.conn.Exec(`UPDATE manager_users SET role=? WHERE id=?`, role, id)
return err
}
func (d *DB) DeleteManagerUser(id int64) error {
_, err := d.conn.Exec(`DELETE FROM manager_users WHERE id=?`, id)
return err
}
func (d *DB) UpdateLoginTimestamp(id int64) error {
_, err := d.conn.Exec(`UPDATE manager_users SET last_login=CURRENT_TIMESTAMP WHERE id=?`, id)
return err
}
func (d *DB) ManagerUserCount() (int, error) {
var count int
err := d.conn.QueryRow(`SELECT COUNT(*) FROM manager_users`).Scan(&count)
return count, err
}
func (d *DB) AuthenticateUser(username, password string) (*ManagerUser, error) {
u, err := d.GetManagerUser(username)
if err != nil {
return nil, err
}
if u == nil {
return nil, nil
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, nil
}
d.UpdateLoginTimestamp(u.ID)
return u, nil
}

View File

@@ -0,0 +1,144 @@
package deploy
import (
"fmt"
"os/exec"
"strings"
)
// CommitInfo holds metadata for a single git commit.
type CommitInfo struct {
Hash string
Author string
Date string
Message string
}
// Clone clones a git repository into dest, checking out the given branch.
func Clone(repo, branch, dest string) (string, error) {
git, err := exec.LookPath("git")
if err != nil {
return "", fmt.Errorf("git not found: %w", err)
}
args := []string{"clone", "--branch", branch, "--progress", repo, dest}
out, err := exec.Command(git, args...).CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git clone: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// Pull performs a fast-forward-only pull in the given directory.
func Pull(dir string) (string, error) {
git, err := exec.LookPath("git")
if err != nil {
return "", fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "pull", "--ff-only")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git pull: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// CurrentCommit returns the hash and message of the latest commit in dir.
func CurrentCommit(dir string) (hash string, message string, err error) {
git, err := exec.LookPath("git")
if err != nil {
return "", "", fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "log", "--oneline", "-1")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", "", fmt.Errorf("git log: %w", err)
}
line := strings.TrimSpace(string(out))
if line == "" {
return "", "", fmt.Errorf("git log: no commits found")
}
parts := strings.SplitN(line, " ", 2)
hash = parts[0]
if len(parts) > 1 {
message = parts[1]
}
return hash, message, nil
}
// GetBranch returns the current branch name for the repository in dir.
func GetBranch(dir string) (string, error) {
git, err := exec.LookPath("git")
if err != nil {
return "", fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "rev-parse", "--abbrev-ref", "HEAD")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git rev-parse: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// HasChanges returns true if the working tree in dir has uncommitted changes.
func HasChanges(dir string) (bool, error) {
git, err := exec.LookPath("git")
if err != nil {
return false, fmt.Errorf("git not found: %w", err)
}
cmd := exec.Command(git, "status", "--porcelain")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status: %w", err)
}
return strings.TrimSpace(string(out)) != "", nil
}
// Log returns the last n commits from the repository in dir.
func Log(dir string, n int) ([]CommitInfo, error) {
git, err := exec.LookPath("git")
if err != nil {
return nil, fmt.Errorf("git not found: %w", err)
}
// Use a delimiter unlikely to appear in commit messages.
const sep = "||SETEC||"
format := fmt.Sprintf("%%h%s%%an%s%%ai%s%%s", sep, sep, sep)
cmd := exec.Command(git, "log", fmt.Sprintf("-n%d", n), fmt.Sprintf("--format=%s", format))
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git log: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
var commits []CommitInfo
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, sep, 4)
if len(parts) < 4 {
continue
}
commits = append(commits, CommitInfo{
Hash: parts[0],
Author: parts[1],
Date: parts[2],
Message: parts[3],
})
}
return commits, nil
}

View File

@@ -0,0 +1,100 @@
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// NpmInstall runs npm install in the given directory.
func NpmInstall(dir string) (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
cmd := exec.Command(npm, "install")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("npm install: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// NpmBuild runs npm run build in the given directory.
func NpmBuild(dir string) (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
cmd := exec.Command(npm, "run", "build")
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("npm run build: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// NpmAudit runs npm audit in the given directory and returns the report.
func NpmAudit(dir string) (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
cmd := exec.Command(npm, "audit")
cmd.Dir = dir
// npm audit exits non-zero when vulnerabilities are found, which is not
// an execution error — we still want the output.
out, err := cmd.CombinedOutput()
if err != nil {
// Return the output even on non-zero exit; the caller can inspect it.
return string(out), fmt.Errorf("npm audit: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// HasPackageJSON returns true if a package.json file exists in dir.
func HasPackageJSON(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "package.json"))
return err == nil && !info.IsDir()
}
// HasNodeModules returns true if a node_modules directory exists in dir.
func HasNodeModules(dir string) bool {
info, err := os.Stat(filepath.Join(dir, "node_modules"))
return err == nil && info.IsDir()
}
// NodeVersion returns the installed Node.js version string.
func NodeVersion() (string, error) {
node, err := exec.LookPath("node")
if err != nil {
return "", fmt.Errorf("node not found: %w", err)
}
out, err := exec.Command(node, "--version").Output()
if err != nil {
return "", fmt.Errorf("node --version: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// NpmVersion returns the installed npm version string.
func NpmVersion() (string, error) {
npm, err := exec.LookPath("npm")
if err != nil {
return "", fmt.Errorf("npm not found: %w", err)
}
out, err := exec.Command(npm, "--version").Output()
if err != nil {
return "", fmt.Errorf("npm --version: %w", err)
}
return strings.TrimSpace(string(out)), nil
}

View File

@@ -0,0 +1,93 @@
package deploy
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// PipPackage holds the name and version of an installed pip package.
type PipPackage struct {
Name string `json:"name"`
Version string `json:"version"`
}
// CreateVenv creates a Python virtual environment at <dir>/venv.
func CreateVenv(dir string) error {
python, err := exec.LookPath("python3")
if err != nil {
return fmt.Errorf("python3 not found: %w", err)
}
venvPath := filepath.Join(dir, "venv")
out, err := exec.Command(python, "-m", "venv", venvPath).CombinedOutput()
if err != nil {
return fmt.Errorf("create venv: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// UpgradePip upgrades pip, setuptools, and wheel inside the virtual environment
// rooted at venvDir.
func UpgradePip(venvDir string) error {
pip := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(pip); err != nil {
return fmt.Errorf("pip not found at %s: %w", pip, err)
}
out, err := exec.Command(pip, "install", "--upgrade", "pip", "setuptools", "wheel").CombinedOutput()
if err != nil {
return fmt.Errorf("upgrade pip: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// InstallRequirements installs packages from a requirements file into the
// virtual environment rooted at venvDir.
func InstallRequirements(venvDir, reqFile string) (string, error) {
pip := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(pip); err != nil {
return "", fmt.Errorf("pip not found at %s: %w", pip, err)
}
if _, err := os.Stat(reqFile); err != nil {
return "", fmt.Errorf("requirements file not found: %w", err)
}
out, err := exec.Command(pip, "install", "-r", reqFile).CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("pip install: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// ListPackages returns all installed packages in the virtual environment
// rooted at venvDir.
func ListPackages(venvDir string) ([]PipPackage, error) {
pip := filepath.Join(venvDir, "bin", "pip")
if _, err := os.Stat(pip); err != nil {
return nil, fmt.Errorf("pip not found at %s: %w", pip, err)
}
out, err := exec.Command(pip, "list", "--format=json").Output()
if err != nil {
return nil, fmt.Errorf("pip list: %w", err)
}
var packages []PipPackage
if err := json.Unmarshal(out, &packages); err != nil {
return nil, fmt.Errorf("parse pip list output: %w", err)
}
return packages, nil
}
// VenvExists returns true if a virtual environment with a working python3
// binary exists at <dir>/venv.
func VenvExists(dir string) bool {
python := filepath.Join(dir, "venv", "bin", "python3")
info, err := os.Stat(python)
return err == nil && !info.IsDir()
}

View File

@@ -0,0 +1,246 @@
package deploy
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// UnitConfig holds the parameters needed to generate a systemd unit file.
type UnitConfig struct {
Name string
Description string
ExecStart string
WorkingDirectory string
User string
Environment map[string]string
After string
RestartPolicy string
}
// GenerateUnit produces the contents of a systemd service unit file from cfg.
func GenerateUnit(cfg UnitConfig) string {
var b strings.Builder
// [Unit]
b.WriteString("[Unit]\n")
if cfg.Description != "" {
fmt.Fprintf(&b, "Description=%s\n", cfg.Description)
}
after := cfg.After
if after == "" {
after = "network.target"
}
fmt.Fprintf(&b, "After=%s\n", after)
// [Service]
b.WriteString("\n[Service]\n")
b.WriteString("Type=simple\n")
if cfg.User != "" {
fmt.Fprintf(&b, "User=%s\n", cfg.User)
}
if cfg.WorkingDirectory != "" {
fmt.Fprintf(&b, "WorkingDirectory=%s\n", cfg.WorkingDirectory)
}
fmt.Fprintf(&b, "ExecStart=%s\n", cfg.ExecStart)
restart := cfg.RestartPolicy
if restart == "" {
restart = "on-failure"
}
fmt.Fprintf(&b, "Restart=%s\n", restart)
b.WriteString("RestartSec=5\n")
// Environment variables — sorted for deterministic output.
if len(cfg.Environment) > 0 {
keys := make([]string, 0, len(cfg.Environment))
for k := range cfg.Environment {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "Environment=%s=%s\n", k, cfg.Environment[k])
}
}
// [Install]
b.WriteString("\n[Install]\n")
b.WriteString("WantedBy=multi-user.target\n")
return b.String()
}
// InstallUnit writes a systemd unit file and reloads the daemon.
func InstallUnit(name, content string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
unitPath := filepath.Join("/etc/systemd/system", name+".service")
if err := os.WriteFile(unitPath, []byte(content), 0644); err != nil {
return fmt.Errorf("write unit file: %w", err)
}
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
if err != nil {
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// RemoveUnit stops, disables, and removes a systemd unit file, then reloads.
func RemoveUnit(name string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
unit := name + ".service"
// Best-effort stop and disable — ignore errors if already stopped/disabled.
exec.Command(systemctl, "stop", unit).Run()
exec.Command(systemctl, "disable", unit).Run()
unitPath := filepath.Join("/etc/systemd/system", unit)
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove unit file: %w", err)
}
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
if err != nil {
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// Start starts a systemd unit.
func Start(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "start", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("start %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Stop stops a systemd unit.
func Stop(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "stop", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("stop %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Restart restarts a systemd unit.
func Restart(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "restart", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("restart %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Enable enables a systemd unit to start on boot.
func Enable(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "enable", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("enable %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// Disable disables a systemd unit from starting on boot.
func Disable(unit string) error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "disable", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("disable %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
}
return nil
}
// IsActive returns true if the given systemd unit is currently active.
func IsActive(unit string) (bool, error) {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return false, fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "is-active", unit).Output()
status := strings.TrimSpace(string(out))
if status == "active" {
return true, nil
}
// is-active exits non-zero for inactive/failed — that is not an error
// in our context, just means the unit is not active.
return false, nil
}
// Status returns the full systemctl status output for a unit.
func Status(unit string) (string, error) {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return "", fmt.Errorf("systemctl not found: %w", err)
}
// systemctl status exits non-zero for stopped services, so we use
// CombinedOutput and only treat missing-binary as a real error.
out, _ := exec.Command(systemctl, "status", unit).CombinedOutput()
return string(out), nil
}
// Logs returns the last n lines of journal output for a systemd unit.
func Logs(unit string, lines int) (string, error) {
journalctl, err := exec.LookPath("journalctl")
if err != nil {
return "", fmt.Errorf("journalctl not found: %w", err)
}
out, err := exec.Command(journalctl, "-u", unit, "-n", fmt.Sprintf("%d", lines), "--no-pager").CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("journalctl: %s: %w", strings.TrimSpace(string(out)), err)
}
return string(out), nil
}
// DaemonReload runs systemctl daemon-reload.
func DaemonReload() error {
systemctl, err := exec.LookPath("systemctl")
if err != nil {
return fmt.Errorf("systemctl not found: %w", err)
}
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
if err != nil {
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}

View File

@@ -0,0 +1,366 @@
package float
import (
"log"
"net/http"
"sync"
"time"
"setec-manager/internal/db"
"github.com/gorilla/websocket"
)
// Bridge manages WebSocket connections for USB passthrough in Float Mode.
type Bridge struct {
db *db.DB
sessions map[string]*bridgeConn
mu sync.RWMutex
upgrader websocket.Upgrader
}
// bridgeConn tracks a single active WebSocket connection and its associated session.
type bridgeConn struct {
sessionID string
conn *websocket.Conn
devices []USBDevice
mu sync.Mutex
done chan struct{}
}
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingInterval = 30 * time.Second
maxMessageSize = 64 * 1024 // 64 KB max frame payload
)
// NewBridge creates a new Bridge with the given database reference.
func NewBridge(database *db.DB) *Bridge {
return &Bridge{
db: database,
sessions: make(map[string]*bridgeConn),
upgrader: websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true // Accept all origins; auth is handled via session token
},
},
}
}
// HandleWebSocket upgrades an HTTP connection to WebSocket and manages the
// binary frame protocol for USB passthrough. The session ID must be provided
// as a "session" query parameter.
func (b *Bridge) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session")
if sessionID == "" {
http.Error(w, "missing session parameter", http.StatusBadRequest)
return
}
// Validate session exists and is not expired
sess, err := b.db.GetFloatSession(sessionID)
if err != nil {
http.Error(w, "invalid session", http.StatusUnauthorized)
return
}
if time.Now().After(sess.ExpiresAt) {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
// Upgrade to WebSocket
conn, err := b.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[float/bridge] upgrade failed for session %s: %v", sessionID, err)
return
}
bc := &bridgeConn{
sessionID: sessionID,
conn: conn,
done: make(chan struct{}),
}
// Register active connection
b.mu.Lock()
// Close any existing connection for this session
if existing, ok := b.sessions[sessionID]; ok {
close(existing.done)
existing.conn.Close()
}
b.sessions[sessionID] = bc
b.mu.Unlock()
log.Printf("[float/bridge] session %s connected from %s", sessionID, r.RemoteAddr)
// Start read/write loops
go b.writePump(bc)
b.readPump(bc)
}
// readPump reads binary frames from the WebSocket and dispatches them.
func (b *Bridge) readPump(bc *bridgeConn) {
defer b.cleanup(bc)
bc.conn.SetReadLimit(maxMessageSize)
bc.conn.SetReadDeadline(time.Now().Add(pongWait))
bc.conn.SetPongHandler(func(string) error {
bc.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
messageType, data, err := bc.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("[float/bridge] session %s read error: %v", bc.sessionID, err)
}
return
}
if messageType != websocket.BinaryMessage {
b.sendError(bc, 0x0001, "expected binary message")
continue
}
frameType, payload, err := DecodeFrame(data)
if err != nil {
b.sendError(bc, 0x0002, "malformed frame: "+err.Error())
continue
}
// Update session ping in DB
b.db.PingFloatSession(bc.sessionID)
switch frameType {
case FrameEnumerate:
b.handleEnumerate(bc)
case FrameOpen:
b.handleOpen(bc, payload)
case FrameClose:
b.handleClose(bc, payload)
case FrameTransferOut:
b.handleTransfer(bc, payload)
case FrameInterrupt:
b.handleInterrupt(bc, payload)
case FramePong:
// Client responded to our ping; no action needed
default:
b.sendError(bc, 0x0003, "unknown frame type")
}
}
}
// writePump sends periodic pings to keep the connection alive.
func (b *Bridge) writePump(bc *bridgeConn) {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
bc.mu.Lock()
bc.conn.SetWriteDeadline(time.Now().Add(writeWait))
err := bc.conn.WriteMessage(websocket.BinaryMessage, EncodeFrame(FramePing, nil))
bc.mu.Unlock()
if err != nil {
return
}
case <-bc.done:
return
}
}
}
// handleEnumerate responds with the current list of USB devices known to this
// session. In a full implementation, this would forward the enumerate request
// to the client-side USB agent and await its response. Here we return the
// cached device list.
func (b *Bridge) handleEnumerate(bc *bridgeConn) {
bc.mu.Lock()
devices := bc.devices
bc.mu.Unlock()
if devices == nil {
devices = []USBDevice{}
}
payload := EncodeDeviceList(devices)
b.sendFrame(bc, FrameEnumResult, payload)
}
// handleOpen processes a device open request. The payload contains
// [deviceID:2] identifying which device to claim.
func (b *Bridge) handleOpen(bc *bridgeConn, payload []byte) {
if len(payload) < 2 {
b.sendError(bc, 0x0010, "open: payload too short")
return
}
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
// Verify the device exists in our known list
bc.mu.Lock()
found := false
for _, dev := range bc.devices {
if dev.DeviceID == deviceID {
found = true
break
}
}
bc.mu.Unlock()
if !found {
b.sendError(bc, 0x0011, "open: device not found")
return
}
// In a real implementation, this would claim the USB device via the host agent.
// For now, acknowledge the open request.
result := make([]byte, 3)
result[0] = payload[0]
result[1] = payload[1]
result[2] = 0x00 // success
b.sendFrame(bc, FrameOpenResult, result)
log.Printf("[float/bridge] session %s opened device 0x%04X", bc.sessionID, deviceID)
}
// handleClose processes a device close request. Payload: [deviceID:2].
func (b *Bridge) handleClose(bc *bridgeConn, payload []byte) {
if len(payload) < 2 {
b.sendError(bc, 0x0020, "close: payload too short")
return
}
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
// Acknowledge close
result := make([]byte, 3)
result[0] = payload[0]
result[1] = payload[1]
result[2] = 0x00 // success
b.sendFrame(bc, FrameCloseResult, result)
log.Printf("[float/bridge] session %s closed device 0x%04X", bc.sessionID, deviceID)
}
// handleTransfer forwards a bulk/interrupt OUT transfer to the USB device.
func (b *Bridge) handleTransfer(bc *bridgeConn, payload []byte) {
deviceID, endpoint, transferData, err := DecodeTransfer(payload)
if err != nil {
b.sendError(bc, 0x0030, "transfer: "+err.Error())
return
}
// In a real implementation, the transfer data would be sent to the USB device
// via the host agent, and the response would be sent back. Here we acknowledge
// receipt of the transfer request.
log.Printf("[float/bridge] session %s transfer to device 0x%04X endpoint 0x%02X: %d bytes",
bc.sessionID, deviceID, endpoint, len(transferData))
// Build transfer result: [deviceID:2][endpoint:1][status:1]
result := make([]byte, 4)
result[0] = byte(deviceID >> 8)
result[1] = byte(deviceID)
result[2] = endpoint
result[3] = 0x00 // success
b.sendFrame(bc, FrameTransferResult, result)
}
// handleInterrupt processes an interrupt transfer request.
func (b *Bridge) handleInterrupt(bc *bridgeConn, payload []byte) {
if len(payload) < 3 {
b.sendError(bc, 0x0040, "interrupt: payload too short")
return
}
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
endpoint := payload[2]
log.Printf("[float/bridge] session %s interrupt on device 0x%04X endpoint 0x%02X",
bc.sessionID, deviceID, endpoint)
// Acknowledge interrupt request
result := make([]byte, 4)
result[0] = payload[0]
result[1] = payload[1]
result[2] = endpoint
result[3] = 0x00 // success
b.sendFrame(bc, FrameInterruptResult, result)
}
// sendFrame writes a binary frame to the WebSocket connection.
func (b *Bridge) sendFrame(bc *bridgeConn, frameType byte, payload []byte) {
bc.mu.Lock()
defer bc.mu.Unlock()
bc.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := bc.conn.WriteMessage(websocket.BinaryMessage, EncodeFrame(frameType, payload)); err != nil {
log.Printf("[float/bridge] session %s write error: %v", bc.sessionID, err)
}
}
// sendError writes an error frame to the WebSocket connection.
func (b *Bridge) sendError(bc *bridgeConn, code uint16, message string) {
b.sendFrame(bc, FrameError, EncodeError(code, message))
}
// cleanup removes a connection from the active sessions and cleans up resources.
func (b *Bridge) cleanup(bc *bridgeConn) {
b.mu.Lock()
if current, ok := b.sessions[bc.sessionID]; ok && current == bc {
delete(b.sessions, bc.sessionID)
}
b.mu.Unlock()
close(bc.done)
bc.conn.Close()
log.Printf("[float/bridge] session %s disconnected", bc.sessionID)
}
// ActiveSessions returns the number of currently connected WebSocket sessions.
func (b *Bridge) ActiveSessions() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.sessions)
}
// DisconnectSession forcibly closes the WebSocket connection for a given session.
func (b *Bridge) DisconnectSession(sessionID string) {
b.mu.Lock()
bc, ok := b.sessions[sessionID]
if ok {
delete(b.sessions, sessionID)
}
b.mu.Unlock()
if ok {
close(bc.done)
bc.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session terminated"),
time.Now().Add(writeWait),
)
bc.conn.Close()
log.Printf("[float/bridge] session %s forcibly disconnected", sessionID)
}
}
// UpdateDeviceList sets the known device list for a session (called when the
// client-side USB agent reports its attached devices).
func (b *Bridge) UpdateDeviceList(sessionID string, devices []USBDevice) {
b.mu.RLock()
bc, ok := b.sessions[sessionID]
b.mu.RUnlock()
if ok {
bc.mu.Lock()
bc.devices = devices
bc.mu.Unlock()
}
}

View File

@@ -0,0 +1,225 @@
package float
import (
"encoding/binary"
"fmt"
)
// Frame type constants define the binary protocol for USB passthrough over WebSocket.
const (
FrameEnumerate byte = 0x01
FrameEnumResult byte = 0x02
FrameOpen byte = 0x03
FrameOpenResult byte = 0x04
FrameClose byte = 0x05
FrameCloseResult byte = 0x06
FrameTransferOut byte = 0x10
FrameTransferIn byte = 0x11
FrameTransferResult byte = 0x12
FrameInterrupt byte = 0x20
FrameInterruptResult byte = 0x21
FramePing byte = 0xFE
FramePong byte = 0xFF
FrameError byte = 0xE0
)
// frameHeaderSize is the fixed size of a frame header: 1 byte type + 4 bytes length.
const frameHeaderSize = 5
// USBDevice represents a USB device detected on the client host.
type USBDevice struct {
VendorID uint16 `json:"vendor_id"`
ProductID uint16 `json:"product_id"`
DeviceID uint16 `json:"device_id"`
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
SerialNumber string `json:"serial_number"`
Class byte `json:"class"`
SubClass byte `json:"sub_class"`
}
// deviceFixedSize is the fixed portion of a serialized USBDevice:
// VendorID(2) + ProductID(2) + DeviceID(2) + Class(1) + SubClass(1) + 3 string lengths (2 each) = 14
const deviceFixedSize = 14
// EncodeFrame builds a binary frame: [type:1][length:4 big-endian][payload:N].
func EncodeFrame(frameType byte, payload []byte) []byte {
frame := make([]byte, frameHeaderSize+len(payload))
frame[0] = frameType
binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload)))
copy(frame[frameHeaderSize:], payload)
return frame
}
// DecodeFrame parses a binary frame into its type and payload.
func DecodeFrame(data []byte) (frameType byte, payload []byte, err error) {
if len(data) < frameHeaderSize {
return 0, nil, fmt.Errorf("frame too short: need at least %d bytes, got %d", frameHeaderSize, len(data))
}
frameType = data[0]
length := binary.BigEndian.Uint32(data[1:5])
if uint32(len(data)-frameHeaderSize) < length {
return 0, nil, fmt.Errorf("frame payload truncated: header says %d bytes, have %d", length, len(data)-frameHeaderSize)
}
payload = make([]byte, length)
copy(payload, data[frameHeaderSize:frameHeaderSize+int(length)])
return frameType, payload, nil
}
// encodeString writes a length-prefixed string (2-byte big-endian length + bytes).
func encodeString(buf []byte, offset int, s string) int {
b := []byte(s)
binary.BigEndian.PutUint16(buf[offset:], uint16(len(b)))
offset += 2
copy(buf[offset:], b)
return offset + len(b)
}
// decodeString reads a length-prefixed string from the buffer.
func decodeString(data []byte, offset int) (string, int, error) {
if offset+2 > len(data) {
return "", 0, fmt.Errorf("string length truncated at offset %d", offset)
}
slen := int(binary.BigEndian.Uint16(data[offset:]))
offset += 2
if offset+slen > len(data) {
return "", 0, fmt.Errorf("string data truncated at offset %d: need %d bytes", offset, slen)
}
s := string(data[offset : offset+slen])
return s, offset + slen, nil
}
// serializeDevice serializes a single USBDevice into bytes.
func serializeDevice(dev USBDevice) []byte {
mfr := []byte(dev.Manufacturer)
prod := []byte(dev.Product)
ser := []byte(dev.SerialNumber)
size := deviceFixedSize + len(mfr) + len(prod) + len(ser)
buf := make([]byte, size)
binary.BigEndian.PutUint16(buf[0:], dev.VendorID)
binary.BigEndian.PutUint16(buf[2:], dev.ProductID)
binary.BigEndian.PutUint16(buf[4:], dev.DeviceID)
buf[6] = dev.Class
buf[7] = dev.SubClass
off := 8
off = encodeString(buf, off, dev.Manufacturer)
off = encodeString(buf, off, dev.Product)
_ = encodeString(buf, off, dev.SerialNumber)
return buf
}
// EncodeDeviceList serializes a slice of USBDevices for a FrameEnumResult payload.
// Format: [count:2 big-endian][device...]
func EncodeDeviceList(devices []USBDevice) []byte {
// First pass: serialize each device to compute total size
serialized := make([][]byte, len(devices))
totalSize := 2 // 2 bytes for count
for i, dev := range devices {
serialized[i] = serializeDevice(dev)
totalSize += len(serialized[i])
}
buf := make([]byte, totalSize)
binary.BigEndian.PutUint16(buf[0:], uint16(len(devices)))
off := 2
for _, s := range serialized {
copy(buf[off:], s)
off += len(s)
}
return buf
}
// DecodeDeviceList deserializes a FrameEnumResult payload into a slice of USBDevices.
func DecodeDeviceList(data []byte) ([]USBDevice, error) {
if len(data) < 2 {
return nil, fmt.Errorf("device list too short: need at least 2 bytes")
}
count := int(binary.BigEndian.Uint16(data[0:]))
off := 2
devices := make([]USBDevice, 0, count)
for i := 0; i < count; i++ {
if off+8 > len(data) {
return nil, fmt.Errorf("device %d: fixed fields truncated at offset %d", i, off)
}
dev := USBDevice{
VendorID: binary.BigEndian.Uint16(data[off:]),
ProductID: binary.BigEndian.Uint16(data[off+2:]),
DeviceID: binary.BigEndian.Uint16(data[off+4:]),
Class: data[off+6],
SubClass: data[off+7],
}
off += 8
var err error
dev.Manufacturer, off, err = decodeString(data, off)
if err != nil {
return nil, fmt.Errorf("device %d manufacturer: %w", i, err)
}
dev.Product, off, err = decodeString(data, off)
if err != nil {
return nil, fmt.Errorf("device %d product: %w", i, err)
}
dev.SerialNumber, off, err = decodeString(data, off)
if err != nil {
return nil, fmt.Errorf("device %d serial: %w", i, err)
}
devices = append(devices, dev)
}
return devices, nil
}
// EncodeTransfer serializes a USB transfer payload.
// Format: [deviceID:2][endpoint:1][data:N]
func EncodeTransfer(deviceID uint16, endpoint byte, data []byte) []byte {
buf := make([]byte, 3+len(data))
binary.BigEndian.PutUint16(buf[0:], deviceID)
buf[2] = endpoint
copy(buf[3:], data)
return buf
}
// DecodeTransfer deserializes a USB transfer payload.
func DecodeTransfer(data []byte) (deviceID uint16, endpoint byte, transferData []byte, err error) {
if len(data) < 3 {
return 0, 0, nil, fmt.Errorf("transfer payload too short: need at least 3 bytes, got %d", len(data))
}
deviceID = binary.BigEndian.Uint16(data[0:])
endpoint = data[2]
transferData = make([]byte, len(data)-3)
copy(transferData, data[3:])
return deviceID, endpoint, transferData, nil
}
// EncodeError serializes an error response payload.
// Format: [code:2 big-endian][message:UTF-8 bytes]
func EncodeError(code uint16, message string) []byte {
msg := []byte(message)
buf := make([]byte, 2+len(msg))
binary.BigEndian.PutUint16(buf[0:], code)
copy(buf[2:], msg)
return buf
}
// DecodeError deserializes an error response payload.
func DecodeError(data []byte) (code uint16, message string) {
if len(data) < 2 {
return 0, ""
}
code = binary.BigEndian.Uint16(data[0:])
message = string(data[2:])
return code, message
}

View File

@@ -0,0 +1,248 @@
package float
import (
"fmt"
"log"
"sync"
"time"
"setec-manager/internal/db"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
// Session represents an active Float Mode session, combining database state
// with the live WebSocket connection reference.
type Session struct {
ID string `json:"id"`
UserID int64 `json:"user_id"`
ClientIP string `json:"client_ip"`
ClientAgent string `json:"client_agent"`
USBBridge bool `json:"usb_bridge"`
ConnectedAt time.Time `json:"connected_at"`
ExpiresAt time.Time `json:"expires_at"`
LastPing *time.Time `json:"last_ping,omitempty"`
conn *websocket.Conn
}
// SessionManager provides in-memory + database-backed session lifecycle
// management for Float Mode connections.
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
db *db.DB
}
// NewSessionManager creates a new SessionManager backed by the given database.
func NewSessionManager(database *db.DB) *SessionManager {
return &SessionManager{
sessions: make(map[string]*Session),
db: database,
}
}
// Create generates a new Float session with a random UUID, storing it in both
// the in-memory map and the database.
func (sm *SessionManager) Create(userID int64, clientIP, agent string, ttl time.Duration) (string, error) {
id := uuid.New().String()
now := time.Now()
expiresAt := now.Add(ttl)
session := &Session{
ID: id,
UserID: userID,
ClientIP: clientIP,
ClientAgent: agent,
ConnectedAt: now,
ExpiresAt: expiresAt,
}
// Persist to database first
if err := sm.db.CreateFloatSession(id, userID, clientIP, agent, expiresAt); err != nil {
return "", fmt.Errorf("create session: db insert: %w", err)
}
// Store in memory
sm.mu.Lock()
sm.sessions[id] = session
sm.mu.Unlock()
log.Printf("[float/session] created session %s for user %d from %s (expires %s)",
id, userID, clientIP, expiresAt.Format(time.RFC3339))
return id, nil
}
// Get retrieves a session by ID, checking the in-memory cache first, then
// falling back to the database. Returns nil and an error if not found.
func (sm *SessionManager) Get(id string) (*Session, error) {
// Check memory first
sm.mu.RLock()
if sess, ok := sm.sessions[id]; ok {
sm.mu.RUnlock()
// Check if expired
if time.Now().After(sess.ExpiresAt) {
sm.Delete(id)
return nil, fmt.Errorf("session %s has expired", id)
}
return sess, nil
}
sm.mu.RUnlock()
// Fall back to database
dbSess, err := sm.db.GetFloatSession(id)
if err != nil {
return nil, fmt.Errorf("get session: %w", err)
}
// Check if expired
if time.Now().After(dbSess.ExpiresAt) {
sm.db.DeleteFloatSession(id)
return nil, fmt.Errorf("session %s has expired", id)
}
// Hydrate into memory
session := &Session{
ID: dbSess.ID,
UserID: dbSess.UserID,
ClientIP: dbSess.ClientIP,
ClientAgent: dbSess.ClientAgent,
USBBridge: dbSess.USBBridge,
ConnectedAt: dbSess.ConnectedAt,
ExpiresAt: dbSess.ExpiresAt,
LastPing: dbSess.LastPing,
}
sm.mu.Lock()
sm.sessions[id] = session
sm.mu.Unlock()
return session, nil
}
// Delete removes a session from both the in-memory map and the database.
func (sm *SessionManager) Delete(id string) error {
sm.mu.Lock()
sess, ok := sm.sessions[id]
if ok {
// Close the WebSocket connection if it exists
if sess.conn != nil {
sess.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session deleted"),
time.Now().Add(5*time.Second),
)
sess.conn.Close()
}
delete(sm.sessions, id)
}
sm.mu.Unlock()
if err := sm.db.DeleteFloatSession(id); err != nil {
return fmt.Errorf("delete session: db delete: %w", err)
}
log.Printf("[float/session] deleted session %s", id)
return nil
}
// Ping updates the last-ping timestamp for a session in both memory and DB.
func (sm *SessionManager) Ping(id string) error {
now := time.Now()
sm.mu.Lock()
if sess, ok := sm.sessions[id]; ok {
sess.LastPing = &now
}
sm.mu.Unlock()
if err := sm.db.PingFloatSession(id); err != nil {
return fmt.Errorf("ping session: %w", err)
}
return nil
}
// CleanExpired removes all sessions that have passed their expiry time.
// Returns the number of sessions removed.
func (sm *SessionManager) CleanExpired() (int, error) {
now := time.Now()
// Clean from memory
sm.mu.Lock()
var expiredIDs []string
for id, sess := range sm.sessions {
if now.After(sess.ExpiresAt) {
expiredIDs = append(expiredIDs, id)
if sess.conn != nil {
sess.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session expired"),
now.Add(5*time.Second),
)
sess.conn.Close()
}
}
}
for _, id := range expiredIDs {
delete(sm.sessions, id)
}
sm.mu.Unlock()
// Clean from database
count, err := sm.db.CleanExpiredFloatSessions()
if err != nil {
return len(expiredIDs), fmt.Errorf("clean expired: db: %w", err)
}
total := int(count)
if total > 0 {
log.Printf("[float/session] cleaned %d expired sessions", total)
}
return total, nil
}
// ActiveCount returns the number of sessions currently in the in-memory map.
func (sm *SessionManager) ActiveCount() int {
sm.mu.RLock()
defer sm.mu.RUnlock()
return len(sm.sessions)
}
// SetConn associates a WebSocket connection with a session.
func (sm *SessionManager) SetConn(id string, conn *websocket.Conn) {
sm.mu.Lock()
if sess, ok := sm.sessions[id]; ok {
sess.conn = conn
sess.USBBridge = true
}
sm.mu.Unlock()
}
// List returns all active (non-expired) sessions from the database.
func (sm *SessionManager) List() ([]Session, error) {
dbSessions, err := sm.db.ListFloatSessions()
if err != nil {
return nil, fmt.Errorf("list sessions: %w", err)
}
sessions := make([]Session, 0, len(dbSessions))
for _, dbs := range dbSessions {
if time.Now().After(dbs.ExpiresAt) {
continue
}
sessions = append(sessions, Session{
ID: dbs.ID,
UserID: dbs.UserID,
ClientIP: dbs.ClientIP,
ClientAgent: dbs.ClientAgent,
USBBridge: dbs.USBBridge,
ConnectedAt: dbs.ConnectedAt,
ExpiresAt: dbs.ExpiresAt,
LastPing: dbs.LastPing,
})
}
return sessions, nil
}

View File

@@ -0,0 +1,272 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"setec-manager/internal/deploy"
)
type autarchStatus struct {
Installed bool `json:"installed"`
InstallDir string `json:"install_dir"`
GitCommit string `json:"git_commit"`
VenvReady bool `json:"venv_ready"`
PipPackages int `json:"pip_packages"`
WebRunning bool `json:"web_running"`
WebStatus string `json:"web_status"`
DNSRunning bool `json:"dns_running"`
DNSStatus string `json:"dns_status"`
}
func (h *Handler) AutarchStatus(w http.ResponseWriter, r *http.Request) {
status := h.getAutarchStatus()
h.render(w, "autarch.html", status)
}
func (h *Handler) AutarchStatusAPI(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.getAutarchStatus())
}
func (h *Handler) getAutarchStatus() autarchStatus {
dir := h.Config.Autarch.InstallDir
status := autarchStatus{InstallDir: dir}
// Check if installed
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
status.Installed = true
}
// Git commit
if hash, message, err := deploy.CurrentCommit(dir); err == nil {
status.GitCommit = hash + " " + message
}
// Venv
status.VenvReady = deploy.VenvExists(dir)
// Pip packages
venvDir := filepath.Join(dir, "venv")
if pkgs, err := deploy.ListPackages(venvDir); err == nil {
status.PipPackages = len(pkgs)
}
// Web service
webActive, _ := deploy.IsActive("autarch-web")
status.WebRunning = webActive
if webActive {
status.WebStatus = "active"
} else {
status.WebStatus = "inactive"
}
// DNS service
dnsActive, _ := deploy.IsActive("autarch-dns")
status.DNSRunning = dnsActive
if dnsActive {
status.DNSStatus = "active"
} else {
status.DNSStatus = "inactive"
}
return status
}
func (h *Handler) AutarchInstall(w http.ResponseWriter, r *http.Request) {
dir := h.Config.Autarch.InstallDir
repo := h.Config.Autarch.GitRepo
branch := h.Config.Autarch.GitBranch
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
writeError(w, http.StatusConflict, "AUTARCH already installed at "+dir)
return
}
depID, _ := h.DB.CreateDeployment(nil, "autarch_install")
var output strings.Builder
steps := []struct {
label string
fn func() error
}{
{"Clone from GitHub", func() error {
os.MkdirAll(filepath.Dir(dir), 0755)
out, err := deploy.Clone(repo, branch, dir)
output.WriteString(out)
return err
}},
{"Create Python venv", func() error {
return deploy.CreateVenv(dir)
}},
{"Upgrade pip", func() error {
venvDir := filepath.Join(dir, "venv")
deploy.UpgradePip(venvDir)
return nil
}},
{"Install pip packages", func() error {
reqFile := filepath.Join(dir, "requirements.txt")
if _, err := os.Stat(reqFile); err != nil {
return nil
}
venvDir := filepath.Join(dir, "venv")
out, err := deploy.InstallRequirements(venvDir, reqFile)
output.WriteString(out)
return err
}},
{"Install npm packages", func() error {
out, _ := deploy.NpmInstall(dir)
output.WriteString(out)
return nil
}},
{"Set permissions", func() error {
exec.Command("chown", "-R", "root:root", dir).Run()
exec.Command("chmod", "-R", "755", dir).Run()
for _, d := range []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"} {
os.MkdirAll(filepath.Join(dir, d), 0755)
}
confPath := filepath.Join(dir, "autarch_settings.conf")
if _, err := os.Stat(confPath); err == nil {
exec.Command("chmod", "600", confPath).Run()
}
return nil
}},
{"Install systemd units", func() error {
h.installAutarchUnits(dir)
return nil
}},
}
for _, step := range steps {
output.WriteString(fmt.Sprintf("\n=== %s ===\n", step.label))
if err := step.fn(); err != nil {
h.DB.FinishDeployment(depID, "failed", output.String())
writeError(w, http.StatusInternalServerError, fmt.Sprintf("%s failed: %v", step.label, err))
return
}
}
h.DB.FinishDeployment(depID, "success", output.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "installed"})
}
func (h *Handler) AutarchUpdate(w http.ResponseWriter, r *http.Request) {
dir := h.Config.Autarch.InstallDir
depID, _ := h.DB.CreateDeployment(nil, "autarch_update")
var output strings.Builder
// Git pull
out, err := deploy.Pull(dir)
output.WriteString(out)
if err != nil {
h.DB.FinishDeployment(depID, "failed", output.String())
writeError(w, http.StatusInternalServerError, "git pull failed")
return
}
// Reinstall pip packages
reqFile := filepath.Join(dir, "requirements.txt")
if _, err := os.Stat(reqFile); err == nil {
venvDir := filepath.Join(dir, "venv")
pipOut, _ := deploy.InstallRequirements(venvDir, reqFile)
output.WriteString(pipOut)
}
// Restart services
deploy.Restart("autarch-web")
deploy.Restart("autarch-dns")
h.DB.FinishDeployment(depID, "success", output.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *Handler) AutarchStart(w http.ResponseWriter, r *http.Request) {
deploy.Start("autarch-web")
deploy.Start("autarch-dns")
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
}
func (h *Handler) AutarchStop(w http.ResponseWriter, r *http.Request) {
deploy.Stop("autarch-web")
deploy.Stop("autarch-dns")
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
func (h *Handler) AutarchRestart(w http.ResponseWriter, r *http.Request) {
deploy.Restart("autarch-web")
deploy.Restart("autarch-dns")
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
}
func (h *Handler) AutarchConfig(w http.ResponseWriter, r *http.Request) {
confPath := filepath.Join(h.Config.Autarch.InstallDir, "autarch_settings.conf")
data, err := os.ReadFile(confPath)
if err != nil {
writeError(w, http.StatusNotFound, "config not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"config": string(data)})
}
func (h *Handler) AutarchConfigUpdate(w http.ResponseWriter, r *http.Request) {
var body struct {
Config string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
confPath := filepath.Join(h.Config.Autarch.InstallDir, "autarch_settings.conf")
if err := os.WriteFile(confPath, []byte(body.Config), 0600); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "saved"})
}
func (h *Handler) AutarchDNSBuild(w http.ResponseWriter, r *http.Request) {
dnsDir := filepath.Join(h.Config.Autarch.InstallDir, "services", "dns-server")
depID, _ := h.DB.CreateDeployment(nil, "dns_build")
cmd := exec.Command("go", "build", "-o", "autarch-dns", ".")
cmd.Dir = dnsDir
out, err := cmd.CombinedOutput()
if err != nil {
h.DB.FinishDeployment(depID, "failed", string(out))
writeError(w, http.StatusInternalServerError, "build failed: "+string(out))
return
}
h.DB.FinishDeployment(depID, "success", string(out))
writeJSON(w, http.StatusOK, map[string]string{"status": "built"})
}
func (h *Handler) installAutarchUnits(dir string) {
webUnit := deploy.GenerateUnit(deploy.UnitConfig{
Name: "autarch-web",
Description: "AUTARCH Web Dashboard",
ExecStart: filepath.Join(dir, "venv", "bin", "python3") + " " + filepath.Join(dir, "autarch_web.py"),
WorkingDirectory: dir,
User: "root",
Environment: map[string]string{"PYTHONUNBUFFERED": "1"},
})
dnsUnit := deploy.GenerateUnit(deploy.UnitConfig{
Name: "autarch-dns",
Description: "AUTARCH DNS Server",
ExecStart: filepath.Join(dir, "services", "dns-server", "autarch-dns") + " --config " + filepath.Join(dir, "data", "dns", "config.json"),
WorkingDirectory: dir,
User: "root",
})
deploy.InstallUnit("autarch-web", webUnit)
deploy.InstallUnit("autarch-dns", dnsUnit)
}

View File

@@ -0,0 +1,146 @@
package handlers
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"
)
func (h *Handler) BackupList(w http.ResponseWriter, r *http.Request) {
backups, err := h.DB.ListBackups()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, backups)
return
}
h.render(w, "backups.html", backups)
}
func (h *Handler) BackupSite(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
site, err := h.DB.GetSite(id)
if err != nil || site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
// Create backup directory
backupDir := h.Config.Backups.Dir
os.MkdirAll(backupDir, 0755)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("site-%s-%s.tar.gz", site.Domain, timestamp)
backupPath := filepath.Join(backupDir, filename)
// Create tar.gz
cmd := exec.Command("tar", "-czf", backupPath, "-C", filepath.Dir(site.AppRoot), filepath.Base(site.AppRoot))
out, err := cmd.CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("backup failed: %s", string(out)))
return
}
// Get file size
info, _ := os.Stat(backupPath)
size := int64(0)
if info != nil {
size = info.Size()
}
bID, _ := h.DB.CreateBackup(&id, "site", backupPath, size)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": bID,
"path": backupPath,
"size": size,
})
}
func (h *Handler) BackupFull(w http.ResponseWriter, r *http.Request) {
backupDir := h.Config.Backups.Dir
os.MkdirAll(backupDir, 0755)
timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("full-system-%s.tar.gz", timestamp)
backupPath := filepath.Join(backupDir, filename)
// Backup key directories
dirs := []string{
h.Config.Nginx.Webroot,
"/etc/nginx",
"/opt/setec-manager/data",
}
args := []string{"-czf", backupPath}
for _, d := range dirs {
if _, err := os.Stat(d); err == nil {
args = append(args, d)
}
}
cmd := exec.Command("tar", args...)
out, err := cmd.CombinedOutput()
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("backup failed: %s", string(out)))
return
}
info, _ := os.Stat(backupPath)
size := int64(0)
if info != nil {
size = info.Size()
}
bID, _ := h.DB.CreateBackup(nil, "full", backupPath, size)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"id": bID,
"path": backupPath,
"size": size,
})
}
func (h *Handler) BackupDelete(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
// Get backup info to delete file
var filePath string
h.DB.Conn().QueryRow(`SELECT file_path FROM backups WHERE id=?`, id).Scan(&filePath)
if filePath != "" {
os.Remove(filePath)
}
h.DB.DeleteBackup(id)
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handler) BackupDownload(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var filePath string
h.DB.Conn().QueryRow(`SELECT file_path FROM backups WHERE id=?`, id).Scan(&filePath)
if filePath == "" {
writeError(w, http.StatusNotFound, "backup not found")
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(filePath)))
http.ServeFile(w, r, filePath)
}

View File

@@ -0,0 +1,151 @@
package handlers
import (
"fmt"
"net/http"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"setec-manager/internal/deploy"
"setec-manager/internal/system"
)
type systemInfo struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
CPUs int `json:"cpus"`
Uptime string `json:"uptime"`
LoadAvg string `json:"load_avg"`
MemTotal string `json:"mem_total"`
MemUsed string `json:"mem_used"`
MemPercent float64 `json:"mem_percent"`
DiskTotal string `json:"disk_total"`
DiskUsed string `json:"disk_used"`
DiskPercent float64 `json:"disk_percent"`
SiteCount int `json:"site_count"`
Services []serviceInfo `json:"services"`
}
type serviceInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Running bool `json:"running"`
}
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
info := h.gatherSystemInfo()
h.render(w, "dashboard.html", info)
}
func (h *Handler) SystemInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.gatherSystemInfo())
}
func (h *Handler) gatherSystemInfo() systemInfo {
info := systemInfo{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
CPUs: runtime.NumCPU(),
}
// Hostname — no wrapper, keep exec.Command
if out, err := exec.Command("hostname").Output(); err == nil {
info.Hostname = strings.TrimSpace(string(out))
}
// Uptime
if ut, err := system.GetUptime(); err == nil {
info.Uptime = "up " + ut.HumanReadable
}
// Load average
if la, err := system.GetLoadAvg(); err == nil {
info.LoadAvg = fmt.Sprintf("%.2f %.2f %.2f", la.Load1, la.Load5, la.Load15)
}
// Memory
if mem, err := system.GetMemory(); err == nil {
info.MemTotal = mem.Total
info.MemUsed = mem.Used
if mem.TotalBytes > 0 {
info.MemPercent = float64(mem.UsedBytes) / float64(mem.TotalBytes) * 100
}
}
// Disk — find the root mount from the disk list
if disks, err := system.GetDisk(); err == nil {
for _, d := range disks {
if d.MountPoint == "/" {
info.DiskTotal = d.Size
info.DiskUsed = d.Used
pct := strings.TrimSuffix(d.UsePercent, "%")
info.DiskPercent, _ = strconv.ParseFloat(pct, 64)
break
}
}
// If no root mount found but we have disks, use the first one
if info.DiskTotal == "" && len(disks) > 0 {
d := disks[0]
info.DiskTotal = d.Size
info.DiskUsed = d.Used
pct := strings.TrimSuffix(d.UsePercent, "%")
info.DiskPercent, _ = strconv.ParseFloat(pct, 64)
}
}
// Site count
if sites, err := h.DB.ListSites(); err == nil {
info.SiteCount = len(sites)
}
// Services
services := []struct{ name, unit string }{
{"Nginx", "nginx"},
{"AUTARCH Web", "autarch-web"},
{"AUTARCH DNS", "autarch-dns"},
{"Setec Manager", "setec-manager"},
}
for _, svc := range services {
si := serviceInfo{Name: svc.name}
active, err := deploy.IsActive(svc.unit)
if err == nil && active {
si.Status = "active"
si.Running = true
} else {
si.Status = "inactive"
si.Running = false
}
info.Services = append(info.Services, si)
}
return info
}
func formatBytes(b float64) string {
units := []string{"B", "KB", "MB", "GB", "TB"}
i := 0
for b >= 1024 && i < len(units)-1 {
b /= 1024
i++
}
return strconv.FormatFloat(b, 'f', 1, 64) + " " + units[i]
}
// uptimeSince returns a human-readable duration.
func uptimeSince(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return strconv.Itoa(days) + "d " + strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
}
if hours > 0 {
return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
}
return strconv.Itoa(mins) + "m"
}

View File

@@ -0,0 +1,184 @@
package handlers
import (
"encoding/json"
"net/http"
"setec-manager/internal/system"
)
type firewallRule struct {
ID int64 `json:"id"`
Direction string `json:"direction"`
Protocol string `json:"protocol"`
Port string `json:"port"`
Source string `json:"source"`
Action string `json:"action"`
Comment string `json:"comment"`
}
type firewallStatus struct {
Enabled bool `json:"enabled"`
Rules []firewallRule `json:"rules"`
UFWOut string `json:"ufw_output"`
}
func (h *Handler) FirewallList(w http.ResponseWriter, r *http.Request) {
status := h.getFirewallStatus()
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, status)
return
}
h.render(w, "firewall.html", status)
}
func (h *Handler) FirewallStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.getFirewallStatus())
}
func (h *Handler) FirewallAddRule(w http.ResponseWriter, r *http.Request) {
var rule firewallRule
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
rule.Port = r.FormValue("port")
rule.Protocol = r.FormValue("protocol")
rule.Source = r.FormValue("source")
rule.Action = r.FormValue("action")
rule.Comment = r.FormValue("comment")
}
if rule.Port == "" {
writeError(w, http.StatusBadRequest, "port is required")
return
}
if rule.Protocol == "" {
rule.Protocol = "tcp"
}
if rule.Action == "" {
rule.Action = "allow"
}
if rule.Source == "" {
rule.Source = "any"
}
ufwRule := system.UFWRule{
Port: rule.Port,
Protocol: rule.Protocol,
Source: rule.Source,
Action: rule.Action,
Comment: rule.Comment,
}
if err := system.FirewallAddRule(ufwRule); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Save to DB
h.DB.Conn().Exec(`INSERT INTO firewall_rules (direction, protocol, port, source, action, comment)
VALUES (?, ?, ?, ?, ?, ?)`, "in", rule.Protocol, rule.Port, rule.Source, rule.Action, rule.Comment)
writeJSON(w, http.StatusCreated, map[string]string{"status": "rule added"})
}
func (h *Handler) FirewallDeleteRule(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
// Get rule from DB to build delete command
var port, protocol, action string
err = h.DB.Conn().QueryRow(`SELECT port, protocol, action FROM firewall_rules WHERE id=?`, id).
Scan(&port, &protocol, &action)
if err != nil {
writeError(w, http.StatusNotFound, "rule not found")
return
}
system.FirewallDeleteRule(system.UFWRule{
Port: port,
Protocol: protocol,
Action: action,
})
h.DB.Conn().Exec(`DELETE FROM firewall_rules WHERE id=?`, id)
writeJSON(w, http.StatusOK, map[string]string{"status": "rule deleted"})
}
func (h *Handler) FirewallEnable(w http.ResponseWriter, r *http.Request) {
if err := system.FirewallEnable(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
}
func (h *Handler) FirewallDisable(w http.ResponseWriter, r *http.Request) {
if err := system.FirewallDisable(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
}
func (h *Handler) getFirewallStatus() firewallStatus {
status := firewallStatus{}
enabled, _, raw, _ := system.FirewallStatus()
status.UFWOut = raw
status.Enabled = enabled
// Load rules from DB
rows, err := h.DB.Conn().Query(`SELECT id, direction, protocol, port, source, action, comment
FROM firewall_rules WHERE enabled=TRUE ORDER BY id`)
if err == nil {
defer rows.Close()
for rows.Next() {
var rule firewallRule
rows.Scan(&rule.ID, &rule.Direction, &rule.Protocol, &rule.Port,
&rule.Source, &rule.Action, &rule.Comment)
status.Rules = append(status.Rules, rule)
}
}
return status
}
func (h *Handler) InstallDefaultFirewall() error {
// Set default policies
system.FirewallSetDefaults("deny", "allow")
// Add default rules
defaultRules := []system.UFWRule{
{Port: "22", Protocol: "tcp", Action: "allow", Comment: "SSH"},
{Port: "80", Protocol: "tcp", Action: "allow", Comment: "HTTP"},
{Port: "443", Protocol: "tcp", Action: "allow", Comment: "HTTPS"},
{Port: "9090", Protocol: "tcp", Action: "allow", Comment: "Setec Manager"},
{Port: "8181", Protocol: "tcp", Action: "allow", Comment: "AUTARCH Web"},
{Port: "53", Protocol: "", Action: "allow", Comment: "AUTARCH DNS"},
}
for _, rule := range defaultRules {
system.FirewallAddRule(rule)
}
// Enable the firewall
system.FirewallEnable()
// Record in DB
dbRules := []firewallRule{
{Port: "22", Protocol: "tcp", Action: "allow", Comment: "SSH"},
{Port: "80", Protocol: "tcp", Action: "allow", Comment: "HTTP"},
{Port: "443", Protocol: "tcp", Action: "allow", Comment: "HTTPS"},
{Port: "9090", Protocol: "tcp", Action: "allow", Comment: "Setec Manager"},
{Port: "8181", Protocol: "tcp", Action: "allow", Comment: "AUTARCH Web"},
{Port: "53", Protocol: "tcp", Action: "allow", Comment: "AUTARCH DNS"},
}
for _, rule := range dbRules {
h.DB.Conn().Exec(`INSERT OR IGNORE INTO firewall_rules (direction, protocol, port, source, action, comment)
VALUES ('in', ?, ?, 'any', ?, ?)`, rule.Protocol, rule.Port, rule.Action, rule.Comment)
}
return nil
}

View File

@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
)
func (h *Handler) FloatRegister(w http.ResponseWriter, r *http.Request) {
if !h.Config.Float.Enabled {
writeError(w, http.StatusServiceUnavailable, "Float Mode is disabled")
return
}
var body struct {
UserAgent string `json:"user_agent"`
}
json.NewDecoder(r.Body).Decode(&body)
// Parse TTL
ttl, err := time.ParseDuration(h.Config.Float.SessionTTL)
if err != nil {
ttl = 24 * time.Hour
}
sessionID := uuid.New().String()
clientIP := r.RemoteAddr
if err := h.DB.CreateFloatSession(sessionID, 0, clientIP, body.UserAgent, time.Now().Add(ttl)); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]string{
"session_id": sessionID,
"expires_in": h.Config.Float.SessionTTL,
})
}
func (h *Handler) FloatSessions(w http.ResponseWriter, r *http.Request) {
// Clean expired sessions first
h.DB.CleanExpiredFloatSessions()
sessions, err := h.DB.ListFloatSessions()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, sessions)
return
}
h.render(w, "float.html", sessions)
}
func (h *Handler) FloatDisconnect(w http.ResponseWriter, r *http.Request) {
id := paramStr(r, "id")
if err := h.DB.DeleteFloatSession(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "disconnected"})
}

View File

@@ -0,0 +1,103 @@
package handlers
import (
"encoding/json"
"html/template"
"io/fs"
"log"
"net/http"
"strconv"
"sync"
"setec-manager/internal/config"
"setec-manager/internal/db"
"setec-manager/internal/hosting"
"setec-manager/web"
"github.com/go-chi/chi/v5"
)
type Handler struct {
Config *config.Config
DB *db.DB
HostingConfigs *hosting.ProviderConfigStore
tmpl *template.Template
once sync.Once
}
func New(cfg *config.Config, database *db.DB, hostingConfigs *hosting.ProviderConfigStore) *Handler {
return &Handler{
Config: cfg,
DB: database,
HostingConfigs: hostingConfigs,
}
}
func (h *Handler) getTemplates() *template.Template {
h.once.Do(func() {
funcMap := template.FuncMap{
"eq": func(a, b interface{}) bool { return a == b },
"ne": func(a, b interface{}) bool { return a != b },
"default": func(val, def interface{}) interface{} {
if val == nil || val == "" || val == 0 || val == false {
return def
}
return val
},
}
var err error
h.tmpl, err = template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, "templates/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
// Also parse from the static FS to make sure it's available
_ = fs.WalkDir(web.StaticFS, ".", func(path string, d fs.DirEntry, err error) error {
return nil
})
})
return h.tmpl
}
type pageData struct {
Title string
Data interface{}
Config *config.Config
}
func (h *Handler) render(w http.ResponseWriter, name string, data interface{}) {
pd := pageData{
Data: data,
Config: h.Config,
}
t := h.getTemplates().Lookup(name)
if t == nil {
http.Error(w, "Template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.Execute(w, pd); err != nil {
log.Printf("[template] %s: %v", name, err)
}
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func paramInt(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(chi.URLParam(r, name), 10, 64)
}
func paramStr(r *http.Request, name string) string {
return chi.URLParam(r, name)
}

View File

@@ -0,0 +1,697 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
"setec-manager/internal/hosting"
)
// providerInfo is the view model sent to the hosting template and JSON responses.
type providerInfo struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Connected bool `json:"connected"`
HasConfig bool `json:"has_config"`
}
// listProviderInfo builds a summary of every registered provider and its config status.
func (h *Handler) listProviderInfo() []providerInfo {
names := hosting.List()
out := make([]providerInfo, 0, len(names))
for _, name := range names {
p, ok := hosting.Get(name)
if !ok {
continue
}
pi := providerInfo{
Name: p.Name(),
DisplayName: p.DisplayName(),
}
if h.HostingConfigs != nil {
cfg, err := h.HostingConfigs.Load(name)
if err == nil && cfg != nil {
pi.HasConfig = true
if cfg.APIKey != "" {
pi.Connected = true
}
}
}
out = append(out, pi)
}
return out
}
// getProvider retrieves the provider from the URL and returns it. On error it
// writes an HTTP error and returns nil.
func (h *Handler) getProvider(w http.ResponseWriter, r *http.Request) hosting.Provider {
name := paramStr(r, "provider")
if name == "" {
writeError(w, http.StatusBadRequest, "missing provider name")
return nil
}
p, ok := hosting.Get(name)
if !ok {
writeError(w, http.StatusNotFound, "hosting provider "+name+" not registered")
return nil
}
return p
}
// configureProvider loads saved credentials for a provider and calls Configure
// on it so it is ready for API calls. Returns false if no config is saved.
func (h *Handler) configureProvider(p hosting.Provider) bool {
if h.HostingConfigs == nil {
return false
}
cfg, err := h.HostingConfigs.Load(p.Name())
if err != nil || cfg == nil || cfg.APIKey == "" {
return false
}
if err := p.Configure(*cfg); err != nil {
log.Printf("[hosting] configure %s: %v", p.Name(), err)
return false
}
return true
}
// ─── Page Handlers ───────────────────────────────────────────────────────────
// HostingProviders renders the hosting management page (GET /hosting).
func (h *Handler) HostingProviders(w http.ResponseWriter, r *http.Request) {
providers := h.listProviderInfo()
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, providers)
return
}
h.render(w, "hosting.html", map[string]interface{}{
"Providers": providers,
})
}
// HostingProviderConfig returns the config page/detail for a single provider.
func (h *Handler) HostingProviderConfig(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
var savedCfg *hosting.ProviderConfig
if h.HostingConfigs != nil {
savedCfg, _ = h.HostingConfigs.Load(p.Name())
}
data := map[string]interface{}{
"Provider": providerInfo{Name: p.Name(), DisplayName: p.DisplayName()},
"Config": savedCfg,
"Providers": h.listProviderInfo(),
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, data)
return
}
h.render(w, "hosting.html", data)
}
// ─── Configuration ───────────────────────────────────────────────────────────
// HostingProviderSave saves API credentials and tests the connection.
func (h *Handler) HostingProviderSave(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
var body struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret"`
Extra map[string]string `json:"extra"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.APIKey == "" {
writeError(w, http.StatusBadRequest, "api_key is required")
return
}
cfg := hosting.ProviderConfig{
APIKey: body.APIKey,
APISecret: body.APISecret,
Extra: body.Extra,
}
// Configure the provider to validate credentials.
if err := p.Configure(cfg); err != nil {
writeError(w, http.StatusBadRequest, "configure: "+err.Error())
return
}
// Test the connection.
connected := true
if err := p.TestConnection(); err != nil {
log.Printf("[hosting] test %s failed: %v", p.Name(), err)
connected = false
}
// Persist.
if h.HostingConfigs != nil {
if err := h.HostingConfigs.Save(p.Name(), cfg); err != nil {
writeError(w, http.StatusInternalServerError, "save config: "+err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "saved",
"connected": connected,
})
}
// HostingProviderTest tests the connection to a provider without saving.
func (h *Handler) HostingProviderTest(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured — save credentials first")
return
}
if err := p.TestConnection(); err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"connected": false,
"error": err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"connected": true,
})
}
// ─── DNS ─────────────────────────────────────────────────────────────────────
// HostingDNSList returns DNS records for a domain.
func (h *Handler) HostingDNSList(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
records, err := p.ListDNSRecords(domain)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, records)
}
// HostingDNSUpdate replaces DNS records for a domain.
func (h *Handler) HostingDNSUpdate(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
var body struct {
Records []hosting.DNSRecord `json:"records"`
Overwrite bool `json:"overwrite"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := p.UpdateDNSRecords(domain, body.Records, body.Overwrite); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// HostingDNSDelete deletes DNS records matching name+type for a domain.
func (h *Handler) HostingDNSDelete(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
var body struct {
Name string `json:"name"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
filter := hosting.DNSRecordFilter{Name: body.Name, Type: body.Type}
if err := p.DeleteDNSRecord(domain, filter); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// HostingDNSReset resets DNS records for a domain to provider defaults.
func (h *Handler) HostingDNSReset(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
if err := p.ResetDNSRecords(domain); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "reset"})
}
// ─── Domains ─────────────────────────────────────────────────────────────────
// HostingDomainsList returns all domains registered with the provider.
func (h *Handler) HostingDomainsList(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domains, err := p.ListDomains()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, domains)
}
// HostingDomainsCheck checks availability of a domain across TLDs.
func (h *Handler) HostingDomainsCheck(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
var body struct {
Domain string `json:"domain"`
TLDs []string `json:"tlds"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
results, err := p.CheckDomainAvailability(body.Domain, body.TLDs)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, results)
}
// HostingDomainsPurchase purchases a domain.
func (h *Handler) HostingDomainsPurchase(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
var req hosting.DomainPurchaseRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
if req.Years <= 0 {
req.Years = 1
}
result, err := p.PurchaseDomain(req)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
// HostingDomainNameservers updates nameservers for a domain.
func (h *Handler) HostingDomainNameservers(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
var body struct {
Nameservers []string `json:"nameservers"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if len(body.Nameservers) == 0 {
writeError(w, http.StatusBadRequest, "nameservers list is empty")
return
}
if err := p.SetNameservers(domain, body.Nameservers); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
// HostingDomainLock toggles the registrar lock on a domain.
func (h *Handler) HostingDomainLock(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
var body struct {
Locked bool `json:"locked"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
var err error
if body.Locked {
err = p.EnableDomainLock(domain)
} else {
err = p.DisableDomainLock(domain)
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "updated", "locked": body.Locked})
}
// HostingDomainPrivacy toggles privacy protection on a domain.
func (h *Handler) HostingDomainPrivacy(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
domain := paramStr(r, "domain")
var body struct {
Privacy bool `json:"privacy"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
var err error
if body.Privacy {
err = p.EnablePrivacyProtection(domain)
} else {
err = p.DisablePrivacyProtection(domain)
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "updated", "privacy": body.Privacy})
}
// ─── VMs / VPS ───────────────────────────────────────────────────────────────
// HostingVMsList lists all VMs for a provider.
func (h *Handler) HostingVMsList(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
vms, err := p.ListVMs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, vms)
}
// HostingVMGet returns details for a single VM.
func (h *Handler) HostingVMGet(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
id := paramStr(r, "id")
vm, err := p.GetVM(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if vm == nil {
writeError(w, http.StatusNotFound, "VM not found")
return
}
writeJSON(w, http.StatusOK, vm)
}
// HostingVMCreate creates a new VM.
func (h *Handler) HostingVMCreate(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
var req hosting.VMCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Plan == "" {
writeError(w, http.StatusBadRequest, "plan is required")
return
}
if req.DataCenterID == "" {
writeError(w, http.StatusBadRequest, "data_center_id is required")
return
}
result, err := p.CreateVM(req)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
// HostingDataCenters lists available data centers.
func (h *Handler) HostingDataCenters(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
dcs, err := p.ListDataCenters()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, dcs)
}
// ─── SSH Keys ────────────────────────────────────────────────────────────────
// HostingSSHKeys lists SSH keys for the provider account.
func (h *Handler) HostingSSHKeys(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
keys, err := p.ListSSHKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, keys)
}
// HostingSSHKeyAdd adds an SSH key.
func (h *Handler) HostingSSHKeyAdd(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
var body struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Name == "" || body.PublicKey == "" {
writeError(w, http.StatusBadRequest, "name and public_key are required")
return
}
key, err := p.AddSSHKey(body.Name, body.PublicKey)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, key)
}
// HostingSSHKeyDelete deletes an SSH key.
func (h *Handler) HostingSSHKeyDelete(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
id := paramStr(r, "id")
if err := p.DeleteSSHKey(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ─── Billing ─────────────────────────────────────────────────────────────────
// HostingSubscriptions lists billing subscriptions.
func (h *Handler) HostingSubscriptions(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
subs, err := p.ListSubscriptions()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, subs)
}
// HostingCatalog returns the product catalog.
func (h *Handler) HostingCatalog(w http.ResponseWriter, r *http.Request) {
p := h.getProvider(w, r)
if p == nil {
return
}
if !h.configureProvider(p) {
writeError(w, http.StatusBadRequest, "provider not configured")
return
}
category := r.URL.Query().Get("category")
items, err := p.GetCatalog(category)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, items)
}

View File

@@ -0,0 +1,133 @@
package handlers
import (
"fmt"
"net/http"
"os/exec"
"strconv"
"strings"
"setec-manager/internal/deploy"
)
func (h *Handler) LogsPage(w http.ResponseWriter, r *http.Request) {
h.render(w, "logs.html", nil)
}
func (h *Handler) LogsSystem(w http.ResponseWriter, r *http.Request) {
linesStr := r.URL.Query().Get("lines")
if linesStr == "" {
linesStr = "100"
}
lines, err := strconv.Atoi(linesStr)
if err != nil {
lines = 100
}
// deploy.Logs requires a unit name; for system-wide logs we pass an empty
// unit and use journalctl directly. However, deploy.Logs always passes -u,
// so we use it with a broad scope by requesting the system journal for a
// pseudo-unit. Instead, keep using journalctl directly for system-wide logs
// since deploy.Logs is unit-scoped.
out, err := exec.Command("journalctl", "-n", strconv.Itoa(lines), "--no-pager", "-o", "short-iso").Output()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"logs": string(out)})
}
func (h *Handler) LogsNginx(w http.ResponseWriter, r *http.Request) {
logType := r.URL.Query().Get("type")
if logType == "" {
logType = "access"
}
var logPath string
switch logType {
case "access":
logPath = "/var/log/nginx/access.log"
case "error":
logPath = "/var/log/nginx/error.log"
default:
writeError(w, http.StatusBadRequest, "invalid log type")
return
}
out, err := exec.Command("tail", "-n", "200", logPath).Output()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"logs": string(out), "type": logType})
}
func (h *Handler) LogsUnit(w http.ResponseWriter, r *http.Request) {
unit := r.URL.Query().Get("unit")
if unit == "" {
writeError(w, http.StatusBadRequest, "unit parameter required")
return
}
linesStr := r.URL.Query().Get("lines")
if linesStr == "" {
linesStr = "100"
}
lines, err := strconv.Atoi(linesStr)
if err != nil {
lines = 100
}
out, err := deploy.Logs(unit, lines)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"logs": out, "unit": unit})
}
func (h *Handler) LogsStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
unit := r.URL.Query().Get("unit")
if unit == "" {
unit = "autarch-web"
}
// SSE live streaming requires journalctl -f which the deploy package does
// not support (it only returns a snapshot). Keep inline exec.Command here.
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "--no-pager", "-o", "short-iso")
stdout, err := cmd.StdoutPipe()
if err != nil {
return
}
cmd.Start()
defer cmd.Process.Kill()
buf := make([]byte, 4096)
for {
select {
case <-r.Context().Done():
return
default:
n, err := stdout.Read(buf)
if err != nil {
return
}
if n > 0 {
lines := strings.Split(strings.TrimSpace(string(buf[:n])), "\n")
for _, line := range lines {
fmt.Fprintf(w, "data: %s\n\n", line)
}
flusher.Flush()
}
}
}
}

View File

@@ -0,0 +1,126 @@
package handlers
import (
"fmt"
"net/http"
"os/exec"
"strings"
"setec-manager/internal/deploy"
"setec-manager/internal/system"
)
func (h *Handler) MonitorPage(w http.ResponseWriter, r *http.Request) {
h.render(w, "monitor.html", nil)
}
func (h *Handler) MonitorCPU(w http.ResponseWriter, r *http.Request) {
cpu, err := system.GetCPUUsage()
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"error": err.Error()})
return
}
// Build a summary line matching the previous top-style format.
sysPct := 0.0
userPct := cpu.Overall
if len(cpu.Cores) > 0 {
// Use aggregate core data for a more accurate breakdown
var totalUser, totalSys float64
for _, c := range cpu.Cores {
totalUser += c.User
totalSys += c.System
}
userPct = totalUser / float64(len(cpu.Cores))
sysPct = totalSys / float64(len(cpu.Cores))
}
cpuLine := fmt.Sprintf("%%Cpu(s): %.1f us, %.1f sy, %.1f id",
userPct, sysPct, cpu.Idle)
writeJSON(w, http.StatusOK, map[string]interface{}{
"cpu": cpuLine,
"overall": cpu.Overall,
"idle": cpu.Idle,
"cores": cpu.Cores,
})
}
func (h *Handler) MonitorMemory(w http.ResponseWriter, r *http.Request) {
mem, err := system.GetMemory()
if err != nil {
writeJSON(w, http.StatusOK, map[string]string{"error": err.Error()})
return
}
result := map[string]interface{}{
"total": mem.Total,
"used": mem.Used,
"free": mem.Free,
"available": mem.Available,
"swap_total": mem.SwapTotal,
"swap_used": mem.SwapUsed,
"swap_free": mem.SwapFree,
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) MonitorDisk(w http.ResponseWriter, r *http.Request) {
disks, err := system.GetDisk()
if err != nil {
writeJSON(w, http.StatusOK, []interface{}{})
return
}
writeJSON(w, http.StatusOK, disks)
}
func (h *Handler) MonitorServices(w http.ResponseWriter, r *http.Request) {
services := []string{"nginx", "autarch-web", "autarch-dns", "setec-manager", "ufw"}
type svcStatus struct {
Name string `json:"name"`
Active string `json:"active"`
Running bool `json:"running"`
Memory string `json:"memory"`
}
var statuses []svcStatus
for _, svc := range services {
ss := svcStatus{Name: svc}
active, err := deploy.IsActive(svc)
if err == nil && active {
ss.Active = "active"
ss.Running = true
} else {
ss.Active = "inactive"
ss.Running = false
}
// Get memory usage — no wrapper exists for this property, so use exec
if ss.Running {
out, err := exec.Command("systemctl", "show", svc, "--property=MemoryCurrent").Output()
if err == nil {
parts := strings.SplitN(string(out), "=", 2)
if len(parts) == 2 {
val := strings.TrimSpace(parts[1])
if val != "[not set]" && val != "" {
bytes := parseUint64(val)
ss.Memory = formatBytes(float64(bytes))
}
}
}
}
statuses = append(statuses, ss)
}
writeJSON(w, http.StatusOK, statuses)
}
// parseUint64 is a helper that returns 0 on failure.
func parseUint64(s string) uint64 {
var n uint64
fmt.Sscanf(s, "%d", &n)
return n
}

View File

@@ -0,0 +1,97 @@
package handlers
import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"setec-manager/internal/nginx"
"setec-manager/internal/system"
)
type nginxStatus struct {
Running bool `json:"running"`
Status string `json:"status"`
ConfigTest string `json:"config_test"`
ConfigOK bool `json:"config_ok"`
}
func (h *Handler) NginxStatus(w http.ResponseWriter, r *http.Request) {
status := nginxStatus{}
status.Status, status.Running = nginx.Status()
testOut, testErr := nginx.Test()
status.ConfigTest = testOut
status.ConfigOK = testErr == nil
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, status)
return
}
h.render(w, "nginx.html", status)
}
func (h *Handler) NginxReload(w http.ResponseWriter, r *http.Request) {
// Validate config first
if _, err := nginx.Test(); err != nil {
writeError(w, http.StatusBadRequest, "nginx config test failed — not reloading")
return
}
if err := nginx.Reload(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
}
func (h *Handler) NginxRestart(w http.ResponseWriter, r *http.Request) {
if err := nginx.Restart(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
}
func (h *Handler) NginxConfigView(w http.ResponseWriter, r *http.Request) {
domain := paramStr(r, "domain")
path := filepath.Join(h.Config.Nginx.SitesAvailable, domain)
data, err := os.ReadFile(path)
if err != nil {
writeError(w, http.StatusNotFound, "config not found for "+domain)
return
}
writeJSON(w, http.StatusOK, map[string]string{"domain": domain, "config": string(data)})
}
func (h *Handler) NginxTest(w http.ResponseWriter, r *http.Request) {
out, err := nginx.Test()
ok := err == nil
writeJSON(w, http.StatusOK, map[string]interface{}{
"output": strings.TrimSpace(out),
"valid": ok,
})
}
func (h *Handler) NginxInstallBase(w http.ResponseWriter, r *http.Request) {
// Install nginx if not present
if _, err := exec.LookPath("nginx"); err != nil {
if _, installErr := system.PackageInstall("nginx"); installErr != nil {
writeError(w, http.StatusInternalServerError, installErr.Error())
return
}
}
// Install snippets
if err := nginx.InstallSnippets(h.Config); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Ensure certbot webroot exists
os.MkdirAll(h.Config.Nginx.CertbotWebroot, 0755)
writeJSON(w, http.StatusOK, map[string]string{"status": "nginx configured"})
}

View File

@@ -0,0 +1,453 @@
package handlers
import (
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"setec-manager/internal/db"
"setec-manager/internal/deploy"
"setec-manager/internal/nginx"
)
var validDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
func isValidDomain(domain string) bool {
if len(domain) > 253 {
return false
}
if net.ParseIP(domain) != nil {
return true
}
return validDomainRegex.MatchString(domain)
}
func (h *Handler) SiteList(w http.ResponseWriter, r *http.Request) {
sites, err := h.DB.ListSites()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Enrich with running status
type siteView struct {
db.Site
Running bool `json:"running"`
Status string `json:"status"`
}
var views []siteView
for _, s := range sites {
sv := siteView{Site: s}
if s.AppType != "static" && s.AppPort > 0 {
unitName := fmt.Sprintf("app-%s", s.Domain)
active, _ := deploy.IsActive(unitName)
sv.Running = active
if active {
sv.Status = "active"
} else {
sv.Status = "inactive"
}
} else {
sv.Status = "static"
sv.Running = s.Enabled
}
views = append(views, sv)
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, views)
return
}
h.render(w, "sites.html", views)
}
func (h *Handler) SiteNewForm(w http.ResponseWriter, r *http.Request) {
h.render(w, "site_new.html", nil)
}
func (h *Handler) SiteCreate(w http.ResponseWriter, r *http.Request) {
var site db.Site
if err := json.NewDecoder(r.Body).Decode(&site); err != nil {
// Try form values
site.Domain = r.FormValue("domain")
site.Aliases = r.FormValue("aliases")
site.AppType = r.FormValue("app_type")
site.AppRoot = r.FormValue("app_root")
site.GitRepo = r.FormValue("git_repo")
site.GitBranch = r.FormValue("git_branch")
site.AppEntry = r.FormValue("app_entry")
}
if site.Domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
if !isValidDomain(site.Domain) {
writeError(w, http.StatusBadRequest, "invalid domain name")
return
}
if site.AppType == "" {
site.AppType = "static"
}
if site.AppRoot == "" {
site.AppRoot = filepath.Join(h.Config.Nginx.Webroot, site.Domain)
}
if site.GitBranch == "" {
site.GitBranch = "main"
}
site.Enabled = true
// Check for duplicate
existing, _ := h.DB.GetSiteByDomain(site.Domain)
if existing != nil {
writeError(w, http.StatusConflict, "domain already exists")
return
}
// Create directory
os.MkdirAll(site.AppRoot, 0755)
// Clone repo if provided
if site.GitRepo != "" {
depID, _ := h.DB.CreateDeployment(nil, "clone")
out, err := deploy.Clone(site.GitRepo, site.GitBranch, site.AppRoot)
if err != nil {
h.DB.FinishDeployment(depID, "failed", out)
writeError(w, http.StatusInternalServerError, "git clone failed: "+out)
return
}
h.DB.FinishDeployment(depID, "success", out)
}
// Save to DB
id, err := h.DB.CreateSite(&site)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
site.ID = id
// Generate nginx config
if err := nginx.GenerateConfig(h.Config, &site); err != nil {
writeError(w, http.StatusInternalServerError, "nginx config: "+err.Error())
return
}
// Enable site
nginx.EnableSite(h.Config, site.Domain)
nginx.Reload()
// Generate systemd unit for non-static apps
if site.AppType != "static" && site.AppEntry != "" {
h.generateAppUnit(&site)
}
if acceptsJSON(r) {
writeJSON(w, http.StatusCreated, site)
return
}
http.Redirect(w, r, fmt.Sprintf("/sites/%d", id), http.StatusSeeOther)
}
func (h *Handler) SiteDetail(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
site, err := h.DB.GetSite(id)
if err != nil || site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
// Get deployment history
deps, _ := h.DB.ListDeployments(&id, 10)
data := map[string]interface{}{
"Site": site,
"Deployments": deps,
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, data)
return
}
h.render(w, "site_detail.html", data)
}
func (h *Handler) SiteUpdate(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
site, err := h.DB.GetSite(id)
if err != nil || site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
var update db.Site
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
// Apply updates
if update.Domain != "" {
site.Domain = update.Domain
}
site.Aliases = update.Aliases
if update.AppType != "" {
site.AppType = update.AppType
}
if update.AppPort > 0 {
site.AppPort = update.AppPort
}
site.AppEntry = update.AppEntry
site.GitRepo = update.GitRepo
site.GitBranch = update.GitBranch
site.SSLEnabled = update.SSLEnabled
site.Enabled = update.Enabled
if err := h.DB.UpdateSite(site); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Regenerate nginx config
nginx.GenerateConfig(h.Config, site)
nginx.Reload()
writeJSON(w, http.StatusOK, site)
}
func (h *Handler) SiteDelete(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
site, err := h.DB.GetSite(id)
if err != nil || site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
// Disable nginx
nginx.DisableSite(h.Config, site.Domain)
nginx.Reload()
// Stop, disable, and remove the systemd unit
unitName := fmt.Sprintf("app-%s", site.Domain)
deploy.RemoveUnit(unitName)
if err := h.DB.DeleteSite(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handler) SiteDeploy(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
site, err := h.DB.GetSite(id)
if err != nil || site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
depID, _ := h.DB.CreateDeployment(&id, "deploy")
var output strings.Builder
// Git pull
if site.GitRepo != "" {
out, err := deploy.Pull(site.AppRoot)
output.WriteString(out)
if err != nil {
h.DB.FinishDeployment(depID, "failed", output.String())
writeError(w, http.StatusInternalServerError, "git pull failed")
return
}
}
// Reinstall deps based on app type
switch site.AppType {
case "python", "autarch":
venvDir := filepath.Join(site.AppRoot, "venv")
reqFile := filepath.Join(site.AppRoot, "requirements.txt")
if _, err := os.Stat(reqFile); err == nil {
out, _ := deploy.InstallRequirements(venvDir, reqFile)
output.WriteString(out)
}
case "node":
out, _ := deploy.NpmInstall(site.AppRoot)
output.WriteString(out)
}
// Restart service
unitName := fmt.Sprintf("app-%s", site.Domain)
deploy.Restart(unitName)
h.DB.FinishDeployment(depID, "success", output.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "deployed"})
}
func (h *Handler) SiteRestart(w http.ResponseWriter, r *http.Request) {
id, _ := paramInt(r, "id")
site, _ := h.DB.GetSite(id)
if site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
unitName := fmt.Sprintf("app-%s", site.Domain)
deploy.Restart(unitName)
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
}
func (h *Handler) SiteStop(w http.ResponseWriter, r *http.Request) {
id, _ := paramInt(r, "id")
site, _ := h.DB.GetSite(id)
if site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
unitName := fmt.Sprintf("app-%s", site.Domain)
deploy.Stop(unitName)
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
}
func (h *Handler) SiteStart(w http.ResponseWriter, r *http.Request) {
id, _ := paramInt(r, "id")
site, _ := h.DB.GetSite(id)
if site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
unitName := fmt.Sprintf("app-%s", site.Domain)
deploy.Start(unitName)
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
}
func (h *Handler) SiteLogs(w http.ResponseWriter, r *http.Request) {
id, _ := paramInt(r, "id")
site, _ := h.DB.GetSite(id)
if site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
unitName := fmt.Sprintf("app-%s", site.Domain)
out, _ := deploy.Logs(unitName, 100)
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, map[string]string{"logs": out})
return
}
h.render(w, "site_detail.html", map[string]interface{}{
"Site": site,
"Logs": out,
})
}
func (h *Handler) SiteLogStream(w http.ResponseWriter, r *http.Request) {
id, _ := paramInt(r, "id")
site, _ := h.DB.GetSite(id)
if site == nil {
writeError(w, http.StatusNotFound, "site not found")
return
}
unitName := fmt.Sprintf("app-%s", site.Domain)
streamJournalctl(w, r, unitName)
}
func (h *Handler) generateAppUnit(site *db.Site) {
var execStart string
switch site.AppType {
case "python":
venvPython := filepath.Join(site.AppRoot, "venv", "bin", "python3")
execStart = fmt.Sprintf("%s %s", venvPython, filepath.Join(site.AppRoot, site.AppEntry))
case "node":
execStart = fmt.Sprintf("/usr/bin/node %s", filepath.Join(site.AppRoot, site.AppEntry))
case "autarch":
venvPython := filepath.Join(site.AppRoot, "venv", "bin", "python3")
execStart = fmt.Sprintf("%s %s", venvPython, filepath.Join(site.AppRoot, "autarch_web.py"))
default:
return
}
unitName := fmt.Sprintf("app-%s", site.Domain)
unitContent := deploy.GenerateUnit(deploy.UnitConfig{
Name: unitName,
Description: fmt.Sprintf("%s (%s)", site.Domain, site.AppType),
ExecStart: execStart,
WorkingDirectory: site.AppRoot,
User: "root",
Environment: map[string]string{"PYTHONUNBUFFERED": "1"},
})
deploy.InstallUnit(unitName, unitContent)
deploy.Enable(unitName)
}
func acceptsJSON(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "application/json")
}
func streamJournalctl(w http.ResponseWriter, r *http.Request, unit string) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "50", "--no-pager", "-o", "short-iso")
stdout, err := cmd.StdoutPipe()
if err != nil {
return
}
cmd.Start()
defer cmd.Process.Kill()
buf := make([]byte, 4096)
for {
select {
case <-r.Context().Done():
return
default:
n, err := stdout.Read(buf)
if err != nil {
return
}
if n > 0 {
fmt.Fprintf(w, "data: %s\n\n", strings.TrimSpace(string(buf[:n])))
flusher.Flush()
}
}
}
}

View File

@@ -0,0 +1,143 @@
package handlers
import (
"fmt"
"net/http"
"time"
"setec-manager/internal/acme"
)
type certInfo struct {
Domain string `json:"domain"`
Issuer string `json:"issuer"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysLeft int `json:"days_left"`
AutoRenew bool `json:"auto_renew"`
}
func (h *Handler) SSLOverview(w http.ResponseWriter, r *http.Request) {
certs := h.listCerts()
h.render(w, "ssl.html", certs)
}
func (h *Handler) SSLStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.listCerts())
}
func (h *Handler) SSLIssue(w http.ResponseWriter, r *http.Request) {
domain := paramStr(r, "domain")
if domain == "" {
writeError(w, http.StatusBadRequest, "domain required")
return
}
client := acme.NewClient(
h.Config.ACME.Email,
h.Config.ACME.Staging,
h.Config.Nginx.CertbotWebroot,
h.Config.ACME.AccountDir,
)
info, err := client.Issue(domain)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot failed: %s", err))
return
}
// Update site SSL paths
site, _ := h.DB.GetSiteByDomain(domain)
if site != nil {
site.SSLEnabled = true
site.SSLCertPath = info.CertPath
site.SSLKeyPath = info.KeyPath
h.DB.UpdateSite(site)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "issued", "cert": info.CertPath})
}
func (h *Handler) SSLRenew(w http.ResponseWriter, r *http.Request) {
domain := paramStr(r, "domain")
client := acme.NewClient(
h.Config.ACME.Email,
h.Config.ACME.Staging,
h.Config.Nginx.CertbotWebroot,
h.Config.ACME.AccountDir,
)
if err := client.Renew(domain); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("renewal failed: %s", err))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "renewed"})
}
func (h *Handler) listCerts() []certInfo {
var certs []certInfo
// First, gather certs from DB-tracked sites
sites, _ := h.DB.ListSites()
for _, site := range sites {
if !site.SSLEnabled || site.SSLCertPath == "" {
continue
}
ci := certInfo{
Domain: site.Domain,
AutoRenew: site.SSLAuto,
}
client := acme.NewClient(
h.Config.ACME.Email,
h.Config.ACME.Staging,
h.Config.Nginx.CertbotWebroot,
h.Config.ACME.AccountDir,
)
info, err := client.GetCertInfo(site.Domain)
if err == nil {
ci.Issuer = info.Issuer
ci.NotBefore = info.ExpiresAt.Add(-90 * 24 * time.Hour) // approximate
ci.NotAfter = info.ExpiresAt
ci.DaysLeft = info.DaysLeft
}
certs = append(certs, ci)
}
// Also check Let's Encrypt certs directory via ACME client
client := acme.NewClient(
h.Config.ACME.Email,
h.Config.ACME.Staging,
h.Config.Nginx.CertbotWebroot,
h.Config.ACME.AccountDir,
)
leCerts, _ := client.ListCerts()
for _, le := range leCerts {
// Skip if already found via site
found := false
for _, c := range certs {
if c.Domain == le.Domain {
found = true
break
}
}
if found {
continue
}
certs = append(certs, certInfo{
Domain: le.Domain,
Issuer: le.Issuer,
NotAfter: le.ExpiresAt,
DaysLeft: le.DaysLeft,
})
}
return certs
}

View File

@@ -0,0 +1,176 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"setec-manager/internal/system"
)
// ── System Users ────────────────────────────────────────────────────
type sysUser struct {
Username string `json:"username"`
UID string `json:"uid"`
HomeDir string `json:"home_dir"`
Shell string `json:"shell"`
}
func (h *Handler) UserList(w http.ResponseWriter, r *http.Request) {
users := listSystemUsers()
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, users)
return
}
h.render(w, "users.html", users)
}
func (h *Handler) UserCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Shell string `json:"shell"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
body.Username = r.FormValue("username")
body.Password = r.FormValue("password")
body.Shell = r.FormValue("shell")
}
if body.Username == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "username and password required")
return
}
if body.Shell == "" {
body.Shell = "/bin/bash"
}
if err := system.CreateUser(body.Username, body.Password, body.Shell); err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("create user failed: %s", err))
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "username": body.Username})
}
func (h *Handler) UserDelete(w http.ResponseWriter, r *http.Request) {
id := paramStr(r, "id") // actually username for system users
if id == "" {
writeError(w, http.StatusBadRequest, "username required")
return
}
// Safety check
if id == "root" || id == "autarch" {
writeError(w, http.StatusForbidden, "cannot delete system accounts")
return
}
if err := system.DeleteUser(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func listSystemUsers() []sysUser {
systemUsers, err := system.ListUsers()
if err != nil {
return nil
}
var users []sysUser
for _, su := range systemUsers {
users = append(users, sysUser{
Username: su.Username,
UID: fmt.Sprintf("%d", su.UID),
HomeDir: su.HomeDir,
Shell: su.Shell,
})
}
return users
}
// ── Panel Users ─────────────────────────────────────────────────────
func (h *Handler) PanelUserList(w http.ResponseWriter, r *http.Request) {
users, err := h.DB.ListManagerUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if acceptsJSON(r) {
writeJSON(w, http.StatusOK, users)
return
}
h.render(w, "users.html", map[string]interface{}{"PanelUsers": users})
}
func (h *Handler) PanelUserCreate(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
body.Username = r.FormValue("username")
body.Password = r.FormValue("password")
body.Role = r.FormValue("role")
}
if body.Username == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "username and password required")
return
}
if body.Role == "" {
body.Role = "admin"
}
id, err := h.DB.CreateManagerUser(body.Username, body.Password, body.Role)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id, "username": body.Username})
}
func (h *Handler) PanelUserUpdate(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var body struct {
Password string `json:"password"`
Role string `json:"role"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.Password != "" {
h.DB.UpdateManagerUserPassword(id, body.Password)
}
if body.Role != "" {
h.DB.UpdateManagerUserRole(id, body.Role)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *Handler) PanelUserDelete(w http.ResponseWriter, r *http.Request) {
id, err := paramInt(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.DB.DeleteManagerUser(id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,107 @@
package hosting
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// ProviderConfigStore manages saved provider configurations on disk.
// Each provider's config is stored as a separate JSON file with restrictive
// permissions (0600) since the files contain API keys.
type ProviderConfigStore struct {
configDir string
}
// NewConfigStore creates a new store rooted at configDir. The directory is
// created on first write if it does not already exist.
func NewConfigStore(configDir string) *ProviderConfigStore {
return &ProviderConfigStore{configDir: configDir}
}
// configPath returns the file path for a provider's config file.
func (s *ProviderConfigStore) configPath(providerName string) string {
return filepath.Join(s.configDir, providerName+".json")
}
// ensureDir creates the config directory if it does not exist.
func (s *ProviderConfigStore) ensureDir() error {
return os.MkdirAll(s.configDir, 0700)
}
// Save writes a provider configuration to disk. It overwrites any existing
// config for the same provider.
func (s *ProviderConfigStore) Save(providerName string, cfg ProviderConfig) error {
if providerName == "" {
return fmt.Errorf("hosting: provider name must not be empty")
}
if err := s.ensureDir(); err != nil {
return fmt.Errorf("hosting: create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("hosting: marshal config for %s: %w", providerName, err)
}
path := s.configPath(providerName)
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("hosting: write config for %s: %w", providerName, err)
}
return nil
}
// Load reads a provider configuration from disk.
func (s *ProviderConfigStore) Load(providerName string) (*ProviderConfig, error) {
path := s.configPath(providerName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("hosting: no config found for provider %q", providerName)
}
return nil, fmt.Errorf("hosting: read config for %s: %w", providerName, err)
}
var cfg ProviderConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("hosting: parse config for %s: %w", providerName, err)
}
return &cfg, nil
}
// Delete removes a provider's saved configuration.
func (s *ProviderConfigStore) Delete(providerName string) error {
path := s.configPath(providerName)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return nil // already gone
}
return fmt.Errorf("hosting: delete config for %s: %w", providerName, err)
}
return nil
}
// ListConfigured returns the names of all providers that have saved configs.
func (s *ProviderConfigStore) ListConfigured() ([]string, error) {
entries, err := os.ReadDir(s.configDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // no directory means no configs
}
return nil, fmt.Errorf("hosting: list configs: %w", err)
}
var names []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".json") {
names = append(names, strings.TrimSuffix(name, ".json"))
}
}
return names, nil
}

View File

@@ -0,0 +1,127 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"setec-manager/internal/hosting"
)
// hostingerSubscription is the Hostinger API representation of a subscription.
type hostingerSubscription struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
RenewalDate string `json:"renewal_date"`
Price struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"price"`
}
// hostingerCatalogItem is the Hostinger API representation of a catalog item.
type hostingerCatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Features map[string]string `json:"features,omitempty"`
}
// hostingerPaymentMethod is the Hostinger API representation of a payment method.
type hostingerPaymentMethod struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Last4 string `json:"last4"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
Default bool `json:"default"`
}
// PaymentMethod is the exported type for payment method information.
type PaymentMethod struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Last4 string `json:"last4"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
Default bool `json:"default"`
}
// ListSubscriptions retrieves all billing subscriptions.
func (c *Client) ListSubscriptions() ([]hosting.Subscription, error) {
var subs []hostingerSubscription
if err := c.doRequest(http.MethodGet, "/api/billing/v1/subscriptions", nil, &subs); err != nil {
return nil, fmt.Errorf("list subscriptions: %w", err)
}
result := make([]hosting.Subscription, 0, len(subs))
for _, s := range subs {
renewsAt, _ := time.Parse(time.RFC3339, s.RenewalDate)
result = append(result, hosting.Subscription{
ID: strconv.Itoa(s.ID),
Name: s.Name,
Status: s.Status,
RenewsAt: renewsAt,
Price: s.Price.Amount,
Currency: s.Price.Currency,
})
}
return result, nil
}
// GetCatalog retrieves the product catalog, optionally filtered by category.
// If category is empty, all catalog items are returned.
func (c *Client) GetCatalog(category string) ([]hosting.CatalogItem, error) {
path := "/api/billing/v1/catalog"
if category != "" {
path += "?" + url.Values{"category": {category}}.Encode()
}
var items []hostingerCatalogItem
if err := c.doRequest(http.MethodGet, path, nil, &items); err != nil {
return nil, fmt.Errorf("get catalog: %w", err)
}
result := make([]hosting.CatalogItem, 0, len(items))
for _, item := range items {
result = append(result, hosting.CatalogItem{
ID: item.ID,
Name: item.Name,
Category: item.Category,
Price: item.Price,
Currency: item.Currency,
Features: item.Features,
})
}
return result, nil
}
// ListPaymentMethods retrieves all payment methods on the account.
// This is a Hostinger-specific method not part of the generic Provider interface.
func (c *Client) ListPaymentMethods() ([]PaymentMethod, error) {
var methods []hostingerPaymentMethod
if err := c.doRequest(http.MethodGet, "/api/billing/v1/payment-methods", nil, &methods); err != nil {
return nil, fmt.Errorf("list payment methods: %w", err)
}
result := make([]PaymentMethod, 0, len(methods))
for _, m := range methods {
result = append(result, PaymentMethod{
ID: strconv.Itoa(m.ID),
Type: m.Type,
Name: m.Name,
Last4: m.Last4,
ExpMonth: m.ExpMonth,
ExpYear: m.ExpYear,
Default: m.Default,
})
}
return result, nil
}

View File

@@ -0,0 +1,172 @@
package hostinger
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"setec-manager/internal/hosting"
)
const (
defaultBaseURL = "https://developers.hostinger.com"
maxRetries = 3
)
// APIError represents an error response from the Hostinger API.
type APIError struct {
StatusCode int `json:"-"`
Message string `json:"error"`
CorrelationID string `json:"correlation_id,omitempty"`
}
func (e *APIError) Error() string {
if e.CorrelationID != "" {
return fmt.Sprintf("hostinger API error %d: %s (correlation_id: %s)", e.StatusCode, e.Message, e.CorrelationID)
}
return fmt.Sprintf("hostinger API error %d: %s", e.StatusCode, e.Message)
}
// Client is the Hostinger API client. It implements hosting.Provider.
type Client struct {
apiToken string
httpClient *http.Client
baseURL string
}
// Compile-time check that Client implements hosting.Provider.
var _ hosting.Provider = (*Client)(nil)
// New creates a new Hostinger API client with the given bearer token.
func New(token string) *Client {
return &Client{
apiToken: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: defaultBaseURL,
}
}
// Name returns the provider identifier.
func (c *Client) Name() string { return "hostinger" }
// DisplayName returns the human-readable provider name.
func (c *Client) DisplayName() string { return "Hostinger" }
// Configure applies the given configuration to the client.
func (c *Client) Configure(cfg hosting.ProviderConfig) error {
if cfg.APIKey == "" {
return fmt.Errorf("hostinger: API key is required")
}
c.apiToken = cfg.APIKey
if cfg.BaseURL != "" {
c.baseURL = cfg.BaseURL
}
return nil
}
// TestConnection verifies the API token by making a lightweight API call.
func (c *Client) TestConnection() error {
_, err := c.ListDomains()
return err
}
// doRequest executes an HTTP request against the Hostinger API.
// body may be nil for requests with no body. result may be nil if the
// response body should be discarded.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
url := c.baseURL + path
var rawBody []byte
if body != nil {
var err error
rawBody, err = json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
var bodyReader io.Reader
if rawBody != nil {
bodyReader = bytes.NewReader(rawBody)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("execute request: %w", err)
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
// Handle rate limiting with retry.
if resp.StatusCode == http.StatusTooManyRequests {
if attempt < maxRetries {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
time.Sleep(retryAfter)
lastErr = &APIError{StatusCode: 429, Message: "rate limited"}
continue
}
return &APIError{StatusCode: 429, Message: "rate limited after retries"}
}
// Handle error responses.
if resp.StatusCode >= 400 {
apiErr := &APIError{StatusCode: resp.StatusCode}
if jsonErr := json.Unmarshal(respBody, apiErr); jsonErr != nil {
apiErr.Message = string(respBody)
}
return apiErr
}
// Parse successful response.
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
}
return nil
}
return lastErr
}
// parseRetryAfter parses the Retry-After header value.
// Returns a default of 1 second if the header is missing or unparseable.
func parseRetryAfter(value string) time.Duration {
if value == "" {
return time.Second
}
seconds, err := strconv.Atoi(value)
if err != nil {
return time.Second
}
if seconds <= 0 {
return time.Second
}
if seconds > 60 {
seconds = 60
}
return time.Duration(seconds) * time.Second
}

View File

@@ -0,0 +1,136 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"setec-manager/internal/hosting"
)
// hostingerDNSRecord is the Hostinger API representation of a DNS record.
type hostingerDNSRecord struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Priority *int `json:"priority,omitempty"`
}
// hostingerDNSUpdateRequest is the request body for updating DNS records.
type hostingerDNSUpdateRequest struct {
Records []hostingerDNSRecord `json:"records"`
Overwrite bool `json:"overwrite"`
}
// hostingerDNSValidateRequest is the request body for validating DNS records.
type hostingerDNSValidateRequest struct {
Records []hostingerDNSRecord `json:"records"`
}
// ListDNSRecords retrieves all DNS records for the given domain.
func (c *Client) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
var apiRecords []hostingerDNSRecord
if err := c.doRequest(http.MethodGet, path, nil, &apiRecords); err != nil {
return nil, fmt.Errorf("list DNS records for %s: %w", domain, err)
}
records := make([]hosting.DNSRecord, 0, len(apiRecords))
for _, r := range apiRecords {
records = append(records, toGenericDNSRecord(r))
}
return records, nil
}
// UpdateDNSRecords updates DNS records for the given domain.
// If overwrite is true, existing records are replaced entirely.
func (c *Client) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
hostingerRecords := make([]hostingerDNSRecord, 0, len(records))
for _, r := range records {
hostingerRecords = append(hostingerRecords, toHostingerDNSRecord(r))
}
// Validate first.
validatePath := fmt.Sprintf("/api/dns/v1/zones/%s/validate", url.PathEscape(domain))
validateReq := hostingerDNSValidateRequest{Records: hostingerRecords}
if err := c.doRequest(http.MethodPost, validatePath, validateReq, nil); err != nil {
return fmt.Errorf("validate DNS records for %s: %w", domain, err)
}
req := hostingerDNSUpdateRequest{
Records: hostingerRecords,
Overwrite: overwrite,
}
if err := c.doRequest(http.MethodPut, path, req, nil); err != nil {
return fmt.Errorf("update DNS records for %s: %w", domain, err)
}
return nil
}
// CreateDNSRecord adds a single DNS record to the domain without overwriting.
func (c *Client) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
return c.UpdateDNSRecords(domain, []hosting.DNSRecord{record}, false)
}
// DeleteDNSRecord removes DNS records matching the given filter.
func (c *Client) DeleteDNSRecord(domain string, filter hosting.DNSRecordFilter) error {
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
params := url.Values{}
if filter.Name != "" {
params.Set("name", filter.Name)
}
if filter.Type != "" {
params.Set("type", filter.Type)
}
if len(params) > 0 {
path += "?" + params.Encode()
}
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("delete DNS record %s/%s for %s: %w", filter.Name, filter.Type, domain, err)
}
return nil
}
// ResetDNSRecords resets the domain's DNS zone to default records.
func (c *Client) ResetDNSRecords(domain string) error {
path := fmt.Sprintf("/api/dns/v1/zones/%s/reset", url.PathEscape(domain))
if err := c.doRequest(http.MethodPost, path, nil, nil); err != nil {
return fmt.Errorf("reset DNS records for %s: %w", domain, err)
}
return nil
}
// toGenericDNSRecord converts a Hostinger DNS record to the generic type.
func toGenericDNSRecord(r hostingerDNSRecord) hosting.DNSRecord {
rec := hosting.DNSRecord{
Type: r.Type,
Name: r.Name,
Content: r.Content,
TTL: r.TTL,
}
if r.Priority != nil {
rec.Priority = *r.Priority
}
return rec
}
// toHostingerDNSRecord converts a generic DNS record to the Hostinger format.
func toHostingerDNSRecord(r hosting.DNSRecord) hostingerDNSRecord {
rec := hostingerDNSRecord{
Type: r.Type,
Name: r.Name,
Content: r.Content,
TTL: r.TTL,
}
if r.Priority != 0 {
p := r.Priority
rec.Priority = &p
}
return rec
}

View File

@@ -0,0 +1,218 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"setec-manager/internal/hosting"
)
// hostingerDomain is the Hostinger API representation of a domain.
type hostingerDomain struct {
Domain string `json:"domain"`
Status string `json:"status"`
ExpirationDate string `json:"expiration_date"`
AutoRenew bool `json:"auto_renew"`
DomainLock bool `json:"domain_lock"`
PrivacyProtection bool `json:"privacy_protection"`
Nameservers []string `json:"nameservers"`
}
// hostingerDomainList wraps the list response.
type hostingerDomainList struct {
Domains []hostingerDomain `json:"domains"`
}
// hostingerAvailabilityRequest is the check-availability request body.
type hostingerAvailabilityRequest struct {
Domains []string `json:"domains"`
}
// hostingerAvailabilityResult is a single domain availability result.
type hostingerAvailabilityResult struct {
Domain string `json:"domain"`
Available bool `json:"available"`
Price *struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"price,omitempty"`
}
// hostingerPurchaseRequest is the domain purchase request body.
type hostingerPurchaseRequest struct {
Domain string `json:"domain"`
Period int `json:"period"`
AutoRenew bool `json:"auto_renew"`
Privacy bool `json:"privacy_protection"`
PaymentMethodID string `json:"payment_method_id,omitempty"`
}
// hostingerNameserversRequest is the body for updating nameservers.
type hostingerNameserversRequest struct {
Nameservers []string `json:"nameservers"`
}
// ListDomains retrieves all domains in the account portfolio.
func (c *Client) ListDomains() ([]hosting.Domain, error) {
var list hostingerDomainList
if err := c.doRequest(http.MethodGet, "/api/domains/v1/portfolio", nil, &list); err != nil {
return nil, fmt.Errorf("list domains: %w", err)
}
domains := make([]hosting.Domain, 0, len(list.Domains))
for _, d := range list.Domains {
domains = append(domains, toSummaryDomain(d))
}
return domains, nil
}
// GetDomain retrieves details for a specific domain.
func (c *Client) GetDomain(domain string) (*hosting.DomainDetail, error) {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s", url.PathEscape(domain))
var d hostingerDomain
if err := c.doRequest(http.MethodGet, path, nil, &d); err != nil {
return nil, fmt.Errorf("get domain %s: %w", domain, err)
}
result := toDetailDomain(d)
return &result, nil
}
// CheckDomainAvailability checks whether the given domain is available for
// registration across the specified TLDs. If tlds is empty, the domain string
// is checked as-is.
func (c *Client) CheckDomainAvailability(domain string, tlds []string) ([]hosting.DomainAvailability, error) {
// Build the list of fully qualified domain names to check.
var domains []string
if len(tlds) == 0 {
domains = []string{domain}
} else {
for _, tld := range tlds {
tld = strings.TrimPrefix(tld, ".")
domains = append(domains, domain+"."+tld)
}
}
req := hostingerAvailabilityRequest{Domains: domains}
var results []hostingerAvailabilityResult
if err := c.doRequest(http.MethodPost, "/api/domains/v1/availability", req, &results); err != nil {
return nil, fmt.Errorf("check domain availability: %w", err)
}
avail := make([]hosting.DomainAvailability, 0, len(results))
for _, r := range results {
da := hosting.DomainAvailability{
Domain: r.Domain,
Available: r.Available,
}
// Extract TLD from the domain name.
if idx := strings.Index(r.Domain, "."); idx >= 0 {
da.TLD = r.Domain[idx+1:]
}
if r.Price != nil {
da.Price = r.Price.Amount
da.Currency = r.Price.Currency
}
avail = append(avail, da)
}
return avail, nil
}
// PurchaseDomain registers a new domain.
func (c *Client) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.OrderResult, error) {
body := hostingerPurchaseRequest{
Domain: req.Domain,
Period: req.Years,
AutoRenew: req.AutoRenew,
Privacy: req.Privacy,
PaymentMethodID: req.PaymentMethod,
}
var d hostingerDomain
if err := c.doRequest(http.MethodPost, "/api/domains/v1/portfolio", body, &d); err != nil {
return nil, fmt.Errorf("purchase domain %s: %w", req.Domain, err)
}
return &hosting.OrderResult{
OrderID: d.Domain,
Status: "completed",
Message: fmt.Sprintf("domain %s registered", d.Domain),
}, nil
}
// SetNameservers updates the nameservers for a domain.
func (c *Client) SetNameservers(domain string, nameservers []string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/nameservers", url.PathEscape(domain))
body := hostingerNameserversRequest{Nameservers: nameservers}
if err := c.doRequest(http.MethodPut, path, body, nil); err != nil {
return fmt.Errorf("set nameservers for %s: %w", domain, err)
}
return nil
}
// EnableDomainLock enables the registrar lock for a domain.
func (c *Client) EnableDomainLock(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/domain-lock", url.PathEscape(domain))
if err := c.doRequest(http.MethodPut, path, nil, nil); err != nil {
return fmt.Errorf("enable domain lock for %s: %w", domain, err)
}
return nil
}
// DisableDomainLock disables the registrar lock for a domain.
func (c *Client) DisableDomainLock(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/domain-lock", url.PathEscape(domain))
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("disable domain lock for %s: %w", domain, err)
}
return nil
}
// EnablePrivacyProtection enables WHOIS privacy protection for a domain.
func (c *Client) EnablePrivacyProtection(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/privacy-protection", url.PathEscape(domain))
if err := c.doRequest(http.MethodPut, path, nil, nil); err != nil {
return fmt.Errorf("enable privacy protection for %s: %w", domain, err)
}
return nil
}
// DisablePrivacyProtection disables WHOIS privacy protection for a domain.
func (c *Client) DisablePrivacyProtection(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/privacy-protection", url.PathEscape(domain))
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("disable privacy protection for %s: %w", domain, err)
}
return nil
}
// toSummaryDomain converts a Hostinger domain to the summary Domain type.
func toSummaryDomain(d hostingerDomain) hosting.Domain {
expires, _ := time.Parse(time.RFC3339, d.ExpirationDate)
return hosting.Domain{
Name: d.Domain,
Status: d.Status,
ExpiresAt: expires,
}
}
// toDetailDomain converts a Hostinger domain to the full DomainDetail type.
func toDetailDomain(d hostingerDomain) hosting.DomainDetail {
expires, _ := time.Parse(time.RFC3339, d.ExpirationDate)
return hosting.DomainDetail{
Name: d.Domain,
Status: d.Status,
Registrar: "hostinger",
ExpiresAt: expires,
AutoRenew: d.AutoRenew,
Locked: d.DomainLock,
PrivacyProtection: d.PrivacyProtection,
Nameservers: d.Nameservers,
}
}

View File

@@ -0,0 +1,7 @@
package hostinger
import "setec-manager/internal/hosting"
func init() {
hosting.Register(New(""))
}

View File

@@ -0,0 +1,219 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"setec-manager/internal/hosting"
)
// hostingerVM is the Hostinger API representation of a virtual machine.
type hostingerVM struct {
ID int `json:"id"`
Hostname string `json:"hostname"`
Status string `json:"status"`
Plan string `json:"plan"`
DataCenter string `json:"data_center"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
OS string `json:"os"`
CPUs int `json:"cpus"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
CreatedAt string `json:"created_at"`
}
// hostingerDataCenter is the Hostinger API representation of a data center.
type hostingerDataCenter struct {
ID int `json:"id"`
Name string `json:"name"`
Location string `json:"location"`
Country string `json:"country"`
}
// hostingerSSHKey is the Hostinger API representation of an SSH key.
type hostingerSSHKey struct {
ID int `json:"id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
CreatedAt string `json:"created_at"`
}
// hostingerCreateVMRequest is the request body for creating a VM.
type hostingerCreateVMRequest struct {
Hostname string `json:"hostname"`
Plan string `json:"plan"`
DataCenterID int `json:"data_center_id"`
OS string `json:"template"`
Password string `json:"password,omitempty"`
SSHKeyID *int `json:"ssh_key_id,omitempty"`
}
// hostingerCreateVMResponse is the response from the VM creation endpoint.
type hostingerCreateVMResponse struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
Message string `json:"message"`
}
// hostingerAddSSHKeyRequest is the request body for adding an SSH key.
type hostingerAddSSHKeyRequest struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
// ListVMs retrieves all virtual machines in the account.
func (c *Client) ListVMs() ([]hosting.VirtualMachine, error) {
var vms []hostingerVM
if err := c.doRequest(http.MethodGet, "/api/vps/v1/virtual-machines", nil, &vms); err != nil {
return nil, fmt.Errorf("list VMs: %w", err)
}
result := make([]hosting.VirtualMachine, 0, len(vms))
for _, vm := range vms {
result = append(result, toGenericVM(vm))
}
return result, nil
}
// GetVM retrieves a specific virtual machine by ID.
func (c *Client) GetVM(id string) (*hosting.VirtualMachine, error) {
path := fmt.Sprintf("/api/vps/v1/virtual-machines/%s", url.PathEscape(id))
var vm hostingerVM
if err := c.doRequest(http.MethodGet, path, nil, &vm); err != nil {
return nil, fmt.Errorf("get VM %s: %w", id, err)
}
result := toGenericVM(vm)
return &result, nil
}
// CreateVM provisions a new virtual machine.
func (c *Client) CreateVM(req hosting.VMCreateRequest) (*hosting.OrderResult, error) {
body := hostingerCreateVMRequest{
Hostname: req.Hostname,
Plan: req.Plan,
OS: req.OS,
Password: req.Password,
}
// Parse data center ID from string to int for the Hostinger API.
dcID, err := strconv.Atoi(req.DataCenterID)
if err != nil {
return nil, fmt.Errorf("invalid data center ID %q: must be numeric", req.DataCenterID)
}
body.DataCenterID = dcID
// Parse SSH key ID if provided.
if req.SSHKeyID != "" {
keyID, err := strconv.Atoi(req.SSHKeyID)
if err != nil {
return nil, fmt.Errorf("invalid SSH key ID %q: must be numeric", req.SSHKeyID)
}
body.SSHKeyID = &keyID
}
var resp hostingerCreateVMResponse
if err := c.doRequest(http.MethodPost, "/api/vps/v1/virtual-machines", body, &resp); err != nil {
return nil, fmt.Errorf("create VM: %w", err)
}
return &hosting.OrderResult{
OrderID: resp.OrderID,
Status: resp.Status,
Message: resp.Message,
}, nil
}
// ListDataCenters retrieves all available data centers.
func (c *Client) ListDataCenters() ([]hosting.DataCenter, error) {
var dcs []hostingerDataCenter
if err := c.doRequest(http.MethodGet, "/api/vps/v1/data-centers", nil, &dcs); err != nil {
return nil, fmt.Errorf("list data centers: %w", err)
}
result := make([]hosting.DataCenter, 0, len(dcs))
for _, dc := range dcs {
result = append(result, hosting.DataCenter{
ID: strconv.Itoa(dc.ID),
Name: dc.Name,
Location: dc.Location,
Country: dc.Country,
})
}
return result, nil
}
// ListSSHKeys retrieves all SSH keys in the account.
func (c *Client) ListSSHKeys() ([]hosting.SSHKey, error) {
var keys []hostingerSSHKey
if err := c.doRequest(http.MethodGet, "/api/vps/v1/public-keys", nil, &keys); err != nil {
return nil, fmt.Errorf("list SSH keys: %w", err)
}
result := make([]hosting.SSHKey, 0, len(keys))
for _, k := range keys {
created, _ := time.Parse(time.RFC3339, k.CreatedAt)
result = append(result, hosting.SSHKey{
ID: strconv.Itoa(k.ID),
Name: k.Name,
PublicKey: k.PublicKey,
CreatedAt: created,
})
}
return result, nil
}
// AddSSHKey uploads a new SSH public key.
func (c *Client) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) {
body := hostingerAddSSHKeyRequest{
Name: name,
PublicKey: publicKey,
}
var key hostingerSSHKey
if err := c.doRequest(http.MethodPost, "/api/vps/v1/public-keys", body, &key); err != nil {
return nil, fmt.Errorf("add SSH key: %w", err)
}
created, _ := time.Parse(time.RFC3339, key.CreatedAt)
return &hosting.SSHKey{
ID: strconv.Itoa(key.ID),
Name: key.Name,
PublicKey: key.PublicKey,
CreatedAt: created,
}, nil
}
// DeleteSSHKey removes an SSH key by ID.
func (c *Client) DeleteSSHKey(id string) error {
path := fmt.Sprintf("/api/vps/v1/public-keys/%s", url.PathEscape(id))
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("delete SSH key %s: %w", id, err)
}
return nil
}
// toGenericVM converts a Hostinger VM to the generic VirtualMachine type.
func toGenericVM(vm hostingerVM) hosting.VirtualMachine {
created, _ := time.Parse(time.RFC3339, vm.CreatedAt)
return hosting.VirtualMachine{
ID: strconv.Itoa(vm.ID),
Hostname: vm.Hostname,
IPAddress: vm.IPv4,
IPv6: vm.IPv6,
Status: vm.Status,
Plan: vm.Plan,
DataCenter: vm.DataCenter,
OS: vm.OS,
CPUs: vm.CPUs,
RAMBytes: int64(vm.RAMMB) * 1024 * 1024,
DiskBytes: int64(vm.DiskGB) * 1024 * 1024 * 1024,
CreatedAt: created,
}
}

View File

@@ -0,0 +1,287 @@
package hosting
import (
"errors"
"sort"
"sync"
"time"
)
// ErrNotSupported is returned when a provider does not support a given operation.
var ErrNotSupported = errors.New("operation not supported by this provider")
// Provider is the interface all hosting service integrations must implement.
// Not all providers support all features -- methods should return ErrNotSupported
// for unsupported operations.
type Provider interface {
// Name returns the provider identifier (e.g. "hostinger", "digitalocean").
Name() string
// DisplayName returns a human-readable provider name.
DisplayName() string
// --- Authentication ---
// Configure applies the given configuration to the provider.
Configure(cfg ProviderConfig) error
// TestConnection verifies that the provider credentials are valid.
TestConnection() error
// --- DNS Management ---
// ListDNSRecords returns all DNS records for a domain.
ListDNSRecords(domain string) ([]DNSRecord, error)
// CreateDNSRecord adds a single DNS record to a domain.
CreateDNSRecord(domain string, record DNSRecord) error
// UpdateDNSRecords replaces DNS records for a domain. If overwrite is true,
// all existing records are removed first.
UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
// DeleteDNSRecord removes DNS records matching the filter.
DeleteDNSRecord(domain string, filter DNSRecordFilter) error
// ResetDNSRecords restores the default DNS records for a domain.
ResetDNSRecords(domain string) error
// --- Domain Management ---
// ListDomains returns all domains on the account.
ListDomains() ([]Domain, error)
// GetDomain returns detailed information about a single domain.
GetDomain(domain string) (*DomainDetail, error)
// CheckDomainAvailability checks registration availability across TLDs.
CheckDomainAvailability(domain string, tlds []string) ([]DomainAvailability, error)
// PurchaseDomain registers a new domain.
PurchaseDomain(req DomainPurchaseRequest) (*OrderResult, error)
// SetNameservers configures the nameservers for a domain.
SetNameservers(domain string, nameservers []string) error
// EnableDomainLock enables the registrar lock on a domain.
EnableDomainLock(domain string) error
// DisableDomainLock disables the registrar lock on a domain.
DisableDomainLock(domain string) error
// EnablePrivacyProtection enables WHOIS privacy for a domain.
EnablePrivacyProtection(domain string) error
// DisablePrivacyProtection disables WHOIS privacy for a domain.
DisablePrivacyProtection(domain string) error
// --- VPS Management ---
// ListVMs returns all virtual machines on the account.
ListVMs() ([]VirtualMachine, error)
// GetVM returns details for a single virtual machine.
GetVM(id string) (*VirtualMachine, error)
// CreateVM provisions a new virtual machine.
CreateVM(req VMCreateRequest) (*OrderResult, error)
// ListDataCenters returns available data center locations.
ListDataCenters() ([]DataCenter, error)
// --- SSH Keys ---
// ListSSHKeys returns all SSH keys on the account.
ListSSHKeys() ([]SSHKey, error)
// AddSSHKey uploads a new SSH public key.
AddSSHKey(name, publicKey string) (*SSHKey, error)
// DeleteSSHKey removes an SSH key by ID.
DeleteSSHKey(id string) error
// --- Billing ---
// ListSubscriptions returns all active subscriptions.
ListSubscriptions() ([]Subscription, error)
// GetCatalog returns available products in a category.
GetCatalog(category string) ([]CatalogItem, error)
}
// ---------------------------------------------------------------------------
// Model types
// ---------------------------------------------------------------------------
// ProviderConfig holds the credentials and settings needed to connect to a
// hosting provider.
type ProviderConfig struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// DNSRecord represents a single DNS record.
type DNSRecord struct {
Name string `json:"name"`
Type string `json:"type"` // A, AAAA, CNAME, MX, TXT, etc.
Content string `json:"content"`
TTL int `json:"ttl,omitempty"` // seconds; 0 means provider default
Priority int `json:"priority,omitempty"` // used by MX, SRV
}
// DNSRecordFilter identifies DNS records to match for deletion or lookup.
type DNSRecordFilter struct {
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
}
// Domain is a summary of a domain on the account.
type Domain struct {
Name string `json:"name"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
}
// DomainDetail contains full information about a domain registration.
type DomainDetail struct {
Name string `json:"name"`
Status string `json:"status"`
Registrar string `json:"registrar"`
RegisteredAt time.Time `json:"registered_at"`
ExpiresAt time.Time `json:"expires_at"`
AutoRenew bool `json:"auto_renew"`
Locked bool `json:"locked"`
PrivacyProtection bool `json:"privacy_protection"`
Nameservers []string `json:"nameservers"`
}
// DomainAvailability reports whether a domain + TLD combination can be
// registered and its price.
type DomainAvailability struct {
Domain string `json:"domain"`
TLD string `json:"tld"`
Available bool `json:"available"`
Price float64 `json:"price"` // in the provider's default currency
Currency string `json:"currency"`
}
// DomainPurchaseRequest contains everything needed to register a domain.
type DomainPurchaseRequest struct {
Domain string `json:"domain"`
Years int `json:"years"`
AutoRenew bool `json:"auto_renew"`
Privacy bool `json:"privacy"`
PaymentMethod string `json:"payment_method,omitempty"`
}
// OrderResult is returned after a purchase or provisioning request.
type OrderResult struct {
OrderID string `json:"order_id"`
Status string `json:"status"` // e.g. "pending", "completed", "failed"
Message string `json:"message,omitempty"`
}
// VirtualMachine represents a VPS instance.
type VirtualMachine struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
IPv6 string `json:"ipv6,omitempty"`
Status string `json:"status"` // running, stopped, provisioning, etc.
Plan string `json:"plan"`
DataCenter string `json:"data_center"`
OS string `json:"os"`
CPUs int `json:"cpus"`
RAMBytes int64 `json:"ram_bytes"`
DiskBytes int64 `json:"disk_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// VMCreateRequest contains everything needed to provision a new VPS.
type VMCreateRequest struct {
Hostname string `json:"hostname"`
Plan string `json:"plan"`
DataCenterID string `json:"data_center_id"`
OS string `json:"os"`
SSHKeyID string `json:"ssh_key_id,omitempty"`
Password string `json:"password,omitempty"`
}
// DataCenter represents a physical hosting location.
type DataCenter struct {
ID string `json:"id"`
Name string `json:"name"`
Location string `json:"location"` // city or region
Country string `json:"country"`
}
// SSHKey is a stored SSH public key.
type SSHKey struct {
ID string `json:"id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
CreatedAt time.Time `json:"created_at"`
}
// Subscription represents a billing subscription.
type Subscription struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // active, cancelled, expired
RenewsAt time.Time `json:"renews_at"`
Price float64 `json:"price"`
Currency string `json:"currency"`
}
// CatalogItem is a purchasable product or plan.
type CatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Features map[string]string `json:"features,omitempty"`
}
// ---------------------------------------------------------------------------
// Provider registry
// ---------------------------------------------------------------------------
var (
mu sync.RWMutex
providers = map[string]Provider{}
)
// Register adds a provider to the global registry. It panics if a provider
// with the same name is already registered.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
name := p.Name()
if _, exists := providers[name]; exists {
panic("hosting: provider already registered: " + name)
}
providers[name] = p
}
// Get returns a registered provider by name.
func Get(name string) (Provider, bool) {
mu.RLock()
defer mu.RUnlock()
p, ok := providers[name]
return p, ok
}
// List returns the names of all registered providers, sorted alphabetically.
func List() []string {
mu.RLock()
defer mu.RUnlock()
names := make([]string, 0, len(providers))
for name := range providers {
names = append(names, name)
}
sort.Strings(names)
return names
}

View File

@@ -0,0 +1,201 @@
package nginx
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"setec-manager/internal/config"
"setec-manager/internal/db"
)
const reverseProxyTemplate = `# Managed by Setec App Manager — do not edit manually
server {
listen 80;
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
location /.well-known/acme-challenge/ {
root {{.CertbotWebroot}};
}
location / {
return 301 https://$host$request_uri;
}
}
{{if .SSLEnabled}}server {
listen 443 ssl http2;
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
ssl_certificate {{.SSLCertPath}};
ssl_certificate_key {{.SSLKeyPath}};
include snippets/ssl-params.conf;
location / {
proxy_pass http://127.0.0.1:{{.AppPort}};
include snippets/proxy-params.conf;
}
# WebSocket / SSE support
location /api/ {
proxy_pass http://127.0.0.1:{{.AppPort}};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
include snippets/proxy-params.conf;
}
}{{end}}
`
const staticSiteTemplate = `# Managed by Setec App Manager — do not edit manually
server {
listen 80;
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
location /.well-known/acme-challenge/ {
root {{.CertbotWebroot}};
}
location / {
return 301 https://$host$request_uri;
}
}
{{if .SSLEnabled}}server {
listen 443 ssl http2;
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
root {{.AppRoot}};
index index.html;
ssl_certificate {{.SSLCertPath}};
ssl_certificate_key {{.SSLKeyPath}};
include snippets/ssl-params.conf;
location / {
try_files $uri $uri/ =404;
}
}{{else}}server {
listen 80;
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
root {{.AppRoot}};
index index.html;
location / {
try_files $uri $uri/ =404;
}
}{{end}}
`
type configData struct {
Domain string
Aliases string
AppRoot string
AppPort int
SSLEnabled bool
SSLCertPath string
SSLKeyPath string
CertbotWebroot string
}
func GenerateConfig(cfg *config.Config, site *db.Site) error {
data := configData{
Domain: site.Domain,
Aliases: site.Aliases,
AppRoot: site.AppRoot,
AppPort: site.AppPort,
SSLEnabled: site.SSLEnabled,
SSLCertPath: site.SSLCertPath,
SSLKeyPath: site.SSLKeyPath,
CertbotWebroot: cfg.Nginx.CertbotWebroot,
}
var tmplStr string
switch site.AppType {
case "static":
tmplStr = staticSiteTemplate
default:
tmplStr = reverseProxyTemplate
}
tmpl, err := template.New("nginx").Parse(tmplStr)
if err != nil {
return fmt.Errorf("parse template: %w", err)
}
path := filepath.Join(cfg.Nginx.SitesAvailable, site.Domain)
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create config: %w", err)
}
defer f.Close()
return tmpl.Execute(f, data)
}
func EnableSite(cfg *config.Config, domain string) error {
src := filepath.Join(cfg.Nginx.SitesAvailable, domain)
dst := filepath.Join(cfg.Nginx.SitesEnabled, domain)
// Remove existing symlink
os.Remove(dst)
return os.Symlink(src, dst)
}
func DisableSite(cfg *config.Config, domain string) error {
dst := filepath.Join(cfg.Nginx.SitesEnabled, domain)
return os.Remove(dst)
}
func Reload() error {
return exec.Command("systemctl", "reload", "nginx").Run()
}
func Restart() error {
return exec.Command("systemctl", "restart", "nginx").Run()
}
func Test() (string, error) {
out, err := exec.Command("nginx", "-t").CombinedOutput()
return string(out), err
}
func Status() (string, bool) {
out, err := exec.Command("systemctl", "is-active", "nginx").Output()
status := strings.TrimSpace(string(out))
return status, err == nil && status == "active"
}
func InstallSnippets(cfg *config.Config) error {
os.MkdirAll(cfg.Nginx.Snippets, 0755)
sslParams := `# SSL params — managed by Setec App Manager
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000" always;
`
proxyParams := `# Proxy params — managed by Setec App Manager
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_request_buffering off;
`
if err := os.WriteFile(filepath.Join(cfg.Nginx.Snippets, "ssl-params.conf"), []byte(sslParams), 0644); err != nil {
return err
}
return os.WriteFile(filepath.Join(cfg.Nginx.Snippets, "proxy-params.conf"), []byte(proxyParams), 0644)
}

View File

@@ -0,0 +1,280 @@
package scheduler
import (
"fmt"
"strconv"
"strings"
"time"
)
// CronExpr represents a parsed 5-field cron expression.
// Each field is expanded into a sorted slice of valid integer values.
type CronExpr struct {
Minutes []int // 0-59
Hours []int // 0-23
DaysOfMonth []int // 1-31
Months []int // 1-12
DaysOfWeek []int // 0-6 (0 = Sunday)
}
// fieldBounds defines the min/max for each cron field.
var fieldBounds = [5][2]int{
{0, 59}, // minute
{0, 23}, // hour
{1, 31}, // day of month
{1, 12}, // month
{0, 6}, // day of week
}
// ParseCron parses a standard 5-field cron expression into a CronExpr.
//
// Supported syntax per field:
// - * all values in range
// - N single number
// - N-M range from N to M inclusive
// - N-M/S range with step S
// - */S full range with step S
// - N,M,O list of values (each element can be a number or range)
func ParseCron(expr string) (*CronExpr, error) {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return nil, fmt.Errorf("cron: expected 5 fields, got %d in %q", len(fields), expr)
}
ce := &CronExpr{}
targets := []*[]int{&ce.Minutes, &ce.Hours, &ce.DaysOfMonth, &ce.Months, &ce.DaysOfWeek}
for i, field := range fields {
vals, err := parseField(field, fieldBounds[i][0], fieldBounds[i][1])
if err != nil {
return nil, fmt.Errorf("cron field %d (%q): %w", i+1, field, err)
}
if len(vals) == 0 {
return nil, fmt.Errorf("cron field %d (%q): produced no values", i+1, field)
}
*targets[i] = vals
}
return ce, nil
}
// parseField parses a single cron field into a sorted slice of ints.
func parseField(field string, min, max int) ([]int, error) {
// Handle lists: "1,3,5" or "1-3,7,10-12"
parts := strings.Split(field, ",")
seen := make(map[int]bool)
for _, part := range parts {
vals, err := parsePart(part, min, max)
if err != nil {
return nil, err
}
for _, v := range vals {
seen[v] = true
}
}
// Collect and sort.
result := make([]int, 0, len(seen))
for v := range seen {
result = append(result, v)
}
sortInts(result)
return result, nil
}
// parsePart parses a single element that may be *, a number, a range, or have a step.
func parsePart(part string, min, max int) ([]int, error) {
// Split on "/" for step.
var stepStr string
base := part
if idx := strings.Index(part, "/"); idx >= 0 {
base = part[:idx]
stepStr = part[idx+1:]
}
// Determine the range.
var lo, hi int
if base == "*" {
lo, hi = min, max
} else if idx := strings.Index(base, "-"); idx >= 0 {
var err error
lo, err = strconv.Atoi(base[:idx])
if err != nil {
return nil, fmt.Errorf("invalid number %q: %w", base[:idx], err)
}
hi, err = strconv.Atoi(base[idx+1:])
if err != nil {
return nil, fmt.Errorf("invalid number %q: %w", base[idx+1:], err)
}
} else {
n, err := strconv.Atoi(base)
if err != nil {
return nil, fmt.Errorf("invalid number %q: %w", base, err)
}
if stepStr == "" {
// Single value, no step.
if n < min || n > max {
return nil, fmt.Errorf("value %d out of range [%d, %d]", n, min, max)
}
return []int{n}, nil
}
// e.g., "5/10" means starting at 5, step 10, up to max.
lo, hi = n, max
}
// Validate bounds.
if lo < min || lo > max {
return nil, fmt.Errorf("value %d out of range [%d, %d]", lo, min, max)
}
if hi < min || hi > max {
return nil, fmt.Errorf("value %d out of range [%d, %d]", hi, min, max)
}
if lo > hi {
return nil, fmt.Errorf("range start %d > end %d", lo, hi)
}
step := 1
if stepStr != "" {
var err error
step, err = strconv.Atoi(stepStr)
if err != nil {
return nil, fmt.Errorf("invalid step %q: %w", stepStr, err)
}
if step < 1 {
return nil, fmt.Errorf("step must be >= 1, got %d", step)
}
}
var vals []int
for v := lo; v <= hi; v += step {
vals = append(vals, v)
}
return vals, nil
}
// NextRun computes the next run time for a cron expression after the given time.
// It searches up to 2 years ahead before giving up.
func NextRun(schedule string, from time.Time) (time.Time, error) {
ce, err := ParseCron(schedule)
if err != nil {
return time.Time{}, err
}
return ce.Next(from)
}
// Next finds the earliest time after "from" that matches the cron expression.
func (ce *CronExpr) Next(from time.Time) (time.Time, error) {
// Start from the next whole minute.
t := from.Truncate(time.Minute).Add(time.Minute)
// Search limit: 2 years of minutes (~1,051,200). We iterate by
// advancing fields intelligently rather than minute-by-minute.
deadline := t.Add(2 * 365 * 24 * time.Hour)
for t.Before(deadline) {
// Check month.
if !contains(ce.Months, int(t.Month())) {
// Advance to next valid month.
t = advanceMonth(t, ce.Months)
continue
}
// Check day of month.
dom := t.Day()
domOk := contains(ce.DaysOfMonth, dom)
dowOk := contains(ce.DaysOfWeek, int(t.Weekday()))
if !domOk || !dowOk {
// Advance one day.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
continue
}
// Check hour.
if !contains(ce.Hours, t.Hour()) {
// Advance to next valid hour today.
nextH := nextVal(ce.Hours, t.Hour())
if nextH == -1 {
// No more valid hours today, go to next day.
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), nextH, 0, 0, 0, t.Location())
}
continue
}
// Check minute.
if !contains(ce.Minutes, t.Minute()) {
nextM := nextVal(ce.Minutes, t.Minute())
if nextM == -1 {
// No more valid minutes this hour, advance hour.
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextM, 0, 0, t.Location())
}
continue
}
// All fields match.
return t, nil
}
return time.Time{}, fmt.Errorf("cron: no matching time found within 2 years for %q", ce.String())
}
// String reconstructs a human-readable representation of the cron expression.
func (ce *CronExpr) String() string {
return fmt.Sprintf("%v %v %v %v %v",
ce.Minutes, ce.Hours, ce.DaysOfMonth, ce.Months, ce.DaysOfWeek)
}
// contains checks if val is in the sorted slice.
func contains(vals []int, val int) bool {
for _, v := range vals {
if v == val {
return true
}
if v > val {
return false
}
}
return false
}
// nextVal returns the smallest value in vals that is > current, or -1.
func nextVal(vals []int, current int) int {
for _, v := range vals {
if v > current {
return v
}
}
return -1
}
// advanceMonth jumps to day 1, hour 0, minute 0 of the next valid month.
func advanceMonth(t time.Time, months []int) time.Time {
cur := int(t.Month())
year := t.Year()
// Find next valid month in this year.
for _, m := range months {
if m > cur {
return time.Date(year, time.Month(m), 1, 0, 0, 0, 0, t.Location())
}
}
// Wrap to first valid month of next year.
return time.Date(year+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
}
// sortInts performs an insertion sort on a small slice.
func sortInts(a []int) {
for i := 1; i < len(a); i++ {
key := a[i]
j := i - 1
for j >= 0 && a[j] > key {
a[j+1] = a[j]
j--
}
a[j+1] = key
}
}

View File

@@ -0,0 +1,279 @@
package scheduler
import (
"database/sql"
"fmt"
"log"
"sync"
"time"
"setec-manager/internal/db"
)
// Job type constants.
const (
JobSSLRenew = "ssl_renew"
JobBackup = "backup"
JobGitPull = "git_pull"
JobRestart = "restart"
JobCleanup = "cleanup"
)
// Job represents a scheduled job stored in the cron_jobs table.
type Job struct {
ID int64 `json:"id"`
SiteID *int64 `json:"site_id"`
JobType string `json:"job_type"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
LastRun *time.Time `json:"last_run"`
NextRun *time.Time `json:"next_run"`
}
// HandlerFunc is the signature for job handler functions.
// siteID may be nil for global jobs (e.g., cleanup).
type HandlerFunc func(siteID *int64) error
// Scheduler manages cron-like scheduled jobs backed by a SQLite database.
type Scheduler struct {
db *db.DB
handlers map[string]HandlerFunc
mu sync.RWMutex
stop chan struct{}
running bool
}
// New creates a new Scheduler attached to the given database.
func New(database *db.DB) *Scheduler {
return &Scheduler{
db: database,
handlers: make(map[string]HandlerFunc),
stop: make(chan struct{}),
}
}
// RegisterHandler registers a function to handle a given job type.
// Must be called before Start.
func (s *Scheduler) RegisterHandler(jobType string, fn HandlerFunc) {
s.mu.Lock()
defer s.mu.Unlock()
s.handlers[jobType] = fn
log.Printf("[scheduler] registered handler for job type %q", jobType)
}
// Start begins the scheduler's ticker goroutine that fires every minute.
func (s *Scheduler) Start() {
s.mu.Lock()
if s.running {
s.mu.Unlock()
log.Printf("[scheduler] already running")
return
}
s.running = true
s.stop = make(chan struct{})
s.mu.Unlock()
log.Printf("[scheduler] starting — checking for due jobs every 60s")
go s.loop()
}
// Stop shuts down the scheduler ticker.
func (s *Scheduler) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return
}
close(s.stop)
s.running = false
log.Printf("[scheduler] stopped")
}
// loop runs the main ticker. It fires immediately on start, then every minute.
func (s *Scheduler) loop() {
// Run once immediately on start.
s.tick()
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.tick()
case <-s.stop:
return
}
}
}
// tick queries for all enabled jobs whose next_run <= now and executes them.
func (s *Scheduler) tick() {
now := time.Now().UTC()
rows, err := s.db.Conn().Query(`
SELECT id, site_id, job_type, schedule, enabled, last_run, next_run
FROM cron_jobs
WHERE enabled = TRUE AND next_run IS NOT NULL AND next_run <= ?
ORDER BY next_run ASC`, now)
if err != nil {
log.Printf("[scheduler] error querying due jobs: %v", err)
return
}
var due []Job
for rows.Next() {
var j Job
var siteID sql.NullInt64
var lastRun, nextRun sql.NullTime
if err := rows.Scan(&j.ID, &siteID, &j.JobType, &j.Schedule, &j.Enabled, &lastRun, &nextRun); err != nil {
log.Printf("[scheduler] error scanning job row: %v", err)
continue
}
if siteID.Valid {
id := siteID.Int64
j.SiteID = &id
}
if lastRun.Valid {
j.LastRun = &lastRun.Time
}
if nextRun.Valid {
j.NextRun = &nextRun.Time
}
due = append(due, j)
}
rows.Close()
if len(due) == 0 {
return
}
log.Printf("[scheduler] %d job(s) due", len(due))
for _, job := range due {
s.executeJob(job, now)
}
}
// executeJob runs a single job's handler and updates the database.
func (s *Scheduler) executeJob(job Job, now time.Time) {
s.mu.RLock()
handler, ok := s.handlers[job.JobType]
s.mu.RUnlock()
if !ok {
log.Printf("[scheduler] no handler for job type %q (job %d), skipping", job.JobType, job.ID)
// Still advance next_run so we don't re-fire every minute.
s.advanceJob(job, now)
return
}
siteLabel := "global"
if job.SiteID != nil {
siteLabel = fmt.Sprintf("site %d", *job.SiteID)
}
log.Printf("[scheduler] executing job %d: type=%s %s schedule=%s", job.ID, job.JobType, siteLabel, job.Schedule)
if err := handler(job.SiteID); err != nil {
log.Printf("[scheduler] job %d (%s) failed: %v", job.ID, job.JobType, err)
} else {
log.Printf("[scheduler] job %d (%s) completed successfully", job.ID, job.JobType)
}
s.advanceJob(job, now)
}
// advanceJob updates last_run to now and computes the next next_run.
func (s *Scheduler) advanceJob(job Job, now time.Time) {
next, err := NextRun(job.Schedule, now)
if err != nil {
log.Printf("[scheduler] cannot compute next run for job %d (%q): %v — disabling", job.ID, job.Schedule, err)
_, _ = s.db.Conn().Exec(`UPDATE cron_jobs SET enabled = FALSE, last_run = ? WHERE id = ?`, now, job.ID)
return
}
_, err = s.db.Conn().Exec(
`UPDATE cron_jobs SET last_run = ?, next_run = ? WHERE id = ?`,
now, next, job.ID)
if err != nil {
log.Printf("[scheduler] error updating job %d: %v", job.ID, err)
}
}
// AddJob inserts a new scheduled job and returns its ID.
// siteID may be nil for global jobs.
func (s *Scheduler) AddJob(siteID *int64, jobType, schedule string) (int64, error) {
// Validate the schedule before inserting.
next, err := NextRun(schedule, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("invalid schedule %q: %w", schedule, err)
}
var sid sql.NullInt64
if siteID != nil {
sid = sql.NullInt64{Int64: *siteID, Valid: true}
}
res, err := s.db.Conn().Exec(
`INSERT INTO cron_jobs (site_id, job_type, schedule, enabled, next_run) VALUES (?, ?, ?, TRUE, ?)`,
sid, jobType, schedule, next)
if err != nil {
return 0, fmt.Errorf("insert cron job: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("get insert id: %w", err)
}
log.Printf("[scheduler] added job %d: type=%s schedule=%s next_run=%s", id, jobType, schedule, next.Format(time.RFC3339))
return id, nil
}
// RemoveJob deletes a scheduled job by ID.
func (s *Scheduler) RemoveJob(id int64) error {
res, err := s.db.Conn().Exec(`DELETE FROM cron_jobs WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete cron job %d: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("cron job %d not found", id)
}
log.Printf("[scheduler] removed job %d", id)
return nil
}
// ListJobs returns all cron jobs with their current state.
func (s *Scheduler) ListJobs() ([]Job, error) {
rows, err := s.db.Conn().Query(`
SELECT id, site_id, job_type, schedule, enabled, last_run, next_run
FROM cron_jobs
ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list cron jobs: %w", err)
}
defer rows.Close()
var jobs []Job
for rows.Next() {
var j Job
var siteID sql.NullInt64
var lastRun, nextRun sql.NullTime
if err := rows.Scan(&j.ID, &siteID, &j.JobType, &j.Schedule, &j.Enabled, &lastRun, &nextRun); err != nil {
return nil, fmt.Errorf("scan cron job: %w", err)
}
if siteID.Valid {
id := siteID.Int64
j.SiteID = &id
}
if lastRun.Valid {
j.LastRun = &lastRun.Time
}
if nextRun.Valid {
j.NextRun = &nextRun.Time
}
jobs = append(jobs, j)
}
return jobs, rows.Err()
}

View File

@@ -0,0 +1,114 @@
package server
import (
"encoding/json"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
Role string `json:"role"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
user, err := s.DB.AuthenticateUser(req.Username, req.Password)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if user == nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Generate JWT
claims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(s.JWTKey)
if err != nil {
http.Error(w, "Token generation failed", http.StatusInternalServerError)
return
}
// Set cookie
http.SetCookie(w, &http.Cookie{
Name: "setec_token",
Value: tokenStr,
Path: "/",
HttpOnly: true,
Secure: s.Config.Server.TLS,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400,
})
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
Username: user.Username,
Role: user.Role,
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "setec_token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
claims := getClaimsFromContext(r.Context())
if claims == nil {
http.Error(w, "Not authenticated", http.StatusUnauthorized)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
})
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
s.renderTemplate(w, "login.html", nil)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,135 @@
package server
import (
"context"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const claimsKey contextKey = "claims"
// authRequired validates JWT from cookie or Authorization header.
func (s *Server) authRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := ""
// Try cookie first
if cookie, err := r.Cookie("setec_token"); err == nil {
tokenStr = cookie.Value
}
// Fall back to Authorization header
if tokenStr == "" {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
tokenStr = strings.TrimPrefix(auth, "Bearer ")
}
}
if tokenStr == "" {
// If HTML request, redirect to login
if acceptsHTML(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
return s.JWTKey, nil
})
if err != nil || !token.Valid {
if acceptsHTML(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// adminRequired checks that the authenticated user has admin role.
func (s *Server) adminRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := getClaimsFromContext(r.Context())
if claims == nil || claims.Role != "admin" {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(r.Context()))
})
}
func getClaimsFromContext(ctx context.Context) *Claims {
claims, _ := ctx.Value(claimsKey).(*Claims)
return claims
}
func acceptsHTML(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept"), "text/html")
}
// ── Rate Limiter ────────────────────────────────────────────────────
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
limit int
window time.Duration
}
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
return &rateLimiter{
attempts: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
func (rl *rateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Remove expired entries
var valid []time.Time
for _, t := range rl.attempts[key] {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= rl.limit {
rl.attempts[key] = valid
return false
}
rl.attempts[key] = append(valid, now)
return true
}
func (s *Server) loginRateLimit(next http.Handler) http.Handler {
limiter := newRateLimiter(5, time.Minute)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !limiter.Allow(ip) {
http.Error(w, "Too many login attempts. Try again in a minute.", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,152 @@
package server
import (
"io/fs"
"net/http"
"setec-manager/internal/handlers"
"setec-manager/web"
"github.com/go-chi/chi/v5"
)
func (s *Server) setupRoutes() {
h := handlers.New(s.Config, s.DB, s.HostingConfigs)
// Static assets (embedded)
staticFS, _ := fs.Sub(web.StaticFS, "static")
s.Router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
// Public routes
s.Router.Group(func(r chi.Router) {
r.Get("/login", s.handleLoginPage)
r.With(s.loginRateLimit).Post("/login", s.handleLogin)
r.Post("/logout", s.handleLogout)
})
// Authenticated routes
s.Router.Group(func(r chi.Router) {
r.Use(s.authRequired)
// Dashboard
r.Get("/", h.Dashboard)
r.Get("/api/system/info", h.SystemInfo)
// Auth status
r.Get("/api/auth/status", s.handleAuthStatus)
// Sites
r.Get("/sites", h.SiteList)
r.Get("/sites/new", h.SiteNewForm)
r.Post("/sites", h.SiteCreate)
r.Get("/sites/{id}", h.SiteDetail)
r.Put("/sites/{id}", h.SiteUpdate)
r.Delete("/sites/{id}", h.SiteDelete)
r.Post("/sites/{id}/deploy", h.SiteDeploy)
r.Post("/sites/{id}/restart", h.SiteRestart)
r.Post("/sites/{id}/stop", h.SiteStop)
r.Post("/sites/{id}/start", h.SiteStart)
r.Get("/sites/{id}/logs", h.SiteLogs)
r.Get("/sites/{id}/logs/stream", h.SiteLogStream)
// AUTARCH
r.Get("/autarch", h.AutarchStatus)
r.Post("/autarch/install", h.AutarchInstall)
r.Post("/autarch/update", h.AutarchUpdate)
r.Get("/autarch/status", h.AutarchStatusAPI)
r.Post("/autarch/start", h.AutarchStart)
r.Post("/autarch/stop", h.AutarchStop)
r.Post("/autarch/restart", h.AutarchRestart)
r.Get("/autarch/config", h.AutarchConfig)
r.Put("/autarch/config", h.AutarchConfigUpdate)
r.Post("/autarch/dns/build", h.AutarchDNSBuild)
// SSL
r.Get("/ssl", h.SSLOverview)
r.Post("/ssl/{domain}/issue", h.SSLIssue)
r.Post("/ssl/{domain}/renew", h.SSLRenew)
r.Get("/api/ssl/status", h.SSLStatus)
// Nginx
r.Get("/nginx", h.NginxStatus)
r.Post("/nginx/reload", h.NginxReload)
r.Post("/nginx/restart", h.NginxRestart)
r.Get("/nginx/config/{domain}", h.NginxConfigView)
r.Post("/nginx/test", h.NginxTest)
// Firewall
r.Get("/firewall", h.FirewallList)
r.Post("/firewall/rules", h.FirewallAddRule)
r.Delete("/firewall/rules/{id}", h.FirewallDeleteRule)
r.Post("/firewall/enable", h.FirewallEnable)
r.Post("/firewall/disable", h.FirewallDisable)
r.Get("/api/firewall/status", h.FirewallStatus)
// System users
r.Get("/users", h.UserList)
r.Post("/users", h.UserCreate)
r.Delete("/users/{id}", h.UserDelete)
// Panel users
r.Get("/panel/users", h.PanelUserList)
r.Post("/panel/users", h.PanelUserCreate)
r.Put("/panel/users/{id}", h.PanelUserUpdate)
r.Delete("/panel/users/{id}", h.PanelUserDelete)
// Backups
r.Get("/backups", h.BackupList)
r.Post("/backups/site/{id}", h.BackupSite)
r.Post("/backups/full", h.BackupFull)
r.Delete("/backups/{id}", h.BackupDelete)
r.Get("/backups/{id}/download", h.BackupDownload)
// Hosting Provider Management
r.Get("/hosting", h.HostingProviders)
r.Get("/hosting/{provider}", h.HostingProviderConfig)
r.Post("/hosting/{provider}/config", h.HostingProviderSave)
r.Post("/hosting/{provider}/test", h.HostingProviderTest)
// DNS
r.Get("/hosting/{provider}/dns/{domain}", h.HostingDNSList)
r.Put("/hosting/{provider}/dns/{domain}", h.HostingDNSUpdate)
r.Delete("/hosting/{provider}/dns/{domain}", h.HostingDNSDelete)
r.Post("/hosting/{provider}/dns/{domain}/reset", h.HostingDNSReset)
// Domains
r.Get("/hosting/{provider}/domains", h.HostingDomainsList)
r.Post("/hosting/{provider}/domains/check", h.HostingDomainsCheck)
r.Post("/hosting/{provider}/domains/purchase", h.HostingDomainsPurchase)
r.Put("/hosting/{provider}/domains/{domain}/nameservers", h.HostingDomainNameservers)
r.Put("/hosting/{provider}/domains/{domain}/lock", h.HostingDomainLock)
r.Put("/hosting/{provider}/domains/{domain}/privacy", h.HostingDomainPrivacy)
// VPS
r.Get("/hosting/{provider}/vms", h.HostingVMsList)
r.Get("/hosting/{provider}/vms/{id}", h.HostingVMGet)
r.Post("/hosting/{provider}/vms", h.HostingVMCreate)
r.Get("/hosting/{provider}/datacenters", h.HostingDataCenters)
// SSH Keys
r.Get("/hosting/{provider}/ssh-keys", h.HostingSSHKeys)
r.Post("/hosting/{provider}/ssh-keys", h.HostingSSHKeyAdd)
r.Delete("/hosting/{provider}/ssh-keys/{id}", h.HostingSSHKeyDelete)
// Billing
r.Get("/hosting/{provider}/subscriptions", h.HostingSubscriptions)
r.Get("/hosting/{provider}/catalog", h.HostingCatalog)
// Monitoring
r.Get("/monitor", h.MonitorPage)
r.Get("/api/monitor/cpu", h.MonitorCPU)
r.Get("/api/monitor/memory", h.MonitorMemory)
r.Get("/api/monitor/disk", h.MonitorDisk)
r.Get("/api/monitor/services", h.MonitorServices)
// Logs
r.Get("/logs", h.LogsPage)
r.Get("/api/logs/system", h.LogsSystem)
r.Get("/api/logs/nginx", h.LogsNginx)
r.Get("/api/logs/stream", h.LogsStream)
// Float Mode
r.Post("/float/register", h.FloatRegister)
r.Get("/float/sessions", h.FloatSessions)
r.Delete("/float/sessions/{id}", h.FloatDisconnect)
r.Get("/float/ws", s.FloatBridge.HandleWebSocket)
})
}

View File

@@ -0,0 +1,199 @@
package server
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"unicode"
)
// ── Security Headers Middleware ──────────────────────────────────────
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-inline'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self'; "+
"connect-src 'self'; "+
"frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
// ── Request Body Limit ──────────────────────────────────────────────
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}
// ── CSRF Protection ─────────────────────────────────────────────────
const csrfTokenLength = 32
const csrfCookieName = "setec_csrf"
const csrfHeaderName = "X-CSRF-Token"
const csrfFormField = "csrf_token"
func generateCSRFToken() string {
b := make([]byte, csrfTokenLength)
rand.Read(b)
return hex.EncodeToString(b)
}
func csrfProtection(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods don't need CSRF validation
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
// Ensure a CSRF cookie exists for forms to use
if _, err := r.Cookie(csrfCookieName); err != nil {
token := generateCSRFToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: false, // JS needs to read this
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 86400,
})
}
next.ServeHTTP(w, r)
return
}
// For mutating requests, validate CSRF token
cookie, err := r.Cookie(csrfCookieName)
if err != nil {
http.Error(w, "CSRF token missing", http.StatusForbidden)
return
}
// Check header first, then form field
token := r.Header.Get(csrfHeaderName)
if token == "" {
token = r.FormValue(csrfFormField)
}
// API requests with JSON Content-Type + Bearer auth skip CSRF
// (they're not vulnerable to CSRF since browsers don't send custom headers)
contentType := r.Header.Get("Content-Type")
authHeader := r.Header.Get("Authorization")
if strings.Contains(contentType, "application/json") && strings.HasPrefix(authHeader, "Bearer ") {
next.ServeHTTP(w, r)
return
}
if token != cookie.Value {
http.Error(w, "CSRF token invalid", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// ── Password Policy ─────────────────────────────────────────────────
type passwordPolicy struct {
MinLength int
RequireUpper bool
RequireLower bool
RequireDigit bool
}
var defaultPasswordPolicy = passwordPolicy{
MinLength: 8,
RequireUpper: true,
RequireLower: true,
RequireDigit: true,
}
func validatePassword(password string) error {
p := defaultPasswordPolicy
if len(password) < p.MinLength {
return fmt.Errorf("password must be at least %d characters", p.MinLength)
}
hasUpper, hasLower, hasDigit := false, false, false
for _, c := range password {
if unicode.IsUpper(c) {
hasUpper = true
}
if unicode.IsLower(c) {
hasLower = true
}
if unicode.IsDigit(c) {
hasDigit = true
}
}
if p.RequireUpper && !hasUpper {
return fmt.Errorf("password must contain at least one uppercase letter")
}
if p.RequireLower && !hasLower {
return fmt.Errorf("password must contain at least one lowercase letter")
}
if p.RequireDigit && !hasDigit {
return fmt.Errorf("password must contain at least one digit")
}
return nil
}
// ── Persistent JWT Key ──────────────────────────────────────────────
func LoadOrCreateJWTKey(dataDir string) ([]byte, error) {
keyPath := filepath.Join(dataDir, ".jwt_key")
// Try to load existing key
data, err := os.ReadFile(keyPath)
if err == nil && len(data) == 32 {
return data, nil
}
// Generate new key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, err
}
// Save with restrictive permissions
os.MkdirAll(dataDir, 0700)
if err := os.WriteFile(keyPath, key, 0600); err != nil {
return nil, err
}
return key, nil
}
// ── Audit Logger ────────────────────────────────────────────────────
func (s *Server) logAudit(r *http.Request, action, detail string) {
claims := getClaimsFromContext(r.Context())
username := "anonymous"
if claims != nil {
username = claims.Username
}
ip := r.RemoteAddr
// Insert into audit log table
s.DB.Conn().Exec(`INSERT INTO audit_log (username, ip, action, detail) VALUES (?, ?, ?, ?)`,
username, ip, action, detail)
}

View File

@@ -0,0 +1,81 @@
package server
import (
"context"
"fmt"
"log"
"net/http"
"path/filepath"
"time"
"setec-manager/internal/config"
"setec-manager/internal/db"
"setec-manager/internal/float"
"setec-manager/internal/hosting"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
)
type Server struct {
Config *config.Config
DB *db.DB
Router *chi.Mux
http *http.Server
JWTKey []byte
FloatBridge *float.Bridge
HostingConfigs *hosting.ProviderConfigStore
}
func New(cfg *config.Config, database *db.DB, jwtKey []byte) *Server {
// Initialize hosting provider config store.
hostingDir := filepath.Join(filepath.Dir(cfg.Database.Path), "hosting")
hostingConfigs := hosting.NewConfigStore(hostingDir)
s := &Server{
Config: cfg,
DB: database,
Router: chi.NewRouter(),
JWTKey: jwtKey,
FloatBridge: float.NewBridge(database),
HostingConfigs: hostingConfigs,
}
s.setupMiddleware()
s.setupRoutes()
return s
}
func (s *Server) setupMiddleware() {
s.Router.Use(chiMiddleware.RequestID)
s.Router.Use(chiMiddleware.RealIP)
s.Router.Use(chiMiddleware.Logger)
s.Router.Use(chiMiddleware.Recoverer)
s.Router.Use(chiMiddleware.Timeout(60 * time.Second))
s.Router.Use(securityHeaders)
s.Router.Use(maxBodySize(10 << 20)) // 10MB max request body
s.Router.Use(csrfProtection)
}
func (s *Server) Start() error {
addr := fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port)
s.http = &http.Server{
Addr: addr,
Handler: s.Router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Printf("[setec] Starting on %s (TLS=%v)", addr, s.Config.Server.TLS)
if s.Config.Server.TLS {
return s.http.ListenAndServeTLS(s.Config.Server.Cert, s.Config.Server.Key)
}
return s.http.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.http.Shutdown(ctx)
}

View File

@@ -0,0 +1,93 @@
package server
import (
"html/template"
"io"
"log"
"net/http"
"sync"
"setec-manager/web"
)
var (
tmplOnce sync.Once
tmpl *template.Template
)
func (s *Server) getTemplates() *template.Template {
tmplOnce.Do(func() {
funcMap := template.FuncMap{
"eq": func(a, b interface{}) bool { return a == b },
"ne": func(a, b interface{}) bool { return a != b },
"default": func(val, def interface{}) interface{} {
if val == nil || val == "" || val == 0 || val == false {
return def
}
return val
},
}
var err error
tmpl, err = template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, "templates/*.html")
if err != nil {
log.Fatalf("Failed to parse templates: %v", err)
}
})
return tmpl
}
type templateData struct {
Title string
Claims *Claims
Data interface{}
Flash string
Config interface{}
}
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
td := templateData{
Data: data,
Config: s.Config,
}
t := s.getTemplates().Lookup(name)
if t == nil {
http.Error(w, "Template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.Execute(w, td); err != nil {
log.Printf("Template render error (%s): %v", name, err)
}
}
func (s *Server) renderTemplateWithClaims(w http.ResponseWriter, r *http.Request, name string, data interface{}) {
td := templateData{
Claims: getClaimsFromContext(r.Context()),
Data: data,
Config: s.Config,
}
t := s.getTemplates().Lookup(name)
if t == nil {
http.Error(w, "Template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.Execute(w, td); err != nil {
log.Printf("Template render error (%s): %v", name, err)
}
}
// renderError sends an error response - HTML for browsers, JSON for API calls.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
if acceptsHTML(r) {
w.WriteHeader(status)
io.WriteString(w, message)
return
}
writeJSON(w, status, map[string]string{"error": message})
}

View File

@@ -0,0 +1,243 @@
package system
import (
"fmt"
"os/exec"
"regexp"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type UFWRule struct {
Direction string `json:"direction"` // "in" or "out"
Protocol string `json:"protocol"` // "tcp", "udp", or "" for both
Port string `json:"port"` // e.g. "22", "80:90"
Source string `json:"source"` // IP/CIDR or "any"/"Anywhere"
Action string `json:"action"` // "allow", "deny", "reject", "limit"
Comment string `json:"comment"`
}
// ── Firewall (UFW) ──────────────────────────────────────────────────
// Status parses `ufw status verbose` and returns the enable state, parsed rules,
// and the raw command output.
func FirewallStatus() (enabled bool, rules []UFWRule, raw string, err error) {
out, cmdErr := exec.Command("ufw", "status", "verbose").CombinedOutput()
raw = string(out)
if cmdErr != nil {
// ufw may return non-zero when inactive; check output
if strings.Contains(raw, "Status: inactive") {
return false, nil, raw, nil
}
err = fmt.Errorf("ufw status failed: %w (%s)", cmdErr, raw)
return
}
enabled = strings.Contains(raw, "Status: active")
// Parse rule lines. After the header block, rules look like:
// 22/tcp ALLOW IN Anywhere # SSH
// 80/tcp ALLOW IN 192.168.1.0/24 # Web
// We find lines after the "---" separator.
lines := strings.Split(raw, "\n")
pastSeparator := false
// Match: port/proto (or port) ACTION DIRECTION source # optional comment
ruleRegex := regexp.MustCompile(
`^(\S+)\s+(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT|FWD)?\s*(.+?)(?:\s+#\s*(.*))?$`,
)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "---") {
pastSeparator = true
continue
}
if !pastSeparator || trimmed == "" {
continue
}
matches := ruleRegex.FindStringSubmatch(trimmed)
if matches == nil {
continue
}
portProto := matches[1]
action := strings.ToLower(matches[2])
direction := strings.ToLower(matches[3])
source := strings.TrimSpace(matches[4])
comment := strings.TrimSpace(matches[5])
if direction == "" {
direction = "in"
}
// Split port/protocol
var port, proto string
if strings.Contains(portProto, "/") {
parts := strings.SplitN(portProto, "/", 2)
port = parts[0]
proto = parts[1]
} else {
port = portProto
}
// Normalize source
if source == "Anywhere" || source == "Anywhere (v6)" {
source = "any"
}
rules = append(rules, UFWRule{
Direction: direction,
Protocol: proto,
Port: port,
Source: source,
Action: action,
Comment: comment,
})
}
return enabled, rules, raw, nil
}
// FirewallEnable enables UFW with --force to skip the interactive prompt.
func FirewallEnable() error {
out, err := exec.Command("ufw", "--force", "enable").CombinedOutput()
if err != nil {
return fmt.Errorf("ufw enable failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallDisable disables UFW.
func FirewallDisable() error {
out, err := exec.Command("ufw", "disable").CombinedOutput()
if err != nil {
return fmt.Errorf("ufw disable failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallAddRule constructs and executes a ufw command from the given rule struct.
func FirewallAddRule(rule UFWRule) error {
if rule.Port == "" {
return fmt.Errorf("port is required")
}
if rule.Action == "" {
rule.Action = "allow"
}
if rule.Protocol == "" {
rule.Protocol = "tcp"
}
if rule.Source == "" || rule.Source == "any" {
rule.Source = ""
}
// Validate action
switch rule.Action {
case "allow", "deny", "reject", "limit":
// valid
default:
return fmt.Errorf("invalid action %q: must be allow, deny, reject, or limit", rule.Action)
}
// Validate protocol
switch rule.Protocol {
case "tcp", "udp":
// valid
default:
return fmt.Errorf("invalid protocol %q: must be tcp or udp", rule.Protocol)
}
// Validate direction
if rule.Direction != "" && rule.Direction != "in" && rule.Direction != "out" {
return fmt.Errorf("invalid direction %q: must be in or out", rule.Direction)
}
// Build argument list
args := []string{rule.Action}
// Direction
if rule.Direction == "out" {
args = append(args, "out")
}
// Source filter
if rule.Source != "" {
args = append(args, "from", rule.Source)
}
args = append(args, "to", "any", "port", rule.Port, "proto", rule.Protocol)
// Comment
if rule.Comment != "" {
args = append(args, "comment", rule.Comment)
}
out, err := exec.Command("ufw", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ufw add rule failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallDeleteRule constructs and executes a ufw delete command for the given rule.
func FirewallDeleteRule(rule UFWRule) error {
if rule.Port == "" {
return fmt.Errorf("port is required")
}
if rule.Action == "" {
rule.Action = "allow"
}
// Build the rule specification that matches what was added
args := []string{"delete", rule.Action}
if rule.Direction == "out" {
args = append(args, "out")
}
if rule.Source != "" && rule.Source != "any" {
args = append(args, "from", rule.Source)
}
portSpec := rule.Port
if rule.Protocol != "" {
portSpec = rule.Port + "/" + rule.Protocol
}
args = append(args, portSpec)
out, err := exec.Command("ufw", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("ufw delete rule failed: %w (%s)", err, string(out))
}
return nil
}
// FirewallSetDefaults sets the default incoming and outgoing policies.
// Valid values are "allow", "deny", "reject".
func FirewallSetDefaults(incoming, outgoing string) error {
validPolicy := map[string]bool{"allow": true, "deny": true, "reject": true}
if incoming != "" {
if !validPolicy[incoming] {
return fmt.Errorf("invalid incoming policy %q: must be allow, deny, or reject", incoming)
}
out, err := exec.Command("ufw", "default", incoming, "incoming").CombinedOutput()
if err != nil {
return fmt.Errorf("setting default incoming policy failed: %w (%s)", err, string(out))
}
}
if outgoing != "" {
if !validPolicy[outgoing] {
return fmt.Errorf("invalid outgoing policy %q: must be allow, deny, or reject", outgoing)
}
out, err := exec.Command("ufw", "default", outgoing, "outgoing").CombinedOutput()
if err != nil {
return fmt.Errorf("setting default outgoing policy failed: %w (%s)", err, string(out))
}
}
return nil
}

View File

@@ -0,0 +1,511 @@
package system
import (
"bufio"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// ── Types ───────────────────────────────────────────────────────────
type CoreUsage struct {
Core int `json:"core"`
User float64 `json:"user"`
System float64 `json:"system"`
Idle float64 `json:"idle"`
IOWait float64 `json:"iowait"`
Percent float64 `json:"percent"`
}
type CPUInfo struct {
Overall float64 `json:"overall"`
Idle float64 `json:"idle"`
Cores []CoreUsage `json:"cores"`
}
type MemInfo struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
FreeBytes uint64 `json:"free_bytes"`
AvailableBytes uint64 `json:"available_bytes"`
BuffersBytes uint64 `json:"buffers_bytes"`
CachedBytes uint64 `json:"cached_bytes"`
SwapTotalBytes uint64 `json:"swap_total_bytes"`
SwapUsedBytes uint64 `json:"swap_used_bytes"`
SwapFreeBytes uint64 `json:"swap_free_bytes"`
Total string `json:"total"`
Used string `json:"used"`
Free string `json:"free"`
Available string `json:"available"`
Buffers string `json:"buffers"`
Cached string `json:"cached"`
SwapTotal string `json:"swap_total"`
SwapUsed string `json:"swap_used"`
SwapFree string `json:"swap_free"`
}
type DiskInfo struct {
Filesystem string `json:"filesystem"`
Size string `json:"size"`
Used string `json:"used"`
Available string `json:"available"`
UsePercent string `json:"use_percent"`
MountPoint string `json:"mount_point"`
}
type NetInfo struct {
Interface string `json:"interface"`
RxBytes uint64 `json:"rx_bytes"`
RxPackets uint64 `json:"rx_packets"`
RxErrors uint64 `json:"rx_errors"`
RxDropped uint64 `json:"rx_dropped"`
TxBytes uint64 `json:"tx_bytes"`
TxPackets uint64 `json:"tx_packets"`
TxErrors uint64 `json:"tx_errors"`
TxDropped uint64 `json:"tx_dropped"`
RxHuman string `json:"rx_human"`
TxHuman string `json:"tx_human"`
}
type UptimeInfo struct {
Seconds float64 `json:"seconds"`
IdleSeconds float64 `json:"idle_seconds"`
HumanReadable string `json:"human_readable"`
}
type LoadInfo struct {
Load1 float64 `json:"load_1"`
Load5 float64 `json:"load_5"`
Load15 float64 `json:"load_15"`
RunningProcs int `json:"running_procs"`
TotalProcs int `json:"total_procs"`
}
type ProcessInfo struct {
PID int `json:"pid"`
User string `json:"user"`
CPU float64 `json:"cpu"`
Mem float64 `json:"mem"`
RSS int64 `json:"rss"`
Command string `json:"command"`
}
// ── CPU ─────────────────────────────────────────────────────────────
// readCPUStats reads /proc/stat and returns a map of cpu label to field slices.
func readCPUStats() (map[string][]uint64, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return nil, fmt.Errorf("opening /proc/stat: %w", err)
}
defer f.Close()
result := make(map[string][]uint64)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
label := fields[0]
var vals []uint64
for _, field := range fields[1:] {
v, _ := strconv.ParseUint(field, 10, 64)
vals = append(vals, v)
}
result[label] = vals
}
return result, scanner.Err()
}
// parseCPUFields converts raw jiffie counts into a CoreUsage.
// Fields: user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice
func parseCPUFields(core int, before, after []uint64) CoreUsage {
cu := CoreUsage{Core: core}
if len(before) < 5 || len(after) < 5 {
return cu
}
// Sum all fields for total jiffies
var totalBefore, totalAfter uint64
for _, v := range before {
totalBefore += v
}
for _, v := range after {
totalAfter += v
}
totalDelta := float64(totalAfter - totalBefore)
if totalDelta == 0 {
return cu
}
userDelta := float64((after[0] + after[1]) - (before[0] + before[1]))
systemDelta := float64(after[2] - before[2])
idleDelta := float64(after[3] - before[3])
var iowaitDelta float64
if len(after) > 4 && len(before) > 4 {
iowaitDelta = float64(after[4] - before[4])
}
cu.User = userDelta / totalDelta * 100
cu.System = systemDelta / totalDelta * 100
cu.Idle = idleDelta / totalDelta * 100
cu.IOWait = iowaitDelta / totalDelta * 100
cu.Percent = 100 - cu.Idle
return cu
}
// GetCPUUsage samples /proc/stat twice with a brief interval to compute usage.
func GetCPUUsage() (CPUInfo, error) {
info := CPUInfo{}
before, err := readCPUStats()
if err != nil {
return info, err
}
time.Sleep(250 * time.Millisecond)
after, err := readCPUStats()
if err != nil {
return info, err
}
// Overall CPU (the "cpu" aggregate line)
if bv, ok := before["cpu"]; ok {
if av, ok := after["cpu"]; ok {
overall := parseCPUFields(-1, bv, av)
info.Overall = overall.Percent
info.Idle = overall.Idle
}
}
// Per-core
for i := 0; ; i++ {
label := fmt.Sprintf("cpu%d", i)
bv, ok1 := before[label]
av, ok2 := after[label]
if !ok1 || !ok2 {
break
}
info.Cores = append(info.Cores, parseCPUFields(i, bv, av))
}
return info, nil
}
// ── Memory ──────────────────────────────────────────────────────────
func GetMemory() (MemInfo, error) {
info := MemInfo{}
f, err := os.Open("/proc/meminfo")
if err != nil {
return info, fmt.Errorf("opening /proc/meminfo: %w", err)
}
defer f.Close()
vals := make(map[string]uint64)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
valStr := strings.TrimSpace(parts[1])
valStr = strings.TrimSuffix(valStr, " kB")
valStr = strings.TrimSpace(valStr)
v, err := strconv.ParseUint(valStr, 10, 64)
if err != nil {
continue
}
vals[key] = v * 1024 // convert kB to bytes
}
if err := scanner.Err(); err != nil {
return info, err
}
info.TotalBytes = vals["MemTotal"]
info.FreeBytes = vals["MemFree"]
info.AvailableBytes = vals["MemAvailable"]
info.BuffersBytes = vals["Buffers"]
info.CachedBytes = vals["Cached"]
info.SwapTotalBytes = vals["SwapTotal"]
info.SwapFreeBytes = vals["SwapFree"]
info.SwapUsedBytes = info.SwapTotalBytes - info.SwapFreeBytes
info.UsedBytes = info.TotalBytes - info.FreeBytes - info.BuffersBytes - info.CachedBytes
if info.UsedBytes > info.TotalBytes {
// Overflow guard: if buffers+cached > total-free, use simpler calculation
info.UsedBytes = info.TotalBytes - info.AvailableBytes
}
info.Total = humanBytes(info.TotalBytes)
info.Used = humanBytes(info.UsedBytes)
info.Free = humanBytes(info.FreeBytes)
info.Available = humanBytes(info.AvailableBytes)
info.Buffers = humanBytes(info.BuffersBytes)
info.Cached = humanBytes(info.CachedBytes)
info.SwapTotal = humanBytes(info.SwapTotalBytes)
info.SwapUsed = humanBytes(info.SwapUsedBytes)
info.SwapFree = humanBytes(info.SwapFreeBytes)
return info, nil
}
// ── Disk ────────────────────────────────────────────────────────────
func GetDisk() ([]DiskInfo, error) {
// Try with filesystem type filters first for real block devices
out, err := exec.Command("df", "-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=ext3").CombinedOutput()
if err != nil {
// Fallback: exclude pseudo filesystems
out, err = exec.Command("df", "-h", "--exclude-type=tmpfs", "--exclude-type=devtmpfs", "--exclude-type=squashfs").CombinedOutput()
if err != nil {
// Last resort: all filesystems
out, err = exec.Command("df", "-h").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("df command failed: %w (%s)", err, string(out))
}
}
}
var disks []DiskInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, line := range lines {
if i == 0 || strings.TrimSpace(line) == "" {
continue // skip header
}
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
disks = append(disks, DiskInfo{
Filesystem: fields[0],
Size: fields[1],
Used: fields[2],
Available: fields[3],
UsePercent: fields[4],
MountPoint: fields[5],
})
}
return disks, nil
}
// ── Network ─────────────────────────────────────────────────────────
func GetNetwork() ([]NetInfo, error) {
f, err := os.Open("/proc/net/dev")
if err != nil {
return nil, fmt.Errorf("opening /proc/net/dev: %w", err)
}
defer f.Close()
var interfaces []NetInfo
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
if lineNum <= 2 {
continue // skip the two header lines
}
line := scanner.Text()
// Format: " iface: rx_bytes rx_packets rx_errs rx_drop ... tx_bytes tx_packets tx_errs tx_drop ..."
colonIdx := strings.Index(line, ":")
if colonIdx < 0 {
continue
}
iface := strings.TrimSpace(line[:colonIdx])
rest := strings.TrimSpace(line[colonIdx+1:])
fields := strings.Fields(rest)
if len(fields) < 10 {
continue
}
rxBytes, _ := strconv.ParseUint(fields[0], 10, 64)
rxPackets, _ := strconv.ParseUint(fields[1], 10, 64)
rxErrors, _ := strconv.ParseUint(fields[2], 10, 64)
rxDropped, _ := strconv.ParseUint(fields[3], 10, 64)
txBytes, _ := strconv.ParseUint(fields[8], 10, 64)
txPackets, _ := strconv.ParseUint(fields[9], 10, 64)
txErrors, _ := strconv.ParseUint(fields[10], 10, 64)
txDropped, _ := strconv.ParseUint(fields[11], 10, 64)
interfaces = append(interfaces, NetInfo{
Interface: iface,
RxBytes: rxBytes,
RxPackets: rxPackets,
RxErrors: rxErrors,
RxDropped: rxDropped,
TxBytes: txBytes,
TxPackets: txPackets,
TxErrors: txErrors,
TxDropped: txDropped,
RxHuman: humanBytes(rxBytes),
TxHuman: humanBytes(txBytes),
})
}
return interfaces, scanner.Err()
}
// ── Uptime ──────────────────────────────────────────────────────────
func GetUptime() (UptimeInfo, error) {
info := UptimeInfo{}
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return info, fmt.Errorf("reading /proc/uptime: %w", err)
}
fields := strings.Fields(strings.TrimSpace(string(data)))
if len(fields) < 2 {
return info, fmt.Errorf("unexpected /proc/uptime format")
}
info.Seconds, _ = strconv.ParseFloat(fields[0], 64)
info.IdleSeconds, _ = strconv.ParseFloat(fields[1], 64)
// Build human readable string
totalSec := int(info.Seconds)
days := totalSec / 86400
hours := (totalSec % 86400) / 3600
minutes := (totalSec % 3600) / 60
seconds := totalSec % 60
parts := []string{}
if days > 0 {
parts = append(parts, fmt.Sprintf("%d day%s", days, plural(days)))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%d hour%s", hours, plural(hours)))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%d minute%s", minutes, plural(minutes)))
}
if len(parts) == 0 || (days == 0 && hours == 0 && minutes == 0) {
parts = append(parts, fmt.Sprintf("%d second%s", seconds, plural(seconds)))
}
info.HumanReadable = strings.Join(parts, ", ")
return info, nil
}
// ── Load Average ────────────────────────────────────────────────────
func GetLoadAvg() (LoadInfo, error) {
info := LoadInfo{}
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return info, fmt.Errorf("reading /proc/loadavg: %w", err)
}
fields := strings.Fields(strings.TrimSpace(string(data)))
if len(fields) < 4 {
return info, fmt.Errorf("unexpected /proc/loadavg format")
}
info.Load1, _ = strconv.ParseFloat(fields[0], 64)
info.Load5, _ = strconv.ParseFloat(fields[1], 64)
info.Load15, _ = strconv.ParseFloat(fields[2], 64)
// fields[3] is "running/total" format
procParts := strings.SplitN(fields[3], "/", 2)
if len(procParts) == 2 {
info.RunningProcs, _ = strconv.Atoi(procParts[0])
info.TotalProcs, _ = strconv.Atoi(procParts[1])
}
return info, nil
}
// ── Top Processes ───────────────────────────────────────────────────
func GetTopProcesses(n int) ([]ProcessInfo, error) {
if n <= 0 {
n = 10
}
// ps aux --sort=-%mem gives us processes sorted by memory usage descending
out, err := exec.Command("ps", "aux", "--sort=-%mem").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("ps command failed: %w (%s)", err, string(out))
}
var procs []ProcessInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for i, line := range lines {
if i == 0 {
continue // skip header
}
if len(procs) >= n {
break
}
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
pid, _ := strconv.Atoi(fields[1])
cpu, _ := strconv.ParseFloat(fields[2], 64)
mem, _ := strconv.ParseFloat(fields[3], 64)
rss, _ := strconv.ParseInt(fields[5], 10, 64)
// Command is everything from field 10 onward (may contain spaces)
command := strings.Join(fields[10:], " ")
procs = append(procs, ProcessInfo{
PID: pid,
User: fields[0],
CPU: cpu,
Mem: mem,
RSS: rss,
Command: command,
})
}
return procs, nil
}
// ── Helpers ─────────────────────────────────────────────────────────
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
if exp >= len(suffixes) {
exp = len(suffixes) - 1
}
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), suffixes[exp])
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

View File

@@ -0,0 +1,213 @@
package system
import (
"fmt"
"os/exec"
"strconv"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type PackageInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Size int64 `json:"size"` // installed size in bytes
SizeStr string `json:"size_str"` // human-readable
Status string `json:"status,omitempty"`
}
// ── APT Operations ──────────────────────────────────────────────────
// PackageUpdate runs `apt-get update` to refresh the package index.
func PackageUpdate() (string, error) {
out, err := exec.Command("apt-get", "update", "-qq").CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get update failed: %w (%s)", err, output)
}
return output, nil
}
// PackageInstall installs one or more packages with apt-get install -y.
// Package names are passed as separate arguments to avoid shell injection.
func PackageInstall(packages ...string) (string, error) {
if len(packages) == 0 {
return "", fmt.Errorf("no packages specified")
}
for _, pkg := range packages {
if err := validatePackageName(pkg); err != nil {
return "", err
}
}
args := append([]string{"install", "-y"}, packages...)
out, err := exec.Command("apt-get", args...).CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get install failed: %w (%s)", err, output)
}
return output, nil
}
// PackageRemove removes one or more packages with apt-get remove -y.
func PackageRemove(packages ...string) (string, error) {
if len(packages) == 0 {
return "", fmt.Errorf("no packages specified")
}
for _, pkg := range packages {
if err := validatePackageName(pkg); err != nil {
return "", err
}
}
args := append([]string{"remove", "-y"}, packages...)
out, err := exec.Command("apt-get", args...).CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get remove failed: %w (%s)", err, output)
}
return output, nil
}
// PackageListInstalled returns all installed packages via dpkg-query.
func PackageListInstalled() ([]PackageInfo, error) {
// dpkg-query format: name\tversion\tinstalled-size (in kB)
out, err := exec.Command(
"dpkg-query",
"--show",
"--showformat=${Package}\t${Version}\t${Installed-Size}\n",
).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("dpkg-query failed: %w (%s)", err, string(out))
}
var packages []PackageInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 3 {
continue
}
// Installed-Size from dpkg is in kibibytes
sizeKB, _ := strconv.ParseInt(strings.TrimSpace(fields[2]), 10, 64)
sizeBytes := sizeKB * 1024
packages = append(packages, PackageInfo{
Name: fields[0],
Version: fields[1],
Size: sizeBytes,
SizeStr: humanBytes(uint64(sizeBytes)),
})
}
return packages, nil
}
// PackageIsInstalled checks if a single package is installed using dpkg -l.
func PackageIsInstalled(pkg string) bool {
if err := validatePackageName(pkg); err != nil {
return false
}
out, err := exec.Command("dpkg", "-l", pkg).CombinedOutput()
if err != nil {
return false
}
// dpkg -l output has lines starting with "ii" for installed packages
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "ii" && fields[1] == pkg {
return true
}
}
return false
}
// PackageUpgrade runs `apt-get upgrade -y` to upgrade all packages.
func PackageUpgrade() (string, error) {
out, err := exec.Command("apt-get", "upgrade", "-y").CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("apt-get upgrade failed: %w (%s)", err, output)
}
return output, nil
}
// PackageSecurityUpdates returns a list of packages with available security updates.
func PackageSecurityUpdates() ([]PackageInfo, error) {
// apt list --upgradable outputs lines like:
// package/suite version arch [upgradable from: old-version]
out, err := exec.Command("apt", "list", "--upgradable").CombinedOutput()
if err != nil {
// apt list may return exit code 1 even with valid output
if len(out) == 0 {
return nil, fmt.Errorf("apt list --upgradable failed: %w", err)
}
}
var securityPkgs []PackageInfo
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
// Skip header/warning lines
if strings.HasPrefix(line, "Listing") || strings.HasPrefix(line, "WARNING") || strings.TrimSpace(line) == "" {
continue
}
// Filter for security updates: look for "-security" in the suite name
if !strings.Contains(line, "-security") {
continue
}
// Parse: "name/suite version arch [upgradable from: old]"
slashIdx := strings.Index(line, "/")
if slashIdx < 0 {
continue
}
name := line[:slashIdx]
// Get version from the fields after the suite
rest := line[slashIdx+1:]
fields := strings.Fields(rest)
var version string
if len(fields) >= 2 {
version = fields[1]
}
securityPkgs = append(securityPkgs, PackageInfo{
Name: name,
Version: version,
Status: "security-update",
})
}
return securityPkgs, nil
}
// ── Helpers ─────────────────────────────────────────────────────────
// validatePackageName does basic validation to prevent obvious injection attempts.
// Package names in Debian must consist of lowercase alphanumerics, +, -, . and
// must be at least 2 characters long.
func validatePackageName(pkg string) error {
if len(pkg) < 2 {
return fmt.Errorf("invalid package name %q: too short", pkg)
}
if len(pkg) > 128 {
return fmt.Errorf("invalid package name %q: too long", pkg)
}
for _, c := range pkg {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == ':') {
return fmt.Errorf("invalid character %q in package name %q", c, pkg)
}
}
return nil
}

View File

@@ -0,0 +1,292 @@
package system
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// ── Types ───────────────────────────────────────────────────────────
type SystemUser struct {
Username string `json:"username"`
UID int `json:"uid"`
GID int `json:"gid"`
Comment string `json:"comment"`
HomeDir string `json:"home_dir"`
Shell string `json:"shell"`
}
type QuotaInfo struct {
Username string `json:"username"`
UsedBytes uint64 `json:"used_bytes"`
UsedHuman string `json:"used_human"`
HomeDir string `json:"home_dir"`
}
// ── Protected accounts ──────────────────────────────────────────────
var protectedUsers = map[string]bool{
"root": true,
"autarch": true,
}
// ── User Management ─────────────────────────────────────────────────
// ListUsers reads /etc/passwd and returns all users with UID >= 1000 and < 65534.
func ListUsers() ([]SystemUser, error) {
f, err := os.Open("/etc/passwd")
if err != nil {
return nil, fmt.Errorf("opening /etc/passwd: %w", err)
}
defer f.Close()
var users []SystemUser
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
continue
}
// Format: username:x:uid:gid:comment:home:shell
fields := strings.Split(line, ":")
if len(fields) < 7 {
continue
}
uid, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
// Only normal user accounts (UID 1000-65533)
if uid < 1000 || uid >= 65534 {
continue
}
gid, _ := strconv.Atoi(fields[3])
users = append(users, SystemUser{
Username: fields[0],
UID: uid,
GID: gid,
Comment: fields[4],
HomeDir: fields[5],
Shell: fields[6],
})
}
return users, scanner.Err()
}
// CreateUser creates a new system user with the given username, password, and shell.
func CreateUser(username, password, shell string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
if shell == "" {
shell = "/bin/bash"
}
// Sanitize: only allow alphanumeric, underscore, hyphen, and dot
for _, c := range username {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') {
return fmt.Errorf("invalid character %q in username", c)
}
}
if len(username) > 32 {
return fmt.Errorf("username too long (max 32 characters)")
}
// Verify the shell exists
if _, err := os.Stat(shell); err != nil {
return fmt.Errorf("shell %q does not exist: %w", shell, err)
}
// Create the user
out, err := exec.Command("useradd", "--create-home", "--shell", shell, username).CombinedOutput()
if err != nil {
return fmt.Errorf("useradd failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
// Set the password via chpasswd
if err := setPasswordViaChpasswd(username, password); err != nil {
// Attempt cleanup on password failure
exec.Command("userdel", "--remove", username).Run()
return fmt.Errorf("user created but password set failed (user removed): %w", err)
}
return nil
}
// DeleteUser removes a system user and their home directory.
func DeleteUser(username string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if protectedUsers[username] {
return fmt.Errorf("cannot delete protected account %q", username)
}
// Verify the user actually exists before attempting deletion
_, err := exec.Command("id", username).CombinedOutput()
if err != nil {
return fmt.Errorf("user %q does not exist", username)
}
// Kill any running processes owned by the user (best effort)
exec.Command("pkill", "-u", username).Run()
out, err := exec.Command("userdel", "--remove", username).CombinedOutput()
if err != nil {
return fmt.Errorf("userdel failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// SetPassword changes the password for an existing user.
func SetPassword(username, password string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if password == "" {
return fmt.Errorf("password is required")
}
// Verify user exists
_, err := exec.Command("id", username).CombinedOutput()
if err != nil {
return fmt.Errorf("user %q does not exist", username)
}
return setPasswordViaChpasswd(username, password)
}
// setPasswordViaChpasswd pipes "user:password" to chpasswd.
func setPasswordViaChpasswd(username, password string) error {
cmd := exec.Command("chpasswd")
cmd.Stdin = strings.NewReader(fmt.Sprintf("%s:%s", username, password))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("chpasswd failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
// AddSSHKey appends a public key to the user's ~/.ssh/authorized_keys file.
func AddSSHKey(username, pubkey string) error {
if username == "" {
return fmt.Errorf("username is required")
}
if pubkey == "" {
return fmt.Errorf("public key is required")
}
// Basic validation: SSH keys should start with a recognized prefix
pubkey = strings.TrimSpace(pubkey)
validPrefixes := []string{"ssh-rsa", "ssh-ed25519", "ssh-dss", "ecdsa-sha2-", "sk-ssh-ed25519", "sk-ecdsa-sha2-"}
valid := false
for _, prefix := range validPrefixes {
if strings.HasPrefix(pubkey, prefix) {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid SSH public key format")
}
// Look up the user's home directory from /etc/passwd
homeDir, err := getUserHome(username)
if err != nil {
return err
}
sshDir := filepath.Join(homeDir, ".ssh")
authKeysPath := filepath.Join(sshDir, "authorized_keys")
// Create .ssh directory if it doesn't exist
if err := os.MkdirAll(sshDir, 0700); err != nil {
return fmt.Errorf("creating .ssh directory: %w", err)
}
// Check for duplicate keys
if existing, err := os.ReadFile(authKeysPath); err == nil {
for _, line := range strings.Split(string(existing), "\n") {
if strings.TrimSpace(line) == pubkey {
return fmt.Errorf("SSH key already exists in authorized_keys")
}
}
}
// Append the key
f, err := os.OpenFile(authKeysPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("opening authorized_keys: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "%s\n", pubkey); err != nil {
return fmt.Errorf("writing authorized_keys: %w", err)
}
// Fix ownership: chown user:user .ssh and authorized_keys
exec.Command("chown", "-R", username+":"+username, sshDir).Run()
return nil
}
// GetUserQuota returns disk usage for a user's home directory.
func GetUserQuota(username string) (QuotaInfo, error) {
info := QuotaInfo{Username: username}
homeDir, err := getUserHome(username)
if err != nil {
return info, err
}
info.HomeDir = homeDir
// Use du -sb for total bytes used in home directory
out, err := exec.Command("du", "-sb", homeDir).CombinedOutput()
if err != nil {
return info, fmt.Errorf("du failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) >= 1 {
info.UsedBytes, _ = strconv.ParseUint(fields[0], 10, 64)
}
info.UsedHuman = humanBytes(info.UsedBytes)
return info, nil
}
// getUserHome looks up a user's home directory from /etc/passwd.
func getUserHome(username string) (string, error) {
f, err := os.Open("/etc/passwd")
if err != nil {
return "", fmt.Errorf("opening /etc/passwd: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Split(scanner.Text(), ":")
if len(fields) >= 6 && fields[0] == username {
return fields[5], nil
}
}
return "", fmt.Errorf("user %q not found in /etc/passwd", username)
}