No One Can Stop Me Now
This commit is contained in:
361
services/setec-manager/internal/acme/acme.go
Normal file
361
services/setec-manager/internal/acme/acme.go
Normal 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
|
||||
}
|
||||
146
services/setec-manager/internal/config/config.go
Normal file
146
services/setec-manager/internal/config/config.go
Normal 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)
|
||||
}
|
||||
46
services/setec-manager/internal/db/backups.go
Normal file
46
services/setec-manager/internal/db/backups.go
Normal 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
|
||||
}
|
||||
163
services/setec-manager/internal/db/db.go
Normal file
163
services/setec-manager/internal/db/db.go
Normal 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
|
||||
);`
|
||||
60
services/setec-manager/internal/db/deployments.go
Normal file
60
services/setec-manager/internal/db/deployments.go
Normal 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()
|
||||
}
|
||||
70
services/setec-manager/internal/db/float.go
Normal file
70
services/setec-manager/internal/db/float.go
Normal 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()
|
||||
}
|
||||
107
services/setec-manager/internal/db/sites.go
Normal file
107
services/setec-manager/internal/db/sites.go
Normal 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
|
||||
}
|
||||
124
services/setec-manager/internal/db/users.go
Normal file
124
services/setec-manager/internal/db/users.go
Normal 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
|
||||
}
|
||||
144
services/setec-manager/internal/deploy/git.go
Normal file
144
services/setec-manager/internal/deploy/git.go
Normal 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
|
||||
}
|
||||
100
services/setec-manager/internal/deploy/node.go
Normal file
100
services/setec-manager/internal/deploy/node.go
Normal 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
|
||||
}
|
||||
93
services/setec-manager/internal/deploy/python.go
Normal file
93
services/setec-manager/internal/deploy/python.go
Normal 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()
|
||||
}
|
||||
246
services/setec-manager/internal/deploy/systemd.go
Normal file
246
services/setec-manager/internal/deploy/systemd.go
Normal 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
|
||||
}
|
||||
366
services/setec-manager/internal/float/bridge.go
Normal file
366
services/setec-manager/internal/float/bridge.go
Normal 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()
|
||||
}
|
||||
}
|
||||
225
services/setec-manager/internal/float/protocol.go
Normal file
225
services/setec-manager/internal/float/protocol.go
Normal 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
|
||||
}
|
||||
248
services/setec-manager/internal/float/session.go
Normal file
248
services/setec-manager/internal/float/session.go
Normal 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
|
||||
}
|
||||
272
services/setec-manager/internal/handlers/autarch.go
Normal file
272
services/setec-manager/internal/handlers/autarch.go
Normal 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)
|
||||
}
|
||||
146
services/setec-manager/internal/handlers/backups.go
Normal file
146
services/setec-manager/internal/handlers/backups.go
Normal 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)
|
||||
}
|
||||
151
services/setec-manager/internal/handlers/dashboard.go
Normal file
151
services/setec-manager/internal/handlers/dashboard.go
Normal 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"
|
||||
}
|
||||
184
services/setec-manager/internal/handlers/firewall.go
Normal file
184
services/setec-manager/internal/handlers/firewall.go
Normal 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
|
||||
}
|
||||
66
services/setec-manager/internal/handlers/float.go
Normal file
66
services/setec-manager/internal/handlers/float.go
Normal 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"})
|
||||
}
|
||||
103
services/setec-manager/internal/handlers/handlers.go
Normal file
103
services/setec-manager/internal/handlers/handlers.go
Normal 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)
|
||||
}
|
||||
697
services/setec-manager/internal/handlers/hosting.go
Normal file
697
services/setec-manager/internal/handlers/hosting.go
Normal 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)
|
||||
}
|
||||
133
services/setec-manager/internal/handlers/logs.go
Normal file
133
services/setec-manager/internal/handlers/logs.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
services/setec-manager/internal/handlers/monitor.go
Normal file
126
services/setec-manager/internal/handlers/monitor.go
Normal 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
|
||||
}
|
||||
97
services/setec-manager/internal/handlers/nginx.go
Normal file
97
services/setec-manager/internal/handlers/nginx.go
Normal 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"})
|
||||
}
|
||||
453
services/setec-manager/internal/handlers/sites.go
Normal file
453
services/setec-manager/internal/handlers/sites.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
services/setec-manager/internal/handlers/ssl.go
Normal file
143
services/setec-manager/internal/handlers/ssl.go
Normal 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
|
||||
}
|
||||
176
services/setec-manager/internal/handlers/users.go
Normal file
176
services/setec-manager/internal/handlers/users.go
Normal 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"})
|
||||
}
|
||||
107
services/setec-manager/internal/hosting/config.go
Normal file
107
services/setec-manager/internal/hosting/config.go
Normal 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
|
||||
}
|
||||
127
services/setec-manager/internal/hosting/hostinger/billing.go
Normal file
127
services/setec-manager/internal/hosting/hostinger/billing.go
Normal 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
|
||||
}
|
||||
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal file
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal 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
|
||||
}
|
||||
136
services/setec-manager/internal/hosting/hostinger/dns.go
Normal file
136
services/setec-manager/internal/hosting/hostinger/dns.go
Normal 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
|
||||
}
|
||||
218
services/setec-manager/internal/hosting/hostinger/domains.go
Normal file
218
services/setec-manager/internal/hosting/hostinger/domains.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package hostinger
|
||||
|
||||
import "setec-manager/internal/hosting"
|
||||
|
||||
func init() {
|
||||
hosting.Register(New(""))
|
||||
}
|
||||
219
services/setec-manager/internal/hosting/hostinger/vps.go
Normal file
219
services/setec-manager/internal/hosting/hostinger/vps.go
Normal 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,
|
||||
}
|
||||
}
|
||||
287
services/setec-manager/internal/hosting/provider.go
Normal file
287
services/setec-manager/internal/hosting/provider.go
Normal 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
|
||||
}
|
||||
201
services/setec-manager/internal/nginx/config.go
Normal file
201
services/setec-manager/internal/nginx/config.go
Normal 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)
|
||||
}
|
||||
280
services/setec-manager/internal/scheduler/cron.go
Normal file
280
services/setec-manager/internal/scheduler/cron.go
Normal 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
|
||||
}
|
||||
}
|
||||
279
services/setec-manager/internal/scheduler/scheduler.go
Normal file
279
services/setec-manager/internal/scheduler/scheduler.go
Normal 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()
|
||||
}
|
||||
114
services/setec-manager/internal/server/auth.go
Normal file
114
services/setec-manager/internal/server/auth.go
Normal 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)
|
||||
}
|
||||
135
services/setec-manager/internal/server/middleware.go
Normal file
135
services/setec-manager/internal/server/middleware.go
Normal 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)
|
||||
})
|
||||
}
|
||||
152
services/setec-manager/internal/server/routes.go
Normal file
152
services/setec-manager/internal/server/routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
199
services/setec-manager/internal/server/security.go
Normal file
199
services/setec-manager/internal/server/security.go
Normal 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)
|
||||
}
|
||||
81
services/setec-manager/internal/server/server.go
Normal file
81
services/setec-manager/internal/server/server.go
Normal 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)
|
||||
}
|
||||
93
services/setec-manager/internal/server/templates.go
Normal file
93
services/setec-manager/internal/server/templates.go
Normal 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})
|
||||
}
|
||||
243
services/setec-manager/internal/system/firewall.go
Normal file
243
services/setec-manager/internal/system/firewall.go
Normal 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
|
||||
}
|
||||
511
services/setec-manager/internal/system/info.go
Normal file
511
services/setec-manager/internal/system/info.go
Normal 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"
|
||||
}
|
||||
213
services/setec-manager/internal/system/packages.go
Normal file
213
services/setec-manager/internal/system/packages.go
Normal 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
|
||||
}
|
||||
292
services/setec-manager/internal/system/users.go
Normal file
292
services/setec-manager/internal/system/users.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user