No One Can Stop Me Now

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

View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Build Setec App Manager for Debian 13 (linux/amd64)
set -e
echo "Building Setec App Manager..."
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o setec-manager ./cmd/
echo "Binary: setec-manager ($(du -h setec-manager | cut -f1))"
echo ""
echo "Deploy to VPS:"
echo " scp setec-manager root@<your-vps>:/opt/setec-manager/"
echo " ssh root@<your-vps> '/opt/setec-manager/setec-manager --setup'"

Binary file not shown.

View File

@@ -0,0 +1,258 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"setec-manager/internal/config"
"setec-manager/internal/db"
"setec-manager/internal/deploy"
"setec-manager/internal/nginx"
"setec-manager/internal/scheduler"
"setec-manager/internal/server"
)
const banner = `
███████╗███████╗████████╗███████╗ ██████╗
██╔════╝██╔════╝╚══██╔══╝██╔════╝██╔════╝
███████╗█████╗ ██║ █████╗ ██║
╚════██║██╔══╝ ██║ ██╔══╝ ██║
███████║███████╗ ██║ ███████╗╚██████╗
╚══════╝╚══════╝ ╚═╝ ╚══════╝ ╚═════╝
A P P M A N A G E R v1.0
darkHal Security Group & Setec Security Labs
`
func main() {
configPath := flag.String("config", "/opt/setec-manager/config.yaml", "Path to config file")
setup := flag.Bool("setup", false, "Run first-time setup")
flag.Parse()
fmt.Print(banner)
// Load config
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("[setec] Failed to load config: %v", err)
}
// Open database
database, err := db.Open(cfg.Database.Path)
if err != nil {
log.Fatalf("[setec] Failed to open database: %v", err)
}
defer database.Close()
// First-time setup
if *setup {
runSetup(cfg, database, *configPath)
return
}
// Check if any admin users exist
count, _ := database.ManagerUserCount()
if count == 0 {
log.Println("[setec] No admin users found. Creating default admin account.")
log.Println("[setec] Username: admin")
log.Println("[setec] Password: autarch")
log.Println("[setec] ** CHANGE THIS IMMEDIATELY **")
database.CreateManagerUser("admin", "autarch", "admin")
}
// Load or create persistent JWT key
dataDir := filepath.Dir(cfg.Database.Path)
jwtKey, err := server.LoadOrCreateJWTKey(dataDir)
if err != nil {
log.Fatalf("[setec] Failed to load JWT key: %v", err)
}
// Create and start server
srv := server.New(cfg, database, jwtKey)
// Start scheduler
sched := scheduler.New(database)
sched.RegisterHandler(scheduler.JobSSLRenew, func(siteID *int64) error {
log.Println("[scheduler] Running SSL renewal")
_, err := exec.Command("certbot", "renew", "--non-interactive").CombinedOutput()
return err
})
sched.RegisterHandler(scheduler.JobCleanup, func(siteID *int64) error {
log.Println("[scheduler] Running cleanup")
return nil
})
sched.RegisterHandler(scheduler.JobBackup, func(siteID *int64) error {
if siteID == nil {
log.Println("[scheduler] Backup job requires a site ID, skipping")
return fmt.Errorf("backup job requires a site ID")
}
site, err := database.GetSite(*siteID)
if err != nil || site == nil {
return fmt.Errorf("backup: site %d not found", *siteID)
}
backupDir := cfg.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)
cmd := exec.Command("tar", "-czf", backupPath, "-C", filepath.Dir(site.AppRoot), filepath.Base(site.AppRoot))
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("backup tar failed: %s: %w", string(out), err)
}
info, _ := os.Stat(backupPath)
size := int64(0)
if info != nil {
size = info.Size()
}
database.CreateBackup(siteID, "site", backupPath, size)
log.Printf("[scheduler] Backup complete for site %s: %s (%d bytes)", site.Domain, backupPath, size)
return nil
})
sched.RegisterHandler(scheduler.JobGitPull, func(siteID *int64) error {
if siteID == nil {
return fmt.Errorf("git_pull job requires a site ID")
}
site, err := database.GetSite(*siteID)
if err != nil || site == nil {
return fmt.Errorf("git_pull: site %d not found", *siteID)
}
if site.GitRepo == "" {
return fmt.Errorf("git_pull: site %s has no git repo configured", site.Domain)
}
output, err := deploy.Pull(site.AppRoot)
if err != nil {
return fmt.Errorf("git_pull %s: %w", site.Domain, err)
}
log.Printf("[scheduler] Git pull for site %s: %s", site.Domain, strings.TrimSpace(output))
return nil
})
sched.RegisterHandler(scheduler.JobRestart, func(siteID *int64) error {
if siteID == nil {
return fmt.Errorf("restart job requires a site ID")
}
site, err := database.GetSite(*siteID)
if err != nil || site == nil {
return fmt.Errorf("restart: site %d not found", *siteID)
}
unitName := fmt.Sprintf("app-%s", site.Domain)
if err := deploy.Restart(unitName); err != nil {
return fmt.Errorf("restart %s: %w", site.Domain, err)
}
log.Printf("[scheduler] Restarted service for site %s (unit: %s)", site.Domain, unitName)
return nil
})
sched.Start()
// Graceful shutdown
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
go func() {
if err := srv.Start(); err != nil {
log.Fatalf("[setec] Server error: %v", err)
}
}()
log.Printf("[setec] Dashboard: https://%s:%d", cfg.Server.Host, cfg.Server.Port)
<-done
log.Println("[setec] Shutting down...")
sched.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
func runSetup(cfg *config.Config, database *db.DB, configPath string) {
log.Println("[setup] Starting first-time setup...")
// Ensure directories exist
dirs := []string{
"/opt/setec-manager/data",
"/opt/setec-manager/data/acme",
"/opt/setec-manager/data/backups",
cfg.Nginx.Webroot,
cfg.Nginx.CertbotWebroot,
cfg.Nginx.SitesAvailable,
cfg.Nginx.SitesEnabled,
}
for _, d := range dirs {
os.MkdirAll(d, 0755)
}
// Install Nginx if needed
log.Println("[setup] Installing nginx...")
execQuiet("apt-get", "update", "-qq")
execQuiet("apt-get", "install", "-y", "nginx", "certbot", "ufw")
// Install nginx snippets
log.Println("[setup] Configuring nginx snippets...")
nginx.InstallSnippets(cfg)
// Create admin user
count, _ := database.ManagerUserCount()
if count == 0 {
log.Println("[setup] Creating default admin user (admin / autarch)")
database.CreateManagerUser("admin", "autarch", "admin")
}
// Save config
cfg.Save(configPath)
// Generate self-signed cert for manager if none exists
if _, err := os.Stat(cfg.Server.Cert); os.IsNotExist(err) {
log.Println("[setup] Generating self-signed TLS cert for manager...")
os.MkdirAll(cfg.ACME.AccountDir, 0755)
execQuiet("openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", cfg.Server.Key, "-out", cfg.Server.Cert,
"-days", "3650", "-nodes",
"-subj", "/CN=setec-manager/O=Setec Security Labs")
}
// Install systemd unit for setec-manager
unit := `[Unit]
Description=Setec App Manager
After=network.target
[Service]
Type=simple
User=root
ExecStart=/opt/setec-manager/setec-manager --config /opt/setec-manager/config.yaml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
`
os.WriteFile("/etc/systemd/system/setec-manager.service", []byte(unit), 0644)
execQuiet("systemctl", "daemon-reload")
log.Println("[setup] Setup complete!")
log.Println("[setup] Start with: systemctl start setec-manager")
log.Printf("[setup] Dashboard will be at: https://<your-ip>:%d\n", cfg.Server.Port)
}
func execQuiet(name string, args ...string) {
log.Printf("[setup] $ %s %s", name, strings.Join(args, " "))
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
if err != nil {
log.Printf("[setup] Warning: %v\n%s", err, string(out))
}
}

View File

@@ -0,0 +1,44 @@
server:
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:
path: "/opt/setec-manager/data/setec.db"
nginx:
sites_available: "/etc/nginx/sites-available"
sites_enabled: "/etc/nginx/sites-enabled"
snippets: "/etc/nginx/snippets"
webroot: "/var/www"
certbot_webroot: "/var/www/certbot"
acme:
email: ""
staging: false
account_dir: "/opt/setec-manager/data/acme"
autarch:
install_dir: "/var/www/autarch"
git_repo: "https://github.com/DigijEth/autarch.git"
git_branch: "main"
web_port: 8181
dns_port: 53
float:
enabled: false
max_sessions: 10
session_ttl: "24h"
backups:
dir: "/opt/setec-manager/data/backups"
max_age_days: 30
max_count: 50
logging:
level: "info"
file: "/var/log/setec-manager.log"
max_size_mb: 100
max_backups: 3

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,790 @@
# Custom Hosting Provider Guide
This guide walks you through creating a new hosting provider integration for Setec Manager. By the end, you will have a provider package that auto-registers with the system and can be used through the same unified API as the built-in Hostinger provider.
---
## Prerequisites
- Go 1.25+ (matching the project's `go.mod`)
- Familiarity with the Go `interface` pattern and HTTP client programming
- An API key or credentials for the hosting provider you are integrating
- A checkout of the `setec-manager` repository
---
## Project Structure
Provider implementations live under `internal/hosting/<provider_name>/`. Each provider is its own Go package.
```
internal/hosting/
provider.go -- Provider interface + model types + registry
store.go -- ProviderConfig, ProviderConfigStore
config.go -- Legacy config store
hostinger/ -- Built-in Hostinger provider
client.go -- HTTP client, auth, retry logic
dns.go -- DNS record operations
myprovider/ -- Your new provider (create this)
provider.go -- init() registration + interface methods
client.go -- HTTP client for the provider's API
dns.go -- (optional) DNS-specific logic
domains.go -- (optional) Domain-specific logic
vms.go -- (optional) VPS-specific logic
```
You can organize files however you like within the package; the only requirement is that the package calls `hosting.Register(...)` in an `init()` function.
---
## The Provider Interface
The `Provider` interface is defined in `internal/hosting/provider.go`. Every provider must implement all methods. Methods that your provider does not support should return `ErrNotSupported`.
```go
type Provider interface {
// Identity
Name() string
DisplayName() string
// Configuration
Configure(config map[string]string) error
TestConnection() error
// DNS
ListDNSRecords(domain string) ([]DNSRecord, error)
CreateDNSRecord(domain string, record DNSRecord) error
UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
DeleteDNSRecord(domain string, recordName, recordType string) error
ResetDNSRecords(domain string) error
// Domains
ListDomains() ([]Domain, error)
GetDomain(domain string) (*Domain, error)
CheckDomainAvailability(domains []string) ([]DomainAvailability, error)
PurchaseDomain(req DomainPurchaseRequest) (*Domain, error)
SetNameservers(domain string, nameservers []string) error
EnableDomainLock(domain string) error
DisableDomainLock(domain string) error
EnablePrivacyProtection(domain string) error
DisablePrivacyProtection(domain string) error
// VMs / VPS
ListVMs() ([]VM, error)
GetVM(id string) (*VM, error)
CreateVM(req VMCreateRequest) (*VM, error)
ListDataCenters() ([]DataCenter, error)
ListSSHKeys() ([]SSHKey, error)
AddSSHKey(name, publicKey string) (*SSHKey, error)
DeleteSSHKey(id string) error
// Billing
ListSubscriptions() ([]Subscription, error)
GetCatalog() ([]CatalogItem, error)
}
```
### Method Reference
#### Identity Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `Name()` | - | `string` | Short machine-readable name (lowercase, no spaces). Used as the registry key and in API URLs. Example: `"hostinger"`, `"cloudflare"`. |
| `DisplayName()` | - | `string` | Human-readable name shown in the UI. Example: `"Hostinger"`, `"Cloudflare"`. |
#### Configuration Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `Configure(config)` | `map[string]string` -- key-value config pairs. Common keys: `"api_key"`, `"api_secret"`, `"base_url"`. | `error` | Called when a user saves credentials. Store them in struct fields. Validate format but do not make API calls. |
| `TestConnection()` | - | `error` | Make a lightweight API call (e.g., list domains) to verify credentials are valid. Return `nil` on success. |
#### DNS Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListDNSRecords(domain)` | `domain string` -- the FQDN | `([]DNSRecord, error)` | Return all DNS records for the zone. |
| `CreateDNSRecord(domain, record)` | `domain string`, `record DNSRecord` | `error` | Add a single record without affecting existing records. |
| `UpdateDNSRecords(domain, records, overwrite)` | `domain string`, `records []DNSRecord`, `overwrite bool` | `error` | Batch update. If `overwrite` is true, replace all records; otherwise merge. |
| `DeleteDNSRecord(domain, recordName, recordType)` | `domain string`, `recordName string` (subdomain or `@`), `recordType string` (e.g. `"A"`) | `error` | Delete matching records. |
| `ResetDNSRecords(domain)` | `domain string` | `error` | Reset the zone to provider defaults. |
#### Domain Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListDomains()` | - | `([]Domain, error)` | Return all domains on the account. |
| `GetDomain(domain)` | `domain string` | `(*Domain, error)` | Return details for a single domain. |
| `CheckDomainAvailability(domains)` | `domains []string` | `([]DomainAvailability, error)` | Check if domains are available for registration and return pricing. |
| `PurchaseDomain(req)` | `req DomainPurchaseRequest` | `(*Domain, error)` | Register a new domain. |
| `SetNameservers(domain, nameservers)` | `domain string`, `nameservers []string` | `error` | Update the authoritative nameservers. |
| `EnableDomainLock(domain)` | `domain string` | `error` | Enable registrar lock (transfer protection). |
| `DisableDomainLock(domain)` | `domain string` | `error` | Disable registrar lock. |
| `EnablePrivacyProtection(domain)` | `domain string` | `error` | Enable WHOIS privacy. |
| `DisablePrivacyProtection(domain)` | `domain string` | `error` | Disable WHOIS privacy. |
#### VM / VPS Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListVMs()` | - | `([]VM, error)` | Return all VPS instances on the account. |
| `GetVM(id)` | `id string` | `(*VM, error)` | Return details for a single VM. |
| `CreateVM(req)` | `req VMCreateRequest` | `(*VM, error)` | Provision a new VPS instance. |
| `ListDataCenters()` | - | `([]DataCenter, error)` | Return available regions/data centers. |
| `ListSSHKeys()` | - | `([]SSHKey, error)` | Return all stored SSH public keys. |
| `AddSSHKey(name, publicKey)` | `name string`, `publicKey string` | `(*SSHKey, error)` | Upload a new SSH public key. |
| `DeleteSSHKey(id)` | `id string` | `error` | Remove an SSH key. |
#### Billing Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
| `ListSubscriptions()` | - | `([]Subscription, error)` | Return all active subscriptions. |
| `GetCatalog()` | - | `([]CatalogItem, error)` | Return purchasable products and plans. |
---
## Type Reference
All model types are defined in `internal/hosting/provider.go`.
### DNSRecord
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Provider-assigned identifier. May be synthesized (e.g., `name/type/priority`). Optional on create. |
| `Type` | `string` | `type` | Record type: `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `NS`, `SRV`, `CAA`. |
| `Name` | `string` | `name` | Subdomain label or `@` for the zone apex. |
| `Content` | `string` | `content` | Record value (IP address, hostname, text, etc.). |
| `TTL` | `int` | `ttl` | Time-to-live in seconds. |
| `Priority` | `int` | `priority` | Priority value for MX and SRV records. Zero for other types. |
### Domain
| Field | Type | JSON | Description |
|---|---|---|---|
| `Name` | `string` | `name` | Fully qualified domain name. |
| `Registrar` | `string` | `registrar` | Registrar name (optional). |
| `Status` | `string` | `status` | Registration status (e.g., `"active"`, `"expired"`, `"pending"`). |
| `ExpiresAt` | `time.Time` | `expires_at` | Expiration date. |
| `AutoRenew` | `bool` | `auto_renew` | Whether automatic renewal is enabled. |
| `Locked` | `bool` | `locked` | Whether transfer lock is enabled. |
| `PrivacyProtection` | `bool` | `privacy_protection` | Whether WHOIS privacy is enabled. |
| `Nameservers` | `[]string` | `nameservers` | Current authoritative nameservers. |
### DomainAvailability
| Field | Type | JSON | Description |
|---|---|---|---|
| `Domain` | `string` | `domain` | The queried domain name. |
| `Available` | `bool` | `available` | Whether the domain is available for registration. |
| `Price` | `float64` | `price` | Purchase price (zero if unavailable). |
| `Currency` | `string` | `currency` | Currency code (e.g., `"USD"`). |
### DomainPurchaseRequest
| Field | Type | JSON | Description |
|---|---|---|---|
| `Domain` | `string` | `domain` | Domain to purchase. |
| `Period` | `int` | `period` | Registration period in years. |
| `AutoRenew` | `bool` | `auto_renew` | Enable auto-renewal. |
| `Privacy` | `bool` | `privacy_protection` | Enable WHOIS privacy. |
| `PaymentID` | `string` | `payment_method_id` | Payment method identifier (optional, provider-specific). |
### VM
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Provider-assigned VM identifier. |
| `Name` | `string` | `name` | Human-readable VM name / hostname. |
| `Status` | `string` | `status` | Current state: `"running"`, `"stopped"`, `"creating"`, `"error"`. |
| `Plan` | `string` | `plan` | Plan/tier identifier. |
| `Region` | `string` | `region` | Data center / region identifier. |
| `IPv4` | `string` | `ipv4` | Public IPv4 address (optional). |
| `IPv6` | `string` | `ipv6` | Public IPv6 address (optional). |
| `OS` | `string` | `os` | Operating system template name (optional). |
| `CPUs` | `int` | `cpus` | Number of virtual CPUs. |
| `MemoryMB` | `int` | `memory_mb` | RAM in megabytes. |
| `DiskGB` | `int` | `disk_gb` | Disk size in gigabytes. |
| `BandwidthGB` | `int` | `bandwidth_gb` | Monthly bandwidth allowance in gigabytes. |
| `CreatedAt` | `time.Time` | `created_at` | Creation timestamp. |
| `Labels` | `map[string]string` | `labels` | Arbitrary key-value labels (optional). |
### VMCreateRequest
| Field | Type | JSON | Description |
|---|---|---|---|
| `Plan` | `string` | `plan` | Plan/tier identifier from the catalog. |
| `DataCenterID` | `string` | `data_center_id` | Target data center from `ListDataCenters()`. |
| `Template` | `string` | `template` | OS template identifier. |
| `Password` | `string` | `password` | Root/admin password for the VM. |
| `Hostname` | `string` | `hostname` | Desired hostname. |
| `SSHKeyID` | `string` | `ssh_key_id` | SSH key to install (optional). |
| `PaymentID` | `string` | `payment_method_id` | Payment method identifier (optional). |
### DataCenter
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Unique identifier used in `VMCreateRequest`. |
| `Name` | `string` | `name` | Short name (e.g., `"US East"`). |
| `Location` | `string` | `location` | City or locality. |
| `Country` | `string` | `country` | ISO country code. |
### SSHKey
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Provider-assigned key identifier. |
| `Name` | `string` | `name` | User-assigned label. |
| `Fingerprint` | `string` | `fingerprint` | Key fingerprint (e.g., `"SHA256:..."`). |
| `PublicKey` | `string` | `public_key` | Full public key string. |
### Subscription
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Subscription identifier. |
| `Name` | `string` | `name` | Product name. |
| `Status` | `string` | `status` | Status: `"active"`, `"cancelled"`, `"expired"`. |
| `Plan` | `string` | `plan` | Plan identifier. |
| `Price` | `float64` | `price` | Recurring price. |
| `Currency` | `string` | `currency` | Currency code. |
| `RenewsAt` | `time.Time` | `renews_at` | Next renewal date. |
| `CreatedAt` | `time.Time` | `created_at` | Subscription start date. |
### CatalogItem
| Field | Type | JSON | Description |
|---|---|---|---|
| `ID` | `string` | `id` | Product/plan identifier. |
| `Name` | `string` | `name` | Product name. |
| `Category` | `string` | `category` | Category: `"vps"`, `"hosting"`, `"domain"`, etc. |
| `PriceCents` | `int` | `price_cents` | Price in cents (e.g., 1199 = $11.99). |
| `Currency` | `string` | `currency` | Currency code. |
| `Period` | `string` | `period` | Billing period: `"monthly"`, `"yearly"`. |
| `Description` | `string` | `description` | Human-readable description (optional). |
### ProviderConfig
Stored in `internal/hosting/store.go`. This is the credential record persisted to disk.
| Field | Type | JSON | Description |
|---|---|---|---|
| `Provider` | `string` | `provider` | Provider name (must match `Provider.Name()`). |
| `APIKey` | `string` | `api_key` | Primary API key or bearer token. |
| `APISecret` | `string` | `api_secret` | Secondary secret (optional, provider-specific). |
| `Extra` | `map[string]string` | `extra` | Additional provider-specific config values. |
| `Connected` | `bool` | `connected` | Whether the last `TestConnection()` succeeded. |
---
## Implementing the Interface
### Step 1: Create the Package
```bash
mkdir -p internal/hosting/myprovider
```
### Step 2: Implement the Provider
Create `internal/hosting/myprovider/provider.go`:
```go
package myprovider
import (
"errors"
"fmt"
"net/http"
"time"
"setec-manager/internal/hosting"
)
// ErrNotSupported is returned by methods this provider does not implement.
var ErrNotSupported = errors.New("myprovider: operation not supported")
// Provider implements hosting.Provider for the MyProvider service.
type Provider struct {
client *http.Client
apiKey string
baseURL string
}
// init registers this provider with the hosting registry.
// This runs automatically when the package is imported.
func init() {
hosting.Register(&Provider{
client: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "https://api.myprovider.com",
})
}
// ── Identity ────────────────────────────────────────────────────────
func (p *Provider) Name() string { return "myprovider" }
func (p *Provider) DisplayName() string { return "My Provider" }
// ── Configuration ───────────────────────────────────────────────────
func (p *Provider) Configure(config map[string]string) error {
key, ok := config["api_key"]
if !ok || key == "" {
return fmt.Errorf("myprovider: api_key is required")
}
p.apiKey = key
if baseURL, ok := config["base_url"]; ok && baseURL != "" {
p.baseURL = baseURL
}
return nil
}
func (p *Provider) TestConnection() error {
// Make a lightweight API call to verify credentials.
// For example, list domains or get account info.
_, err := p.ListDomains()
return err
}
// ── DNS ─────────────────────────────────────────────────────────────
func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
// TODO: Implement API call to list DNS records
return nil, ErrNotSupported
}
func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
return ErrNotSupported
}
func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
return ErrNotSupported
}
func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
return ErrNotSupported
}
func (p *Provider) ResetDNSRecords(domain string) error {
return ErrNotSupported
}
// ── Domains ─────────────────────────────────────────────────────────
func (p *Provider) ListDomains() ([]hosting.Domain, error) {
return nil, ErrNotSupported
}
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) {
return nil, ErrNotSupported
}
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) {
return nil, ErrNotSupported
}
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) {
return nil, ErrNotSupported
}
func (p *Provider) SetNameservers(domain string, nameservers []string) error {
return ErrNotSupported
}
func (p *Provider) EnableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) EnablePrivacyProtection(domain string) error { return ErrNotSupported }
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }
// ── VMs / VPS ───────────────────────────────────────────────────────
func (p *Provider) ListVMs() ([]hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) GetVM(id string) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error) { return nil, ErrNotSupported }
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) DeleteSSHKey(id string) error { return ErrNotSupported }
// ── Billing ─────────────────────────────────────────────────────────
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error) { return nil, ErrNotSupported }
```
---
## Registration
Registration happens automatically via Go's `init()` mechanism. When the main binary imports the provider package (even as a side-effect import), the `init()` function runs and calls `hosting.Register()`.
In `cmd/main.go` (or wherever the binary entry point is), add a blank import:
```go
import (
// Register hosting providers
_ "setec-manager/internal/hosting/hostinger"
_ "setec-manager/internal/hosting/myprovider"
)
```
The `hosting.Register()` function stores the provider instance in a global `map[string]Provider` protected by a `sync.RWMutex`:
```go
// From internal/hosting/provider.go
func Register(p Provider) {
registryMu.Lock()
defer registryMu.Unlock()
registry[p.Name()] = p
}
```
After registration, the provider is accessible via `hosting.Get("myprovider")` and appears in `hosting.List()`.
---
## Configuration Storage
When a user configures your provider (via the UI or API), the system:
1. Calls `provider.Configure(map[string]string{"api_key": "..."})` to set credentials in memory.
2. Calls `provider.TestConnection()` to verify the credentials work.
3. Saves a `ProviderConfig` to disk via `ProviderConfigStore.Save()`.
The config file is written to `<config_dir>/<provider_name>.json` with `0600` permissions:
```json
{
"provider": "myprovider",
"api_key": "sk-abc123...",
"api_secret": "",
"extra": {
"base_url": "https://api.myprovider.com/v2"
},
"connected": true
}
```
On startup, `ProviderConfigStore.loadAll()` reads all JSON files from the config directory, and for each one that matches a registered provider, calls `Configure()` to restore credentials.
---
## Error Handling
### The ErrNotSupported Pattern
Define a sentinel error in your provider package:
```go
var ErrNotSupported = errors.New("myprovider: operation not supported")
```
Return this error from any interface method your provider does not implement. The HTTP handler layer checks for this error and returns HTTP 501 (Not Implemented) to the client.
### API Errors
For errors from the upstream provider API, return a descriptive error with context:
```go
return fmt.Errorf("myprovider: list domains: %w", err)
```
### Rate Limiting
If the provider has rate limits, handle them inside your client. See the Hostinger implementation in `internal/hosting/hostinger/client.go` for a reference pattern:
1. Check for HTTP 429 responses.
2. Read the `Retry-After` header.
3. Sleep and retry (up to a maximum number of retries).
4. Return a clear error if retries are exhausted.
```go
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
if attempt < maxRetries {
time.Sleep(retryAfter)
continue
}
return fmt.Errorf("myprovider: rate limited after %d retries", maxRetries)
}
```
---
## Testing
### Unit Tests
Create `internal/hosting/myprovider/provider_test.go`:
```go
package myprovider
import (
"testing"
"setec-manager/internal/hosting"
)
func TestProviderImplementsInterface(t *testing.T) {
var _ hosting.Provider = (*Provider)(nil)
}
func TestName(t *testing.T) {
p := &Provider{}
if p.Name() != "myprovider" {
t.Errorf("expected name 'myprovider', got %q", p.Name())
}
}
func TestConfigure(t *testing.T) {
p := &Provider{}
err := p.Configure(map[string]string{})
if err == nil {
t.Error("expected error when api_key is missing")
}
err = p.Configure(map[string]string{"api_key": "test-key"})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if p.apiKey != "test-key" {
t.Errorf("expected apiKey 'test-key', got %q", p.apiKey)
}
}
func TestUnsupportedMethodsReturnError(t *testing.T) {
p := &Provider{}
_, err := p.ListVMs()
if err != ErrNotSupported {
t.Errorf("ListVMs: expected ErrNotSupported, got %v", err)
}
_, err = p.GetCatalog()
if err != ErrNotSupported {
t.Errorf("GetCatalog: expected ErrNotSupported, got %v", err)
}
}
```
### Integration Tests
For integration tests against the real API, use build tags to prevent them from running in CI:
```go
//go:build integration
package myprovider
import (
"os"
"testing"
)
func TestListDomainsIntegration(t *testing.T) {
key := os.Getenv("MYPROVIDER_API_KEY")
if key == "" {
t.Skip("MYPROVIDER_API_KEY not set")
}
p := &Provider{}
p.Configure(map[string]string{"api_key": key})
domains, err := p.ListDomains()
if err != nil {
t.Fatalf("ListDomains failed: %v", err)
}
t.Logf("Found %d domains", len(domains))
}
```
Run integration tests:
```bash
go test -tags=integration ./internal/hosting/myprovider/ -v
```
### Registration Test
Verify that importing the package registers the provider:
```go
package myprovider_test
import (
"testing"
"setec-manager/internal/hosting"
_ "setec-manager/internal/hosting/myprovider"
)
func TestRegistration(t *testing.T) {
p, err := hosting.Get("myprovider")
if err != nil {
t.Fatalf("provider not registered: %v", err)
}
if p.DisplayName() == "" {
t.Error("DisplayName is empty")
}
}
```
---
## Example: Skeleton Provider (DNS Only)
This is a complete, minimal provider that implements only DNS management. All other methods return `ErrNotSupported`. You can copy this file and fill in the DNS methods with real API calls.
```go
package dnsonlyprovider
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"setec-manager/internal/hosting"
)
var ErrNotSupported = errors.New("dnsonlyprovider: operation not supported")
type Provider struct {
client *http.Client
apiKey string
baseURL string
}
func init() {
hosting.Register(&Provider{
client: &http.Client{Timeout: 30 * time.Second},
baseURL: "https://api.dns-only.example.com/v1",
})
}
func (p *Provider) Name() string { return "dnsonlyprovider" }
func (p *Provider) DisplayName() string { return "DNS-Only Provider" }
func (p *Provider) Configure(config map[string]string) error {
key, ok := config["api_key"]
if !ok || key == "" {
return fmt.Errorf("dnsonlyprovider: api_key is required")
}
p.apiKey = key
return nil
}
func (p *Provider) TestConnection() error {
// Try listing zones as a health check.
req, _ := http.NewRequest("GET", p.baseURL+"/zones", nil)
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("dnsonlyprovider: connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("dnsonlyprovider: API returned %d: %s", resp.StatusCode, body)
}
return nil
}
// ── DNS (implemented) ───────────────────────────────────────────────
func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/zones/%s/records", p.baseURL, domain), nil)
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("dnsonlyprovider: list records: %w", err)
}
defer resp.Body.Close()
var records []hosting.DNSRecord
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
return nil, fmt.Errorf("dnsonlyprovider: parse records: %w", err)
}
return records, nil
}
func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
// Implementation: POST to /zones/{domain}/records
return ErrNotSupported // replace with real implementation
}
func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
// Implementation: PUT to /zones/{domain}/records
return ErrNotSupported // replace with real implementation
}
func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
// Implementation: DELETE /zones/{domain}/records?name=...&type=...
return ErrNotSupported // replace with real implementation
}
func (p *Provider) ResetDNSRecords(domain string) error {
return ErrNotSupported
}
// ── Everything else: not supported ──────────────────────────────────
func (p *Provider) ListDomains() ([]hosting.Domain, error) { return nil, ErrNotSupported }
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) { return nil, ErrNotSupported }
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) { return nil, ErrNotSupported }
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) { return nil, ErrNotSupported }
func (p *Provider) SetNameservers(domain string, nameservers []string) error { return ErrNotSupported }
func (p *Provider) EnableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) EnablePrivacyProtection(domain string) error { return ErrNotSupported }
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }
func (p *Provider) ListVMs() ([]hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) GetVM(id string) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error) { return nil, ErrNotSupported }
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) DeleteSSHKey(id string) error { return ErrNotSupported }
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error) { return nil, ErrNotSupported }
```
---
## Example: Full Provider Structure
For a provider that implements all capabilities, organize the code across multiple files:
```
internal/hosting/fullprovider/
provider.go -- init(), Name(), DisplayName(), Configure(), TestConnection()
client.go -- HTTP client with auth, retry, rate-limit handling
dns.go -- ListDNSRecords, CreateDNSRecord, UpdateDNSRecords, DeleteDNSRecord, ResetDNSRecords
domains.go -- ListDomains, GetDomain, CheckDomainAvailability, PurchaseDomain, nameserver/lock/privacy methods
vms.go -- ListVMs, GetVM, CreateVM, ListDataCenters
ssh.go -- ListSSHKeys, AddSSHKey, DeleteSSHKey
billing.go -- ListSubscriptions, GetCatalog
types.go -- Provider-specific API request/response types
```
Each file focuses on a single capability area. The `client.go` file provides a shared `doRequest()` method (similar to the Hostinger client) that handles authentication headers, JSON marshaling, error parsing, and retry logic.
### Key Patterns from the Hostinger Implementation
1. **Separate API types from generic types.** Define provider-specific request/response structs (e.g., `hostingerDNSRecord`) and conversion functions (`toGenericDNSRecord`, `toHostingerDNSRecord`).
2. **Validate before mutating.** The Hostinger DNS implementation calls a `/validate` endpoint before applying updates. If your provider offers similar validation, use it.
3. **Synthesize IDs when the API does not provide them.** Hostinger does not return record IDs in zone listings, so the client synthesizes them from `name/type/priority`.
4. **Handle rate limits transparently.** The client retries on HTTP 429 with exponential back-off, capping at 60 seconds per retry and 3 retries total. This keeps rate-limit handling invisible to the caller.

View File

@@ -0,0 +1,859 @@
# Hosting Provider Integration System
## Overview
Setec Manager includes a pluggable hosting provider architecture that lets you manage DNS records, domains, VPS instances, SSH keys, and billing subscriptions through a unified interface. The system is built around a Go `Provider` interface defined in `internal/hosting/provider.go`. Each hosting provider (e.g., Hostinger) implements this interface and auto-registers itself at import time via an `init()` function.
### Architecture
```
internal/hosting/
provider.go -- Provider interface, model types, global registry
store.go -- ProviderConfig type, ProviderConfigStore (disk persistence)
config.go -- Legacy config store (being superseded by store.go)
hostinger/
client.go -- Hostinger HTTP client with retry/rate-limit handling
dns.go -- Hostinger DNS implementation
```
The registry is a process-global `map[string]Provider` guarded by a `sync.RWMutex`. Providers call `hosting.Register(&Provider{})` inside their package `init()` function. The main binary imports the provider package (e.g., `_ "setec-manager/internal/hosting/hostinger"`) to trigger registration.
Provider credentials are stored as individual JSON files in a protected directory (`0700` directory, `0600` files) managed by `ProviderConfigStore`. Each file is named `<provider>.json` and contains the `ProviderConfig` struct:
```json
{
"provider": "hostinger",
"api_key": "Bearer ...",
"api_secret": "",
"extra": {},
"connected": true
}
```
---
## Supported Providers
### Hostinger (Built-in)
| Capability | Supported | Notes |
|---|---|---|
| DNS Management | Yes | Full CRUD, validation before writes, zone reset |
| Domain Management | Yes | List, lookup, availability check, purchase, nameservers, lock, privacy |
| VPS Management | Yes | List, create, get details, data center listing |
| SSH Key Management | Yes | Add, list, delete |
| Billing | Yes | Subscriptions and catalog |
The Hostinger provider communicates with `https://developers.hostinger.com` using a Bearer token. It includes automatic retry with back-off on HTTP 429 (rate limit) responses, up to 3 retries per request.
---
## Configuration
### Via the UI
1. Navigate to the Hosting Providers section in the Setec Manager dashboard.
2. Select "Hostinger" from the provider list.
3. Enter your API token (obtained from hPanel -- see [Hostinger Setup Guide](hostinger-setup.md)).
4. Click "Test Connection" to verify the token is valid.
5. Click "Save" to persist the configuration.
### Via Config Files
Provider configurations are stored as JSON files in the config directory (typically `/opt/setec-manager/data/hosting/`).
Create or edit the file directly:
```bash
mkdir -p /opt/setec-manager/data/hosting
cat > /opt/setec-manager/data/hosting/hostinger.json << 'EOF'
{
"provider": "hostinger",
"api_key": "YOUR_BEARER_TOKEN_HERE",
"api_secret": "",
"extra": {},
"connected": true
}
EOF
chmod 600 /opt/setec-manager/data/hosting/hostinger.json
```
### Via API
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/configure \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_HOSTINGER_API_TOKEN"
}'
```
---
## API Reference
All hosting endpoints require authentication via JWT (cookie or `Authorization: Bearer` header). The base URL is `https://your-server:9090`.
### Provider Management
#### List Providers
```
GET /api/hosting/providers
```
Returns all registered hosting providers and their connection status.
```bash
curl -s https://your-server:9090/api/hosting/providers \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"name": "hostinger",
"display_name": "Hostinger",
"connected": true
}
]
```
#### Configure Provider
```
POST /api/hosting/providers/{provider}/configure
```
Sets the API credentials for a provider.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/configure \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_TOKEN"
}'
```
**Response:**
```json
{
"status": "configured"
}
```
#### Test Connection
```
POST /api/hosting/providers/{provider}/test
```
Verifies that the saved credentials are valid by making a test API call.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/test \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "ok",
"message": "Connection successful"
}
```
#### Remove Provider Configuration
```
DELETE /api/hosting/providers/{provider}
```
Deletes saved credentials for a provider.
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "deleted"
}
```
---
## DNS Management
### List DNS Records
```
GET /api/hosting/providers/{provider}/dns/{domain}
```
Returns all DNS records for the specified domain.
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/dns/example.com \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "@/A/0",
"type": "A",
"name": "@",
"content": "93.184.216.34",
"ttl": 14400,
"priority": 0
},
{
"id": "www/CNAME/0",
"type": "CNAME",
"name": "www",
"content": "example.com",
"ttl": 14400,
"priority": 0
},
{
"id": "@/MX/10",
"type": "MX",
"name": "@",
"content": "mail.example.com",
"ttl": 14400,
"priority": 10
}
]
```
### Create DNS Record
```
POST /api/hosting/providers/{provider}/dns/{domain}
```
Adds a new DNS record without overwriting existing records.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/dns/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "A",
"name": "api",
"content": "93.184.216.35",
"ttl": 3600
}'
```
**Response:**
```json
{
"status": "created"
}
```
### Update DNS Records (Batch)
```
PUT /api/hosting/providers/{provider}/dns/{domain}
```
Updates DNS records for a domain. If `overwrite` is `true`, all existing records are replaced; otherwise the records are merged.
The Hostinger provider validates records against the API before applying changes.
```bash
curl -X PUT https://your-server:9090/api/hosting/providers/hostinger/dns/example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"records": [
{
"type": "A",
"name": "@",
"content": "93.184.216.34",
"ttl": 14400
},
{
"type": "CNAME",
"name": "www",
"content": "example.com",
"ttl": 14400
}
],
"overwrite": false
}'
```
**Response:**
```json
{
"status": "updated"
}
```
### Delete DNS Record
```
DELETE /api/hosting/providers/{provider}/dns/{domain}?name={name}&type={type}
```
Removes DNS records matching the given name and type.
```bash
curl -X DELETE "https://your-server:9090/api/hosting/providers/hostinger/dns/example.com?name=api&type=A" \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "deleted"
}
```
### Reset DNS Zone
```
POST /api/hosting/providers/{provider}/dns/{domain}/reset
```
Resets the domain's DNS zone to the provider's default records. This is destructive and removes all custom records.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/dns/example.com/reset \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "reset"
}
```
### Supported DNS Record Types
| Type | Description | Priority Field |
|---|---|---|
| A | IPv4 address | No |
| AAAA | IPv6 address | No |
| CNAME | Canonical name / alias | No |
| MX | Mail exchange | Yes |
| TXT | Text record (SPF, DKIM, etc.) | No |
| NS | Name server | No |
| SRV | Service record | Yes |
| CAA | Certificate Authority Authorization | No |
---
## Domain Management
### List Domains
```
GET /api/hosting/providers/{provider}/domains
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/domains \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"name": "example.com",
"registrar": "Hostinger",
"status": "active",
"expires_at": "2027-03-15T00:00:00Z",
"auto_renew": true,
"locked": true,
"privacy_protection": true,
"nameservers": ["ns1.dns-parking.com", "ns2.dns-parking.com"]
}
]
```
### Get Domain Details
```
GET /api/hosting/providers/{provider}/domains/{domain}
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/domains/example.com \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"name": "example.com",
"registrar": "Hostinger",
"status": "active",
"expires_at": "2027-03-15T00:00:00Z",
"auto_renew": true,
"locked": true,
"privacy_protection": true,
"nameservers": ["ns1.dns-parking.com", "ns2.dns-parking.com"]
}
```
### Check Domain Availability
```
POST /api/hosting/providers/{provider}/domains/check
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/check \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domains": ["cool-project.com", "cool-project.io", "cool-project.dev"]
}'
```
**Response:**
```json
[
{
"domain": "cool-project.com",
"available": true,
"price": 9.99,
"currency": "USD"
},
{
"domain": "cool-project.io",
"available": false
},
{
"domain": "cool-project.dev",
"available": true,
"price": 14.99,
"currency": "USD"
}
]
```
### Purchase Domain
```
POST /api/hosting/providers/{provider}/domains/purchase
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/purchase \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "cool-project.com",
"period": 1,
"auto_renew": true,
"privacy_protection": true,
"payment_method_id": "pm_abc123"
}'
```
**Response:**
```json
{
"name": "cool-project.com",
"status": "active",
"expires_at": "2027-03-11T00:00:00Z",
"auto_renew": true,
"locked": false,
"privacy_protection": true,
"nameservers": ["ns1.dns-parking.com", "ns2.dns-parking.com"]
}
```
### Set Nameservers
```
PUT /api/hosting/providers/{provider}/domains/{domain}/nameservers
```
```bash
curl -X PUT https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/nameservers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"nameservers": ["ns1.cloudflare.com", "ns2.cloudflare.com"]
}'
```
**Response:**
```json
{
"status": "updated"
}
```
### Enable Domain Lock
```
POST /api/hosting/providers/{provider}/domains/{domain}/lock
```
Prevents unauthorized domain transfers.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/lock \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "locked"
}
```
### Disable Domain Lock
```
DELETE /api/hosting/providers/{provider}/domains/{domain}/lock
```
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/lock \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "unlocked"
}
```
### Enable Privacy Protection
```
POST /api/hosting/providers/{provider}/domains/{domain}/privacy
```
Enables WHOIS privacy protection to hide registrant details.
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/privacy \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "enabled"
}
```
### Disable Privacy Protection
```
DELETE /api/hosting/providers/{provider}/domains/{domain}/privacy
```
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/privacy \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "disabled"
}
```
---
## VPS Management
### List Virtual Machines
```
GET /api/hosting/providers/{provider}/vms
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/vms \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "vm-abc123",
"name": "production-1",
"status": "running",
"plan": "kvm-2",
"region": "us-east-1",
"ipv4": "93.184.216.34",
"ipv6": "2606:2800:220:1:248:1893:25c8:1946",
"os": "Ubuntu 22.04",
"cpus": 2,
"memory_mb": 4096,
"disk_gb": 80,
"bandwidth_gb": 4000,
"created_at": "2025-01-15T10:30:00Z",
"labels": {
"env": "production"
}
}
]
```
### Get VM Details
```
GET /api/hosting/providers/{provider}/vms/{id}
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/vms/vm-abc123 \
-H "Authorization: Bearer $TOKEN"
```
**Response:** Same shape as a single item from the list response.
### Create VM
```
POST /api/hosting/providers/{provider}/vms
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/vms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan": "kvm-2",
"data_center_id": "us-east-1",
"template": "ubuntu-22.04",
"password": "SecurePassword123!",
"hostname": "web-server-2",
"ssh_key_id": "key-abc123",
"payment_method_id": "pm_abc123"
}'
```
**Response:**
```json
{
"id": "vm-def456",
"name": "web-server-2",
"status": "creating",
"plan": "kvm-2",
"region": "us-east-1",
"os": "Ubuntu 22.04",
"cpus": 2,
"memory_mb": 4096,
"disk_gb": 80,
"bandwidth_gb": 4000,
"created_at": "2026-03-11T14:00:00Z"
}
```
### List Data Centers
```
GET /api/hosting/providers/{provider}/datacenters
```
Returns available regions/data centers for VM creation.
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/datacenters \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "us-east-1",
"name": "US East",
"location": "New York",
"country": "US"
},
{
"id": "eu-west-1",
"name": "EU West",
"location": "Amsterdam",
"country": "NL"
}
]
```
---
## SSH Key Management
### List SSH Keys
```
GET /api/hosting/providers/{provider}/ssh-keys
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/ssh-keys \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "key-abc123",
"name": "deploy-key",
"fingerprint": "SHA256:abcd1234...",
"public_key": "ssh-ed25519 AAAAC3Nz..."
}
]
```
### Add SSH Key
```
POST /api/hosting/providers/{provider}/ssh-keys
```
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/ssh-keys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "new-deploy-key",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... user@host"
}'
```
**Response:**
```json
{
"id": "key-def456",
"name": "new-deploy-key",
"fingerprint": "SHA256:efgh5678...",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI..."
}
```
### Delete SSH Key
```
DELETE /api/hosting/providers/{provider}/ssh-keys/{id}
```
```bash
curl -X DELETE https://your-server:9090/api/hosting/providers/hostinger/ssh-keys/key-def456 \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"status": "deleted"
}
```
---
## Billing
### List Subscriptions
```
GET /api/hosting/providers/{provider}/subscriptions
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/subscriptions \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "sub-abc123",
"name": "Premium Web Hosting",
"status": "active",
"plan": "premium-hosting-48m",
"price": 2.99,
"currency": "USD",
"renews_at": "2027-03-15T00:00:00Z",
"created_at": "2023-03-15T00:00:00Z"
}
]
```
### Get Product Catalog
```
GET /api/hosting/providers/{provider}/catalog
```
```bash
curl -s https://your-server:9090/api/hosting/providers/hostinger/catalog \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
[
{
"id": "kvm-2",
"name": "KVM 2",
"category": "vps",
"price_cents": 1199,
"currency": "USD",
"period": "monthly",
"description": "2 vCPU, 4 GB RAM, 80 GB SSD"
},
{
"id": "premium-hosting-12m",
"name": "Premium Web Hosting",
"category": "hosting",
"price_cents": 299,
"currency": "USD",
"period": "monthly",
"description": "100 websites, 100 GB SSD, free SSL"
}
]
```
---
## Error Responses
All endpoints return errors in a consistent format:
```json
{
"error": "description of what went wrong"
}
```
| HTTP Status | Meaning |
|---|---|
| 400 | Bad request (invalid parameters) |
| 401 | Authentication required or token invalid |
| 404 | Provider or resource not found |
| 409 | Conflict (e.g., duplicate resource) |
| 429 | Rate limited by the upstream provider |
| 500 | Internal server error |
| 501 | Provider does not support this operation (`ErrNotSupported`) |
When a provider does not implement a particular capability, the endpoint returns HTTP 501 with an `ErrNotSupported` error message. This allows partial implementations where a provider only supports DNS management, for example.

View File

@@ -0,0 +1,365 @@
# Hostinger Setup Guide
This guide covers configuring the Hostinger hosting provider integration in Setec Manager.
---
## Getting Your API Token
Hostinger provides API access through bearer tokens generated in the hPanel control panel.
### Step-by-Step
1. **Log in to hPanel.** Go to [https://hpanel.hostinger.com](https://hpanel.hostinger.com) and sign in with your Hostinger account.
2. **Navigate to your profile.** Click your profile icon or name in the top-right corner of the dashboard.
3. **Open Account Settings.** Select "Account Settings" or "Profile" from the dropdown menu.
4. **Go to the API section.** Look for the "API" or "API Tokens" tab. This may be under "Account" > "API" depending on your hPanel version.
5. **Generate a new token.** Click "Create API Token" or "Generate Token."
- Give the token a descriptive name (e.g., `setec-manager`).
- Select the permissions/scopes you need. For full Setec Manager integration, grant:
- DNS management (read/write)
- Domain management (read/write)
- VPS management (read/write)
- Billing (read)
- Set an expiration if desired (recommended: no expiration for server-to-server use, but rotate periodically).
6. **Copy the token.** The token is shown only once. Copy it immediately and store it securely. It will look like a long alphanumeric string.
**Important:** Treat this token like a password. Anyone with the token has API access to your Hostinger account.
---
## Configuring in Setec Manager
### Via the Web UI
1. Log in to your Setec Manager dashboard at `https://your-server:9090`.
2. Navigate to the Hosting Providers section.
3. Click "Hostinger" from the provider list.
4. Paste your API token into the "API Key" field.
5. Click "Test Connection" -- you should see a success message confirming the token is valid.
6. Click "Save Configuration" to persist the credentials.
### Via the API
```bash
# Set your Setec Manager JWT token
export TOKEN="your-setec-manager-jwt"
# Configure the Hostinger provider
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/configure \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_HOSTINGER_BEARER_TOKEN"
}'
# Verify the connection
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/test \
-H "Authorization: Bearer $TOKEN"
```
### Via Config File
Create the config file directly on the server:
```bash
sudo mkdir -p /opt/setec-manager/data/hosting
sudo tee /opt/setec-manager/data/hosting/hostinger.json > /dev/null << 'EOF'
{
"provider": "hostinger",
"api_key": "YOUR_HOSTINGER_BEARER_TOKEN",
"api_secret": "",
"extra": {},
"connected": true
}
EOF
sudo chmod 600 /opt/setec-manager/data/hosting/hostinger.json
```
Restart Setec Manager for the config to be loaded:
```bash
sudo systemctl restart setec-manager
```
---
## Available Features
The Hostinger provider supports all major integration capabilities:
| Feature | Status | Notes |
|---|---|---|
| DNS Record Listing | Supported | Lists all records in a zone |
| DNS Record Creation | Supported | Adds records without overwriting |
| DNS Record Update (Batch) | Supported | Validates before applying; supports overwrite mode |
| DNS Record Deletion | Supported | Filter by name and/or type |
| DNS Zone Reset | Supported | Resets to Hostinger default records |
| Domain Listing | Supported | All domains on the account |
| Domain Details | Supported | Full WHOIS and registration info |
| Domain Availability Check | Supported | Batch check with pricing |
| Domain Purchase | Supported | Requires valid payment method |
| Nameserver Management | Supported | Update authoritative nameservers |
| Domain Lock | Supported | Enable/disable transfer lock |
| Privacy Protection | Supported | Enable/disable WHOIS privacy |
| VPS Listing | Supported | All VPS instances |
| VPS Details | Supported | Full specs, IP, status |
| VPS Creation | Supported | Requires plan, template, data center |
| Data Center Listing | Supported | Available regions for VM creation |
| SSH Key Management | Supported | Add, list, delete public keys |
| Subscription Listing | Supported | Active billing subscriptions |
| Product Catalog | Supported | Available plans and pricing |
---
## Rate Limits
The Hostinger API enforces rate limiting on all endpoints. The Setec Manager integration handles rate limits automatically:
- **Detection:** HTTP 429 (Too Many Requests) responses are detected.
- **Retry-After header:** The client reads the `Retry-After` header to determine how long to wait.
- **Automatic retry:** Up to 3 retries are attempted with the specified back-off.
- **Back-off cap:** Individual retry delays are capped at 60 seconds.
- **Failure:** If all retries are exhausted, the error is returned to the caller.
### Best Practices
- Avoid rapid-fire bulk operations. Space out batch DNS updates.
- Use the batch `UpdateDNSRecords` endpoint with multiple records in one call instead of creating records one at a time.
- Cache domain and VM listings on the client side when possible.
- If you see frequent 429 errors in logs, reduce the frequency of polling operations.
---
## DNS Record Management
### Hostinger API Endpoints Used
| Operation | Hostinger API Path |
|---|---|
| List records | `GET /api/dns/v1/zones/{domain}` |
| Update records | `PUT /api/dns/v1/zones/{domain}` |
| Validate records | `POST /api/dns/v1/zones/{domain}/validate` |
| Delete records | `DELETE /api/dns/v1/zones/{domain}` |
| Reset zone | `POST /api/dns/v1/zones/{domain}/reset` |
### Supported Record Types
| Type | Example Content | Priority | Notes |
|---|---|---|---|
| A | `93.184.216.34` | No | IPv4 address |
| AAAA | `2606:2800:220:1::` | No | IPv6 address |
| CNAME | `example.com` | No | Must be a hostname, not an IP |
| MX | `mail.example.com` | Yes | Priority determines delivery order (lower = higher priority) |
| TXT | `v=spf1 include:...` | No | Used for SPF, DKIM, domain verification |
| NS | `ns1.example.com` | No | Nameserver delegation |
| SRV | `sip.example.com` | Yes | Service location records |
| CAA | `letsencrypt.org` | No | Certificate authority authorization |
### Record ID Synthesis
Hostinger does not return unique record IDs in zone listings. Setec Manager synthesizes an ID from `name/type/priority` for each record. For example, an MX record for the root domain with priority 10 gets the ID `@/MX/10`. This ID is used internally for tracking but should not be passed back to the Hostinger API.
### Validation Before Write
The Hostinger provider validates DNS records before applying changes. When you call `UpdateDNSRecords`, the system:
1. Converts generic `DNSRecord` structs to Hostinger-specific format.
2. Sends the records to the `/validate` endpoint.
3. If validation passes, sends the actual update to the zone endpoint.
4. If validation fails, returns the validation error without modifying the zone.
This prevents malformed records from corrupting your DNS zone.
---
## Domain Management
### Purchasing Domains
Before purchasing a domain:
1. Check availability using the availability check endpoint.
2. Note the price and currency in the response.
3. Ensure you have a valid payment method configured in your Hostinger account.
4. Submit the purchase request with the `payment_method_id` from your Hostinger account.
```bash
# Check availability
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/check \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"domains": ["my-new-site.com"]}'
# Purchase (if available)
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/purchase \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "my-new-site.com",
"period": 1,
"auto_renew": true,
"privacy_protection": true
}'
```
### Domain Transfers
Domain transfers are initiated outside of Setec Manager through the Hostinger hPanel. Once a domain is transferred to your Hostinger account, it will appear in `ListDomains` and can be managed through Setec Manager.
### WHOIS Privacy
Hostinger offers WHOIS privacy protection (also called "Domain Privacy Protection") that replaces your personal contact information in WHOIS records with proxy information. Enable it to keep your registrant details private:
```bash
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/domains/example.com/privacy \
-H "Authorization: Bearer $TOKEN"
```
---
## VPS Management
### Creating a VM
To create a VPS instance, you need three pieces of information:
1. **Plan ID** -- Get from the catalog endpoint (`GET /api/hosting/providers/hostinger/catalog`).
2. **Data Center ID** -- Get from the data centers endpoint (`GET /api/hosting/providers/hostinger/datacenters`).
3. **Template** -- The OS template name (e.g., `"ubuntu-22.04"`, `"debian-12"`, `"centos-9"`).
```bash
# List available plans
curl -s https://your-server:9090/api/hosting/providers/hostinger/catalog \
-H "Authorization: Bearer $TOKEN" | jq '.[] | select(.category == "vps")'
# List data centers
curl -s https://your-server:9090/api/hosting/providers/hostinger/datacenters \
-H "Authorization: Bearer $TOKEN"
# Create the VM
curl -X POST https://your-server:9090/api/hosting/providers/hostinger/vms \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan": "kvm-2",
"data_center_id": "us-east-1",
"template": "ubuntu-22.04",
"password": "YourSecurePassword!",
"hostname": "app-server",
"ssh_key_id": "key-abc123"
}'
```
### Docker Support
Hostinger VPS instances support Docker out of the box on Linux templates. After creating a VM:
1. SSH into the new VM.
2. Install Docker using the standard installation method for your chosen OS.
3. Alternatively, select a Docker-optimized template if available in your Hostinger account.
### VM Status Values
| Status | Description |
|---|---|
| `running` | VM is powered on and operational |
| `stopped` | VM is powered off |
| `creating` | VM is being provisioned (may take a few minutes) |
| `error` | VM encountered an error during provisioning |
| `suspended` | VM is suspended (usually billing-related) |
---
## Troubleshooting
### Common Errors
#### "hostinger API error 401: Unauthorized"
**Cause:** The API token is invalid, expired, or revoked.
**Fix:**
1. Log in to hPanel and verify the token exists and is not expired.
2. Generate a new token if needed.
3. Update the configuration in Setec Manager.
#### "hostinger API error 403: Forbidden"
**Cause:** The API token does not have the required permissions/scopes.
**Fix:**
1. Check the token's permissions in hPanel.
2. Ensure the token has read/write access for the feature you are trying to use (DNS, domains, VPS, billing).
3. Generate a new token with the correct scopes if needed.
#### "hostinger API error 429: rate limited"
**Cause:** Too many API requests in a short period.
**Fix:**
- The client retries automatically up to 3 times. If you still see this error, you are making requests too frequently.
- Space out bulk operations.
- Use batch endpoints (e.g., `UpdateDNSRecords` with multiple records) instead of individual calls.
#### "hostinger API error 404: Not Found"
**Cause:** The domain, VM, or resource does not exist in your Hostinger account.
**Fix:**
- Verify the domain is registered with Hostinger (not just DNS-hosted).
- Check that the VM ID is correct.
- Ensure the domain's DNS zone is active in Hostinger.
#### "validate DNS records: hostinger API error 422"
**Cause:** One or more DNS records failed validation.
**Fix:**
- Check record types are valid (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA).
- Verify content format matches the record type (e.g., A records must be valid IPv4 addresses).
- Ensure TTL is a positive integer.
- MX and SRV records require a priority value.
- CNAME records cannot coexist with other record types at the same name.
#### "connection failed" or "execute request" errors
**Cause:** Network connectivity issue between Setec Manager and `developers.hostinger.com`.
**Fix:**
- Verify the server has outbound HTTPS access.
- Check DNS resolution: `dig developers.hostinger.com`.
- Check if a firewall is blocking outbound port 443.
- Verify the server's system clock is accurate (TLS certificate validation requires correct time).
#### "hosting provider 'hostinger' not registered"
**Cause:** The Hostinger provider package was not imported in the binary.
**Fix:**
- Ensure `cmd/main.go` includes the blank import: `_ "setec-manager/internal/hosting/hostinger"`.
- Rebuild and restart Setec Manager.
### Checking Logs
Setec Manager logs hosting provider operations to the configured log file (default: `/var/log/setec-manager.log`). Look for lines containing `hostinger` or `hosting`:
```bash
grep -i hostinger /var/log/setec-manager.log | tail -20
```
### Testing Connectivity Manually
You can test the Hostinger API directly from the server to rule out Setec Manager issues:
```bash
curl -s -H "Authorization: Bearer YOUR_HOSTINGER_TOKEN" \
https://developers.hostinger.com/api/dns/v1/zones/your-domain.com
```
If this succeeds but Setec Manager fails, the issue is in the Setec Manager configuration. If this also fails, the issue is with the token or network connectivity.

View File

@@ -0,0 +1,25 @@
module setec-manager
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

View File

@@ -0,0 +1,65 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,9 @@
package web
import "embed"
//go:embed templates/*.html
var TemplateFS embed.FS
//go:embed static
var StaticFS embed.FS

View File

@@ -0,0 +1,370 @@
/* Setec App Manager — Dark Theme */
:root {
--primary: #6366f1;
--primary-hover: #818cf8;
--surface: #222536;
--bg: #1a1b2e;
--text: #e2e8f0;
--text-muted: #94a3b8;
--border: #2e3148;
--ok: #22c55e;
--err: #ef4444;
--warn: #f59e0b;
--radius: 6px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
min-height: 100vh;
line-height: 1.5;
}
/* ── Sidebar ── */
.sidebar {
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.sidebar-header {
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar-header h1 {
font-size: 1.25rem;
color: var(--primary);
letter-spacing: 0.1em;
}
.sidebar-header .subtitle {
font-size: .75rem;
color: var(--text-muted);
}
.nav-menu {
list-style: none;
flex: 1;
padding: .5rem 0;
overflow-y: auto;
}
.sidebar-logo {
width: 160px;
height: auto;
}
.nav-icon {
width: 18px;
height: 18px;
vertical-align: middle;
margin-right: 8px;
flex-shrink: 0;
}
.nav-link {
display: flex;
align-items: center;
padding: .55rem 1rem;
color: var(--text-muted);
text-decoration: none;
font-size: .9rem;
transition: background .15s, color .15s;
}
.nav-link:hover,
.nav-link.active {
background: rgba(99, 102, 241, .1);
color: var(--primary);
}
.sidebar-footer {
padding: .75rem 1rem;
border-top: 1px solid var(--border);
font-size: .8rem;
}
.sidebar-footer .user-badge {
color: var(--text-muted);
display: block;
margin-bottom: .25rem;
}
.sidebar-footer .logout {
color: var(--err);
font-size: .8rem;
}
/* ── Content ── */
.content {
margin-left: 220px;
flex: 1;
padding: 1.5rem 2rem;
min-height: 100vh;
}
.content h2 {
margin-bottom: 1rem;
font-size: 1.4rem;
}
.content h3 {
margin: 1.25rem 0 .75rem;
font-size: 1.1rem;
color: var(--text-muted);
}
/* ── Stat Cards / Grid ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
}
.stat-card h3 {
margin: 0 0 .5rem;
font-size: .95rem;
color: var(--text-muted);
}
.stat-card .big-number {
font-size: 2rem;
font-weight: 700;
color: var(--primary);
}
/* ── Data Table ── */
.data-table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 1rem;
}
.data-table th,
.data-table td {
padding: .6rem .85rem;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: .9rem;
}
.data-table th {
background: rgba(99, 102, 241, .08);
color: var(--text-muted);
font-weight: 600;
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .04em;
}
.data-table tbody tr:hover {
background: rgba(99, 102, 241, .04);
}
.data-table a {
color: var(--primary);
text-decoration: none;
}
.data-table a:hover {
text-decoration: underline;
}
/* ── Buttons ── */
.btn {
display: inline-block;
padding: .5rem 1rem;
font-size: .875rem;
font-weight: 500;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
transition: background .15s, border-color .15s;
line-height: 1.4;
}
.btn:hover {
background: var(--border);
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.btn-primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-sm {
padding: .3rem .6rem;
font-size: .8rem;
}
/* ── Badges ── */
.badge {
display: inline-block;
padding: .15rem .5rem;
font-size: .75rem;
font-weight: 600;
border-radius: 999px;
background: var(--border);
color: var(--text-muted);
}
.badge-ok {
background: rgba(34, 197, 94, .15);
color: var(--ok);
}
.badge-err {
background: rgba(239, 68, 68, .15);
color: var(--err);
}
/* ── Forms ── */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: .3rem;
font-size: .85rem;
color: var(--text-muted);
}
.form-group input[type="text"],
.form-group input[type="password"],
.form-group input[type="email"],
.form-group select,
.form-group textarea {
width: 100%;
padding: .5rem .75rem;
font-size: .9rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
outline: none;
transition: border-color .15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--primary);
}
/* ── Progress Bar ── */
.progress-bar {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
margin: .4rem 0;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: width .3s ease;
}
/* ── Login Page ── */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg);
}
.login-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
width: 100%;
max-width: 380px;
}
.login-card h1 {
color: var(--primary);
letter-spacing: 0.1em;
margin-bottom: .25rem;
}
.login-card .subtitle {
color: var(--text-muted);
margin-bottom: 1.5rem;
font-size: .85rem;
}
.login-card .btn {
width: 100%;
margin-top: .5rem;
}
/* ── Error Message ── */
.error-msg {
margin-top: .75rem;
padding: .5rem .75rem;
background: rgba(239, 68, 68, .1);
border: 1px solid rgba(239, 68, 68, .3);
border-radius: var(--radius);
color: var(--err);
font-size: .85rem;
}
/* ── Utility ── */
code {
background: var(--bg);
padding: .1rem .35rem;
border-radius: 3px;
font-size: .85em;
}
p { margin-bottom: .35rem; }
/* ── Responsive ── */
@media (max-width: 768px) {
.sidebar { width: 60px; }
.sidebar-header h1 { font-size: .9rem; }
.sidebar-header .subtitle,
.sidebar-footer .user-badge { display: none; }
.nav-link { padding: .5rem; font-size: .75rem; text-align: center; }
.content { margin-left: 60px; padding: 1rem; }
.stats-grid { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<defs>
<linearGradient id="fg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#a855f7"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="#1e1e2e"/>
<path d="M16 3 L28 9 L28 19 Q28 27 16 30 Q4 27 4 19 L4 9 Z"
fill="none" stroke="url(#fg)" stroke-width="1.5" opacity="0.6"/>
<text x="16" y="22" font-family="monospace" font-size="16" font-weight="bold"
fill="url(#fg)" text-anchor="middle">S</text>
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<!-- Dashboard -->
<symbol id="icon-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</symbol>
<!-- Sites / Globe -->
<symbol id="icon-sites" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</symbol>
<!-- Shield / Security -->
<symbol id="icon-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</symbol>
<!-- Lock / SSL -->
<symbol id="icon-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</symbol>
<!-- Server / Nginx -->
<symbol id="icon-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</symbol>
<!-- Firewall / Shield-off -->
<symbol id="icon-firewall" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/>
</symbol>
<!-- Users -->
<symbol id="icon-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</symbol>
<!-- Backup / Archive -->
<symbol id="icon-backup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/>
<line x1="10" y1="12" x2="14" y2="12"/>
</symbol>
<!-- Monitor / Activity -->
<symbol id="icon-monitor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</symbol>
<!-- Logs / Terminal -->
<symbol id="icon-logs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</symbol>
<!-- Float / USB / Link -->
<symbol id="icon-float" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</symbol>
<!-- Hosting / Cloud-Server -->
<symbol id="icon-hosting" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
<line x1="12" y1="13" x2="12" y2="17"/><circle cx="12" cy="13" r="1"/>
</symbol>
<!-- AUTARCH / Hexagon -->
<symbol id="icon-autarch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 60" width="300" height="60">
<defs>
<linearGradient id="brandGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#a855f7"/>
</linearGradient>
</defs>
<!-- Shield icon -->
<path d="M30 5 L52 15 L52 33 Q52 48 30 53 Q8 48 8 33 L8 15 Z"
fill="url(#brandGrad)" opacity="0.15" stroke="url(#brandGrad)" stroke-width="1.5"/>
<text x="30" y="38" font-family="monospace" font-size="24" font-weight="bold"
fill="url(#brandGrad)" text-anchor="middle">S</text>
<!-- SETEC text -->
<text x="70" y="32" font-family="monospace" font-size="26" font-weight="bold"
fill="url(#brandGrad)" letter-spacing="4">SETEC</text>
<!-- Subtitle -->
<text x="70" y="48" font-family="monospace" font-size="11" fill="#6b7280"
letter-spacing="2">APP MANAGER</text>
</svg>

After

Width:  |  Height:  |  Size: 939 B

View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:1" />
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Shield shape -->
<path d="M100 10 L180 50 L180 120 Q180 170 100 190 Q20 170 20 120 L20 50 Z"
fill="url(#grad)" opacity="0.15" stroke="url(#grad)" stroke-width="2"/>
<!-- Inner shield -->
<path d="M100 25 L165 58 L165 115 Q165 158 100 175 Q35 158 35 115 L35 58 Z"
fill="none" stroke="url(#grad)" stroke-width="1.5" opacity="0.4"/>
<!-- S letter -->
<text x="100" y="125" font-family="monospace" font-size="90" font-weight="bold"
fill="url(#grad)" text-anchor="middle" filter="url(#glow)">S</text>
<!-- Circuit lines -->
<line x1="55" y1="155" x2="30" y2="155" stroke="#6366f1" stroke-width="1.5" opacity="0.5"/>
<circle cx="30" cy="155" r="3" fill="#6366f1" opacity="0.5"/>
<line x1="145" y1="155" x2="170" y2="155" stroke="#a855f7" stroke-width="1.5" opacity="0.5"/>
<circle cx="170" cy="155" r="3" fill="#a855f7" opacity="0.5"/>
<line x1="100" y1="45" x2="100" y2="25" stroke="#6366f1" stroke-width="1.5" opacity="0.5"/>
<circle cx="100" cy="22" r="3" fill="#6366f1" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 50" width="180" height="50">
<defs>
<linearGradient id="sideGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#a855f7"/>
</linearGradient>
</defs>
<!-- Mini shield -->
<path d="M22 4 L38 11 L38 25 Q38 36 22 40 Q6 36 6 25 L6 11 Z"
fill="url(#sideGrad)" opacity="0.2" stroke="url(#sideGrad)" stroke-width="1"/>
<text x="22" y="28" font-family="monospace" font-size="17" font-weight="bold"
fill="url(#sideGrad)" text-anchor="middle">S</text>
<!-- Text -->
<text x="52" y="24" font-family="monospace" font-size="18" font-weight="bold"
fill="#f9fafb" letter-spacing="3">SETEC</text>
<text x="52" y="38" font-family="monospace" font-size="8" fill="#6b7280"
letter-spacing="1.5">APP MANAGER</text>
</svg>

After

Width:  |  Height:  |  Size: 899 B

View File

@@ -0,0 +1,109 @@
/* Setec App Manager — Client JS */
// ── API helper ──
const api = {
async get(url) {
const r = await fetch(url, { credentials: 'same-origin' });
if (r.status === 401) { window.location.href = '/login'; return null; }
if (!r.ok) throw new Error(`GET ${url}: ${r.status}`);
return r.json();
},
async post(url, body) {
const r = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined
});
if (r.status === 401) { window.location.href = '/login'; return null; }
if (!r.ok) throw new Error(`POST ${url}: ${r.status}`);
return r.json();
},
async del(url) {
const r = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
if (r.status === 401) { window.location.href = '/login'; return null; }
if (!r.ok) throw new Error(`DELETE ${url}: ${r.status}`);
return r.json();
}
};
// ── Active nav highlight ──
(function highlightNav() {
const path = window.location.pathname;
document.querySelectorAll('.nav-link').forEach(link => {
const href = link.getAttribute('href');
if (href === '/' && path === '/') {
link.classList.add('active');
} else if (href !== '/' && path.startsWith(href)) {
link.classList.add('active');
}
});
})();
// ── Dashboard auto-refresh ──
(function dashboardRefresh() {
if (window.location.pathname !== '/') return;
const INTERVAL = 10000; // 10 seconds
async function refresh() {
try {
const data = await api.get('/api/stats');
if (!data) return;
// Update stat card values if elements exist
const updates = {
cpuBar: { style: `width: ${data.cpu || 0}%` },
memBar: { style: `width: ${data.mem_percent || 0}%` },
diskBar: { style: `width: ${data.disk_percent || 0}%` },
};
for (const [id, props] of Object.entries(updates)) {
const el = document.getElementById(id);
if (el && props.style) el.setAttribute('style', props.style);
if (el && props.text) el.textContent = props.text;
}
} catch (e) {
console.warn('Stats refresh failed:', e.message);
}
}
setInterval(refresh, INTERVAL);
})();
// ── Monitor page auto-refresh ──
(function monitorRefresh() {
if (window.location.pathname !== '/monitor') return;
const INTERVAL = 5000;
async function refresh() {
try {
const data = await api.get('/api/stats');
if (!data) return;
const bar = (id, pct) => {
const el = document.getElementById(id);
if (el) el.style.width = pct + '%';
};
const txt = (id, val) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
};
bar('cpuBar', data.cpu || 0);
txt('cpuText', (data.cpu || 0).toFixed(1) + '%');
bar('memBar', data.mem_percent || 0);
bar('diskBar', data.disk_percent || 0);
if (data.mem_text) txt('memText', data.mem_text);
if (data.disk_text) txt('diskText', data.disk_text);
if (data.net_in) txt('netIn', data.net_in);
if (data.net_out) txt('netOut', data.net_out);
} catch (e) {
console.warn('Monitor refresh failed:', e.message);
}
}
setInterval(refresh, INTERVAL);
})();

View File

@@ -0,0 +1,50 @@
{{define "content"}}
<h2>AUTARCH</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Status</h3>
{{if .Data.Installed}}
<span class="badge badge-ok">Installed</span>
<p><strong>Version:</strong> {{.Data.Version}}</p>
{{else}}
<span class="badge badge-err">Not Installed</span>
{{end}}
<p><strong>Service:</strong>
{{if .Data.Running}}
<span class="badge badge-ok">Running</span>
{{else}}
<span class="badge badge-err">Stopped</span>
{{end}}
</p>
</div>
<div class="stat-card">
<h3>Info</h3>
<p><strong>Port:</strong> {{.Data.Port}}</p>
<p><strong>Uptime:</strong> {{.Data.Uptime}}</p>
{{if .Data.UpdateAvailable}}
<p><span class="badge badge-ok">Update Available: {{.Data.LatestVersion}}</span></p>
{{end}}
</div>
</div>
<h3>Actions</h3>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
{{if not .Data.Installed}}
<form method="POST" action="/autarch/install"><button class="btn btn-primary">Install</button></form>
{{else}}
{{if .Data.Running}}
<form method="POST" action="/autarch/stop"><button class="btn">Stop</button></form>
<form method="POST" action="/autarch/restart"><button class="btn">Restart</button></form>
{{else}}
<form method="POST" action="/autarch/start"><button class="btn btn-primary">Start</button></form>
{{end}}
{{if .Data.UpdateAvailable}}
<form method="POST" action="/autarch/update"><button class="btn btn-primary">Update</button></form>
{{end}}
<form method="POST" action="/autarch/uninstall" onsubmit="return confirm('Uninstall AUTARCH?')">
<button class="btn">Uninstall</button>
</form>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,31 @@
{{define "content"}}
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>Backups</h2>
<form method="POST" action="/backups/create"><button class="btn btn-primary">Create Backup</button></form>
</div>
<table class="data-table">
<thead>
<tr><th>Name</th><th>Size</th><th>Date</th><th>Type</th><th>Actions</th></tr>
</thead>
<tbody>
{{range .Data.Backups}}
<tr>
<td>{{.Name}}</td>
<td>{{.Size}}</td>
<td>{{.Date}}</td>
<td><span class="badge badge-ok">{{.Type}}</span></td>
<td>
<a href="/backups/download/{{.Name}}" class="btn btn-sm">Download</a>
<form method="POST" action="/backups/restore/{{.Name}}" style="display:inline"
onsubmit="return confirm('Restore from {{.Name}}?')">
<button class="btn btn-sm">Restore</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5">No backups found.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setec App Manager{{if .Title}} — {{.Title}}{{end}}</title>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header">
<img src="/static/img/sidebar-logo.svg" alt="Setec" class="sidebar-logo">
</div>
<ul class="nav-menu">
<li><a href="/" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-dashboard"/></svg> Dashboard</a></li>
<li><a href="/sites" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-sites"/></svg> Sites</a></li>
<li><a href="/autarch" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-autarch"/></svg> AUTARCH</a></li>
<li><a href="/ssl" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-lock"/></svg> SSL/TLS</a></li>
<li><a href="/nginx" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-server"/></svg> Nginx</a></li>
<li><a href="/firewall" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-firewall"/></svg> Firewall</a></li>
<li><a href="/users" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-users"/></svg> Users</a></li>
<li><a href="/backups" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-backup"/></svg> Backups</a></li>
<li><a href="/hosting" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-hosting"/></svg> Hosting</a></li>
<li><a href="/monitor" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-monitor"/></svg> Monitor</a></li>
<li><a href="/logs" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-logs"/></svg> Logs</a></li>
<li><a href="/float/sessions" class="nav-link"><svg class="nav-icon"><use href="/static/img/icons.svg#icon-float"/></svg> Float Mode</a></li>
</ul>
<div class="sidebar-footer">
{{if .Claims}}<span class="user-badge">{{.Claims.Username}}</span>{{end}}
<a href="/logout" class="nav-link logout">Logout</a>
</div>
</nav>
<main class="content">
{{template "content" .}}
</main>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
{{define "content"}}
<h2>Dashboard</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>System</h3>
<p><strong>Hostname:</strong> {{.Data.Hostname}}</p>
<p><strong>OS:</strong> {{.Data.OS}} / {{.Data.Arch}}</p>
<p><strong>CPUs:</strong> {{.Data.CPUs}}</p>
<p><strong>Uptime:</strong> {{.Data.Uptime}}</p>
<p><strong>Load:</strong> {{.Data.LoadAvg}}</p>
</div>
<div class="stat-card">
<h3>Memory</h3>
<div class="progress-bar">
<div class="progress-fill" style="width: {{printf "%.0f" .Data.MemPercent}}%"></div>
</div>
<p>{{.Data.MemUsed}} / {{.Data.MemTotal}} ({{printf "%.1f" .Data.MemPercent}}%)</p>
</div>
<div class="stat-card">
<h3>Disk</h3>
<div class="progress-bar">
<div class="progress-fill" style="width: {{printf "%.0f" .Data.DiskPercent}}%"></div>
</div>
<p>{{.Data.DiskUsed}} / {{.Data.DiskTotal}} ({{printf "%.1f" .Data.DiskPercent}}%)</p>
</div>
<div class="stat-card">
<h3>Sites</h3>
<p class="big-number">{{.Data.SiteCount}}</p>
<a href="/sites" class="btn btn-sm">Manage Sites</a>
</div>
</div>
<h3>Services</h3>
<table class="data-table">
<thead>
<tr><th>Service</th><th>Status</th></tr>
</thead>
<tbody>
{{range .Data.Services}}
<tr>
<td>{{.Name}}</td>
<td>
{{if .Running}}
<span class="badge badge-ok">{{.Status}}</span>
{{else}}
<span class="badge badge-err">{{.Status}}</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,40 @@
{{define "content"}}
<h2>Firewall Rules</h2>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p><strong>Status:</strong>
{{if .Data.Active}}
<span class="badge badge-ok">Active</span>
{{else}}
<span class="badge badge-err">Inactive</span>
{{end}}
</p>
<form method="POST" action="/firewall/reload"><button class="btn btn-sm">Reload</button></form>
</div>
<table class="data-table">
<thead>
<tr><th>#</th><th>Action</th><th>From</th><th>To</th><th>Port</th><th>Proto</th></tr>
</thead>
<tbody>
{{range .Data.Rules}}
<tr>
<td>{{.Num}}</td>
<td>
{{if eq .Action "ALLOW"}}
<span class="badge badge-ok">ALLOW</span>
{{else}}
<span class="badge badge-err">{{.Action}}</span>
{{end}}
</td>
<td>{{.From}}</td>
<td>{{.To}}</td>
<td>{{.Port}}</td>
<td>{{.Proto}}</td>
</tr>
{{else}}
<tr><td colspan="6">No rules configured.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,40 @@
{{define "content"}}
<h2>Float Mode Sessions</h2>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<p>Active Sessions: <strong>{{len .Data.Sessions}}</strong></p>
<form method="POST" action="/float/sessions/new"><button class="btn btn-primary">New Session</button></form>
</div>
<table class="data-table">
<thead>
<tr><th>Session ID</th><th>Target</th><th>Started</th><th>Status</th><th>Actions</th></tr>
</thead>
<tbody>
{{range .Data.Sessions}}
<tr>
<td><code>{{.ID}}</code></td>
<td>{{.Target}}</td>
<td>{{.Started}}</td>
<td>
{{if eq .Status "active"}}
<span class="badge badge-ok">Active</span>
{{else if eq .Status "paused"}}
<span class="badge">Paused</span>
{{else}}
<span class="badge badge-err">{{.Status}}</span>
{{end}}
</td>
<td>
<a href="/float/sessions/{{.ID}}" class="btn btn-sm">View</a>
<form method="POST" action="/float/sessions/{{.ID}}/stop" style="display:inline">
<button class="btn btn-sm">Stop</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5">No active float sessions.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,783 @@
{{define "content"}}
<h2>Hosting Providers</h2>
<!-- Provider Cards -->
<div class="stats-grid" id="provider-cards">
{{if .Data.Providers}}
{{range .Data.Providers}}
<div class="stat-card provider-card" data-provider="{{.Name}}" onclick="selectProvider('{{.Name}}')">
<h3>{{.DisplayName}}</h3>
<p>
{{if .Connected}}
<span class="badge badge-ok">Connected</span>
{{else if .HasConfig}}
<span class="badge badge-err">Disconnected</span>
{{else}}
<span class="badge">Not Configured</span>
{{end}}
</p>
</div>
{{end}}
{{else}}
<div class="stat-card">
<h3>No Providers</h3>
<p style="color: var(--text-muted);">No hosting providers are registered. Providers are loaded at server start.</p>
</div>
{{end}}
</div>
<!-- Tab Navigation -->
<div id="hosting-tabs" style="display:none; margin-top:1.5rem;">
<div style="display:flex; gap:.25rem; border-bottom:1px solid var(--border); margin-bottom:1rem;">
<button class="btn btn-sm tab-btn active" data-tab="config" onclick="switchTab('config')">Configuration</button>
<button class="btn btn-sm tab-btn" data-tab="dns" onclick="switchTab('dns')">DNS</button>
<button class="btn btn-sm tab-btn" data-tab="domains" onclick="switchTab('domains')">Domains</button>
<button class="btn btn-sm tab-btn" data-tab="vps" onclick="switchTab('vps')">VPS</button>
<button class="btn btn-sm tab-btn" data-tab="ssh" onclick="switchTab('ssh')">SSH Keys</button>
<button class="btn btn-sm tab-btn" data-tab="billing" onclick="switchTab('billing')">Billing</button>
</div>
<!-- ═══════ CONFIG TAB ═══════ -->
<div class="tab-panel" id="tab-config">
<h3>API Configuration</h3>
<div style="max-width:500px;">
<div class="form-group">
<label>API Key / Bearer Token</label>
<input type="password" id="cfg-api-key" placeholder="Enter API key">
</div>
<div class="form-group">
<label>API Secret (optional)</label>
<input type="password" id="cfg-api-secret" placeholder="Enter API secret if required">
</div>
<div style="display:flex; gap:.5rem;">
<button class="btn btn-primary" onclick="saveConfig()">Save Credentials</button>
<button class="btn" onclick="testConnection()">Test Connection</button>
</div>
<div id="config-status" style="margin-top:.75rem;"></div>
</div>
</div>
<!-- ═══════ DNS TAB ═══════ -->
<div class="tab-panel" id="tab-dns" style="display:none;">
<div style="display:flex; align-items:center; gap:.75rem; margin-bottom:1rem;">
<h3 style="margin:0;">DNS Records</h3>
<div class="form-group" style="margin:0;">
<select id="dns-domain-select" onchange="loadDNS()">
<option value="">-- select domain --</option>
</select>
</div>
<button class="btn btn-sm" onclick="loadDNS()">Refresh</button>
<button class="btn btn-sm" onclick="showAddDNS()" style="margin-left:auto;">Add Record</button>
</div>
<!-- Add/Edit DNS form (hidden by default) -->
<div id="dns-add-form" style="display:none; margin-bottom:1rem; padding:1rem; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);">
<div style="display:flex; gap:.75rem; flex-wrap:wrap;">
<div class="form-group" style="margin:0; flex:0 0 120px;">
<label>Type</label>
<select id="dns-type">
<option>A</option><option>AAAA</option><option>CNAME</option>
<option>MX</option><option>TXT</option><option>NS</option>
<option>SRV</option><option>CAA</option>
</select>
</div>
<div class="form-group" style="margin:0; flex:1; min-width:140px;">
<label>Name</label>
<input type="text" id="dns-name" placeholder="@ or subdomain">
</div>
<div class="form-group" style="margin:0; flex:2; min-width:200px;">
<label>Content</label>
<input type="text" id="dns-content" placeholder="Value">
</div>
<div class="form-group" style="margin:0; flex:0 0 80px;">
<label>TTL</label>
<input type="text" id="dns-ttl" value="3600">
</div>
<div class="form-group" style="margin:0; flex:0 0 80px;">
<label>Priority</label>
<input type="text" id="dns-priority" value="0">
</div>
</div>
<div style="margin-top:.5rem; display:flex; gap:.5rem;">
<button class="btn btn-primary btn-sm" onclick="saveDNSRecord()">Save Record</button>
<button class="btn btn-sm" onclick="hideAddDNS()">Cancel</button>
</div>
</div>
<table class="data-table" id="dns-table">
<thead>
<tr><th>Type</th><th>Name</th><th>Content</th><th>TTL</th><th>Priority</th><th>Actions</th></tr>
</thead>
<tbody id="dns-body">
<tr><td colspan="6" style="color:var(--text-muted); text-align:center;">Select a domain to load DNS records</td></tr>
</tbody>
</table>
<div style="margin-top:.75rem;">
<button class="btn btn-sm" onclick="resetDNS()" style="color:var(--err);">Reset DNS to Defaults</button>
</div>
</div>
<!-- ═══════ DOMAINS TAB ═══════ -->
<div class="tab-panel" id="tab-domains" style="display:none;">
<h3>Registered Domains</h3>
<table class="data-table" id="domains-table">
<thead>
<tr><th>Domain</th><th>Status</th><th>Expires</th><th>Locked</th><th>Privacy</th><th>Actions</th></tr>
</thead>
<tbody id="domains-body">
<tr><td colspan="6" style="color:var(--text-muted); text-align:center;">Loading...</td></tr>
</tbody>
</table>
<h3>Check Availability</h3>
<div style="display:flex; gap:.5rem; max-width:600px; margin-bottom:.5rem;">
<div class="form-group" style="margin:0; flex:1;">
<input type="text" id="domain-check-input" placeholder="mydomain">
</div>
<div class="form-group" style="margin:0; flex:1;">
<input type="text" id="domain-check-tlds" placeholder="com,net,org,io" value="com,net,org,io,dev">
</div>
<button class="btn btn-primary" onclick="checkDomain()">Check</button>
</div>
<div id="domain-check-result" style="margin-bottom:1rem;"></div>
<h3>Purchase Domain</h3>
<div style="max-width:500px;">
<div class="form-group">
<label>Domain (full, e.g. example.com)</label>
<input type="text" id="purchase-domain" placeholder="example.com">
</div>
<div style="display:flex; gap:.75rem;">
<div class="form-group" style="flex:1;">
<label>Period (years)</label>
<select id="purchase-period">
<option value="1">1 year</option>
<option value="2">2 years</option>
<option value="3">3 years</option>
<option value="5">5 years</option>
</select>
</div>
<div class="form-group" style="flex:1;">
<label>Options</label>
<div style="display:flex; gap:1rem; padding-top:.35rem;">
<label style="font-size:.85rem;"><input type="checkbox" id="purchase-autorenew" checked> Auto-Renew</label>
<label style="font-size:.85rem;"><input type="checkbox" id="purchase-privacy" checked> Privacy</label>
</div>
</div>
</div>
<button class="btn btn-primary" onclick="purchaseDomain()">Purchase</button>
</div>
</div>
<!-- ═══════ VPS TAB ═══════ -->
<div class="tab-panel" id="tab-vps" style="display:none;">
<div style="display:flex; align-items:center; gap:.75rem; margin-bottom:1rem;">
<h3 style="margin:0;">Virtual Machines</h3>
<button class="btn btn-sm" onclick="loadVMs()">Refresh</button>
<button class="btn btn-sm btn-primary" onclick="showCreateVM()" style="margin-left:auto;">Create VM</button>
</div>
<table class="data-table" id="vms-table">
<thead>
<tr><th>Hostname</th><th>Status</th><th>Plan</th><th>Data Center</th><th>IP Address</th><th>CPU</th><th>RAM</th><th>Disk</th></tr>
</thead>
<tbody id="vms-body">
<tr><td colspan="8" style="color:var(--text-muted); text-align:center;">Loading...</td></tr>
</tbody>
</table>
<!-- Create VM form (hidden by default) -->
<div id="vm-create-form" style="display:none; margin-top:1rem; padding:1rem; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);">
<h3>Create New VM</h3>
<div style="display:flex; gap:.75rem; flex-wrap:wrap;">
<div class="form-group" style="flex:1; min-width:200px;">
<label>Hostname</label>
<input type="text" id="vm-hostname" placeholder="my-server">
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label>Data Center</label>
<select id="vm-datacenter"><option value="">Loading...</option></select>
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label>Plan</label>
<input type="text" id="vm-plan" placeholder="Plan ID">
</div>
</div>
<div style="display:flex; gap:.75rem; flex-wrap:wrap;">
<div class="form-group" style="flex:1; min-width:200px;">
<label>OS Template</label>
<input type="text" id="vm-template" placeholder="ubuntu-22.04">
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label>Root Password</label>
<input type="password" id="vm-password" placeholder="Secure password">
</div>
<div class="form-group" style="flex:1; min-width:200px;">
<label>SSH Key (optional)</label>
<select id="vm-sshkey"><option value="">None</option></select>
</div>
</div>
<div style="display:flex; gap:.5rem;">
<button class="btn btn-primary" onclick="createVM()">Create VM</button>
<button class="btn" onclick="document.getElementById('vm-create-form').style.display='none'">Cancel</button>
</div>
</div>
</div>
<!-- ═══════ SSH KEYS TAB ═══════ -->
<div class="tab-panel" id="tab-ssh" style="display:none;">
<div style="display:flex; align-items:center; gap:.75rem; margin-bottom:1rem;">
<h3 style="margin:0;">SSH Keys</h3>
<button class="btn btn-sm" onclick="loadSSHKeys()">Refresh</button>
</div>
<table class="data-table" id="ssh-table">
<thead>
<tr><th>Name</th><th>Fingerprint</th><th>Actions</th></tr>
</thead>
<tbody id="ssh-body">
<tr><td colspan="3" style="color:var(--text-muted); text-align:center;">Loading...</td></tr>
</tbody>
</table>
<h3>Add SSH Key</h3>
<div style="max-width:600px;">
<div class="form-group">
<label>Key Name</label>
<input type="text" id="ssh-name" placeholder="my-laptop">
</div>
<div class="form-group">
<label>Public Key</label>
<textarea id="ssh-pubkey" rows="4" placeholder="ssh-rsa AAAA... user@host" style="width:100%;padding:.5rem .75rem;font-size:.9rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-family:monospace;resize:vertical;"></textarea>
</div>
<button class="btn btn-primary" onclick="addSSHKey()">Add Key</button>
</div>
</div>
<!-- ═══════ BILLING TAB ═══════ -->
<div class="tab-panel" id="tab-billing" style="display:none;">
<h3>Subscriptions</h3>
<table class="data-table" id="subs-table">
<thead>
<tr><th>Name</th><th>Plan</th><th>Status</th><th>Price</th><th>Renews</th></tr>
</thead>
<tbody id="subs-body">
<tr><td colspan="5" style="color:var(--text-muted); text-align:center;">Loading...</td></tr>
</tbody>
</table>
<h3>Product Catalog</h3>
<table class="data-table" id="catalog-table">
<thead>
<tr><th>Name</th><th>Category</th><th>Price</th><th>Period</th><th>Description</th></tr>
</thead>
<tbody id="catalog-body">
<tr><td colspan="5" style="color:var(--text-muted); text-align:center;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Toast notification -->
<div id="toast" style="display:none; position:fixed; bottom:1.5rem; right:1.5rem; padding:.65rem 1.25rem; border-radius:var(--radius); font-size:.875rem; z-index:100; max-width:400px;"></div>
<script>
(function() {
let _provider = '';
// ── Toast ────────────────────────────────────────────────────────────────
function toast(msg, ok) {
const el = document.getElementById('toast');
el.textContent = msg;
el.style.display = 'block';
el.style.background = ok ? 'rgba(34,197,94,.15)' : 'rgba(239,68,68,.15)';
el.style.color = ok ? 'var(--ok)' : 'var(--err)';
el.style.border = '1px solid ' + (ok ? 'rgba(34,197,94,.3)' : 'rgba(239,68,68,.3)');
clearTimeout(el._t);
el._t = setTimeout(function() { el.style.display = 'none'; }, 4000);
}
window._toast = toast;
// ── API helpers ──────────────────────────────────────────────────────────
function api(method, path, body) {
const opts = {
method: method,
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }
};
if (body !== undefined) opts.body = JSON.stringify(body);
return fetch(path, opts).then(function(r) {
return r.json().then(function(d) {
if (!r.ok) throw new Error(d.error || 'Request failed');
return d;
});
});
}
// ── Provider selection ───────────────────────────────────────────────────
window.selectProvider = function(name) {
_provider = name;
// Highlight card
document.querySelectorAll('.provider-card').forEach(function(c) {
c.style.borderColor = c.dataset.provider === name ? 'var(--primary)' : 'var(--border)';
});
document.getElementById('hosting-tabs').style.display = 'block';
switchTab('config');
// Pre-load domain list for DNS tab
loadDomainOptions();
};
// ── Tab switching ────────────────────────────────────────────────────────
window.switchTab = function(tab) {
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
var panel = document.getElementById('tab-' + tab);
if (panel) panel.style.display = 'block';
document.querySelectorAll('.tab-btn[data-tab="'+tab+'"]').forEach(function(b) { b.classList.add('active'); });
// Auto-load data when switching tabs
if (tab === 'dns') loadDomainOptions();
if (tab === 'domains') loadDomains();
if (tab === 'vps') { loadVMs(); loadDataCenters(); }
if (tab === 'ssh') loadSSHKeys();
if (tab === 'billing') { loadSubscriptions(); loadCatalog(); }
};
// ── Config tab ───────────────────────────────────────────────────────────
window.saveConfig = function() {
var key = document.getElementById('cfg-api-key').value.trim();
var secret = document.getElementById('cfg-api-secret').value.trim();
if (!key) { toast('API key is required', false); return; }
api('POST', '/hosting/' + _provider + '/config', { api_key: key, api_secret: secret })
.then(function(d) {
toast('Credentials saved' + (d.connected ? ' — connection OK' : ' — connection failed'), d.connected);
updateConfigStatus(d.connected);
})
.catch(function(e) { toast('Save failed: ' + e.message, false); });
};
window.testConnection = function() {
api('POST', '/hosting/' + _provider + '/test', {})
.then(function(d) {
if (d.connected) {
toast('Connection successful', true);
updateConfigStatus(true);
} else {
toast('Connection failed: ' + (d.error || 'unknown error'), false);
updateConfigStatus(false);
}
})
.catch(function(e) { toast('Test failed: ' + e.message, false); });
};
function updateConfigStatus(ok) {
var el = document.getElementById('config-status');
if (ok) {
el.innerHTML = '<span class="badge badge-ok">Connected</span>';
} else {
el.innerHTML = '<span class="badge badge-err">Disconnected</span>';
}
// Update provider card badge
var card = document.querySelector('.provider-card[data-provider="'+_provider+'"]');
if (card) {
var p = card.querySelector('p');
if (p) p.innerHTML = ok
? '<span class="badge badge-ok">Connected</span>'
: '<span class="badge badge-err">Disconnected</span>';
}
}
// ── DNS tab ──────────────────────────────────────────────────────────────
function loadDomainOptions() {
api('GET', '/hosting/' + _provider + '/domains')
.then(function(domains) {
var sel = document.getElementById('dns-domain-select');
var prev = sel.value;
sel.innerHTML = '<option value="">-- select domain --</option>';
if (domains && domains.length) {
domains.forEach(function(d) {
var opt = document.createElement('option');
opt.value = d.name;
opt.textContent = d.name;
sel.appendChild(opt);
});
}
if (prev) { sel.value = prev; }
})
.catch(function() {});
}
window.loadDNS = function() {
var domain = document.getElementById('dns-domain-select').value;
if (!domain) return;
var body = document.getElementById('dns-body');
body.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted)">Loading...</td></tr>';
api('GET', '/hosting/' + _provider + '/dns/' + encodeURIComponent(domain))
.then(function(records) {
if (!records || !records.length) {
body.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted)">No records found</td></tr>';
return;
}
body.innerHTML = '';
records.forEach(function(rec) {
var tr = document.createElement('tr');
tr.innerHTML =
'<td><span class="badge">' + esc(rec.type) + '</span></td>' +
'<td>' + esc(rec.name) + '</td>' +
'<td><code>' + esc(rec.content) + '</code></td>' +
'<td>' + rec.ttl + '</td>' +
'<td>' + (rec.priority || '') + '</td>' +
'<td><button class="btn btn-sm" onclick="deleteDNSRecord(\'' + esc(rec.name) + '\',\'' + esc(rec.type) + '\')" style="color:var(--err);">Delete</button></td>';
body.appendChild(tr);
});
})
.catch(function(e) {
body.innerHTML = '<tr><td colspan="6" style="color:var(--err);text-align:center">' + esc(e.message) + '</td></tr>';
});
};
window.showAddDNS = function() { document.getElementById('dns-add-form').style.display = 'block'; };
window.hideAddDNS = function() { document.getElementById('dns-add-form').style.display = 'none'; };
window.saveDNSRecord = function() {
var domain = document.getElementById('dns-domain-select').value;
if (!domain) { toast('Select a domain first', false); return; }
var record = {
type: document.getElementById('dns-type').value,
name: document.getElementById('dns-name').value.trim(),
content: document.getElementById('dns-content').value.trim(),
ttl: parseInt(document.getElementById('dns-ttl').value) || 3600,
priority: parseInt(document.getElementById('dns-priority').value) || 0
};
api('PUT', '/hosting/' + _provider + '/dns/' + encodeURIComponent(domain), { records: [record], overwrite: false })
.then(function() { toast('DNS record saved', true); hideAddDNS(); loadDNS(); })
.catch(function(e) { toast('Save failed: ' + e.message, false); });
};
window.deleteDNSRecord = function(name, type) {
var domain = document.getElementById('dns-domain-select').value;
if (!confirm('Delete ' + type + ' record for ' + name + '?')) return;
api('DELETE', '/hosting/' + _provider + '/dns/' + encodeURIComponent(domain), { name: name, type: type })
.then(function() { toast('Record deleted', true); loadDNS(); })
.catch(function(e) { toast('Delete failed: ' + e.message, false); });
};
window.resetDNS = function() {
var domain = document.getElementById('dns-domain-select').value;
if (!domain) { toast('Select a domain first', false); return; }
if (!confirm('Reset ALL DNS records for ' + domain + ' to defaults? This cannot be undone.')) return;
api('POST', '/hosting/' + _provider + '/dns/' + encodeURIComponent(domain) + '/reset', {})
.then(function() { toast('DNS reset to defaults', true); loadDNS(); })
.catch(function(e) { toast('Reset failed: ' + e.message, false); });
};
// ── Domains tab ──────────────────────────────────────────────────────────
window.loadDomains = function() {
var body = document.getElementById('domains-body');
body.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted)">Loading...</td></tr>';
api('GET', '/hosting/' + _provider + '/domains')
.then(function(domains) {
if (!domains || !domains.length) {
body.innerHTML = '<tr><td colspan="6" style="text-align:center; color:var(--text-muted)">No domains found</td></tr>';
return;
}
body.innerHTML = '';
domains.forEach(function(d) {
var tr = document.createElement('tr');
var exp = d.expires_at ? new Date(d.expires_at).toLocaleDateString() : '-';
tr.innerHTML =
'<td><strong>' + esc(d.name) + '</strong></td>' +
'<td><span class="badge ' + (d.status === 'active' ? 'badge-ok' : 'badge-err') + '">' + esc(d.status) + '</span></td>' +
'<td>' + exp + '</td>' +
'<td>' +
'<button class="btn btn-sm" onclick="toggleLock(\'' + esc(d.name) + '\',true)">Lock</button> ' +
'<button class="btn btn-sm" onclick="toggleLock(\'' + esc(d.name) + '\',false)">Unlock</button>' +
'</td>' +
'<td>' +
'<button class="btn btn-sm" onclick="togglePrivacy(\'' + esc(d.name) + '\',true)">On</button> ' +
'<button class="btn btn-sm" onclick="togglePrivacy(\'' + esc(d.name) + '\',false)">Off</button>' +
'</td>' +
'<td><button class="btn btn-sm" onclick="editNameservers(\'' + esc(d.name) + '\')">NS</button></td>';
body.appendChild(tr);
});
})
.catch(function(e) {
body.innerHTML = '<tr><td colspan="6" style="color:var(--err);text-align:center">' + esc(e.message) + '</td></tr>';
});
};
window.toggleLock = function(domain, lock) {
api('PUT', '/hosting/' + _provider + '/domains/' + encodeURIComponent(domain) + '/lock', { locked: lock })
.then(function() { toast('Lock ' + (lock ? 'enabled' : 'disabled'), true); loadDomains(); })
.catch(function(e) { toast(e.message, false); });
};
window.togglePrivacy = function(domain, privacy) {
api('PUT', '/hosting/' + _provider + '/domains/' + encodeURIComponent(domain) + '/privacy', { privacy: privacy })
.then(function() { toast('Privacy ' + (privacy ? 'enabled' : 'disabled'), true); loadDomains(); })
.catch(function(e) { toast(e.message, false); });
};
window.editNameservers = function(domain) {
var ns = prompt('Enter nameservers (comma-separated):', '');
if (ns === null) return;
var list = ns.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
if (!list.length) { toast('Provide at least one nameserver', false); return; }
api('PUT', '/hosting/' + _provider + '/domains/' + encodeURIComponent(domain) + '/nameservers', { nameservers: list })
.then(function() { toast('Nameservers updated', true); loadDomains(); })
.catch(function(e) { toast(e.message, false); });
};
window.checkDomain = function() {
var domain = document.getElementById('domain-check-input').value.trim();
if (!domain) return;
var tldsRaw = document.getElementById('domain-check-tlds').value.trim();
var tlds = tldsRaw.split(',').map(function(s) { return s.trim().replace(/^\./, ''); }).filter(Boolean);
if (!tlds.length) tlds = ['com','net','org','io','dev'];
var el = document.getElementById('domain-check-result');
el.innerHTML = '<span style="color:var(--text-muted)">Checking...</span>';
api('POST', '/hosting/' + _provider + '/domains/check', { domain: domain, tlds: tlds })
.then(function(results) {
el.innerHTML = '';
if (!results || !results.length) { el.innerHTML = '<span style="color:var(--text-muted)">No results</span>'; return; }
results.forEach(function(r) {
var badge = r.available
? '<span class="badge badge-ok">Available</span>'
: '<span class="badge badge-err">Taken</span>';
var price = r.available && r.price ? ' &mdash; ' + (r.currency||'USD') + ' ' + r.price.toFixed(2) : '';
el.innerHTML += '<p>' + esc(r.domain) + '.' + esc(r.tld) + ' ' + badge + price + '</p>';
});
})
.catch(function(e) { el.innerHTML = '<span style="color:var(--err)">' + esc(e.message) + '</span>'; });
};
window.purchaseDomain = function() {
var domain = document.getElementById('purchase-domain').value.trim();
if (!domain) { toast('Enter a domain', false); return; }
if (!confirm('Purchase ' + domain + '? This may charge your account.')) return;
api('POST', '/hosting/' + _provider + '/domains/purchase', {
domain: domain,
years: parseInt(document.getElementById('purchase-period').value),
auto_renew: document.getElementById('purchase-autorenew').checked,
privacy: document.getElementById('purchase-privacy').checked
})
.then(function() { toast('Domain purchased successfully', true); loadDomains(); })
.catch(function(e) { toast('Purchase failed: ' + e.message, false); });
};
// ── VPS tab ──────────────────────────────────────────────────────────────
window.loadVMs = function() {
var body = document.getElementById('vms-body');
body.innerHTML = '<tr><td colspan="8" style="text-align:center; color:var(--text-muted)">Loading...</td></tr>';
api('GET', '/hosting/' + _provider + '/vms')
.then(function(vms) {
if (!vms || !vms.length) {
body.innerHTML = '<tr><td colspan="8" style="text-align:center; color:var(--text-muted)">No VMs found</td></tr>';
return;
}
body.innerHTML = '';
vms.forEach(function(vm) {
var statusClass = vm.status === 'running' ? 'badge-ok' : (vm.status === 'stopped' ? 'badge-err' : '');
var ramGB = vm.ram_bytes ? (vm.ram_bytes / (1024*1024*1024)).toFixed(1) : '0';
var diskGB = vm.disk_bytes ? (vm.disk_bytes / (1024*1024*1024)).toFixed(0) : '0';
var tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.onclick = function() { viewVM(vm.id); };
tr.innerHTML =
'<td><strong>' + esc(vm.hostname || vm.id) + '</strong></td>' +
'<td><span class="badge ' + statusClass + '">' + esc(vm.status) + '</span></td>' +
'<td>' + esc(vm.plan) + '</td>' +
'<td>' + esc(vm.data_center) + '</td>' +
'<td><code>' + esc(vm.ip_address || '-') + '</code></td>' +
'<td>' + vm.cpus + '</td>' +
'<td>' + ramGB + ' GB</td>' +
'<td>' + diskGB + ' GB</td>';
body.appendChild(tr);
});
})
.catch(function(e) {
body.innerHTML = '<tr><td colspan="8" style="color:var(--err);text-align:center">' + esc(e.message) + '</td></tr>';
});
};
function viewVM(id) {
api('GET', '/hosting/' + _provider + '/vms/' + encodeURIComponent(id))
.then(function(vm) {
alert('VM: ' + vm.hostname + '\nIP: ' + (vm.ip_address || 'N/A') + '\nStatus: ' + vm.status + '\nOS: ' + (vm.os || 'N/A'));
})
.catch(function(e) { toast(e.message, false); });
}
function loadDataCenters() {
api('GET', '/hosting/' + _provider + '/datacenters')
.then(function(dcs) {
var sel = document.getElementById('vm-datacenter');
sel.innerHTML = '<option value="">-- select --</option>';
if (dcs && dcs.length) {
dcs.forEach(function(dc) {
var opt = document.createElement('option');
opt.value = dc.id;
opt.textContent = dc.name + ' (' + dc.location + ')';
sel.appendChild(opt);
});
}
})
.catch(function() {});
// Also load SSH keys for the VM form
api('GET', '/hosting/' + _provider + '/ssh-keys')
.then(function(keys) {
var sel = document.getElementById('vm-sshkey');
sel.innerHTML = '<option value="">None</option>';
if (keys && keys.length) {
keys.forEach(function(k) {
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = k.name;
sel.appendChild(opt);
});
}
})
.catch(function() {});
}
window.showCreateVM = function() {
document.getElementById('vm-create-form').style.display = 'block';
loadDataCenters();
};
window.createVM = function() {
var req = {
hostname: document.getElementById('vm-hostname').value.trim(),
plan: document.getElementById('vm-plan').value.trim(),
data_center_id: document.getElementById('vm-datacenter').value,
template: document.getElementById('vm-template').value.trim(),
password: document.getElementById('vm-password').value
};
var sshKey = document.getElementById('vm-sshkey').value;
if (sshKey) req.ssh_key_id = sshKey;
if (!req.plan || !req.data_center_id) {
toast('Plan and data center are required', false);
return;
}
api('POST', '/hosting/' + _provider + '/vms', req)
.then(function(vm) {
toast('VM created: ' + (vm.name || vm.id), true);
document.getElementById('vm-create-form').style.display = 'none';
loadVMs();
})
.catch(function(e) { toast('Create failed: ' + e.message, false); });
};
// ── SSH Keys tab ─────────────────────────────────────────────────────────
window.loadSSHKeys = function() {
var body = document.getElementById('ssh-body');
body.innerHTML = '<tr><td colspan="3" style="text-align:center; color:var(--text-muted)">Loading...</td></tr>';
api('GET', '/hosting/' + _provider + '/ssh-keys')
.then(function(keys) {
if (!keys || !keys.length) {
body.innerHTML = '<tr><td colspan="3" style="text-align:center; color:var(--text-muted)">No SSH keys found</td></tr>';
return;
}
body.innerHTML = '';
keys.forEach(function(k) {
var tr = document.createElement('tr');
tr.innerHTML =
'<td><strong>' + esc(k.name) + '</strong></td>' +
'<td><code>' + esc(k.fingerprint || '-') + '</code></td>' +
'<td><button class="btn btn-sm" onclick="deleteSSHKey(\'' + esc(k.id) + '\',\'' + esc(k.name) + '\')" style="color:var(--err);">Delete</button></td>';
body.appendChild(tr);
});
})
.catch(function(e) {
body.innerHTML = '<tr><td colspan="3" style="color:var(--err);text-align:center">' + esc(e.message) + '</td></tr>';
});
};
window.addSSHKey = function() {
var name = document.getElementById('ssh-name').value.trim();
var pubkey = document.getElementById('ssh-pubkey').value.trim();
if (!name || !pubkey) { toast('Name and public key are required', false); return; }
api('POST', '/hosting/' + _provider + '/ssh-keys', { name: name, public_key: pubkey })
.then(function() {
toast('SSH key added', true);
document.getElementById('ssh-name').value = '';
document.getElementById('ssh-pubkey').value = '';
loadSSHKeys();
})
.catch(function(e) { toast('Add failed: ' + e.message, false); });
};
window.deleteSSHKey = function(id, name) {
if (!confirm('Delete SSH key "' + name + '"?')) return;
api('DELETE', '/hosting/' + _provider + '/ssh-keys/' + encodeURIComponent(id))
.then(function() { toast('Key deleted', true); loadSSHKeys(); })
.catch(function(e) { toast('Delete failed: ' + e.message, false); });
};
// ── Billing tab ──────────────────────────────────────────────────────────
function loadSubscriptions() {
var body = document.getElementById('subs-body');
body.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--text-muted)">Loading...</td></tr>';
api('GET', '/hosting/' + _provider + '/subscriptions')
.then(function(subs) {
if (!subs || !subs.length) {
body.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--text-muted)">No subscriptions found</td></tr>';
return;
}
body.innerHTML = '';
subs.forEach(function(s) {
var statusClass = s.status === 'active' ? 'badge-ok' : (s.status === 'cancelled' ? 'badge-err' : '');
var renews = s.renews_at ? new Date(s.renews_at).toLocaleDateString() : '-';
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + esc(s.name) + '</td>' +
'<td>' + esc(s.plan) + '</td>' +
'<td><span class="badge ' + statusClass + '">' + esc(s.status) + '</span></td>' +
'<td>' + (s.currency || 'USD') + ' ' + (s.price || 0).toFixed(2) + '</td>' +
'<td>' + renews + '</td>';
body.appendChild(tr);
});
})
.catch(function(e) {
body.innerHTML = '<tr><td colspan="5" style="color:var(--err);text-align:center">' + esc(e.message) + '</td></tr>';
});
}
function loadCatalog() {
var body = document.getElementById('catalog-body');
body.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--text-muted)">Loading...</td></tr>';
api('GET', '/hosting/' + _provider + '/catalog')
.then(function(items) {
if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="5" style="text-align:center; color:var(--text-muted)">No catalog items</td></tr>';
return;
}
body.innerHTML = '';
items.forEach(function(item) {
var price = (item.price_cents / 100).toFixed(2);
var tr = document.createElement('tr');
tr.innerHTML =
'<td><strong>' + esc(item.name) + '</strong></td>' +
'<td><span class="badge">' + esc(item.category) + '</span></td>' +
'<td>' + (item.currency || 'USD') + ' ' + price + '</td>' +
'<td>' + esc(item.period || '-') + '</td>' +
'<td style="color:var(--text-muted); font-size:.85rem;">' + esc(item.description || '') + '</td>';
body.appendChild(tr);
});
})
.catch(function(e) {
body.innerHTML = '<tr><td colspan="5" style="color:var(--err);text-align:center">' + esc(e.message) + '</td></tr>';
});
}
// ── Utility ──────────────────────────────────────────────────────────────
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
})();
</script>
{{end}}

View File

@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setec App Manager — Login</title>
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="login-page">
<div class="login-card">
<img src="/static/img/logo.svg" alt="Setec" style="width:80px;margin:0 auto 1rem;display:block">
<h1>SETEC</h1>
<p class="subtitle">App Manager</p>
<form id="loginForm" method="POST" action="/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary">Login</button>
<div id="error" class="error-msg" style="display:none"></div>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const res = await fetch('/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
});
if (res.ok) {
window.location.href = '/';
} else {
const err = document.getElementById('error');
err.textContent = 'Invalid credentials';
err.style.display = 'block';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
{{define "content"}}
<h2>Logs</h2>
<div style="display:flex;gap:.5rem;margin-bottom:1rem;flex-wrap:wrap">
<select id="logSource" class="form-group" style="margin:0" onchange="loadLogs()">
{{range .Data.Sources}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<select id="logLines" class="form-group" style="margin:0" onchange="loadLogs()">
<option value="50">50 lines</option>
<option value="100" selected>100 lines</option>
<option value="500">500 lines</option>
</select>
<button class="btn btn-sm" onclick="loadLogs()">Refresh</button>
<label style="display:flex;align-items:center;gap:.3rem">
<input type="checkbox" id="autoScroll" checked> Auto-scroll
</label>
</div>
<pre id="logOutput" style="background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:1rem;max-height:60vh;overflow:auto;font-size:.85rem;white-space:pre-wrap">{{.Data.Lines}}</pre>
<script>
async function loadLogs() {
const src = document.getElementById('logSource').value;
const n = document.getElementById('logLines').value;
try {
const r = await fetch('/api/logs?source=' + encodeURIComponent(src) + '&lines=' + n);
const d = await r.json();
const el = document.getElementById('logOutput');
el.textContent = d.lines || '';
if (document.getElementById('autoScroll').checked) {
el.scrollTop = el.scrollHeight;
}
} catch(e) { console.error('log fetch failed', e); }
}
</script>
{{end}}

View File

@@ -0,0 +1,50 @@
{{define "content"}}
<h2>System Monitor</h2>
<div class="stats-grid" id="monitorStats">
<div class="stat-card">
<h3>CPU</h3>
<div class="progress-bar">
<div class="progress-fill" id="cpuBar" style="width: {{printf "%.0f" .Data.CPU}}%"></div>
</div>
<p id="cpuText">{{printf "%.1f" .Data.CPU}}%</p>
</div>
<div class="stat-card">
<h3>Memory</h3>
<div class="progress-bar">
<div class="progress-fill" id="memBar" style="width: {{printf "%.0f" .Data.MemPercent}}%"></div>
</div>
<p id="memText">{{.Data.MemUsed}} / {{.Data.MemTotal}} ({{printf "%.1f" .Data.MemPercent}}%)</p>
</div>
<div class="stat-card">
<h3>Disk</h3>
<div class="progress-bar">
<div class="progress-fill" id="diskBar" style="width: {{printf "%.0f" .Data.DiskPercent}}%"></div>
</div>
<p id="diskText">{{.Data.DiskUsed}} / {{.Data.DiskTotal}} ({{printf "%.1f" .Data.DiskPercent}}%)</p>
</div>
<div class="stat-card">
<h3>Network</h3>
<p><strong>In:</strong> <span id="netIn">{{.Data.NetIn}}</span></p>
<p><strong>Out:</strong> <span id="netOut">{{.Data.NetOut}}</span></p>
</div>
</div>
<h3>Processes (Top 10)</h3>
<table class="data-table">
<thead>
<tr><th>PID</th><th>Name</th><th>CPU %</th><th>Mem %</th><th>User</th></tr>
</thead>
<tbody>
{{range .Data.Processes}}
<tr>
<td>{{.PID}}</td>
<td>{{.Name}}</td>
<td>{{printf "%.1f" .CPU}}</td>
<td>{{printf "%.1f" .Mem}}</td>
<td>{{.User}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,58 @@
{{define "content"}}
<h2>Nginx</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Service Status</h3>
{{if .Data.Running}}
<span class="badge badge-ok">Running</span>
{{else}}
<span class="badge badge-err">Stopped</span>
{{end}}
<p><strong>Version:</strong> {{.Data.Version}}</p>
<p><strong>Config Test:</strong>
{{if .Data.ConfigOK}}
<span class="badge badge-ok">OK</span>
{{else}}
<span class="badge badge-err">Error</span>
{{end}}
</p>
</div>
<div class="stat-card">
<h3>Connections</h3>
<p><strong>Active:</strong> {{.Data.ActiveConns}}</p>
<p><strong>Requests:</strong> {{.Data.TotalRequests}}</p>
</div>
</div>
<h3>Actions</h3>
<div style="display:flex;gap:.5rem">
<form method="POST" action="/nginx/reload"><button class="btn btn-primary">Reload</button></form>
<form method="POST" action="/nginx/restart"><button class="btn">Restart</button></form>
<form method="POST" action="/nginx/test"><button class="btn">Test Config</button></form>
</div>
<h3>Virtual Hosts</h3>
<table class="data-table">
<thead>
<tr><th>Server Name</th><th>Listen</th><th>Enabled</th></tr>
</thead>
<tbody>
{{range .Data.VHosts}}
<tr>
<td>{{.ServerName}}</td>
<td>{{.Listen}}</td>
<td>
{{if .Enabled}}
<span class="badge badge-ok">Yes</span>
{{else}}
<span class="badge badge-err">No</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="3">No virtual hosts configured.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,63 @@
{{define "content"}}
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>{{.Data.Site.Domain}}</h2>
<div style="display:flex;gap:.5rem">
{{if .Data.Site.Running}}
<form method="POST" action="/sites/{{.Data.Site.ID}}/stop"><button class="btn btn-sm">Stop</button></form>
{{else}}
<form method="POST" action="/sites/{{.Data.Site.ID}}/start"><button class="btn btn-sm btn-primary">Start</button></form>
{{end}}
<form method="POST" action="/sites/{{.Data.Site.ID}}/deploy"><button class="btn btn-sm btn-primary">Deploy</button></form>
<a href="/sites" class="btn btn-sm">Back</a>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Info</h3>
<p><strong>Type:</strong> {{.Data.Site.Type}}</p>
<p><strong>Root:</strong> {{.Data.Site.Root}}</p>
<p><strong>Status:</strong>
{{if .Data.Site.Running}}
<span class="badge badge-ok">Running</span>
{{else}}
<span class="badge badge-err">Stopped</span>
{{end}}
</p>
</div>
<div class="stat-card">
<h3>SSL</h3>
{{if .Data.Site.SSL}}
<span class="badge badge-ok">Active</span>
<p><strong>Expires:</strong> {{.Data.Site.SSLExpiry}}</p>
{{else}}
<span class="badge badge-err">Not Configured</span>
{{end}}
</div>
</div>
<h3>Deployment History</h3>
<table class="data-table">
<thead>
<tr><th>Date</th><th>Commit</th><th>Status</th><th>Duration</th></tr>
</thead>
<tbody>
{{range .Data.Deployments}}
<tr>
<td>{{.Date}}</td>
<td><code>{{.Commit}}</code></td>
<td>
{{if eq .Status "ok"}}
<span class="badge badge-ok">OK</span>
{{else}}
<span class="badge badge-err">{{.Status}}</span>
{{end}}
</td>
<td>{{.Duration}}</td>
</tr>
{{else}}
<tr><td colspan="4">No deployments yet.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,31 @@
{{define "content"}}
<h2>New Site</h2>
<form id="siteForm" method="POST" action="/sites">
<div class="form-group">
<label for="domain">Domain</label>
<input type="text" id="domain" name="domain" placeholder="example.com" required>
</div>
<div class="form-group">
<label for="type">Site Type</label>
<select id="type" name="type">
<option value="static">Static</option>
<option value="proxy">Reverse Proxy</option>
<option value="php">PHP</option>
<option value="node">Node.js</option>
</select>
</div>
<div class="form-group">
<label for="root">Document Root / Upstream</label>
<input type="text" id="root" name="root" placeholder="/var/www/example.com">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="ssl" value="1"> Enable SSL (Let's Encrypt)
</label>
</div>
<div style="display:flex;gap:.5rem">
<button type="submit" class="btn btn-primary">Create Site</button>
<a href="/sites" class="btn">Cancel</a>
</div>
</form>
{{end}}

View File

@@ -0,0 +1,44 @@
{{define "content"}}
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>Sites</h2>
<a href="/sites/new" class="btn btn-primary">+ New Site</a>
</div>
<table class="data-table">
<thead>
<tr>
<th>Domain</th>
<th>Type</th>
<th>SSL</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Sites}}
<tr>
<td><a href="/sites/{{.ID}}">{{.Domain}}</a></td>
<td>{{.Type}}</td>
<td>
{{if .SSL}}
<span class="badge badge-ok">Active</span>
{{else}}
<span class="badge badge-err">None</span>
{{end}}
</td>
<td>
{{if .Running}}
<span class="badge badge-ok">Running</span>
{{else}}
<span class="badge badge-err">Stopped</span>
{{end}}
</td>
<td>
<a href="/sites/{{.ID}}" class="btn btn-sm">View</a>
</td>
</tr>
{{else}}
<tr><td colspan="5">No sites configured.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,31 @@
{{define "content"}}
<h2>SSL / TLS Certificates</h2>
<table class="data-table">
<thead>
<tr><th>Domain</th><th>Issuer</th><th>Expires</th><th>Status</th><th>Actions</th></tr>
</thead>
<tbody>
{{range .Data.Certs}}
<tr>
<td>{{.Domain}}</td>
<td>{{.Issuer}}</td>
<td>{{.Expiry}}</td>
<td>
{{if .Valid}}
<span class="badge badge-ok">Valid</span>
{{else}}
<span class="badge badge-err">Expired</span>
{{end}}
</td>
<td>
<form method="POST" action="/ssl/renew/{{.Domain}}" style="display:inline">
<button class="btn btn-sm">Renew</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="5">No certificates found.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,56 @@
{{define "content"}}
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>Users</h2>
<button class="btn btn-primary" onclick="document.getElementById('newUserForm').style.display='block'">+ Add User</button>
</div>
<div id="newUserForm" style="display:none;margin-bottom:1.5rem">
<div class="stat-card">
<h3>New User</h3>
<form method="POST" action="/users">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role">
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</select>
</div>
<div style="display:flex;gap:.5rem">
<button type="submit" class="btn btn-primary btn-sm">Create</button>
<button type="button" class="btn btn-sm" onclick="this.closest('#newUserForm').style.display='none'">Cancel</button>
</div>
</form>
</div>
</div>
<table class="data-table">
<thead>
<tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr>
</thead>
<tbody>
{{range .Data.Users}}
<tr>
<td>{{.Username}}</td>
<td><span class="badge badge-ok">{{.Role}}</span></td>
<td>{{.Created}}</td>
<td>
<form method="POST" action="/users/{{.Username}}/delete" style="display:inline"
onsubmit="return confirm('Delete user {{.Username}}?')">
<button class="btn btn-sm">Delete</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4">No users.</td></tr>
{{end}}
</tbody>
</table>
{{end}}