No One Can Stop Me Now
This commit is contained in:
12
services/setec-manager/build.sh
Normal file
12
services/setec-manager/build.sh
Normal 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'"
|
||||
BIN
services/setec-manager/cmd.exe
Normal file
BIN
services/setec-manager/cmd.exe
Normal file
Binary file not shown.
258
services/setec-manager/cmd/main.go
Normal file
258
services/setec-manager/cmd/main.go
Normal 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))
|
||||
}
|
||||
}
|
||||
44
services/setec-manager/config.yaml
Normal file
44
services/setec-manager/config.yaml
Normal 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
|
||||
1660
services/setec-manager/docs/api-reference.md
Normal file
1660
services/setec-manager/docs/api-reference.md
Normal file
File diff suppressed because it is too large
Load Diff
790
services/setec-manager/docs/custom-provider-guide.md
Normal file
790
services/setec-manager/docs/custom-provider-guide.md
Normal 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.
|
||||
859
services/setec-manager/docs/hosting-providers.md
Normal file
859
services/setec-manager/docs/hosting-providers.md
Normal 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.
|
||||
365
services/setec-manager/docs/hostinger-setup.md
Normal file
365
services/setec-manager/docs/hostinger-setup.md
Normal 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.
|
||||
25
services/setec-manager/go.mod
Normal file
25
services/setec-manager/go.mod
Normal 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
|
||||
)
|
||||
65
services/setec-manager/go.sum
Normal file
65
services/setec-manager/go.sum
Normal 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=
|
||||
361
services/setec-manager/internal/acme/acme.go
Normal file
361
services/setec-manager/internal/acme/acme.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client wraps the certbot CLI for Let's Encrypt ACME certificate management.
|
||||
type Client struct {
|
||||
Email string
|
||||
Staging bool
|
||||
Webroot string
|
||||
AccountDir string
|
||||
}
|
||||
|
||||
// CertInfo holds parsed certificate metadata.
|
||||
type CertInfo struct {
|
||||
Domain string `json:"domain"`
|
||||
CertPath string `json:"cert_path"`
|
||||
KeyPath string `json:"key_path"`
|
||||
ChainPath string `json:"chain_path"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Issuer string `json:"issuer"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
}
|
||||
|
||||
// domainRegex validates domain names (basic RFC 1123 hostname check).
|
||||
var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
// NewClient creates a new ACME client.
|
||||
func NewClient(email string, staging bool, webroot, accountDir string) *Client {
|
||||
return &Client{
|
||||
Email: email,
|
||||
Staging: staging,
|
||||
Webroot: webroot,
|
||||
AccountDir: accountDir,
|
||||
}
|
||||
}
|
||||
|
||||
// validateDomain checks that a domain name is syntactically valid before passing
|
||||
// it to certbot. This prevents command injection and catches obvious typos.
|
||||
func validateDomain(domain string) error {
|
||||
if domain == "" {
|
||||
return fmt.Errorf("domain name is empty")
|
||||
}
|
||||
if len(domain) > 253 {
|
||||
return fmt.Errorf("domain name too long: %d characters (max 253)", len(domain))
|
||||
}
|
||||
if !domainRegex.MatchString(domain) {
|
||||
return fmt.Errorf("invalid domain name: %q", domain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Issue requests a new certificate from Let's Encrypt for the given domain
|
||||
// using the webroot challenge method.
|
||||
func (c *Client) Issue(domain string) (*CertInfo, error) {
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return nil, fmt.Errorf("issue: %w", err)
|
||||
}
|
||||
|
||||
if err := c.EnsureCertbotInstalled(); err != nil {
|
||||
return nil, fmt.Errorf("issue: %w", err)
|
||||
}
|
||||
|
||||
// Ensure webroot directory exists
|
||||
if err := os.MkdirAll(c.Webroot, 0755); err != nil {
|
||||
return nil, fmt.Errorf("issue: create webroot: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"certonly", "--webroot",
|
||||
"-w", c.Webroot,
|
||||
"-d", domain,
|
||||
"--non-interactive",
|
||||
"--agree-tos",
|
||||
"-m", c.Email,
|
||||
}
|
||||
if c.Staging {
|
||||
args = append(args, "--staging")
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("certbot certonly failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
return c.GetCertInfo(domain)
|
||||
}
|
||||
|
||||
// Renew renews the certificate for a specific domain.
|
||||
func (c *Client) Renew(domain string) error {
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return fmt.Errorf("renew: %w", err)
|
||||
}
|
||||
|
||||
if err := c.EnsureCertbotInstalled(); err != nil {
|
||||
return fmt.Errorf("renew: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "renew",
|
||||
"--cert-name", domain,
|
||||
"--non-interactive",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("certbot renew failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenewAll renews all certificates managed by certbot that are due for renewal.
|
||||
func (c *Client) RenewAll() (string, error) {
|
||||
if err := c.EnsureCertbotInstalled(); err != nil {
|
||||
return "", fmt.Errorf("renew all: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "renew", "--non-interactive")
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := string(out)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("certbot renew --all failed: %s: %w", strings.TrimSpace(output), err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// Revoke revokes the certificate for a given domain.
|
||||
func (c *Client) Revoke(domain string) error {
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return fmt.Errorf("revoke: %w", err)
|
||||
}
|
||||
|
||||
if err := c.EnsureCertbotInstalled(); err != nil {
|
||||
return fmt.Errorf("revoke: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "revoke",
|
||||
"--cert-name", domain,
|
||||
"--non-interactive",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("certbot revoke failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a certificate and its renewal configuration from certbot.
|
||||
func (c *Client) Delete(domain string) error {
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
|
||||
if err := c.EnsureCertbotInstalled(); err != nil {
|
||||
return fmt.Errorf("delete: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "delete",
|
||||
"--cert-name", domain,
|
||||
"--non-interactive",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("certbot delete failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCerts scans /etc/letsencrypt/live/ and parses each certificate to return
|
||||
// metadata including expiry dates and issuer information.
|
||||
func (c *Client) ListCerts() ([]CertInfo, error) {
|
||||
liveDir := "/etc/letsencrypt/live"
|
||||
entries, err := os.ReadDir(liveDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // No certs directory yet
|
||||
}
|
||||
return nil, fmt.Errorf("list certs: read live dir: %w", err)
|
||||
}
|
||||
|
||||
var certs []CertInfo
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
domain := entry.Name()
|
||||
// Skip the README directory certbot sometimes creates
|
||||
if domain == "README" {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := c.GetCertInfo(domain)
|
||||
if err != nil {
|
||||
// Log but skip certs we can't parse
|
||||
continue
|
||||
}
|
||||
certs = append(certs, *info)
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// GetCertInfo reads and parses the X.509 certificate at the standard Let's
|
||||
// Encrypt live path for a domain, returning structured metadata.
|
||||
func (c *Client) GetCertInfo(domain string) (*CertInfo, error) {
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return nil, fmt.Errorf("get cert info: %w", err)
|
||||
}
|
||||
|
||||
liveDir := filepath.Join("/etc/letsencrypt/live", domain)
|
||||
|
||||
certPath := filepath.Join(liveDir, "fullchain.pem")
|
||||
keyPath := filepath.Join(liveDir, "privkey.pem")
|
||||
chainPath := filepath.Join(liveDir, "chain.pem")
|
||||
|
||||
data, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cert info: read cert: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("get cert info: no PEM block found in %s", certPath)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cert info: parse x509: %w", err)
|
||||
}
|
||||
|
||||
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
|
||||
|
||||
return &CertInfo{
|
||||
Domain: domain,
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
ChainPath: chainPath,
|
||||
ExpiresAt: cert.NotAfter,
|
||||
Issuer: cert.Issuer.CommonName,
|
||||
DaysLeft: daysLeft,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnsureCertbotInstalled checks whether certbot is available in PATH. If not,
|
||||
// it attempts to install it via apt-get.
|
||||
func (c *Client) EnsureCertbotInstalled() error {
|
||||
if _, err := exec.LookPath("certbot"); err == nil {
|
||||
return nil // Already installed
|
||||
}
|
||||
|
||||
// Attempt to install via apt-get
|
||||
cmd := exec.Command("apt-get", "update", "-qq")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("apt-get update failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
cmd = exec.Command("apt-get", "install", "-y", "-qq", "certbot")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("apt-get install certbot failed: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
|
||||
// Verify installation succeeded
|
||||
if _, err := exec.LookPath("certbot"); err != nil {
|
||||
return fmt.Errorf("certbot still not found after installation attempt")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSelfSigned creates a self-signed X.509 certificate and private key
|
||||
// for testing or as a fallback when Let's Encrypt is unavailable.
|
||||
func (c *Client) GenerateSelfSigned(domain, certPath, keyPath string) error {
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return fmt.Errorf("generate self-signed: %w", err)
|
||||
}
|
||||
|
||||
// Ensure output directories exist
|
||||
if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil {
|
||||
return fmt.Errorf("generate self-signed: create cert dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
|
||||
return fmt.Errorf("generate self-signed: create key dir: %w", err)
|
||||
}
|
||||
|
||||
// Generate ECDSA P-256 private key
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate self-signed: generate key: %w", err)
|
||||
}
|
||||
|
||||
// Build the certificate template
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate self-signed: serial number: %w", err)
|
||||
}
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: domain,
|
||||
Organization: []string{"Setec Security Labs"},
|
||||
},
|
||||
DNSNames: []string{domain},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
// Self-sign the certificate
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate self-signed: create cert: %w", err)
|
||||
}
|
||||
|
||||
// Write certificate PEM
|
||||
certFile, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate self-signed: write cert: %w", err)
|
||||
}
|
||||
defer certFile.Close()
|
||||
|
||||
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
|
||||
return fmt.Errorf("generate self-signed: encode cert PEM: %w", err)
|
||||
}
|
||||
|
||||
// Write private key PEM
|
||||
keyDER, err := x509.MarshalECPrivateKey(privKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate self-signed: marshal key: %w", err)
|
||||
}
|
||||
|
||||
keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate self-signed: write key: %w", err)
|
||||
}
|
||||
defer keyFile.Close()
|
||||
|
||||
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
|
||||
return fmt.Errorf("generate self-signed: encode key PEM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
146
services/setec-manager/internal/config/config.go
Normal file
146
services/setec-manager/internal/config/config.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
Nginx NginxConfig `yaml:"nginx"`
|
||||
ACME ACMEConfig `yaml:"acme"`
|
||||
Autarch AutarchConfig `yaml:"autarch"`
|
||||
Float FloatConfig `yaml:"float"`
|
||||
Backups BackupsConfig `yaml:"backups"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
TLS bool `yaml:"tls"`
|
||||
Cert string `yaml:"cert"`
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type NginxConfig struct {
|
||||
SitesAvailable string `yaml:"sites_available"`
|
||||
SitesEnabled string `yaml:"sites_enabled"`
|
||||
Snippets string `yaml:"snippets"`
|
||||
Webroot string `yaml:"webroot"`
|
||||
CertbotWebroot string `yaml:"certbot_webroot"`
|
||||
}
|
||||
|
||||
type ACMEConfig struct {
|
||||
Email string `yaml:"email"`
|
||||
Staging bool `yaml:"staging"`
|
||||
AccountDir string `yaml:"account_dir"`
|
||||
}
|
||||
|
||||
type AutarchConfig struct {
|
||||
InstallDir string `yaml:"install_dir"`
|
||||
GitRepo string `yaml:"git_repo"`
|
||||
GitBranch string `yaml:"git_branch"`
|
||||
WebPort int `yaml:"web_port"`
|
||||
DNSPort int `yaml:"dns_port"`
|
||||
}
|
||||
|
||||
type FloatConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
MaxSessions int `yaml:"max_sessions"`
|
||||
SessionTTL string `yaml:"session_ttl"`
|
||||
}
|
||||
|
||||
type BackupsConfig struct {
|
||||
Dir string `yaml:"dir"`
|
||||
MaxAgeDays int `yaml:"max_age_days"`
|
||||
MaxCount int `yaml:"max_count"`
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
File string `yaml:"file"`
|
||||
MaxSizeMB int `yaml:"max_size_mb"`
|
||||
MaxBackups int `yaml:"max_backups"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: "0.0.0.0",
|
||||
Port: 9090,
|
||||
TLS: true,
|
||||
Cert: "/opt/setec-manager/data/acme/manager.crt",
|
||||
Key: "/opt/setec-manager/data/acme/manager.key",
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: "/opt/setec-manager/data/setec.db",
|
||||
},
|
||||
Nginx: NginxConfig{
|
||||
SitesAvailable: "/etc/nginx/sites-available",
|
||||
SitesEnabled: "/etc/nginx/sites-enabled",
|
||||
Snippets: "/etc/nginx/snippets",
|
||||
Webroot: "/var/www",
|
||||
CertbotWebroot: "/var/www/certbot",
|
||||
},
|
||||
ACME: ACMEConfig{
|
||||
Email: "",
|
||||
Staging: false,
|
||||
AccountDir: "/opt/setec-manager/data/acme",
|
||||
},
|
||||
Autarch: AutarchConfig{
|
||||
InstallDir: "/var/www/autarch",
|
||||
GitRepo: "https://github.com/DigijEth/autarch.git",
|
||||
GitBranch: "main",
|
||||
WebPort: 8181,
|
||||
DNSPort: 53,
|
||||
},
|
||||
Float: FloatConfig{
|
||||
Enabled: false,
|
||||
MaxSessions: 10,
|
||||
SessionTTL: "24h",
|
||||
},
|
||||
Backups: BackupsConfig{
|
||||
Dir: "/opt/setec-manager/data/backups",
|
||||
MaxAgeDays: 30,
|
||||
MaxCount: 50,
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
File: "/var/log/setec-manager.log",
|
||||
MaxSizeMB: 100,
|
||||
MaxBackups: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) Save(path string) error {
|
||||
data, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
46
services/setec-manager/internal/db/backups.go
Normal file
46
services/setec-manager/internal/db/backups.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type Backup struct {
|
||||
ID int64 `json:"id"`
|
||||
SiteID *int64 `json:"site_id"`
|
||||
BackupType string `json:"backup_type"`
|
||||
FilePath string `json:"file_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (d *DB) CreateBackup(siteID *int64, backupType, filePath string, sizeBytes int64) (int64, error) {
|
||||
result, err := d.conn.Exec(`INSERT INTO backups (site_id, backup_type, file_path, size_bytes)
|
||||
VALUES (?, ?, ?, ?)`, siteID, backupType, filePath, sizeBytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) ListBackups() ([]Backup, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, site_id, backup_type, file_path, size_bytes, created_at
|
||||
FROM backups ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var backups []Backup
|
||||
for rows.Next() {
|
||||
var b Backup
|
||||
if err := rows.Scan(&b.ID, &b.SiteID, &b.BackupType, &b.FilePath,
|
||||
&b.SizeBytes, &b.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backups = append(backups, b)
|
||||
}
|
||||
return backups, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) DeleteBackup(id int64) error {
|
||||
_, err := d.conn.Exec(`DELETE FROM backups WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
163
services/setec-manager/internal/db/db.go
Normal file
163
services/setec-manager/internal/db/db.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*DB, error) {
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
|
||||
conn, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(1) // SQLite single-writer
|
||||
|
||||
db := &DB{conn: conn}
|
||||
if err := db.migrate(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() error {
|
||||
return d.conn.Close()
|
||||
}
|
||||
|
||||
func (d *DB) Conn() *sql.DB {
|
||||
return d.conn
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
migrations := []string{
|
||||
migrateSites,
|
||||
migrateSystemUsers,
|
||||
migrateManagerUsers,
|
||||
migrateDeployments,
|
||||
migrateCronJobs,
|
||||
migrateFirewallRules,
|
||||
migrateFloatSessions,
|
||||
migrateBackups,
|
||||
migrateAuditLog,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := d.conn.Exec(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const migrateSites = `CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL UNIQUE,
|
||||
aliases TEXT DEFAULT '',
|
||||
app_type TEXT NOT NULL DEFAULT 'static',
|
||||
app_root TEXT NOT NULL,
|
||||
app_port INTEGER DEFAULT 0,
|
||||
app_entry TEXT DEFAULT '',
|
||||
git_repo TEXT DEFAULT '',
|
||||
git_branch TEXT DEFAULT 'main',
|
||||
ssl_enabled BOOLEAN DEFAULT FALSE,
|
||||
ssl_cert_path TEXT DEFAULT '',
|
||||
ssl_key_path TEXT DEFAULT '',
|
||||
ssl_auto BOOLEAN DEFAULT TRUE,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const migrateSystemUsers = `CREATE TABLE IF NOT EXISTS system_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
uid INTEGER,
|
||||
home_dir TEXT,
|
||||
shell TEXT DEFAULT '/bin/bash',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const migrateManagerUsers = `CREATE TABLE IF NOT EXISTS manager_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'admin',
|
||||
force_change BOOLEAN DEFAULT FALSE,
|
||||
last_login DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const migrateDeployments = `CREATE TABLE IF NOT EXISTS deployments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER REFERENCES sites(id),
|
||||
action TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
output TEXT DEFAULT '',
|
||||
started_at DATETIME,
|
||||
finished_at DATETIME
|
||||
);`
|
||||
|
||||
const migrateCronJobs = `CREATE TABLE IF NOT EXISTS cron_jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER REFERENCES sites(id),
|
||||
job_type TEXT NOT NULL,
|
||||
schedule TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT TRUE,
|
||||
last_run DATETIME,
|
||||
next_run DATETIME
|
||||
);`
|
||||
|
||||
const migrateFirewallRules = `CREATE TABLE IF NOT EXISTS firewall_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
direction TEXT DEFAULT 'in',
|
||||
protocol TEXT DEFAULT 'tcp',
|
||||
port TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'any',
|
||||
action TEXT DEFAULT 'allow',
|
||||
comment TEXT DEFAULT '',
|
||||
enabled BOOLEAN DEFAULT TRUE
|
||||
);`
|
||||
|
||||
const migrateFloatSessions = `CREATE TABLE IF NOT EXISTS float_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES manager_users(id),
|
||||
client_ip TEXT,
|
||||
client_agent TEXT,
|
||||
usb_bridge BOOLEAN DEFAULT FALSE,
|
||||
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_ping DATETIME,
|
||||
expires_at DATETIME
|
||||
);`
|
||||
|
||||
const migrateAuditLog = `CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
const migrateBackups = `CREATE TABLE IF NOT EXISTS backups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER REFERENCES sites(id),
|
||||
backup_type TEXT DEFAULT 'site',
|
||||
file_path TEXT NOT NULL,
|
||||
size_bytes INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
60
services/setec-manager/internal/db/deployments.go
Normal file
60
services/setec-manager/internal/db/deployments.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type Deployment struct {
|
||||
ID int64 `json:"id"`
|
||||
SiteID *int64 `json:"site_id"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
func (d *DB) CreateDeployment(siteID *int64, action string) (int64, error) {
|
||||
result, err := d.conn.Exec(`INSERT INTO deployments (site_id, action, status, started_at)
|
||||
VALUES (?, ?, 'running', CURRENT_TIMESTAMP)`, siteID, action)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) FinishDeployment(id int64, status, output string) error {
|
||||
_, err := d.conn.Exec(`UPDATE deployments SET status=?, output=?, finished_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?`, status, output, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ListDeployments(siteID *int64, limit int) ([]Deployment, error) {
|
||||
var rows_query string
|
||||
var args []interface{}
|
||||
|
||||
if siteID != nil {
|
||||
rows_query = `SELECT id, site_id, action, status, output, started_at, finished_at
|
||||
FROM deployments WHERE site_id=? ORDER BY id DESC LIMIT ?`
|
||||
args = []interface{}{*siteID, limit}
|
||||
} else {
|
||||
rows_query = `SELECT id, site_id, action, status, output, started_at, finished_at
|
||||
FROM deployments ORDER BY id DESC LIMIT ?`
|
||||
args = []interface{}{limit}
|
||||
}
|
||||
|
||||
rows, err := d.conn.Query(rows_query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var deps []Deployment
|
||||
for rows.Next() {
|
||||
var dep Deployment
|
||||
if err := rows.Scan(&dep.ID, &dep.SiteID, &dep.Action, &dep.Status,
|
||||
&dep.Output, &dep.StartedAt, &dep.FinishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deps = append(deps, dep)
|
||||
}
|
||||
return deps, rows.Err()
|
||||
}
|
||||
70
services/setec-manager/internal/db/float.go
Normal file
70
services/setec-manager/internal/db/float.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
type FloatSession struct {
|
||||
ID string `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
ClientAgent string `json:"client_agent"`
|
||||
USBBridge bool `json:"usb_bridge"`
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
LastPing *time.Time `json:"last_ping"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (d *DB) CreateFloatSession(id string, userID int64, clientIP, agent string, expiresAt time.Time) error {
|
||||
_, err := d.conn.Exec(`INSERT INTO float_sessions (id, user_id, client_ip, client_agent, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`, id, userID, clientIP, agent, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetFloatSession(id string) (*FloatSession, error) {
|
||||
var s FloatSession
|
||||
err := d.conn.QueryRow(`SELECT id, user_id, client_ip, client_agent, usb_bridge,
|
||||
connected_at, last_ping, expires_at FROM float_sessions WHERE id=?`, id).
|
||||
Scan(&s.ID, &s.UserID, &s.ClientIP, &s.ClientAgent, &s.USBBridge,
|
||||
&s.ConnectedAt, &s.LastPing, &s.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListFloatSessions() ([]FloatSession, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, user_id, client_ip, client_agent, usb_bridge,
|
||||
connected_at, last_ping, expires_at FROM float_sessions ORDER BY connected_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []FloatSession
|
||||
for rows.Next() {
|
||||
var s FloatSession
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.ClientIP, &s.ClientAgent, &s.USBBridge,
|
||||
&s.ConnectedAt, &s.LastPing, &s.ExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) DeleteFloatSession(id string) error {
|
||||
_, err := d.conn.Exec(`DELETE FROM float_sessions WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) PingFloatSession(id string) error {
|
||||
_, err := d.conn.Exec(`UPDATE float_sessions SET last_ping=CURRENT_TIMESTAMP WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) CleanExpiredFloatSessions() (int64, error) {
|
||||
result, err := d.conn.Exec(`DELETE FROM float_sessions WHERE expires_at < CURRENT_TIMESTAMP`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
107
services/setec-manager/internal/db/sites.go
Normal file
107
services/setec-manager/internal/db/sites.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Site struct {
|
||||
ID int64 `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Aliases string `json:"aliases"`
|
||||
AppType string `json:"app_type"`
|
||||
AppRoot string `json:"app_root"`
|
||||
AppPort int `json:"app_port"`
|
||||
AppEntry string `json:"app_entry"`
|
||||
GitRepo string `json:"git_repo"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
SSLEnabled bool `json:"ssl_enabled"`
|
||||
SSLCertPath string `json:"ssl_cert_path"`
|
||||
SSLKeyPath string `json:"ssl_key_path"`
|
||||
SSLAuto bool `json:"ssl_auto"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (d *DB) ListSites() ([]Site, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, domain, aliases, app_type, app_root, app_port,
|
||||
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
|
||||
ssl_auto, enabled, created_at, updated_at FROM sites ORDER BY domain`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sites []Site
|
||||
for rows.Next() {
|
||||
var s Site
|
||||
if err := rows.Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
|
||||
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
|
||||
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
|
||||
&s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sites = append(sites, s)
|
||||
}
|
||||
return sites, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetSite(id int64) (*Site, error) {
|
||||
var s Site
|
||||
err := d.conn.QueryRow(`SELECT id, domain, aliases, app_type, app_root, app_port,
|
||||
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
|
||||
ssl_auto, enabled, created_at, updated_at FROM sites WHERE id = ?`, id).
|
||||
Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
|
||||
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
|
||||
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
|
||||
&s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &s, err
|
||||
}
|
||||
|
||||
func (d *DB) GetSiteByDomain(domain string) (*Site, error) {
|
||||
var s Site
|
||||
err := d.conn.QueryRow(`SELECT id, domain, aliases, app_type, app_root, app_port,
|
||||
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path,
|
||||
ssl_auto, enabled, created_at, updated_at FROM sites WHERE domain = ?`, domain).
|
||||
Scan(&s.ID, &s.Domain, &s.Aliases, &s.AppType, &s.AppRoot,
|
||||
&s.AppPort, &s.AppEntry, &s.GitRepo, &s.GitBranch, &s.SSLEnabled,
|
||||
&s.SSLCertPath, &s.SSLKeyPath, &s.SSLAuto, &s.Enabled,
|
||||
&s.CreatedAt, &s.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &s, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateSite(s *Site) (int64, error) {
|
||||
result, err := d.conn.Exec(`INSERT INTO sites (domain, aliases, app_type, app_root, app_port,
|
||||
app_entry, git_repo, git_branch, ssl_enabled, ssl_cert_path, ssl_key_path, ssl_auto, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.Domain, s.Aliases, s.AppType, s.AppRoot, s.AppPort,
|
||||
s.AppEntry, s.GitRepo, s.GitBranch, s.SSLEnabled,
|
||||
s.SSLCertPath, s.SSLKeyPath, s.SSLAuto, s.Enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateSite(s *Site) error {
|
||||
_, err := d.conn.Exec(`UPDATE sites SET domain=?, aliases=?, app_type=?, app_root=?,
|
||||
app_port=?, app_entry=?, git_repo=?, git_branch=?, ssl_enabled=?,
|
||||
ssl_cert_path=?, ssl_key_path=?, ssl_auto=?, enabled=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?`,
|
||||
s.Domain, s.Aliases, s.AppType, s.AppRoot, s.AppPort,
|
||||
s.AppEntry, s.GitRepo, s.GitBranch, s.SSLEnabled,
|
||||
s.SSLCertPath, s.SSLKeyPath, s.SSLAuto, s.Enabled, s.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSite(id int64) error {
|
||||
_, err := d.conn.Exec(`DELETE FROM sites WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
124
services/setec-manager/internal/db/users.go
Normal file
124
services/setec-manager/internal/db/users.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type ManagerUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
ForceChange bool `json:"force_change"`
|
||||
LastLogin *time.Time `json:"last_login"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (d *DB) ListManagerUsers() ([]ManagerUser, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, username, password_hash, role, force_change,
|
||||
last_login, created_at FROM manager_users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []ManagerUser
|
||||
for rows.Next() {
|
||||
var u ManagerUser
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.ForceChange, &u.LastLogin, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) GetManagerUser(username string) (*ManagerUser, error) {
|
||||
var u ManagerUser
|
||||
err := d.conn.QueryRow(`SELECT id, username, password_hash, role, force_change,
|
||||
last_login, created_at FROM manager_users WHERE username = ?`, username).
|
||||
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.ForceChange, &u.LastLogin, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (d *DB) GetManagerUserByID(id int64) (*ManagerUser, error) {
|
||||
var u ManagerUser
|
||||
err := d.conn.QueryRow(`SELECT id, username, password_hash, role, force_change,
|
||||
last_login, created_at FROM manager_users WHERE id = ?`, id).
|
||||
Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.ForceChange, &u.LastLogin, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &u, err
|
||||
}
|
||||
|
||||
func (d *DB) CreateManagerUser(username, password, role string) (int64, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result, err := d.conn.Exec(`INSERT INTO manager_users (username, password_hash, role)
|
||||
VALUES (?, ?, ?)`, username, string(hash), role)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateManagerUserPassword(id int64, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.conn.Exec(`UPDATE manager_users SET password_hash=?, force_change=FALSE WHERE id=?`,
|
||||
string(hash), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateManagerUserRole(id int64, role string) error {
|
||||
_, err := d.conn.Exec(`UPDATE manager_users SET role=? WHERE id=?`, role, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) DeleteManagerUser(id int64) error {
|
||||
_, err := d.conn.Exec(`DELETE FROM manager_users WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) UpdateLoginTimestamp(id int64) error {
|
||||
_, err := d.conn.Exec(`UPDATE manager_users SET last_login=CURRENT_TIMESTAMP WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) ManagerUserCount() (int, error) {
|
||||
var count int
|
||||
err := d.conn.QueryRow(`SELECT COUNT(*) FROM manager_users`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (d *DB) AuthenticateUser(username, password string) (*ManagerUser, error) {
|
||||
u, err := d.GetManagerUser(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
d.UpdateLoginTimestamp(u.ID)
|
||||
return u, nil
|
||||
}
|
||||
144
services/setec-manager/internal/deploy/git.go
Normal file
144
services/setec-manager/internal/deploy/git.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CommitInfo holds metadata for a single git commit.
|
||||
type CommitInfo struct {
|
||||
Hash string
|
||||
Author string
|
||||
Date string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Clone clones a git repository into dest, checking out the given branch.
|
||||
func Clone(repo, branch, dest string) (string, error) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
|
||||
args := []string{"clone", "--branch", branch, "--progress", repo, dest}
|
||||
out, err := exec.Command(git, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("git clone: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// Pull performs a fast-forward-only pull in the given directory.
|
||||
func Pull(dir string) (string, error) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(git, "pull", "--ff-only")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("git pull: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// CurrentCommit returns the hash and message of the latest commit in dir.
|
||||
func CurrentCommit(dir string) (hash string, message string, err error) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(git, "log", "--oneline", "-1")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("git log: %w", err)
|
||||
}
|
||||
|
||||
line := strings.TrimSpace(string(out))
|
||||
if line == "" {
|
||||
return "", "", fmt.Errorf("git log: no commits found")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
hash = parts[0]
|
||||
if len(parts) > 1 {
|
||||
message = parts[1]
|
||||
}
|
||||
return hash, message, nil
|
||||
}
|
||||
|
||||
// GetBranch returns the current branch name for the repository in dir.
|
||||
func GetBranch(dir string) (string, error) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(git, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git rev-parse: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// HasChanges returns true if the working tree in dir has uncommitted changes.
|
||||
func HasChanges(dir string) (bool, error) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(git, "status", "--porcelain")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("git status: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)) != "", nil
|
||||
}
|
||||
|
||||
// Log returns the last n commits from the repository in dir.
|
||||
func Log(dir string, n int) ([]CommitInfo, error) {
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git not found: %w", err)
|
||||
}
|
||||
|
||||
// Use a delimiter unlikely to appear in commit messages.
|
||||
const sep = "||SETEC||"
|
||||
format := fmt.Sprintf("%%h%s%%an%s%%ai%s%%s", sep, sep, sep)
|
||||
|
||||
cmd := exec.Command(git, "log", fmt.Sprintf("-n%d", n), fmt.Sprintf("--format=%s", format))
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("git log: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
var commits []CommitInfo
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, sep, 4)
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
commits = append(commits, CommitInfo{
|
||||
Hash: parts[0],
|
||||
Author: parts[1],
|
||||
Date: parts[2],
|
||||
Message: parts[3],
|
||||
})
|
||||
}
|
||||
return commits, nil
|
||||
}
|
||||
100
services/setec-manager/internal/deploy/node.go
Normal file
100
services/setec-manager/internal/deploy/node.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NpmInstall runs npm install in the given directory.
|
||||
func NpmInstall(dir string) (string, error) {
|
||||
npm, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("npm not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(npm, "install")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("npm install: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// NpmBuild runs npm run build in the given directory.
|
||||
func NpmBuild(dir string) (string, error) {
|
||||
npm, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("npm not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(npm, "run", "build")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("npm run build: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// NpmAudit runs npm audit in the given directory and returns the report.
|
||||
func NpmAudit(dir string) (string, error) {
|
||||
npm, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("npm not found: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(npm, "audit")
|
||||
cmd.Dir = dir
|
||||
// npm audit exits non-zero when vulnerabilities are found, which is not
|
||||
// an execution error — we still want the output.
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Return the output even on non-zero exit; the caller can inspect it.
|
||||
return string(out), fmt.Errorf("npm audit: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// HasPackageJSON returns true if a package.json file exists in dir.
|
||||
func HasPackageJSON(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, "package.json"))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// HasNodeModules returns true if a node_modules directory exists in dir.
|
||||
func HasNodeModules(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, "node_modules"))
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// NodeVersion returns the installed Node.js version string.
|
||||
func NodeVersion() (string, error) {
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("node not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(node, "--version").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("node --version: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// NpmVersion returns the installed npm version string.
|
||||
func NpmVersion() (string, error) {
|
||||
npm, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("npm not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(npm, "--version").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("npm --version: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
93
services/setec-manager/internal/deploy/python.go
Normal file
93
services/setec-manager/internal/deploy/python.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PipPackage holds the name and version of an installed pip package.
|
||||
type PipPackage struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// CreateVenv creates a Python virtual environment at <dir>/venv.
|
||||
func CreateVenv(dir string) error {
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
return fmt.Errorf("python3 not found: %w", err)
|
||||
}
|
||||
|
||||
venvPath := filepath.Join(dir, "venv")
|
||||
out, err := exec.Command(python, "-m", "venv", venvPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create venv: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpgradePip upgrades pip, setuptools, and wheel inside the virtual environment
|
||||
// rooted at venvDir.
|
||||
func UpgradePip(venvDir string) error {
|
||||
pip := filepath.Join(venvDir, "bin", "pip")
|
||||
if _, err := os.Stat(pip); err != nil {
|
||||
return fmt.Errorf("pip not found at %s: %w", pip, err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(pip, "install", "--upgrade", "pip", "setuptools", "wheel").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("upgrade pip: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallRequirements installs packages from a requirements file into the
|
||||
// virtual environment rooted at venvDir.
|
||||
func InstallRequirements(venvDir, reqFile string) (string, error) {
|
||||
pip := filepath.Join(venvDir, "bin", "pip")
|
||||
if _, err := os.Stat(pip); err != nil {
|
||||
return "", fmt.Errorf("pip not found at %s: %w", pip, err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(reqFile); err != nil {
|
||||
return "", fmt.Errorf("requirements file not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(pip, "install", "-r", reqFile).CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("pip install: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ListPackages returns all installed packages in the virtual environment
|
||||
// rooted at venvDir.
|
||||
func ListPackages(venvDir string) ([]PipPackage, error) {
|
||||
pip := filepath.Join(venvDir, "bin", "pip")
|
||||
if _, err := os.Stat(pip); err != nil {
|
||||
return nil, fmt.Errorf("pip not found at %s: %w", pip, err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(pip, "list", "--format=json").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pip list: %w", err)
|
||||
}
|
||||
|
||||
var packages []PipPackage
|
||||
if err := json.Unmarshal(out, &packages); err != nil {
|
||||
return nil, fmt.Errorf("parse pip list output: %w", err)
|
||||
}
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// VenvExists returns true if a virtual environment with a working python3
|
||||
// binary exists at <dir>/venv.
|
||||
func VenvExists(dir string) bool {
|
||||
python := filepath.Join(dir, "venv", "bin", "python3")
|
||||
info, err := os.Stat(python)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
246
services/setec-manager/internal/deploy/systemd.go
Normal file
246
services/setec-manager/internal/deploy/systemd.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnitConfig holds the parameters needed to generate a systemd unit file.
|
||||
type UnitConfig struct {
|
||||
Name string
|
||||
Description string
|
||||
ExecStart string
|
||||
WorkingDirectory string
|
||||
User string
|
||||
Environment map[string]string
|
||||
After string
|
||||
RestartPolicy string
|
||||
}
|
||||
|
||||
// GenerateUnit produces the contents of a systemd service unit file from cfg.
|
||||
func GenerateUnit(cfg UnitConfig) string {
|
||||
var b strings.Builder
|
||||
|
||||
// [Unit]
|
||||
b.WriteString("[Unit]\n")
|
||||
if cfg.Description != "" {
|
||||
fmt.Fprintf(&b, "Description=%s\n", cfg.Description)
|
||||
}
|
||||
after := cfg.After
|
||||
if after == "" {
|
||||
after = "network.target"
|
||||
}
|
||||
fmt.Fprintf(&b, "After=%s\n", after)
|
||||
|
||||
// [Service]
|
||||
b.WriteString("\n[Service]\n")
|
||||
b.WriteString("Type=simple\n")
|
||||
if cfg.User != "" {
|
||||
fmt.Fprintf(&b, "User=%s\n", cfg.User)
|
||||
}
|
||||
if cfg.WorkingDirectory != "" {
|
||||
fmt.Fprintf(&b, "WorkingDirectory=%s\n", cfg.WorkingDirectory)
|
||||
}
|
||||
fmt.Fprintf(&b, "ExecStart=%s\n", cfg.ExecStart)
|
||||
|
||||
restart := cfg.RestartPolicy
|
||||
if restart == "" {
|
||||
restart = "on-failure"
|
||||
}
|
||||
fmt.Fprintf(&b, "Restart=%s\n", restart)
|
||||
b.WriteString("RestartSec=5\n")
|
||||
|
||||
// Environment variables — sorted for deterministic output.
|
||||
if len(cfg.Environment) > 0 {
|
||||
keys := make([]string, 0, len(cfg.Environment))
|
||||
for k := range cfg.Environment {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, "Environment=%s=%s\n", k, cfg.Environment[k])
|
||||
}
|
||||
}
|
||||
|
||||
// [Install]
|
||||
b.WriteString("\n[Install]\n")
|
||||
b.WriteString("WantedBy=multi-user.target\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// InstallUnit writes a systemd unit file and reloads the daemon.
|
||||
func InstallUnit(name, content string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
unitPath := filepath.Join("/etc/systemd/system", name+".service")
|
||||
if err := os.WriteFile(unitPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("write unit file: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUnit stops, disables, and removes a systemd unit file, then reloads.
|
||||
func RemoveUnit(name string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
unit := name + ".service"
|
||||
|
||||
// Best-effort stop and disable — ignore errors if already stopped/disabled.
|
||||
exec.Command(systemctl, "stop", unit).Run()
|
||||
exec.Command(systemctl, "disable", unit).Run()
|
||||
|
||||
unitPath := filepath.Join("/etc/systemd/system", unit)
|
||||
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove unit file: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts a systemd unit.
|
||||
func Start(unit string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "start", unit).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("start %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops a systemd unit.
|
||||
func Stop(unit string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "stop", unit).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart restarts a systemd unit.
|
||||
func Restart(unit string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "restart", unit).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("restart %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable enables a systemd unit to start on boot.
|
||||
func Enable(unit string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "enable", unit).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable disables a systemd unit from starting on boot.
|
||||
func Disable(unit string) error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "disable", unit).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("disable %s: %s: %w", unit, strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActive returns true if the given systemd unit is currently active.
|
||||
func IsActive(unit string) (bool, error) {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "is-active", unit).Output()
|
||||
status := strings.TrimSpace(string(out))
|
||||
if status == "active" {
|
||||
return true, nil
|
||||
}
|
||||
// is-active exits non-zero for inactive/failed — that is not an error
|
||||
// in our context, just means the unit is not active.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Status returns the full systemctl status output for a unit.
|
||||
func Status(unit string) (string, error) {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
// systemctl status exits non-zero for stopped services, so we use
|
||||
// CombinedOutput and only treat missing-binary as a real error.
|
||||
out, _ := exec.Command(systemctl, "status", unit).CombinedOutput()
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// Logs returns the last n lines of journal output for a systemd unit.
|
||||
func Logs(unit string, lines int) (string, error) {
|
||||
journalctl, err := exec.LookPath("journalctl")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("journalctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(journalctl, "-u", unit, "-n", fmt.Sprintf("%d", lines), "--no-pager").CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("journalctl: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// DaemonReload runs systemctl daemon-reload.
|
||||
func DaemonReload() error {
|
||||
systemctl, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl not found: %w", err)
|
||||
}
|
||||
|
||||
out, err := exec.Command(systemctl, "daemon-reload").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon-reload: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
366
services/setec-manager/internal/float/bridge.go
Normal file
366
services/setec-manager/internal/float/bridge.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package float
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/db"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Bridge manages WebSocket connections for USB passthrough in Float Mode.
|
||||
type Bridge struct {
|
||||
db *db.DB
|
||||
sessions map[string]*bridgeConn
|
||||
mu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// bridgeConn tracks a single active WebSocket connection and its associated session.
|
||||
type bridgeConn struct {
|
||||
sessionID string
|
||||
conn *websocket.Conn
|
||||
devices []USBDevice
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingInterval = 30 * time.Second
|
||||
maxMessageSize = 64 * 1024 // 64 KB max frame payload
|
||||
)
|
||||
|
||||
// NewBridge creates a new Bridge with the given database reference.
|
||||
func NewBridge(database *db.DB) *Bridge {
|
||||
return &Bridge{
|
||||
db: database,
|
||||
sessions: make(map[string]*bridgeConn),
|
||||
upgrader: websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Accept all origins; auth is handled via session token
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket upgrades an HTTP connection to WebSocket and manages the
|
||||
// binary frame protocol for USB passthrough. The session ID must be provided
|
||||
// as a "session" query parameter.
|
||||
func (b *Bridge) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.URL.Query().Get("session")
|
||||
if sessionID == "" {
|
||||
http.Error(w, "missing session parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate session exists and is not expired
|
||||
sess, err := b.db.GetFloatSession(sessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade to WebSocket
|
||||
conn, err := b.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[float/bridge] upgrade failed for session %s: %v", sessionID, err)
|
||||
return
|
||||
}
|
||||
|
||||
bc := &bridgeConn{
|
||||
sessionID: sessionID,
|
||||
conn: conn,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Register active connection
|
||||
b.mu.Lock()
|
||||
// Close any existing connection for this session
|
||||
if existing, ok := b.sessions[sessionID]; ok {
|
||||
close(existing.done)
|
||||
existing.conn.Close()
|
||||
}
|
||||
b.sessions[sessionID] = bc
|
||||
b.mu.Unlock()
|
||||
|
||||
log.Printf("[float/bridge] session %s connected from %s", sessionID, r.RemoteAddr)
|
||||
|
||||
// Start read/write loops
|
||||
go b.writePump(bc)
|
||||
b.readPump(bc)
|
||||
}
|
||||
|
||||
// readPump reads binary frames from the WebSocket and dispatches them.
|
||||
func (b *Bridge) readPump(bc *bridgeConn) {
|
||||
defer b.cleanup(bc)
|
||||
|
||||
bc.conn.SetReadLimit(maxMessageSize)
|
||||
bc.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
bc.conn.SetPongHandler(func(string) error {
|
||||
bc.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
messageType, data, err := bc.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
log.Printf("[float/bridge] session %s read error: %v", bc.sessionID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if messageType != websocket.BinaryMessage {
|
||||
b.sendError(bc, 0x0001, "expected binary message")
|
||||
continue
|
||||
}
|
||||
|
||||
frameType, payload, err := DecodeFrame(data)
|
||||
if err != nil {
|
||||
b.sendError(bc, 0x0002, "malformed frame: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
// Update session ping in DB
|
||||
b.db.PingFloatSession(bc.sessionID)
|
||||
|
||||
switch frameType {
|
||||
case FrameEnumerate:
|
||||
b.handleEnumerate(bc)
|
||||
case FrameOpen:
|
||||
b.handleOpen(bc, payload)
|
||||
case FrameClose:
|
||||
b.handleClose(bc, payload)
|
||||
case FrameTransferOut:
|
||||
b.handleTransfer(bc, payload)
|
||||
case FrameInterrupt:
|
||||
b.handleInterrupt(bc, payload)
|
||||
case FramePong:
|
||||
// Client responded to our ping; no action needed
|
||||
default:
|
||||
b.sendError(bc, 0x0003, "unknown frame type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump sends periodic pings to keep the connection alive.
|
||||
func (b *Bridge) writePump(bc *bridgeConn) {
|
||||
ticker := time.NewTicker(pingInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
bc.mu.Lock()
|
||||
bc.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
err := bc.conn.WriteMessage(websocket.BinaryMessage, EncodeFrame(FramePing, nil))
|
||||
bc.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case <-bc.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleEnumerate responds with the current list of USB devices known to this
|
||||
// session. In a full implementation, this would forward the enumerate request
|
||||
// to the client-side USB agent and await its response. Here we return the
|
||||
// cached device list.
|
||||
func (b *Bridge) handleEnumerate(bc *bridgeConn) {
|
||||
bc.mu.Lock()
|
||||
devices := bc.devices
|
||||
bc.mu.Unlock()
|
||||
|
||||
if devices == nil {
|
||||
devices = []USBDevice{}
|
||||
}
|
||||
|
||||
payload := EncodeDeviceList(devices)
|
||||
b.sendFrame(bc, FrameEnumResult, payload)
|
||||
}
|
||||
|
||||
// handleOpen processes a device open request. The payload contains
|
||||
// [deviceID:2] identifying which device to claim.
|
||||
func (b *Bridge) handleOpen(bc *bridgeConn, payload []byte) {
|
||||
if len(payload) < 2 {
|
||||
b.sendError(bc, 0x0010, "open: payload too short")
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
|
||||
|
||||
// Verify the device exists in our known list
|
||||
bc.mu.Lock()
|
||||
found := false
|
||||
for _, dev := range bc.devices {
|
||||
if dev.DeviceID == deviceID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
bc.mu.Unlock()
|
||||
|
||||
if !found {
|
||||
b.sendError(bc, 0x0011, "open: device not found")
|
||||
return
|
||||
}
|
||||
|
||||
// In a real implementation, this would claim the USB device via the host agent.
|
||||
// For now, acknowledge the open request.
|
||||
result := make([]byte, 3)
|
||||
result[0] = payload[0]
|
||||
result[1] = payload[1]
|
||||
result[2] = 0x00 // success
|
||||
b.sendFrame(bc, FrameOpenResult, result)
|
||||
|
||||
log.Printf("[float/bridge] session %s opened device 0x%04X", bc.sessionID, deviceID)
|
||||
}
|
||||
|
||||
// handleClose processes a device close request. Payload: [deviceID:2].
|
||||
func (b *Bridge) handleClose(bc *bridgeConn, payload []byte) {
|
||||
if len(payload) < 2 {
|
||||
b.sendError(bc, 0x0020, "close: payload too short")
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
|
||||
|
||||
// Acknowledge close
|
||||
result := make([]byte, 3)
|
||||
result[0] = payload[0]
|
||||
result[1] = payload[1]
|
||||
result[2] = 0x00 // success
|
||||
b.sendFrame(bc, FrameCloseResult, result)
|
||||
|
||||
log.Printf("[float/bridge] session %s closed device 0x%04X", bc.sessionID, deviceID)
|
||||
}
|
||||
|
||||
// handleTransfer forwards a bulk/interrupt OUT transfer to the USB device.
|
||||
func (b *Bridge) handleTransfer(bc *bridgeConn, payload []byte) {
|
||||
deviceID, endpoint, transferData, err := DecodeTransfer(payload)
|
||||
if err != nil {
|
||||
b.sendError(bc, 0x0030, "transfer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// In a real implementation, the transfer data would be sent to the USB device
|
||||
// via the host agent, and the response would be sent back. Here we acknowledge
|
||||
// receipt of the transfer request.
|
||||
log.Printf("[float/bridge] session %s transfer to device 0x%04X endpoint 0x%02X: %d bytes",
|
||||
bc.sessionID, deviceID, endpoint, len(transferData))
|
||||
|
||||
// Build transfer result: [deviceID:2][endpoint:1][status:1]
|
||||
result := make([]byte, 4)
|
||||
result[0] = byte(deviceID >> 8)
|
||||
result[1] = byte(deviceID)
|
||||
result[2] = endpoint
|
||||
result[3] = 0x00 // success
|
||||
b.sendFrame(bc, FrameTransferResult, result)
|
||||
}
|
||||
|
||||
// handleInterrupt processes an interrupt transfer request.
|
||||
func (b *Bridge) handleInterrupt(bc *bridgeConn, payload []byte) {
|
||||
if len(payload) < 3 {
|
||||
b.sendError(bc, 0x0040, "interrupt: payload too short")
|
||||
return
|
||||
}
|
||||
|
||||
deviceID := uint16(payload[0])<<8 | uint16(payload[1])
|
||||
endpoint := payload[2]
|
||||
|
||||
log.Printf("[float/bridge] session %s interrupt on device 0x%04X endpoint 0x%02X",
|
||||
bc.sessionID, deviceID, endpoint)
|
||||
|
||||
// Acknowledge interrupt request
|
||||
result := make([]byte, 4)
|
||||
result[0] = payload[0]
|
||||
result[1] = payload[1]
|
||||
result[2] = endpoint
|
||||
result[3] = 0x00 // success
|
||||
b.sendFrame(bc, FrameInterruptResult, result)
|
||||
}
|
||||
|
||||
// sendFrame writes a binary frame to the WebSocket connection.
|
||||
func (b *Bridge) sendFrame(bc *bridgeConn, frameType byte, payload []byte) {
|
||||
bc.mu.Lock()
|
||||
defer bc.mu.Unlock()
|
||||
|
||||
bc.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := bc.conn.WriteMessage(websocket.BinaryMessage, EncodeFrame(frameType, payload)); err != nil {
|
||||
log.Printf("[float/bridge] session %s write error: %v", bc.sessionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError writes an error frame to the WebSocket connection.
|
||||
func (b *Bridge) sendError(bc *bridgeConn, code uint16, message string) {
|
||||
b.sendFrame(bc, FrameError, EncodeError(code, message))
|
||||
}
|
||||
|
||||
// cleanup removes a connection from the active sessions and cleans up resources.
|
||||
func (b *Bridge) cleanup(bc *bridgeConn) {
|
||||
b.mu.Lock()
|
||||
if current, ok := b.sessions[bc.sessionID]; ok && current == bc {
|
||||
delete(b.sessions, bc.sessionID)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
close(bc.done)
|
||||
bc.conn.Close()
|
||||
|
||||
log.Printf("[float/bridge] session %s disconnected", bc.sessionID)
|
||||
}
|
||||
|
||||
// ActiveSessions returns the number of currently connected WebSocket sessions.
|
||||
func (b *Bridge) ActiveSessions() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.sessions)
|
||||
}
|
||||
|
||||
// DisconnectSession forcibly closes the WebSocket connection for a given session.
|
||||
func (b *Bridge) DisconnectSession(sessionID string) {
|
||||
b.mu.Lock()
|
||||
bc, ok := b.sessions[sessionID]
|
||||
if ok {
|
||||
delete(b.sessions, sessionID)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
close(bc.done)
|
||||
bc.conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session terminated"),
|
||||
time.Now().Add(writeWait),
|
||||
)
|
||||
bc.conn.Close()
|
||||
log.Printf("[float/bridge] session %s forcibly disconnected", sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateDeviceList sets the known device list for a session (called when the
|
||||
// client-side USB agent reports its attached devices).
|
||||
func (b *Bridge) UpdateDeviceList(sessionID string, devices []USBDevice) {
|
||||
b.mu.RLock()
|
||||
bc, ok := b.sessions[sessionID]
|
||||
b.mu.RUnlock()
|
||||
|
||||
if ok {
|
||||
bc.mu.Lock()
|
||||
bc.devices = devices
|
||||
bc.mu.Unlock()
|
||||
}
|
||||
}
|
||||
225
services/setec-manager/internal/float/protocol.go
Normal file
225
services/setec-manager/internal/float/protocol.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package float
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Frame type constants define the binary protocol for USB passthrough over WebSocket.
|
||||
const (
|
||||
FrameEnumerate byte = 0x01
|
||||
FrameEnumResult byte = 0x02
|
||||
FrameOpen byte = 0x03
|
||||
FrameOpenResult byte = 0x04
|
||||
FrameClose byte = 0x05
|
||||
FrameCloseResult byte = 0x06
|
||||
FrameTransferOut byte = 0x10
|
||||
FrameTransferIn byte = 0x11
|
||||
FrameTransferResult byte = 0x12
|
||||
FrameInterrupt byte = 0x20
|
||||
FrameInterruptResult byte = 0x21
|
||||
FramePing byte = 0xFE
|
||||
FramePong byte = 0xFF
|
||||
FrameError byte = 0xE0
|
||||
)
|
||||
|
||||
// frameHeaderSize is the fixed size of a frame header: 1 byte type + 4 bytes length.
|
||||
const frameHeaderSize = 5
|
||||
|
||||
// USBDevice represents a USB device detected on the client host.
|
||||
type USBDevice struct {
|
||||
VendorID uint16 `json:"vendor_id"`
|
||||
ProductID uint16 `json:"product_id"`
|
||||
DeviceID uint16 `json:"device_id"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Product string `json:"product"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Class byte `json:"class"`
|
||||
SubClass byte `json:"sub_class"`
|
||||
}
|
||||
|
||||
// deviceFixedSize is the fixed portion of a serialized USBDevice:
|
||||
// VendorID(2) + ProductID(2) + DeviceID(2) + Class(1) + SubClass(1) + 3 string lengths (2 each) = 14
|
||||
const deviceFixedSize = 14
|
||||
|
||||
// EncodeFrame builds a binary frame: [type:1][length:4 big-endian][payload:N].
|
||||
func EncodeFrame(frameType byte, payload []byte) []byte {
|
||||
frame := make([]byte, frameHeaderSize+len(payload))
|
||||
frame[0] = frameType
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload)))
|
||||
copy(frame[frameHeaderSize:], payload)
|
||||
return frame
|
||||
}
|
||||
|
||||
// DecodeFrame parses a binary frame into its type and payload.
|
||||
func DecodeFrame(data []byte) (frameType byte, payload []byte, err error) {
|
||||
if len(data) < frameHeaderSize {
|
||||
return 0, nil, fmt.Errorf("frame too short: need at least %d bytes, got %d", frameHeaderSize, len(data))
|
||||
}
|
||||
|
||||
frameType = data[0]
|
||||
length := binary.BigEndian.Uint32(data[1:5])
|
||||
|
||||
if uint32(len(data)-frameHeaderSize) < length {
|
||||
return 0, nil, fmt.Errorf("frame payload truncated: header says %d bytes, have %d", length, len(data)-frameHeaderSize)
|
||||
}
|
||||
|
||||
payload = make([]byte, length)
|
||||
copy(payload, data[frameHeaderSize:frameHeaderSize+int(length)])
|
||||
return frameType, payload, nil
|
||||
}
|
||||
|
||||
// encodeString writes a length-prefixed string (2-byte big-endian length + bytes).
|
||||
func encodeString(buf []byte, offset int, s string) int {
|
||||
b := []byte(s)
|
||||
binary.BigEndian.PutUint16(buf[offset:], uint16(len(b)))
|
||||
offset += 2
|
||||
copy(buf[offset:], b)
|
||||
return offset + len(b)
|
||||
}
|
||||
|
||||
// decodeString reads a length-prefixed string from the buffer.
|
||||
func decodeString(data []byte, offset int) (string, int, error) {
|
||||
if offset+2 > len(data) {
|
||||
return "", 0, fmt.Errorf("string length truncated at offset %d", offset)
|
||||
}
|
||||
slen := int(binary.BigEndian.Uint16(data[offset:]))
|
||||
offset += 2
|
||||
if offset+slen > len(data) {
|
||||
return "", 0, fmt.Errorf("string data truncated at offset %d: need %d bytes", offset, slen)
|
||||
}
|
||||
s := string(data[offset : offset+slen])
|
||||
return s, offset + slen, nil
|
||||
}
|
||||
|
||||
// serializeDevice serializes a single USBDevice into bytes.
|
||||
func serializeDevice(dev USBDevice) []byte {
|
||||
mfr := []byte(dev.Manufacturer)
|
||||
prod := []byte(dev.Product)
|
||||
ser := []byte(dev.SerialNumber)
|
||||
|
||||
size := deviceFixedSize + len(mfr) + len(prod) + len(ser)
|
||||
buf := make([]byte, size)
|
||||
|
||||
binary.BigEndian.PutUint16(buf[0:], dev.VendorID)
|
||||
binary.BigEndian.PutUint16(buf[2:], dev.ProductID)
|
||||
binary.BigEndian.PutUint16(buf[4:], dev.DeviceID)
|
||||
buf[6] = dev.Class
|
||||
buf[7] = dev.SubClass
|
||||
|
||||
off := 8
|
||||
off = encodeString(buf, off, dev.Manufacturer)
|
||||
off = encodeString(buf, off, dev.Product)
|
||||
_ = encodeString(buf, off, dev.SerialNumber)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// EncodeDeviceList serializes a slice of USBDevices for a FrameEnumResult payload.
|
||||
// Format: [count:2 big-endian][device...]
|
||||
func EncodeDeviceList(devices []USBDevice) []byte {
|
||||
// First pass: serialize each device to compute total size
|
||||
serialized := make([][]byte, len(devices))
|
||||
totalSize := 2 // 2 bytes for count
|
||||
for i, dev := range devices {
|
||||
serialized[i] = serializeDevice(dev)
|
||||
totalSize += len(serialized[i])
|
||||
}
|
||||
|
||||
buf := make([]byte, totalSize)
|
||||
binary.BigEndian.PutUint16(buf[0:], uint16(len(devices)))
|
||||
off := 2
|
||||
for _, s := range serialized {
|
||||
copy(buf[off:], s)
|
||||
off += len(s)
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// DecodeDeviceList deserializes a FrameEnumResult payload into a slice of USBDevices.
|
||||
func DecodeDeviceList(data []byte) ([]USBDevice, error) {
|
||||
if len(data) < 2 {
|
||||
return nil, fmt.Errorf("device list too short: need at least 2 bytes")
|
||||
}
|
||||
|
||||
count := int(binary.BigEndian.Uint16(data[0:]))
|
||||
off := 2
|
||||
|
||||
devices := make([]USBDevice, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
if off+8 > len(data) {
|
||||
return nil, fmt.Errorf("device %d: fixed fields truncated at offset %d", i, off)
|
||||
}
|
||||
|
||||
dev := USBDevice{
|
||||
VendorID: binary.BigEndian.Uint16(data[off:]),
|
||||
ProductID: binary.BigEndian.Uint16(data[off+2:]),
|
||||
DeviceID: binary.BigEndian.Uint16(data[off+4:]),
|
||||
Class: data[off+6],
|
||||
SubClass: data[off+7],
|
||||
}
|
||||
off += 8
|
||||
|
||||
var err error
|
||||
dev.Manufacturer, off, err = decodeString(data, off)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %d manufacturer: %w", i, err)
|
||||
}
|
||||
dev.Product, off, err = decodeString(data, off)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %d product: %w", i, err)
|
||||
}
|
||||
dev.SerialNumber, off, err = decodeString(data, off)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device %d serial: %w", i, err)
|
||||
}
|
||||
|
||||
devices = append(devices, dev)
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// EncodeTransfer serializes a USB transfer payload.
|
||||
// Format: [deviceID:2][endpoint:1][data:N]
|
||||
func EncodeTransfer(deviceID uint16, endpoint byte, data []byte) []byte {
|
||||
buf := make([]byte, 3+len(data))
|
||||
binary.BigEndian.PutUint16(buf[0:], deviceID)
|
||||
buf[2] = endpoint
|
||||
copy(buf[3:], data)
|
||||
return buf
|
||||
}
|
||||
|
||||
// DecodeTransfer deserializes a USB transfer payload.
|
||||
func DecodeTransfer(data []byte) (deviceID uint16, endpoint byte, transferData []byte, err error) {
|
||||
if len(data) < 3 {
|
||||
return 0, 0, nil, fmt.Errorf("transfer payload too short: need at least 3 bytes, got %d", len(data))
|
||||
}
|
||||
|
||||
deviceID = binary.BigEndian.Uint16(data[0:])
|
||||
endpoint = data[2]
|
||||
transferData = make([]byte, len(data)-3)
|
||||
copy(transferData, data[3:])
|
||||
return deviceID, endpoint, transferData, nil
|
||||
}
|
||||
|
||||
// EncodeError serializes an error response payload.
|
||||
// Format: [code:2 big-endian][message:UTF-8 bytes]
|
||||
func EncodeError(code uint16, message string) []byte {
|
||||
msg := []byte(message)
|
||||
buf := make([]byte, 2+len(msg))
|
||||
binary.BigEndian.PutUint16(buf[0:], code)
|
||||
copy(buf[2:], msg)
|
||||
return buf
|
||||
}
|
||||
|
||||
// DecodeError deserializes an error response payload.
|
||||
func DecodeError(data []byte) (code uint16, message string) {
|
||||
if len(data) < 2 {
|
||||
return 0, ""
|
||||
}
|
||||
code = binary.BigEndian.Uint16(data[0:])
|
||||
message = string(data[2:])
|
||||
return code, message
|
||||
}
|
||||
248
services/setec-manager/internal/float/session.go
Normal file
248
services/setec-manager/internal/float/session.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package float
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/db"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Session represents an active Float Mode session, combining database state
|
||||
// with the live WebSocket connection reference.
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
ClientAgent string `json:"client_agent"`
|
||||
USBBridge bool `json:"usb_bridge"`
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastPing *time.Time `json:"last_ping,omitempty"`
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
// SessionManager provides in-memory + database-backed session lifecycle
|
||||
// management for Float Mode connections.
|
||||
type SessionManager struct {
|
||||
sessions map[string]*Session
|
||||
mu sync.RWMutex
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
// NewSessionManager creates a new SessionManager backed by the given database.
|
||||
func NewSessionManager(database *db.DB) *SessionManager {
|
||||
return &SessionManager{
|
||||
sessions: make(map[string]*Session),
|
||||
db: database,
|
||||
}
|
||||
}
|
||||
|
||||
// Create generates a new Float session with a random UUID, storing it in both
|
||||
// the in-memory map and the database.
|
||||
func (sm *SessionManager) Create(userID int64, clientIP, agent string, ttl time.Duration) (string, error) {
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(ttl)
|
||||
|
||||
session := &Session{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
ClientIP: clientIP,
|
||||
ClientAgent: agent,
|
||||
ConnectedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
// Persist to database first
|
||||
if err := sm.db.CreateFloatSession(id, userID, clientIP, agent, expiresAt); err != nil {
|
||||
return "", fmt.Errorf("create session: db insert: %w", err)
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
sm.mu.Lock()
|
||||
sm.sessions[id] = session
|
||||
sm.mu.Unlock()
|
||||
|
||||
log.Printf("[float/session] created session %s for user %d from %s (expires %s)",
|
||||
id, userID, clientIP, expiresAt.Format(time.RFC3339))
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Get retrieves a session by ID, checking the in-memory cache first, then
|
||||
// falling back to the database. Returns nil and an error if not found.
|
||||
func (sm *SessionManager) Get(id string) (*Session, error) {
|
||||
// Check memory first
|
||||
sm.mu.RLock()
|
||||
if sess, ok := sm.sessions[id]; ok {
|
||||
sm.mu.RUnlock()
|
||||
// Check if expired
|
||||
if time.Now().After(sess.ExpiresAt) {
|
||||
sm.Delete(id)
|
||||
return nil, fmt.Errorf("session %s has expired", id)
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
sm.mu.RUnlock()
|
||||
|
||||
// Fall back to database
|
||||
dbSess, err := sm.db.GetFloatSession(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get session: %w", err)
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(dbSess.ExpiresAt) {
|
||||
sm.db.DeleteFloatSession(id)
|
||||
return nil, fmt.Errorf("session %s has expired", id)
|
||||
}
|
||||
|
||||
// Hydrate into memory
|
||||
session := &Session{
|
||||
ID: dbSess.ID,
|
||||
UserID: dbSess.UserID,
|
||||
ClientIP: dbSess.ClientIP,
|
||||
ClientAgent: dbSess.ClientAgent,
|
||||
USBBridge: dbSess.USBBridge,
|
||||
ConnectedAt: dbSess.ConnectedAt,
|
||||
ExpiresAt: dbSess.ExpiresAt,
|
||||
LastPing: dbSess.LastPing,
|
||||
}
|
||||
|
||||
sm.mu.Lock()
|
||||
sm.sessions[id] = session
|
||||
sm.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Delete removes a session from both the in-memory map and the database.
|
||||
func (sm *SessionManager) Delete(id string) error {
|
||||
sm.mu.Lock()
|
||||
sess, ok := sm.sessions[id]
|
||||
if ok {
|
||||
// Close the WebSocket connection if it exists
|
||||
if sess.conn != nil {
|
||||
sess.conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session deleted"),
|
||||
time.Now().Add(5*time.Second),
|
||||
)
|
||||
sess.conn.Close()
|
||||
}
|
||||
delete(sm.sessions, id)
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
|
||||
if err := sm.db.DeleteFloatSession(id); err != nil {
|
||||
return fmt.Errorf("delete session: db delete: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[float/session] deleted session %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping updates the last-ping timestamp for a session in both memory and DB.
|
||||
func (sm *SessionManager) Ping(id string) error {
|
||||
now := time.Now()
|
||||
|
||||
sm.mu.Lock()
|
||||
if sess, ok := sm.sessions[id]; ok {
|
||||
sess.LastPing = &now
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
|
||||
if err := sm.db.PingFloatSession(id); err != nil {
|
||||
return fmt.Errorf("ping session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExpired removes all sessions that have passed their expiry time.
|
||||
// Returns the number of sessions removed.
|
||||
func (sm *SessionManager) CleanExpired() (int, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Clean from memory
|
||||
sm.mu.Lock()
|
||||
var expiredIDs []string
|
||||
for id, sess := range sm.sessions {
|
||||
if now.After(sess.ExpiresAt) {
|
||||
expiredIDs = append(expiredIDs, id)
|
||||
if sess.conn != nil {
|
||||
sess.conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "session expired"),
|
||||
now.Add(5*time.Second),
|
||||
)
|
||||
sess.conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range expiredIDs {
|
||||
delete(sm.sessions, id)
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
|
||||
// Clean from database
|
||||
count, err := sm.db.CleanExpiredFloatSessions()
|
||||
if err != nil {
|
||||
return len(expiredIDs), fmt.Errorf("clean expired: db: %w", err)
|
||||
}
|
||||
|
||||
total := int(count)
|
||||
if total > 0 {
|
||||
log.Printf("[float/session] cleaned %d expired sessions", total)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// ActiveCount returns the number of sessions currently in the in-memory map.
|
||||
func (sm *SessionManager) ActiveCount() int {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return len(sm.sessions)
|
||||
}
|
||||
|
||||
// SetConn associates a WebSocket connection with a session.
|
||||
func (sm *SessionManager) SetConn(id string, conn *websocket.Conn) {
|
||||
sm.mu.Lock()
|
||||
if sess, ok := sm.sessions[id]; ok {
|
||||
sess.conn = conn
|
||||
sess.USBBridge = true
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
}
|
||||
|
||||
// List returns all active (non-expired) sessions from the database.
|
||||
func (sm *SessionManager) List() ([]Session, error) {
|
||||
dbSessions, err := sm.db.ListFloatSessions()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
|
||||
sessions := make([]Session, 0, len(dbSessions))
|
||||
for _, dbs := range dbSessions {
|
||||
if time.Now().After(dbs.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
sessions = append(sessions, Session{
|
||||
ID: dbs.ID,
|
||||
UserID: dbs.UserID,
|
||||
ClientIP: dbs.ClientIP,
|
||||
ClientAgent: dbs.ClientAgent,
|
||||
USBBridge: dbs.USBBridge,
|
||||
ConnectedAt: dbs.ConnectedAt,
|
||||
ExpiresAt: dbs.ExpiresAt,
|
||||
LastPing: dbs.LastPing,
|
||||
})
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
272
services/setec-manager/internal/handlers/autarch.go
Normal file
272
services/setec-manager/internal/handlers/autarch.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"setec-manager/internal/deploy"
|
||||
)
|
||||
|
||||
type autarchStatus struct {
|
||||
Installed bool `json:"installed"`
|
||||
InstallDir string `json:"install_dir"`
|
||||
GitCommit string `json:"git_commit"`
|
||||
VenvReady bool `json:"venv_ready"`
|
||||
PipPackages int `json:"pip_packages"`
|
||||
WebRunning bool `json:"web_running"`
|
||||
WebStatus string `json:"web_status"`
|
||||
DNSRunning bool `json:"dns_running"`
|
||||
DNSStatus string `json:"dns_status"`
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := h.getAutarchStatus()
|
||||
h.render(w, "autarch.html", status)
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.getAutarchStatus())
|
||||
}
|
||||
|
||||
func (h *Handler) getAutarchStatus() autarchStatus {
|
||||
dir := h.Config.Autarch.InstallDir
|
||||
status := autarchStatus{InstallDir: dir}
|
||||
|
||||
// Check if installed
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
status.Installed = true
|
||||
}
|
||||
|
||||
// Git commit
|
||||
if hash, message, err := deploy.CurrentCommit(dir); err == nil {
|
||||
status.GitCommit = hash + " " + message
|
||||
}
|
||||
|
||||
// Venv
|
||||
status.VenvReady = deploy.VenvExists(dir)
|
||||
|
||||
// Pip packages
|
||||
venvDir := filepath.Join(dir, "venv")
|
||||
if pkgs, err := deploy.ListPackages(venvDir); err == nil {
|
||||
status.PipPackages = len(pkgs)
|
||||
}
|
||||
|
||||
// Web service
|
||||
webActive, _ := deploy.IsActive("autarch-web")
|
||||
status.WebRunning = webActive
|
||||
if webActive {
|
||||
status.WebStatus = "active"
|
||||
} else {
|
||||
status.WebStatus = "inactive"
|
||||
}
|
||||
|
||||
// DNS service
|
||||
dnsActive, _ := deploy.IsActive("autarch-dns")
|
||||
status.DNSRunning = dnsActive
|
||||
if dnsActive {
|
||||
status.DNSStatus = "active"
|
||||
} else {
|
||||
status.DNSStatus = "inactive"
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchInstall(w http.ResponseWriter, r *http.Request) {
|
||||
dir := h.Config.Autarch.InstallDir
|
||||
repo := h.Config.Autarch.GitRepo
|
||||
branch := h.Config.Autarch.GitBranch
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
writeError(w, http.StatusConflict, "AUTARCH already installed at "+dir)
|
||||
return
|
||||
}
|
||||
|
||||
depID, _ := h.DB.CreateDeployment(nil, "autarch_install")
|
||||
var output strings.Builder
|
||||
|
||||
steps := []struct {
|
||||
label string
|
||||
fn func() error
|
||||
}{
|
||||
{"Clone from GitHub", func() error {
|
||||
os.MkdirAll(filepath.Dir(dir), 0755)
|
||||
out, err := deploy.Clone(repo, branch, dir)
|
||||
output.WriteString(out)
|
||||
return err
|
||||
}},
|
||||
{"Create Python venv", func() error {
|
||||
return deploy.CreateVenv(dir)
|
||||
}},
|
||||
{"Upgrade pip", func() error {
|
||||
venvDir := filepath.Join(dir, "venv")
|
||||
deploy.UpgradePip(venvDir)
|
||||
return nil
|
||||
}},
|
||||
{"Install pip packages", func() error {
|
||||
reqFile := filepath.Join(dir, "requirements.txt")
|
||||
if _, err := os.Stat(reqFile); err != nil {
|
||||
return nil
|
||||
}
|
||||
venvDir := filepath.Join(dir, "venv")
|
||||
out, err := deploy.InstallRequirements(venvDir, reqFile)
|
||||
output.WriteString(out)
|
||||
return err
|
||||
}},
|
||||
{"Install npm packages", func() error {
|
||||
out, _ := deploy.NpmInstall(dir)
|
||||
output.WriteString(out)
|
||||
return nil
|
||||
}},
|
||||
{"Set permissions", func() error {
|
||||
exec.Command("chown", "-R", "root:root", dir).Run()
|
||||
exec.Command("chmod", "-R", "755", dir).Run()
|
||||
for _, d := range []string{"data", "data/certs", "data/dns", "results", "dossiers", "models"} {
|
||||
os.MkdirAll(filepath.Join(dir, d), 0755)
|
||||
}
|
||||
confPath := filepath.Join(dir, "autarch_settings.conf")
|
||||
if _, err := os.Stat(confPath); err == nil {
|
||||
exec.Command("chmod", "600", confPath).Run()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"Install systemd units", func() error {
|
||||
h.installAutarchUnits(dir)
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
output.WriteString(fmt.Sprintf("\n=== %s ===\n", step.label))
|
||||
if err := step.fn(); err != nil {
|
||||
h.DB.FinishDeployment(depID, "failed", output.String())
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("%s failed: %v", step.label, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.DB.FinishDeployment(depID, "success", output.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "installed"})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
dir := h.Config.Autarch.InstallDir
|
||||
|
||||
depID, _ := h.DB.CreateDeployment(nil, "autarch_update")
|
||||
var output strings.Builder
|
||||
|
||||
// Git pull
|
||||
out, err := deploy.Pull(dir)
|
||||
output.WriteString(out)
|
||||
if err != nil {
|
||||
h.DB.FinishDeployment(depID, "failed", output.String())
|
||||
writeError(w, http.StatusInternalServerError, "git pull failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Reinstall pip packages
|
||||
reqFile := filepath.Join(dir, "requirements.txt")
|
||||
if _, err := os.Stat(reqFile); err == nil {
|
||||
venvDir := filepath.Join(dir, "venv")
|
||||
pipOut, _ := deploy.InstallRequirements(venvDir, reqFile)
|
||||
output.WriteString(pipOut)
|
||||
}
|
||||
|
||||
// Restart services
|
||||
deploy.Restart("autarch-web")
|
||||
deploy.Restart("autarch-dns")
|
||||
|
||||
h.DB.FinishDeployment(depID, "success", output.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchStart(w http.ResponseWriter, r *http.Request) {
|
||||
deploy.Start("autarch-web")
|
||||
deploy.Start("autarch-dns")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchStop(w http.ResponseWriter, r *http.Request) {
|
||||
deploy.Stop("autarch-web")
|
||||
deploy.Stop("autarch-dns")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchRestart(w http.ResponseWriter, r *http.Request) {
|
||||
deploy.Restart("autarch-web")
|
||||
deploy.Restart("autarch-dns")
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchConfig(w http.ResponseWriter, r *http.Request) {
|
||||
confPath := filepath.Join(h.Config.Autarch.InstallDir, "autarch_settings.conf")
|
||||
data, err := os.ReadFile(confPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "config not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"config": string(data)})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchConfigUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Config string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
confPath := filepath.Join(h.Config.Autarch.InstallDir, "autarch_settings.conf")
|
||||
if err := os.WriteFile(confPath, []byte(body.Config), 0600); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "saved"})
|
||||
}
|
||||
|
||||
func (h *Handler) AutarchDNSBuild(w http.ResponseWriter, r *http.Request) {
|
||||
dnsDir := filepath.Join(h.Config.Autarch.InstallDir, "services", "dns-server")
|
||||
|
||||
depID, _ := h.DB.CreateDeployment(nil, "dns_build")
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", "autarch-dns", ".")
|
||||
cmd.Dir = dnsDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
if err != nil {
|
||||
h.DB.FinishDeployment(depID, "failed", string(out))
|
||||
writeError(w, http.StatusInternalServerError, "build failed: "+string(out))
|
||||
return
|
||||
}
|
||||
|
||||
h.DB.FinishDeployment(depID, "success", string(out))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "built"})
|
||||
}
|
||||
|
||||
func (h *Handler) installAutarchUnits(dir string) {
|
||||
webUnit := deploy.GenerateUnit(deploy.UnitConfig{
|
||||
Name: "autarch-web",
|
||||
Description: "AUTARCH Web Dashboard",
|
||||
ExecStart: filepath.Join(dir, "venv", "bin", "python3") + " " + filepath.Join(dir, "autarch_web.py"),
|
||||
WorkingDirectory: dir,
|
||||
User: "root",
|
||||
Environment: map[string]string{"PYTHONUNBUFFERED": "1"},
|
||||
})
|
||||
|
||||
dnsUnit := deploy.GenerateUnit(deploy.UnitConfig{
|
||||
Name: "autarch-dns",
|
||||
Description: "AUTARCH DNS Server",
|
||||
ExecStart: filepath.Join(dir, "services", "dns-server", "autarch-dns") + " --config " + filepath.Join(dir, "data", "dns", "config.json"),
|
||||
WorkingDirectory: dir,
|
||||
User: "root",
|
||||
})
|
||||
|
||||
deploy.InstallUnit("autarch-web", webUnit)
|
||||
deploy.InstallUnit("autarch-dns", dnsUnit)
|
||||
}
|
||||
146
services/setec-manager/internal/handlers/backups.go
Normal file
146
services/setec-manager/internal/handlers/backups.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (h *Handler) BackupList(w http.ResponseWriter, r *http.Request) {
|
||||
backups, err := h.DB.ListBackups()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, backups)
|
||||
return
|
||||
}
|
||||
h.render(w, "backups.html", backups)
|
||||
}
|
||||
|
||||
func (h *Handler) BackupSite(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := h.DB.GetSite(id)
|
||||
if err != nil || site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Create backup directory
|
||||
backupDir := h.Config.Backups.Dir
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("site-%s-%s.tar.gz", site.Domain, timestamp)
|
||||
backupPath := filepath.Join(backupDir, filename)
|
||||
|
||||
// Create tar.gz
|
||||
cmd := exec.Command("tar", "-czf", backupPath, "-C", filepath.Dir(site.AppRoot), filepath.Base(site.AppRoot))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("backup failed: %s", string(out)))
|
||||
return
|
||||
}
|
||||
|
||||
// Get file size
|
||||
info, _ := os.Stat(backupPath)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
bID, _ := h.DB.CreateBackup(&id, "site", backupPath, size)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": bID,
|
||||
"path": backupPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) BackupFull(w http.ResponseWriter, r *http.Request) {
|
||||
backupDir := h.Config.Backups.Dir
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
|
||||
timestamp := time.Now().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("full-system-%s.tar.gz", timestamp)
|
||||
backupPath := filepath.Join(backupDir, filename)
|
||||
|
||||
// Backup key directories
|
||||
dirs := []string{
|
||||
h.Config.Nginx.Webroot,
|
||||
"/etc/nginx",
|
||||
"/opt/setec-manager/data",
|
||||
}
|
||||
|
||||
args := []string{"-czf", backupPath}
|
||||
for _, d := range dirs {
|
||||
if _, err := os.Stat(d); err == nil {
|
||||
args = append(args, d)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("tar", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("backup failed: %s", string(out)))
|
||||
return
|
||||
}
|
||||
|
||||
info, _ := os.Stat(backupPath)
|
||||
size := int64(0)
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
bID, _ := h.DB.CreateBackup(nil, "full", backupPath, size)
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": bID,
|
||||
"path": backupPath,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) BackupDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
// Get backup info to delete file
|
||||
var filePath string
|
||||
h.DB.Conn().QueryRow(`SELECT file_path FROM backups WHERE id=?`, id).Scan(&filePath)
|
||||
if filePath != "" {
|
||||
os.Remove(filePath)
|
||||
}
|
||||
|
||||
h.DB.DeleteBackup(id)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) BackupDownload(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var filePath string
|
||||
h.DB.Conn().QueryRow(`SELECT file_path FROM backups WHERE id=?`, id).Scan(&filePath)
|
||||
if filePath == "" {
|
||||
writeError(w, http.StatusNotFound, "backup not found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(filePath)))
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
151
services/setec-manager/internal/handlers/dashboard.go
Normal file
151
services/setec-manager/internal/handlers/dashboard.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/deploy"
|
||||
"setec-manager/internal/system"
|
||||
)
|
||||
|
||||
type systemInfo struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
CPUs int `json:"cpus"`
|
||||
Uptime string `json:"uptime"`
|
||||
LoadAvg string `json:"load_avg"`
|
||||
MemTotal string `json:"mem_total"`
|
||||
MemUsed string `json:"mem_used"`
|
||||
MemPercent float64 `json:"mem_percent"`
|
||||
DiskTotal string `json:"disk_total"`
|
||||
DiskUsed string `json:"disk_used"`
|
||||
DiskPercent float64 `json:"disk_percent"`
|
||||
SiteCount int `json:"site_count"`
|
||||
Services []serviceInfo `json:"services"`
|
||||
}
|
||||
|
||||
type serviceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
|
||||
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
info := h.gatherSystemInfo()
|
||||
h.render(w, "dashboard.html", info)
|
||||
}
|
||||
|
||||
func (h *Handler) SystemInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.gatherSystemInfo())
|
||||
}
|
||||
|
||||
func (h *Handler) gatherSystemInfo() systemInfo {
|
||||
info := systemInfo{
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
CPUs: runtime.NumCPU(),
|
||||
}
|
||||
|
||||
// Hostname — no wrapper, keep exec.Command
|
||||
if out, err := exec.Command("hostname").Output(); err == nil {
|
||||
info.Hostname = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// Uptime
|
||||
if ut, err := system.GetUptime(); err == nil {
|
||||
info.Uptime = "up " + ut.HumanReadable
|
||||
}
|
||||
|
||||
// Load average
|
||||
if la, err := system.GetLoadAvg(); err == nil {
|
||||
info.LoadAvg = fmt.Sprintf("%.2f %.2f %.2f", la.Load1, la.Load5, la.Load15)
|
||||
}
|
||||
|
||||
// Memory
|
||||
if mem, err := system.GetMemory(); err == nil {
|
||||
info.MemTotal = mem.Total
|
||||
info.MemUsed = mem.Used
|
||||
if mem.TotalBytes > 0 {
|
||||
info.MemPercent = float64(mem.UsedBytes) / float64(mem.TotalBytes) * 100
|
||||
}
|
||||
}
|
||||
|
||||
// Disk — find the root mount from the disk list
|
||||
if disks, err := system.GetDisk(); err == nil {
|
||||
for _, d := range disks {
|
||||
if d.MountPoint == "/" {
|
||||
info.DiskTotal = d.Size
|
||||
info.DiskUsed = d.Used
|
||||
pct := strings.TrimSuffix(d.UsePercent, "%")
|
||||
info.DiskPercent, _ = strconv.ParseFloat(pct, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
// If no root mount found but we have disks, use the first one
|
||||
if info.DiskTotal == "" && len(disks) > 0 {
|
||||
d := disks[0]
|
||||
info.DiskTotal = d.Size
|
||||
info.DiskUsed = d.Used
|
||||
pct := strings.TrimSuffix(d.UsePercent, "%")
|
||||
info.DiskPercent, _ = strconv.ParseFloat(pct, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Site count
|
||||
if sites, err := h.DB.ListSites(); err == nil {
|
||||
info.SiteCount = len(sites)
|
||||
}
|
||||
|
||||
// Services
|
||||
services := []struct{ name, unit string }{
|
||||
{"Nginx", "nginx"},
|
||||
{"AUTARCH Web", "autarch-web"},
|
||||
{"AUTARCH DNS", "autarch-dns"},
|
||||
{"Setec Manager", "setec-manager"},
|
||||
}
|
||||
for _, svc := range services {
|
||||
si := serviceInfo{Name: svc.name}
|
||||
active, err := deploy.IsActive(svc.unit)
|
||||
if err == nil && active {
|
||||
si.Status = "active"
|
||||
si.Running = true
|
||||
} else {
|
||||
si.Status = "inactive"
|
||||
si.Running = false
|
||||
}
|
||||
info.Services = append(info.Services, si)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func formatBytes(b float64) string {
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
i := 0
|
||||
for b >= 1024 && i < len(units)-1 {
|
||||
b /= 1024
|
||||
i++
|
||||
}
|
||||
return strconv.FormatFloat(b, 'f', 1, 64) + " " + units[i]
|
||||
}
|
||||
|
||||
// uptimeSince returns a human-readable duration.
|
||||
func uptimeSince(d time.Duration) string {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
mins := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return strconv.Itoa(days) + "d " + strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
|
||||
}
|
||||
if hours > 0 {
|
||||
return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m"
|
||||
}
|
||||
return strconv.Itoa(mins) + "m"
|
||||
}
|
||||
184
services/setec-manager/internal/handlers/firewall.go
Normal file
184
services/setec-manager/internal/handlers/firewall.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"setec-manager/internal/system"
|
||||
)
|
||||
|
||||
type firewallRule struct {
|
||||
ID int64 `json:"id"`
|
||||
Direction string `json:"direction"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port string `json:"port"`
|
||||
Source string `json:"source"`
|
||||
Action string `json:"action"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
type firewallStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Rules []firewallRule `json:"rules"`
|
||||
UFWOut string `json:"ufw_output"`
|
||||
}
|
||||
|
||||
func (h *Handler) FirewallList(w http.ResponseWriter, r *http.Request) {
|
||||
status := h.getFirewallStatus()
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
h.render(w, "firewall.html", status)
|
||||
}
|
||||
|
||||
func (h *Handler) FirewallStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.getFirewallStatus())
|
||||
}
|
||||
|
||||
func (h *Handler) FirewallAddRule(w http.ResponseWriter, r *http.Request) {
|
||||
var rule firewallRule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
rule.Port = r.FormValue("port")
|
||||
rule.Protocol = r.FormValue("protocol")
|
||||
rule.Source = r.FormValue("source")
|
||||
rule.Action = r.FormValue("action")
|
||||
rule.Comment = r.FormValue("comment")
|
||||
}
|
||||
|
||||
if rule.Port == "" {
|
||||
writeError(w, http.StatusBadRequest, "port is required")
|
||||
return
|
||||
}
|
||||
if rule.Protocol == "" {
|
||||
rule.Protocol = "tcp"
|
||||
}
|
||||
if rule.Action == "" {
|
||||
rule.Action = "allow"
|
||||
}
|
||||
if rule.Source == "" {
|
||||
rule.Source = "any"
|
||||
}
|
||||
|
||||
ufwRule := system.UFWRule{
|
||||
Port: rule.Port,
|
||||
Protocol: rule.Protocol,
|
||||
Source: rule.Source,
|
||||
Action: rule.Action,
|
||||
Comment: rule.Comment,
|
||||
}
|
||||
|
||||
if err := system.FirewallAddRule(ufwRule); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
h.DB.Conn().Exec(`INSERT INTO firewall_rules (direction, protocol, port, source, action, comment)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, "in", rule.Protocol, rule.Port, rule.Source, rule.Action, rule.Comment)
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "rule added"})
|
||||
}
|
||||
|
||||
func (h *Handler) FirewallDeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
// Get rule from DB to build delete command
|
||||
var port, protocol, action string
|
||||
err = h.DB.Conn().QueryRow(`SELECT port, protocol, action FROM firewall_rules WHERE id=?`, id).
|
||||
Scan(&port, &protocol, &action)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
|
||||
system.FirewallDeleteRule(system.UFWRule{
|
||||
Port: port,
|
||||
Protocol: protocol,
|
||||
Action: action,
|
||||
})
|
||||
h.DB.Conn().Exec(`DELETE FROM firewall_rules WHERE id=?`, id)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "rule deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) FirewallEnable(w http.ResponseWriter, r *http.Request) {
|
||||
if err := system.FirewallEnable(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
|
||||
}
|
||||
|
||||
func (h *Handler) FirewallDisable(w http.ResponseWriter, r *http.Request) {
|
||||
if err := system.FirewallDisable(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
||||
}
|
||||
|
||||
func (h *Handler) getFirewallStatus() firewallStatus {
|
||||
status := firewallStatus{}
|
||||
|
||||
enabled, _, raw, _ := system.FirewallStatus()
|
||||
status.UFWOut = raw
|
||||
status.Enabled = enabled
|
||||
|
||||
// Load rules from DB
|
||||
rows, err := h.DB.Conn().Query(`SELECT id, direction, protocol, port, source, action, comment
|
||||
FROM firewall_rules WHERE enabled=TRUE ORDER BY id`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var rule firewallRule
|
||||
rows.Scan(&rule.ID, &rule.Direction, &rule.Protocol, &rule.Port,
|
||||
&rule.Source, &rule.Action, &rule.Comment)
|
||||
status.Rules = append(status.Rules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func (h *Handler) InstallDefaultFirewall() error {
|
||||
// Set default policies
|
||||
system.FirewallSetDefaults("deny", "allow")
|
||||
|
||||
// Add default rules
|
||||
defaultRules := []system.UFWRule{
|
||||
{Port: "22", Protocol: "tcp", Action: "allow", Comment: "SSH"},
|
||||
{Port: "80", Protocol: "tcp", Action: "allow", Comment: "HTTP"},
|
||||
{Port: "443", Protocol: "tcp", Action: "allow", Comment: "HTTPS"},
|
||||
{Port: "9090", Protocol: "tcp", Action: "allow", Comment: "Setec Manager"},
|
||||
{Port: "8181", Protocol: "tcp", Action: "allow", Comment: "AUTARCH Web"},
|
||||
{Port: "53", Protocol: "", Action: "allow", Comment: "AUTARCH DNS"},
|
||||
}
|
||||
|
||||
for _, rule := range defaultRules {
|
||||
system.FirewallAddRule(rule)
|
||||
}
|
||||
|
||||
// Enable the firewall
|
||||
system.FirewallEnable()
|
||||
|
||||
// Record in DB
|
||||
dbRules := []firewallRule{
|
||||
{Port: "22", Protocol: "tcp", Action: "allow", Comment: "SSH"},
|
||||
{Port: "80", Protocol: "tcp", Action: "allow", Comment: "HTTP"},
|
||||
{Port: "443", Protocol: "tcp", Action: "allow", Comment: "HTTPS"},
|
||||
{Port: "9090", Protocol: "tcp", Action: "allow", Comment: "Setec Manager"},
|
||||
{Port: "8181", Protocol: "tcp", Action: "allow", Comment: "AUTARCH Web"},
|
||||
{Port: "53", Protocol: "tcp", Action: "allow", Comment: "AUTARCH DNS"},
|
||||
}
|
||||
for _, rule := range dbRules {
|
||||
h.DB.Conn().Exec(`INSERT OR IGNORE INTO firewall_rules (direction, protocol, port, source, action, comment)
|
||||
VALUES ('in', ?, ?, 'any', ?, ?)`, rule.Protocol, rule.Port, rule.Action, rule.Comment)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
66
services/setec-manager/internal/handlers/float.go
Normal file
66
services/setec-manager/internal/handlers/float.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (h *Handler) FloatRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.Config.Float.Enabled {
|
||||
writeError(w, http.StatusServiceUnavailable, "Float Mode is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
// Parse TTL
|
||||
ttl, err := time.ParseDuration(h.Config.Float.SessionTTL)
|
||||
if err != nil {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
clientIP := r.RemoteAddr
|
||||
|
||||
if err := h.DB.CreateFloatSession(sessionID, 0, clientIP, body.UserAgent, time.Now().Add(ttl)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{
|
||||
"session_id": sessionID,
|
||||
"expires_in": h.Config.Float.SessionTTL,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) FloatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
// Clean expired sessions first
|
||||
h.DB.CleanExpiredFloatSessions()
|
||||
|
||||
sessions, err := h.DB.ListFloatSessions()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, sessions)
|
||||
return
|
||||
}
|
||||
h.render(w, "float.html", sessions)
|
||||
}
|
||||
|
||||
func (h *Handler) FloatDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
id := paramStr(r, "id")
|
||||
if err := h.DB.DeleteFloatSession(id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "disconnected"})
|
||||
}
|
||||
103
services/setec-manager/internal/handlers/handlers.go
Normal file
103
services/setec-manager/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"setec-manager/internal/config"
|
||||
"setec-manager/internal/db"
|
||||
"setec-manager/internal/hosting"
|
||||
"setec-manager/web"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Config *config.Config
|
||||
DB *db.DB
|
||||
HostingConfigs *hosting.ProviderConfigStore
|
||||
tmpl *template.Template
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, database *db.DB, hostingConfigs *hosting.ProviderConfigStore) *Handler {
|
||||
return &Handler{
|
||||
Config: cfg,
|
||||
DB: database,
|
||||
HostingConfigs: hostingConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) getTemplates() *template.Template {
|
||||
h.once.Do(func() {
|
||||
funcMap := template.FuncMap{
|
||||
"eq": func(a, b interface{}) bool { return a == b },
|
||||
"ne": func(a, b interface{}) bool { return a != b },
|
||||
"default": func(val, def interface{}) interface{} {
|
||||
if val == nil || val == "" || val == 0 || val == false {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
},
|
||||
}
|
||||
|
||||
var err error
|
||||
h.tmpl, err = template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse templates: %v", err)
|
||||
}
|
||||
|
||||
// Also parse from the static FS to make sure it's available
|
||||
_ = fs.WalkDir(web.StaticFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return h.tmpl
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Data interface{}
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
func (h *Handler) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
pd := pageData{
|
||||
Data: data,
|
||||
Config: h.Config,
|
||||
}
|
||||
|
||||
t := h.getTemplates().Lookup(name)
|
||||
if t == nil {
|
||||
http.Error(w, "Template not found: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.Execute(w, pd); err != nil {
|
||||
log.Printf("[template] %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func paramInt(r *http.Request, name string) (int64, error) {
|
||||
return strconv.ParseInt(chi.URLParam(r, name), 10, 64)
|
||||
}
|
||||
|
||||
func paramStr(r *http.Request, name string) string {
|
||||
return chi.URLParam(r, name)
|
||||
}
|
||||
697
services/setec-manager/internal/handlers/hosting.go
Normal file
697
services/setec-manager/internal/handlers/hosting.go
Normal file
@@ -0,0 +1,697 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"setec-manager/internal/hosting"
|
||||
)
|
||||
|
||||
// providerInfo is the view model sent to the hosting template and JSON responses.
|
||||
type providerInfo struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Connected bool `json:"connected"`
|
||||
HasConfig bool `json:"has_config"`
|
||||
}
|
||||
|
||||
// listProviderInfo builds a summary of every registered provider and its config status.
|
||||
func (h *Handler) listProviderInfo() []providerInfo {
|
||||
names := hosting.List()
|
||||
out := make([]providerInfo, 0, len(names))
|
||||
for _, name := range names {
|
||||
p, ok := hosting.Get(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pi := providerInfo{
|
||||
Name: p.Name(),
|
||||
DisplayName: p.DisplayName(),
|
||||
}
|
||||
if h.HostingConfigs != nil {
|
||||
cfg, err := h.HostingConfigs.Load(name)
|
||||
if err == nil && cfg != nil {
|
||||
pi.HasConfig = true
|
||||
if cfg.APIKey != "" {
|
||||
pi.Connected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, pi)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getProvider retrieves the provider from the URL and returns it. On error it
|
||||
// writes an HTTP error and returns nil.
|
||||
func (h *Handler) getProvider(w http.ResponseWriter, r *http.Request) hosting.Provider {
|
||||
name := paramStr(r, "provider")
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing provider name")
|
||||
return nil
|
||||
}
|
||||
p, ok := hosting.Get(name)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "hosting provider "+name+" not registered")
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// configureProvider loads saved credentials for a provider and calls Configure
|
||||
// on it so it is ready for API calls. Returns false if no config is saved.
|
||||
func (h *Handler) configureProvider(p hosting.Provider) bool {
|
||||
if h.HostingConfigs == nil {
|
||||
return false
|
||||
}
|
||||
cfg, err := h.HostingConfigs.Load(p.Name())
|
||||
if err != nil || cfg == nil || cfg.APIKey == "" {
|
||||
return false
|
||||
}
|
||||
if err := p.Configure(*cfg); err != nil {
|
||||
log.Printf("[hosting] configure %s: %v", p.Name(), err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Page Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
// HostingProviders renders the hosting management page (GET /hosting).
|
||||
func (h *Handler) HostingProviders(w http.ResponseWriter, r *http.Request) {
|
||||
providers := h.listProviderInfo()
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, providers)
|
||||
return
|
||||
}
|
||||
h.render(w, "hosting.html", map[string]interface{}{
|
||||
"Providers": providers,
|
||||
})
|
||||
}
|
||||
|
||||
// HostingProviderConfig returns the config page/detail for a single provider.
|
||||
func (h *Handler) HostingProviderConfig(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var savedCfg *hosting.ProviderConfig
|
||||
if h.HostingConfigs != nil {
|
||||
savedCfg, _ = h.HostingConfigs.Load(p.Name())
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Provider": providerInfo{Name: p.Name(), DisplayName: p.DisplayName()},
|
||||
"Config": savedCfg,
|
||||
"Providers": h.listProviderInfo(),
|
||||
}
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
return
|
||||
}
|
||||
h.render(w, "hosting.html", data)
|
||||
}
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
// HostingProviderSave saves API credentials and tests the connection.
|
||||
func (h *Handler) HostingProviderSave(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.APIKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "api_key is required")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := hosting.ProviderConfig{
|
||||
APIKey: body.APIKey,
|
||||
APISecret: body.APISecret,
|
||||
Extra: body.Extra,
|
||||
}
|
||||
|
||||
// Configure the provider to validate credentials.
|
||||
if err := p.Configure(cfg); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "configure: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Test the connection.
|
||||
connected := true
|
||||
if err := p.TestConnection(); err != nil {
|
||||
log.Printf("[hosting] test %s failed: %v", p.Name(), err)
|
||||
connected = false
|
||||
}
|
||||
|
||||
// Persist.
|
||||
if h.HostingConfigs != nil {
|
||||
if err := h.HostingConfigs.Save(p.Name(), cfg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "save config: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "saved",
|
||||
"connected": connected,
|
||||
})
|
||||
}
|
||||
|
||||
// HostingProviderTest tests the connection to a provider without saving.
|
||||
func (h *Handler) HostingProviderTest(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured — save credentials first")
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.TestConnection(); err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"connected": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"connected": true,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── DNS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
// HostingDNSList returns DNS records for a domain.
|
||||
func (h *Handler) HostingDNSList(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
records, err := p.ListDNSRecords(domain)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, records)
|
||||
}
|
||||
|
||||
// HostingDNSUpdate replaces DNS records for a domain.
|
||||
func (h *Handler) HostingDNSUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
var body struct {
|
||||
Records []hosting.DNSRecord `json:"records"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.UpdateDNSRecords(domain, body.Records, body.Overwrite); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// HostingDNSDelete deletes DNS records matching name+type for a domain.
|
||||
func (h *Handler) HostingDNSDelete(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
filter := hosting.DNSRecordFilter{Name: body.Name, Type: body.Type}
|
||||
if err := p.DeleteDNSRecord(domain, filter); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// HostingDNSReset resets DNS records for a domain to provider defaults.
|
||||
func (h *Handler) HostingDNSReset(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
if err := p.ResetDNSRecords(domain); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "reset"})
|
||||
}
|
||||
|
||||
// ─── Domains ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// HostingDomainsList returns all domains registered with the provider.
|
||||
func (h *Handler) HostingDomainsList(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domains, err := p.ListDomains()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, domains)
|
||||
}
|
||||
|
||||
// HostingDomainsCheck checks availability of a domain across TLDs.
|
||||
func (h *Handler) HostingDomainsCheck(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Domain string `json:"domain"`
|
||||
TLDs []string `json:"tlds"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "domain is required")
|
||||
return
|
||||
}
|
||||
|
||||
results, err := p.CheckDomainAvailability(body.Domain, body.TLDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
|
||||
// HostingDomainsPurchase purchases a domain.
|
||||
func (h *Handler) HostingDomainsPurchase(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req hosting.DomainPurchaseRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "domain is required")
|
||||
return
|
||||
}
|
||||
if req.Years <= 0 {
|
||||
req.Years = 1
|
||||
}
|
||||
|
||||
result, err := p.PurchaseDomain(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// HostingDomainNameservers updates nameservers for a domain.
|
||||
func (h *Handler) HostingDomainNameservers(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
var body struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if len(body.Nameservers) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "nameservers list is empty")
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.SetNameservers(domain, body.Nameservers); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// HostingDomainLock toggles the registrar lock on a domain.
|
||||
func (h *Handler) HostingDomainLock(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
var body struct {
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if body.Locked {
|
||||
err = p.EnableDomainLock(domain)
|
||||
} else {
|
||||
err = p.DisableDomainLock(domain)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "updated", "locked": body.Locked})
|
||||
}
|
||||
|
||||
// HostingDomainPrivacy toggles privacy protection on a domain.
|
||||
func (h *Handler) HostingDomainPrivacy(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
var body struct {
|
||||
Privacy bool `json:"privacy"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if body.Privacy {
|
||||
err = p.EnablePrivacyProtection(domain)
|
||||
} else {
|
||||
err = p.DisablePrivacyProtection(domain)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "updated", "privacy": body.Privacy})
|
||||
}
|
||||
|
||||
// ─── VMs / VPS ───────────────────────────────────────────────────────────────
|
||||
|
||||
// HostingVMsList lists all VMs for a provider.
|
||||
func (h *Handler) HostingVMsList(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
vms, err := p.ListVMs()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, vms)
|
||||
}
|
||||
|
||||
// HostingVMGet returns details for a single VM.
|
||||
func (h *Handler) HostingVMGet(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := paramStr(r, "id")
|
||||
vm, err := p.GetVM(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if vm == nil {
|
||||
writeError(w, http.StatusNotFound, "VM not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, vm)
|
||||
}
|
||||
|
||||
// HostingVMCreate creates a new VM.
|
||||
func (h *Handler) HostingVMCreate(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var req hosting.VMCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Plan == "" {
|
||||
writeError(w, http.StatusBadRequest, "plan is required")
|
||||
return
|
||||
}
|
||||
if req.DataCenterID == "" {
|
||||
writeError(w, http.StatusBadRequest, "data_center_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := p.CreateVM(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// HostingDataCenters lists available data centers.
|
||||
func (h *Handler) HostingDataCenters(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
dcs, err := p.ListDataCenters()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dcs)
|
||||
}
|
||||
|
||||
// ─── SSH Keys ────────────────────────────────────────────────────────────────
|
||||
|
||||
// HostingSSHKeys lists SSH keys for the provider account.
|
||||
func (h *Handler) HostingSSHKeys(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
keys, err := p.ListSSHKeys()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// HostingSSHKeyAdd adds an SSH key.
|
||||
func (h *Handler) HostingSSHKeyAdd(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Name == "" || body.PublicKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and public_key are required")
|
||||
return
|
||||
}
|
||||
|
||||
key, err := p.AddSSHKey(body.Name, body.PublicKey)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, key)
|
||||
}
|
||||
|
||||
// HostingSSHKeyDelete deletes an SSH key.
|
||||
func (h *Handler) HostingSSHKeyDelete(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := paramStr(r, "id")
|
||||
if err := p.DeleteSSHKey(id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ─── Billing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// HostingSubscriptions lists billing subscriptions.
|
||||
func (h *Handler) HostingSubscriptions(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
subs, err := p.ListSubscriptions()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, subs)
|
||||
}
|
||||
|
||||
// HostingCatalog returns the product catalog.
|
||||
func (h *Handler) HostingCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
p := h.getProvider(w, r)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if !h.configureProvider(p) {
|
||||
writeError(w, http.StatusBadRequest, "provider not configured")
|
||||
return
|
||||
}
|
||||
|
||||
category := r.URL.Query().Get("category")
|
||||
items, err := p.GetCatalog(category)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, items)
|
||||
}
|
||||
133
services/setec-manager/internal/handlers/logs.go
Normal file
133
services/setec-manager/internal/handlers/logs.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"setec-manager/internal/deploy"
|
||||
)
|
||||
|
||||
func (h *Handler) LogsPage(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "logs.html", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) LogsSystem(w http.ResponseWriter, r *http.Request) {
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
if linesStr == "" {
|
||||
linesStr = "100"
|
||||
}
|
||||
lines, err := strconv.Atoi(linesStr)
|
||||
if err != nil {
|
||||
lines = 100
|
||||
}
|
||||
|
||||
// deploy.Logs requires a unit name; for system-wide logs we pass an empty
|
||||
// unit and use journalctl directly. However, deploy.Logs always passes -u,
|
||||
// so we use it with a broad scope by requesting the system journal for a
|
||||
// pseudo-unit. Instead, keep using journalctl directly for system-wide logs
|
||||
// since deploy.Logs is unit-scoped.
|
||||
out, err := exec.Command("journalctl", "-n", strconv.Itoa(lines), "--no-pager", "-o", "short-iso").Output()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"logs": string(out)})
|
||||
}
|
||||
|
||||
func (h *Handler) LogsNginx(w http.ResponseWriter, r *http.Request) {
|
||||
logType := r.URL.Query().Get("type")
|
||||
if logType == "" {
|
||||
logType = "access"
|
||||
}
|
||||
|
||||
var logPath string
|
||||
switch logType {
|
||||
case "access":
|
||||
logPath = "/var/log/nginx/access.log"
|
||||
case "error":
|
||||
logPath = "/var/log/nginx/error.log"
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid log type")
|
||||
return
|
||||
}
|
||||
|
||||
out, err := exec.Command("tail", "-n", "200", logPath).Output()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"logs": string(out), "type": logType})
|
||||
}
|
||||
|
||||
func (h *Handler) LogsUnit(w http.ResponseWriter, r *http.Request) {
|
||||
unit := r.URL.Query().Get("unit")
|
||||
if unit == "" {
|
||||
writeError(w, http.StatusBadRequest, "unit parameter required")
|
||||
return
|
||||
}
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
if linesStr == "" {
|
||||
linesStr = "100"
|
||||
}
|
||||
lines, err := strconv.Atoi(linesStr)
|
||||
if err != nil {
|
||||
lines = 100
|
||||
}
|
||||
|
||||
out, err := deploy.Logs(unit, lines)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"logs": out, "unit": unit})
|
||||
}
|
||||
|
||||
func (h *Handler) LogsStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
unit := r.URL.Query().Get("unit")
|
||||
if unit == "" {
|
||||
unit = "autarch-web"
|
||||
}
|
||||
|
||||
// SSE live streaming requires journalctl -f which the deploy package does
|
||||
// not support (it only returns a snapshot). Keep inline exec.Command here.
|
||||
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "--no-pager", "-o", "short-iso")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd.Start()
|
||||
defer cmd.Process.Kill()
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
default:
|
||||
n, err := stdout.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
lines := strings.Split(strings.TrimSpace(string(buf[:n])), "\n")
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(w, "data: %s\n\n", line)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
services/setec-manager/internal/handlers/monitor.go
Normal file
126
services/setec-manager/internal/handlers/monitor.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"setec-manager/internal/deploy"
|
||||
"setec-manager/internal/system"
|
||||
)
|
||||
|
||||
func (h *Handler) MonitorPage(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "monitor.html", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) MonitorCPU(w http.ResponseWriter, r *http.Request) {
|
||||
cpu, err := system.GetCPUUsage()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a summary line matching the previous top-style format.
|
||||
sysPct := 0.0
|
||||
userPct := cpu.Overall
|
||||
if len(cpu.Cores) > 0 {
|
||||
// Use aggregate core data for a more accurate breakdown
|
||||
var totalUser, totalSys float64
|
||||
for _, c := range cpu.Cores {
|
||||
totalUser += c.User
|
||||
totalSys += c.System
|
||||
}
|
||||
userPct = totalUser / float64(len(cpu.Cores))
|
||||
sysPct = totalSys / float64(len(cpu.Cores))
|
||||
}
|
||||
cpuLine := fmt.Sprintf("%%Cpu(s): %.1f us, %.1f sy, %.1f id",
|
||||
userPct, sysPct, cpu.Idle)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"cpu": cpuLine,
|
||||
"overall": cpu.Overall,
|
||||
"idle": cpu.Idle,
|
||||
"cores": cpu.Cores,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) MonitorMemory(w http.ResponseWriter, r *http.Request) {
|
||||
mem, err := system.GetMemory()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"total": mem.Total,
|
||||
"used": mem.Used,
|
||||
"free": mem.Free,
|
||||
"available": mem.Available,
|
||||
"swap_total": mem.SwapTotal,
|
||||
"swap_used": mem.SwapUsed,
|
||||
"swap_free": mem.SwapFree,
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *Handler) MonitorDisk(w http.ResponseWriter, r *http.Request) {
|
||||
disks, err := system.GetDisk()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, disks)
|
||||
}
|
||||
|
||||
func (h *Handler) MonitorServices(w http.ResponseWriter, r *http.Request) {
|
||||
services := []string{"nginx", "autarch-web", "autarch-dns", "setec-manager", "ufw"}
|
||||
|
||||
type svcStatus struct {
|
||||
Name string `json:"name"`
|
||||
Active string `json:"active"`
|
||||
Running bool `json:"running"`
|
||||
Memory string `json:"memory"`
|
||||
}
|
||||
|
||||
var statuses []svcStatus
|
||||
for _, svc := range services {
|
||||
ss := svcStatus{Name: svc}
|
||||
active, err := deploy.IsActive(svc)
|
||||
if err == nil && active {
|
||||
ss.Active = "active"
|
||||
ss.Running = true
|
||||
} else {
|
||||
ss.Active = "inactive"
|
||||
ss.Running = false
|
||||
}
|
||||
|
||||
// Get memory usage — no wrapper exists for this property, so use exec
|
||||
if ss.Running {
|
||||
out, err := exec.Command("systemctl", "show", svc, "--property=MemoryCurrent").Output()
|
||||
if err == nil {
|
||||
parts := strings.SplitN(string(out), "=", 2)
|
||||
if len(parts) == 2 {
|
||||
val := strings.TrimSpace(parts[1])
|
||||
if val != "[not set]" && val != "" {
|
||||
bytes := parseUint64(val)
|
||||
ss.Memory = formatBytes(float64(bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statuses = append(statuses, ss)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, statuses)
|
||||
}
|
||||
|
||||
// parseUint64 is a helper that returns 0 on failure.
|
||||
func parseUint64(s string) uint64 {
|
||||
var n uint64
|
||||
fmt.Sscanf(s, "%d", &n)
|
||||
return n
|
||||
}
|
||||
97
services/setec-manager/internal/handlers/nginx.go
Normal file
97
services/setec-manager/internal/handlers/nginx.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"setec-manager/internal/nginx"
|
||||
"setec-manager/internal/system"
|
||||
)
|
||||
|
||||
type nginxStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
ConfigTest string `json:"config_test"`
|
||||
ConfigOK bool `json:"config_ok"`
|
||||
}
|
||||
|
||||
func (h *Handler) NginxStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status := nginxStatus{}
|
||||
status.Status, status.Running = nginx.Status()
|
||||
|
||||
testOut, testErr := nginx.Test()
|
||||
status.ConfigTest = testOut
|
||||
status.ConfigOK = testErr == nil
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
h.render(w, "nginx.html", status)
|
||||
}
|
||||
|
||||
func (h *Handler) NginxReload(w http.ResponseWriter, r *http.Request) {
|
||||
// Validate config first
|
||||
if _, err := nginx.Test(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "nginx config test failed — not reloading")
|
||||
return
|
||||
}
|
||||
if err := nginx.Reload(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
|
||||
}
|
||||
|
||||
func (h *Handler) NginxRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := nginx.Restart(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
|
||||
}
|
||||
|
||||
func (h *Handler) NginxConfigView(w http.ResponseWriter, r *http.Request) {
|
||||
domain := paramStr(r, "domain")
|
||||
path := filepath.Join(h.Config.Nginx.SitesAvailable, domain)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "config not found for "+domain)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"domain": domain, "config": string(data)})
|
||||
}
|
||||
|
||||
func (h *Handler) NginxTest(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := nginx.Test()
|
||||
ok := err == nil
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"output": strings.TrimSpace(out),
|
||||
"valid": ok,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) NginxInstallBase(w http.ResponseWriter, r *http.Request) {
|
||||
// Install nginx if not present
|
||||
if _, err := exec.LookPath("nginx"); err != nil {
|
||||
if _, installErr := system.PackageInstall("nginx"); installErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, installErr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Install snippets
|
||||
if err := nginx.InstallSnippets(h.Config); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure certbot webroot exists
|
||||
os.MkdirAll(h.Config.Nginx.CertbotWebroot, 0755)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "nginx configured"})
|
||||
}
|
||||
453
services/setec-manager/internal/handlers/sites.go
Normal file
453
services/setec-manager/internal/handlers/sites.go
Normal file
@@ -0,0 +1,453 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"setec-manager/internal/db"
|
||||
"setec-manager/internal/deploy"
|
||||
"setec-manager/internal/nginx"
|
||||
)
|
||||
|
||||
var validDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
func isValidDomain(domain string) bool {
|
||||
if len(domain) > 253 {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(domain) != nil {
|
||||
return true
|
||||
}
|
||||
return validDomainRegex.MatchString(domain)
|
||||
}
|
||||
|
||||
func (h *Handler) SiteList(w http.ResponseWriter, r *http.Request) {
|
||||
sites, err := h.DB.ListSites()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Enrich with running status
|
||||
type siteView struct {
|
||||
db.Site
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var views []siteView
|
||||
for _, s := range sites {
|
||||
sv := siteView{Site: s}
|
||||
if s.AppType != "static" && s.AppPort > 0 {
|
||||
unitName := fmt.Sprintf("app-%s", s.Domain)
|
||||
active, _ := deploy.IsActive(unitName)
|
||||
sv.Running = active
|
||||
if active {
|
||||
sv.Status = "active"
|
||||
} else {
|
||||
sv.Status = "inactive"
|
||||
}
|
||||
} else {
|
||||
sv.Status = "static"
|
||||
sv.Running = s.Enabled
|
||||
}
|
||||
views = append(views, sv)
|
||||
}
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, views)
|
||||
return
|
||||
}
|
||||
h.render(w, "sites.html", views)
|
||||
}
|
||||
|
||||
func (h *Handler) SiteNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "site_new.html", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) SiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var site db.Site
|
||||
if err := json.NewDecoder(r.Body).Decode(&site); err != nil {
|
||||
// Try form values
|
||||
site.Domain = r.FormValue("domain")
|
||||
site.Aliases = r.FormValue("aliases")
|
||||
site.AppType = r.FormValue("app_type")
|
||||
site.AppRoot = r.FormValue("app_root")
|
||||
site.GitRepo = r.FormValue("git_repo")
|
||||
site.GitBranch = r.FormValue("git_branch")
|
||||
site.AppEntry = r.FormValue("app_entry")
|
||||
}
|
||||
|
||||
if site.Domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "domain is required")
|
||||
return
|
||||
}
|
||||
if !isValidDomain(site.Domain) {
|
||||
writeError(w, http.StatusBadRequest, "invalid domain name")
|
||||
return
|
||||
}
|
||||
if site.AppType == "" {
|
||||
site.AppType = "static"
|
||||
}
|
||||
if site.AppRoot == "" {
|
||||
site.AppRoot = filepath.Join(h.Config.Nginx.Webroot, site.Domain)
|
||||
}
|
||||
if site.GitBranch == "" {
|
||||
site.GitBranch = "main"
|
||||
}
|
||||
site.Enabled = true
|
||||
|
||||
// Check for duplicate
|
||||
existing, _ := h.DB.GetSiteByDomain(site.Domain)
|
||||
if existing != nil {
|
||||
writeError(w, http.StatusConflict, "domain already exists")
|
||||
return
|
||||
}
|
||||
|
||||
// Create directory
|
||||
os.MkdirAll(site.AppRoot, 0755)
|
||||
|
||||
// Clone repo if provided
|
||||
if site.GitRepo != "" {
|
||||
depID, _ := h.DB.CreateDeployment(nil, "clone")
|
||||
out, err := deploy.Clone(site.GitRepo, site.GitBranch, site.AppRoot)
|
||||
if err != nil {
|
||||
h.DB.FinishDeployment(depID, "failed", out)
|
||||
writeError(w, http.StatusInternalServerError, "git clone failed: "+out)
|
||||
return
|
||||
}
|
||||
h.DB.FinishDeployment(depID, "success", out)
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
id, err := h.DB.CreateSite(&site)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
site.ID = id
|
||||
|
||||
// Generate nginx config
|
||||
if err := nginx.GenerateConfig(h.Config, &site); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "nginx config: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Enable site
|
||||
nginx.EnableSite(h.Config, site.Domain)
|
||||
nginx.Reload()
|
||||
|
||||
// Generate systemd unit for non-static apps
|
||||
if site.AppType != "static" && site.AppEntry != "" {
|
||||
h.generateAppUnit(&site)
|
||||
}
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusCreated, site)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/sites/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) SiteDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := h.DB.GetSite(id)
|
||||
if err != nil || site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Get deployment history
|
||||
deps, _ := h.DB.ListDeployments(&id, 10)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Site": site,
|
||||
"Deployments": deps,
|
||||
}
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
return
|
||||
}
|
||||
h.render(w, "site_detail.html", data)
|
||||
}
|
||||
|
||||
func (h *Handler) SiteUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := h.DB.GetSite(id)
|
||||
if err != nil || site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
var update db.Site
|
||||
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if update.Domain != "" {
|
||||
site.Domain = update.Domain
|
||||
}
|
||||
site.Aliases = update.Aliases
|
||||
if update.AppType != "" {
|
||||
site.AppType = update.AppType
|
||||
}
|
||||
if update.AppPort > 0 {
|
||||
site.AppPort = update.AppPort
|
||||
}
|
||||
site.AppEntry = update.AppEntry
|
||||
site.GitRepo = update.GitRepo
|
||||
site.GitBranch = update.GitBranch
|
||||
site.SSLEnabled = update.SSLEnabled
|
||||
site.Enabled = update.Enabled
|
||||
|
||||
if err := h.DB.UpdateSite(site); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Regenerate nginx config
|
||||
nginx.GenerateConfig(h.Config, site)
|
||||
nginx.Reload()
|
||||
|
||||
writeJSON(w, http.StatusOK, site)
|
||||
}
|
||||
|
||||
func (h *Handler) SiteDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := h.DB.GetSite(id)
|
||||
if err != nil || site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Disable nginx
|
||||
nginx.DisableSite(h.Config, site.Domain)
|
||||
nginx.Reload()
|
||||
|
||||
// Stop, disable, and remove the systemd unit
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
deploy.RemoveUnit(unitName)
|
||||
|
||||
if err := h.DB.DeleteSite(id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func (h *Handler) SiteDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
site, err := h.DB.GetSite(id)
|
||||
if err != nil || site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
depID, _ := h.DB.CreateDeployment(&id, "deploy")
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Git pull
|
||||
if site.GitRepo != "" {
|
||||
out, err := deploy.Pull(site.AppRoot)
|
||||
output.WriteString(out)
|
||||
if err != nil {
|
||||
h.DB.FinishDeployment(depID, "failed", output.String())
|
||||
writeError(w, http.StatusInternalServerError, "git pull failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Reinstall deps based on app type
|
||||
switch site.AppType {
|
||||
case "python", "autarch":
|
||||
venvDir := filepath.Join(site.AppRoot, "venv")
|
||||
reqFile := filepath.Join(site.AppRoot, "requirements.txt")
|
||||
if _, err := os.Stat(reqFile); err == nil {
|
||||
out, _ := deploy.InstallRequirements(venvDir, reqFile)
|
||||
output.WriteString(out)
|
||||
}
|
||||
case "node":
|
||||
out, _ := deploy.NpmInstall(site.AppRoot)
|
||||
output.WriteString(out)
|
||||
}
|
||||
|
||||
// Restart service
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
deploy.Restart(unitName)
|
||||
|
||||
h.DB.FinishDeployment(depID, "success", output.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deployed"})
|
||||
}
|
||||
|
||||
func (h *Handler) SiteRestart(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := paramInt(r, "id")
|
||||
site, _ := h.DB.GetSite(id)
|
||||
if site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
deploy.Restart(unitName)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
|
||||
}
|
||||
|
||||
func (h *Handler) SiteStop(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := paramInt(r, "id")
|
||||
site, _ := h.DB.GetSite(id)
|
||||
if site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
deploy.Stop(unitName)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||
}
|
||||
|
||||
func (h *Handler) SiteStart(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := paramInt(r, "id")
|
||||
site, _ := h.DB.GetSite(id)
|
||||
if site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
deploy.Start(unitName)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
||||
}
|
||||
|
||||
func (h *Handler) SiteLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := paramInt(r, "id")
|
||||
site, _ := h.DB.GetSite(id)
|
||||
if site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
out, _ := deploy.Logs(unitName, 100)
|
||||
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"logs": out})
|
||||
return
|
||||
}
|
||||
h.render(w, "site_detail.html", map[string]interface{}{
|
||||
"Site": site,
|
||||
"Logs": out,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) SiteLogStream(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := paramInt(r, "id")
|
||||
site, _ := h.DB.GetSite(id)
|
||||
if site == nil {
|
||||
writeError(w, http.StatusNotFound, "site not found")
|
||||
return
|
||||
}
|
||||
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
streamJournalctl(w, r, unitName)
|
||||
}
|
||||
|
||||
func (h *Handler) generateAppUnit(site *db.Site) {
|
||||
var execStart string
|
||||
|
||||
switch site.AppType {
|
||||
case "python":
|
||||
venvPython := filepath.Join(site.AppRoot, "venv", "bin", "python3")
|
||||
execStart = fmt.Sprintf("%s %s", venvPython, filepath.Join(site.AppRoot, site.AppEntry))
|
||||
case "node":
|
||||
execStart = fmt.Sprintf("/usr/bin/node %s", filepath.Join(site.AppRoot, site.AppEntry))
|
||||
case "autarch":
|
||||
venvPython := filepath.Join(site.AppRoot, "venv", "bin", "python3")
|
||||
execStart = fmt.Sprintf("%s %s", venvPython, filepath.Join(site.AppRoot, "autarch_web.py"))
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
unitName := fmt.Sprintf("app-%s", site.Domain)
|
||||
unitContent := deploy.GenerateUnit(deploy.UnitConfig{
|
||||
Name: unitName,
|
||||
Description: fmt.Sprintf("%s (%s)", site.Domain, site.AppType),
|
||||
ExecStart: execStart,
|
||||
WorkingDirectory: site.AppRoot,
|
||||
User: "root",
|
||||
Environment: map[string]string{"PYTHONUNBUFFERED": "1"},
|
||||
})
|
||||
|
||||
deploy.InstallUnit(unitName, unitContent)
|
||||
deploy.Enable(unitName)
|
||||
}
|
||||
|
||||
func acceptsJSON(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return strings.Contains(accept, "application/json")
|
||||
}
|
||||
|
||||
func streamJournalctl(w http.ResponseWriter, r *http.Request, unit string) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "50", "--no-pager", "-o", "short-iso")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd.Start()
|
||||
defer cmd.Process.Kill()
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
default:
|
||||
n, err := stdout.Read(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
fmt.Fprintf(w, "data: %s\n\n", strings.TrimSpace(string(buf[:n])))
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
services/setec-manager/internal/handlers/ssl.go
Normal file
143
services/setec-manager/internal/handlers/ssl.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/acme"
|
||||
)
|
||||
|
||||
type certInfo struct {
|
||||
Domain string `json:"domain"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
}
|
||||
|
||||
func (h *Handler) SSLOverview(w http.ResponseWriter, r *http.Request) {
|
||||
certs := h.listCerts()
|
||||
h.render(w, "ssl.html", certs)
|
||||
}
|
||||
|
||||
func (h *Handler) SSLStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.listCerts())
|
||||
}
|
||||
|
||||
func (h *Handler) SSLIssue(w http.ResponseWriter, r *http.Request) {
|
||||
domain := paramStr(r, "domain")
|
||||
if domain == "" {
|
||||
writeError(w, http.StatusBadRequest, "domain required")
|
||||
return
|
||||
}
|
||||
|
||||
client := acme.NewClient(
|
||||
h.Config.ACME.Email,
|
||||
h.Config.ACME.Staging,
|
||||
h.Config.Nginx.CertbotWebroot,
|
||||
h.Config.ACME.AccountDir,
|
||||
)
|
||||
|
||||
info, err := client.Issue(domain)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot failed: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Update site SSL paths
|
||||
site, _ := h.DB.GetSiteByDomain(domain)
|
||||
if site != nil {
|
||||
site.SSLEnabled = true
|
||||
site.SSLCertPath = info.CertPath
|
||||
site.SSLKeyPath = info.KeyPath
|
||||
h.DB.UpdateSite(site)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "issued", "cert": info.CertPath})
|
||||
}
|
||||
|
||||
func (h *Handler) SSLRenew(w http.ResponseWriter, r *http.Request) {
|
||||
domain := paramStr(r, "domain")
|
||||
|
||||
client := acme.NewClient(
|
||||
h.Config.ACME.Email,
|
||||
h.Config.ACME.Staging,
|
||||
h.Config.Nginx.CertbotWebroot,
|
||||
h.Config.ACME.AccountDir,
|
||||
)
|
||||
|
||||
if err := client.Renew(domain); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("renewal failed: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "renewed"})
|
||||
}
|
||||
|
||||
func (h *Handler) listCerts() []certInfo {
|
||||
var certs []certInfo
|
||||
|
||||
// First, gather certs from DB-tracked sites
|
||||
sites, _ := h.DB.ListSites()
|
||||
for _, site := range sites {
|
||||
if !site.SSLEnabled || site.SSLCertPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
ci := certInfo{
|
||||
Domain: site.Domain,
|
||||
AutoRenew: site.SSLAuto,
|
||||
}
|
||||
|
||||
client := acme.NewClient(
|
||||
h.Config.ACME.Email,
|
||||
h.Config.ACME.Staging,
|
||||
h.Config.Nginx.CertbotWebroot,
|
||||
h.Config.ACME.AccountDir,
|
||||
)
|
||||
|
||||
info, err := client.GetCertInfo(site.Domain)
|
||||
if err == nil {
|
||||
ci.Issuer = info.Issuer
|
||||
ci.NotBefore = info.ExpiresAt.Add(-90 * 24 * time.Hour) // approximate
|
||||
ci.NotAfter = info.ExpiresAt
|
||||
ci.DaysLeft = info.DaysLeft
|
||||
}
|
||||
|
||||
certs = append(certs, ci)
|
||||
}
|
||||
|
||||
// Also check Let's Encrypt certs directory via ACME client
|
||||
client := acme.NewClient(
|
||||
h.Config.ACME.Email,
|
||||
h.Config.ACME.Staging,
|
||||
h.Config.Nginx.CertbotWebroot,
|
||||
h.Config.ACME.AccountDir,
|
||||
)
|
||||
|
||||
leCerts, _ := client.ListCerts()
|
||||
for _, le := range leCerts {
|
||||
// Skip if already found via site
|
||||
found := false
|
||||
for _, c := range certs {
|
||||
if c.Domain == le.Domain {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
|
||||
certs = append(certs, certInfo{
|
||||
Domain: le.Domain,
|
||||
Issuer: le.Issuer,
|
||||
NotAfter: le.ExpiresAt,
|
||||
DaysLeft: le.DaysLeft,
|
||||
})
|
||||
}
|
||||
|
||||
return certs
|
||||
}
|
||||
176
services/setec-manager/internal/handlers/users.go
Normal file
176
services/setec-manager/internal/handlers/users.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"setec-manager/internal/system"
|
||||
)
|
||||
|
||||
// ── System Users ────────────────────────────────────────────────────
|
||||
|
||||
type sysUser struct {
|
||||
Username string `json:"username"`
|
||||
UID string `json:"uid"`
|
||||
HomeDir string `json:"home_dir"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
|
||||
func (h *Handler) UserList(w http.ResponseWriter, r *http.Request) {
|
||||
users := listSystemUsers()
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
return
|
||||
}
|
||||
h.render(w, "users.html", users)
|
||||
}
|
||||
|
||||
func (h *Handler) UserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
body.Username = r.FormValue("username")
|
||||
body.Password = r.FormValue("password")
|
||||
body.Shell = r.FormValue("shell")
|
||||
}
|
||||
|
||||
if body.Username == "" || body.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "username and password required")
|
||||
return
|
||||
}
|
||||
if body.Shell == "" {
|
||||
body.Shell = "/bin/bash"
|
||||
}
|
||||
|
||||
if err := system.CreateUser(body.Username, body.Password, body.Shell); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("create user failed: %s", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"status": "created", "username": body.Username})
|
||||
}
|
||||
|
||||
func (h *Handler) UserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := paramStr(r, "id") // actually username for system users
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "username required")
|
||||
return
|
||||
}
|
||||
|
||||
// Safety check
|
||||
if id == "root" || id == "autarch" {
|
||||
writeError(w, http.StatusForbidden, "cannot delete system accounts")
|
||||
return
|
||||
}
|
||||
|
||||
if err := system.DeleteUser(id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func listSystemUsers() []sysUser {
|
||||
systemUsers, err := system.ListUsers()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var users []sysUser
|
||||
for _, su := range systemUsers {
|
||||
users = append(users, sysUser{
|
||||
Username: su.Username,
|
||||
UID: fmt.Sprintf("%d", su.UID),
|
||||
HomeDir: su.HomeDir,
|
||||
Shell: su.Shell,
|
||||
})
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
// ── Panel Users ─────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) PanelUserList(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.DB.ListManagerUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if acceptsJSON(r) {
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
return
|
||||
}
|
||||
h.render(w, "users.html", map[string]interface{}{"PanelUsers": users})
|
||||
}
|
||||
|
||||
func (h *Handler) PanelUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
body.Username = r.FormValue("username")
|
||||
body.Password = r.FormValue("password")
|
||||
body.Role = r.FormValue("role")
|
||||
}
|
||||
|
||||
if body.Username == "" || body.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "username and password required")
|
||||
return
|
||||
}
|
||||
if body.Role == "" {
|
||||
body.Role = "admin"
|
||||
}
|
||||
|
||||
id, err := h.DB.CreateManagerUser(body.Username, body.Password, body.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{"id": id, "username": body.Username})
|
||||
}
|
||||
|
||||
func (h *Handler) PanelUserUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
if body.Password != "" {
|
||||
h.DB.UpdateManagerUserPassword(id, body.Password)
|
||||
}
|
||||
if body.Role != "" {
|
||||
h.DB.UpdateManagerUserRole(id, body.Role)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
func (h *Handler) PanelUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := paramInt(r, "id")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.DeleteManagerUser(id); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
107
services/setec-manager/internal/hosting/config.go
Normal file
107
services/setec-manager/internal/hosting/config.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package hosting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProviderConfigStore manages saved provider configurations on disk.
|
||||
// Each provider's config is stored as a separate JSON file with restrictive
|
||||
// permissions (0600) since the files contain API keys.
|
||||
type ProviderConfigStore struct {
|
||||
configDir string
|
||||
}
|
||||
|
||||
// NewConfigStore creates a new store rooted at configDir. The directory is
|
||||
// created on first write if it does not already exist.
|
||||
func NewConfigStore(configDir string) *ProviderConfigStore {
|
||||
return &ProviderConfigStore{configDir: configDir}
|
||||
}
|
||||
|
||||
// configPath returns the file path for a provider's config file.
|
||||
func (s *ProviderConfigStore) configPath(providerName string) string {
|
||||
return filepath.Join(s.configDir, providerName+".json")
|
||||
}
|
||||
|
||||
// ensureDir creates the config directory if it does not exist.
|
||||
func (s *ProviderConfigStore) ensureDir() error {
|
||||
return os.MkdirAll(s.configDir, 0700)
|
||||
}
|
||||
|
||||
// Save writes a provider configuration to disk. It overwrites any existing
|
||||
// config for the same provider.
|
||||
func (s *ProviderConfigStore) Save(providerName string, cfg ProviderConfig) error {
|
||||
if providerName == "" {
|
||||
return fmt.Errorf("hosting: provider name must not be empty")
|
||||
}
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return fmt.Errorf("hosting: create config dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("hosting: marshal config for %s: %w", providerName, err)
|
||||
}
|
||||
|
||||
path := s.configPath(providerName)
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("hosting: write config for %s: %w", providerName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads a provider configuration from disk.
|
||||
func (s *ProviderConfigStore) Load(providerName string) (*ProviderConfig, error) {
|
||||
path := s.configPath(providerName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("hosting: no config found for provider %q", providerName)
|
||||
}
|
||||
return nil, fmt.Errorf("hosting: read config for %s: %w", providerName, err)
|
||||
}
|
||||
|
||||
var cfg ProviderConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("hosting: parse config for %s: %w", providerName, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Delete removes a provider's saved configuration.
|
||||
func (s *ProviderConfigStore) Delete(providerName string) error {
|
||||
path := s.configPath(providerName)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // already gone
|
||||
}
|
||||
return fmt.Errorf("hosting: delete config for %s: %w", providerName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListConfigured returns the names of all providers that have saved configs.
|
||||
func (s *ProviderConfigStore) ListConfigured() ([]string, error) {
|
||||
entries, err := os.ReadDir(s.configDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil // no directory means no configs
|
||||
}
|
||||
return nil, fmt.Errorf("hosting: list configs: %w", err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".json") {
|
||||
names = append(names, strings.TrimSuffix(name, ".json"))
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
127
services/setec-manager/internal/hosting/hostinger/billing.go
Normal file
127
services/setec-manager/internal/hosting/hostinger/billing.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package hostinger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/hosting"
|
||||
)
|
||||
|
||||
// hostingerSubscription is the Hostinger API representation of a subscription.
|
||||
type hostingerSubscription struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
RenewalDate string `json:"renewal_date"`
|
||||
Price struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"price"`
|
||||
}
|
||||
|
||||
// hostingerCatalogItem is the Hostinger API representation of a catalog item.
|
||||
type hostingerCatalogItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
Features map[string]string `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// hostingerPaymentMethod is the Hostinger API representation of a payment method.
|
||||
type hostingerPaymentMethod struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Last4 string `json:"last4"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
// PaymentMethod is the exported type for payment method information.
|
||||
type PaymentMethod struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Last4 string `json:"last4"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
// ListSubscriptions retrieves all billing subscriptions.
|
||||
func (c *Client) ListSubscriptions() ([]hosting.Subscription, error) {
|
||||
var subs []hostingerSubscription
|
||||
if err := c.doRequest(http.MethodGet, "/api/billing/v1/subscriptions", nil, &subs); err != nil {
|
||||
return nil, fmt.Errorf("list subscriptions: %w", err)
|
||||
}
|
||||
|
||||
result := make([]hosting.Subscription, 0, len(subs))
|
||||
for _, s := range subs {
|
||||
renewsAt, _ := time.Parse(time.RFC3339, s.RenewalDate)
|
||||
result = append(result, hosting.Subscription{
|
||||
ID: strconv.Itoa(s.ID),
|
||||
Name: s.Name,
|
||||
Status: s.Status,
|
||||
RenewsAt: renewsAt,
|
||||
Price: s.Price.Amount,
|
||||
Currency: s.Price.Currency,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCatalog retrieves the product catalog, optionally filtered by category.
|
||||
// If category is empty, all catalog items are returned.
|
||||
func (c *Client) GetCatalog(category string) ([]hosting.CatalogItem, error) {
|
||||
path := "/api/billing/v1/catalog"
|
||||
if category != "" {
|
||||
path += "?" + url.Values{"category": {category}}.Encode()
|
||||
}
|
||||
|
||||
var items []hostingerCatalogItem
|
||||
if err := c.doRequest(http.MethodGet, path, nil, &items); err != nil {
|
||||
return nil, fmt.Errorf("get catalog: %w", err)
|
||||
}
|
||||
|
||||
result := make([]hosting.CatalogItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, hosting.CatalogItem{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Category: item.Category,
|
||||
Price: item.Price,
|
||||
Currency: item.Currency,
|
||||
Features: item.Features,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListPaymentMethods retrieves all payment methods on the account.
|
||||
// This is a Hostinger-specific method not part of the generic Provider interface.
|
||||
func (c *Client) ListPaymentMethods() ([]PaymentMethod, error) {
|
||||
var methods []hostingerPaymentMethod
|
||||
if err := c.doRequest(http.MethodGet, "/api/billing/v1/payment-methods", nil, &methods); err != nil {
|
||||
return nil, fmt.Errorf("list payment methods: %w", err)
|
||||
}
|
||||
|
||||
result := make([]PaymentMethod, 0, len(methods))
|
||||
for _, m := range methods {
|
||||
result = append(result, PaymentMethod{
|
||||
ID: strconv.Itoa(m.ID),
|
||||
Type: m.Type,
|
||||
Name: m.Name,
|
||||
Last4: m.Last4,
|
||||
ExpMonth: m.ExpMonth,
|
||||
ExpYear: m.ExpYear,
|
||||
Default: m.Default,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal file
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package hostinger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/hosting"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://developers.hostinger.com"
|
||||
maxRetries = 3
|
||||
)
|
||||
|
||||
// APIError represents an error response from the Hostinger API.
|
||||
type APIError struct {
|
||||
StatusCode int `json:"-"`
|
||||
Message string `json:"error"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.CorrelationID != "" {
|
||||
return fmt.Sprintf("hostinger API error %d: %s (correlation_id: %s)", e.StatusCode, e.Message, e.CorrelationID)
|
||||
}
|
||||
return fmt.Sprintf("hostinger API error %d: %s", e.StatusCode, e.Message)
|
||||
}
|
||||
|
||||
// Client is the Hostinger API client. It implements hosting.Provider.
|
||||
type Client struct {
|
||||
apiToken string
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// Compile-time check that Client implements hosting.Provider.
|
||||
var _ hosting.Provider = (*Client)(nil)
|
||||
|
||||
// New creates a new Hostinger API client with the given bearer token.
|
||||
func New(token string) *Client {
|
||||
return &Client{
|
||||
apiToken: token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: defaultBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider identifier.
|
||||
func (c *Client) Name() string { return "hostinger" }
|
||||
|
||||
// DisplayName returns the human-readable provider name.
|
||||
func (c *Client) DisplayName() string { return "Hostinger" }
|
||||
|
||||
// Configure applies the given configuration to the client.
|
||||
func (c *Client) Configure(cfg hosting.ProviderConfig) error {
|
||||
if cfg.APIKey == "" {
|
||||
return fmt.Errorf("hostinger: API key is required")
|
||||
}
|
||||
c.apiToken = cfg.APIKey
|
||||
if cfg.BaseURL != "" {
|
||||
c.baseURL = cfg.BaseURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection verifies the API token by making a lightweight API call.
|
||||
func (c *Client) TestConnection() error {
|
||||
_, err := c.ListDomains()
|
||||
return err
|
||||
}
|
||||
|
||||
// doRequest executes an HTTP request against the Hostinger API.
|
||||
// body may be nil for requests with no body. result may be nil if the
|
||||
// response body should be discarded.
|
||||
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
|
||||
url := c.baseURL + path
|
||||
|
||||
var rawBody []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
rawBody, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
var bodyReader io.Reader
|
||||
if rawBody != nil {
|
||||
bodyReader = bytes.NewReader(rawBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("execute request: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
// Handle rate limiting with retry.
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
if attempt < maxRetries {
|
||||
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||||
time.Sleep(retryAfter)
|
||||
lastErr = &APIError{StatusCode: 429, Message: "rate limited"}
|
||||
continue
|
||||
}
|
||||
return &APIError{StatusCode: 429, Message: "rate limited after retries"}
|
||||
}
|
||||
|
||||
// Handle error responses.
|
||||
if resp.StatusCode >= 400 {
|
||||
apiErr := &APIError{StatusCode: resp.StatusCode}
|
||||
if jsonErr := json.Unmarshal(respBody, apiErr); jsonErr != nil {
|
||||
apiErr.Message = string(respBody)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// Parse successful response.
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// parseRetryAfter parses the Retry-After header value.
|
||||
// Returns a default of 1 second if the header is missing or unparseable.
|
||||
func parseRetryAfter(value string) time.Duration {
|
||||
if value == "" {
|
||||
return time.Second
|
||||
}
|
||||
seconds, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return time.Second
|
||||
}
|
||||
if seconds <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
if seconds > 60 {
|
||||
seconds = 60
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
136
services/setec-manager/internal/hosting/hostinger/dns.go
Normal file
136
services/setec-manager/internal/hosting/hostinger/dns.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package hostinger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"setec-manager/internal/hosting"
|
||||
)
|
||||
|
||||
// hostingerDNSRecord is the Hostinger API representation of a DNS record.
|
||||
type hostingerDNSRecord struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
// hostingerDNSUpdateRequest is the request body for updating DNS records.
|
||||
type hostingerDNSUpdateRequest struct {
|
||||
Records []hostingerDNSRecord `json:"records"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
|
||||
// hostingerDNSValidateRequest is the request body for validating DNS records.
|
||||
type hostingerDNSValidateRequest struct {
|
||||
Records []hostingerDNSRecord `json:"records"`
|
||||
}
|
||||
|
||||
// ListDNSRecords retrieves all DNS records for the given domain.
|
||||
func (c *Client) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
|
||||
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
|
||||
|
||||
var apiRecords []hostingerDNSRecord
|
||||
if err := c.doRequest(http.MethodGet, path, nil, &apiRecords); err != nil {
|
||||
return nil, fmt.Errorf("list DNS records for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
records := make([]hosting.DNSRecord, 0, len(apiRecords))
|
||||
for _, r := range apiRecords {
|
||||
records = append(records, toGenericDNSRecord(r))
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// UpdateDNSRecords updates DNS records for the given domain.
|
||||
// If overwrite is true, existing records are replaced entirely.
|
||||
func (c *Client) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
|
||||
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
|
||||
|
||||
hostingerRecords := make([]hostingerDNSRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
hostingerRecords = append(hostingerRecords, toHostingerDNSRecord(r))
|
||||
}
|
||||
|
||||
// Validate first.
|
||||
validatePath := fmt.Sprintf("/api/dns/v1/zones/%s/validate", url.PathEscape(domain))
|
||||
validateReq := hostingerDNSValidateRequest{Records: hostingerRecords}
|
||||
if err := c.doRequest(http.MethodPost, validatePath, validateReq, nil); err != nil {
|
||||
return fmt.Errorf("validate DNS records for %s: %w", domain, err)
|
||||
}
|
||||
|
||||
req := hostingerDNSUpdateRequest{
|
||||
Records: hostingerRecords,
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
if err := c.doRequest(http.MethodPut, path, req, nil); err != nil {
|
||||
return fmt.Errorf("update DNS records for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDNSRecord adds a single DNS record to the domain without overwriting.
|
||||
func (c *Client) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
|
||||
return c.UpdateDNSRecords(domain, []hosting.DNSRecord{record}, false)
|
||||
}
|
||||
|
||||
// DeleteDNSRecord removes DNS records matching the given filter.
|
||||
func (c *Client) DeleteDNSRecord(domain string, filter hosting.DNSRecordFilter) error {
|
||||
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
|
||||
|
||||
params := url.Values{}
|
||||
if filter.Name != "" {
|
||||
params.Set("name", filter.Name)
|
||||
}
|
||||
if filter.Type != "" {
|
||||
params.Set("type", filter.Type)
|
||||
}
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("delete DNS record %s/%s for %s: %w", filter.Name, filter.Type, domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetDNSRecords resets the domain's DNS zone to default records.
|
||||
func (c *Client) ResetDNSRecords(domain string) error {
|
||||
path := fmt.Sprintf("/api/dns/v1/zones/%s/reset", url.PathEscape(domain))
|
||||
if err := c.doRequest(http.MethodPost, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("reset DNS records for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toGenericDNSRecord converts a Hostinger DNS record to the generic type.
|
||||
func toGenericDNSRecord(r hostingerDNSRecord) hosting.DNSRecord {
|
||||
rec := hosting.DNSRecord{
|
||||
Type: r.Type,
|
||||
Name: r.Name,
|
||||
Content: r.Content,
|
||||
TTL: r.TTL,
|
||||
}
|
||||
if r.Priority != nil {
|
||||
rec.Priority = *r.Priority
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// toHostingerDNSRecord converts a generic DNS record to the Hostinger format.
|
||||
func toHostingerDNSRecord(r hosting.DNSRecord) hostingerDNSRecord {
|
||||
rec := hostingerDNSRecord{
|
||||
Type: r.Type,
|
||||
Name: r.Name,
|
||||
Content: r.Content,
|
||||
TTL: r.TTL,
|
||||
}
|
||||
if r.Priority != 0 {
|
||||
p := r.Priority
|
||||
rec.Priority = &p
|
||||
}
|
||||
return rec
|
||||
}
|
||||
218
services/setec-manager/internal/hosting/hostinger/domains.go
Normal file
218
services/setec-manager/internal/hosting/hostinger/domains.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package hostinger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/hosting"
|
||||
)
|
||||
|
||||
// hostingerDomain is the Hostinger API representation of a domain.
|
||||
type hostingerDomain struct {
|
||||
Domain string `json:"domain"`
|
||||
Status string `json:"status"`
|
||||
ExpirationDate string `json:"expiration_date"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
DomainLock bool `json:"domain_lock"`
|
||||
PrivacyProtection bool `json:"privacy_protection"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
}
|
||||
|
||||
// hostingerDomainList wraps the list response.
|
||||
type hostingerDomainList struct {
|
||||
Domains []hostingerDomain `json:"domains"`
|
||||
}
|
||||
|
||||
// hostingerAvailabilityRequest is the check-availability request body.
|
||||
type hostingerAvailabilityRequest struct {
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
|
||||
// hostingerAvailabilityResult is a single domain availability result.
|
||||
type hostingerAvailabilityResult struct {
|
||||
Domain string `json:"domain"`
|
||||
Available bool `json:"available"`
|
||||
Price *struct {
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"price,omitempty"`
|
||||
}
|
||||
|
||||
// hostingerPurchaseRequest is the domain purchase request body.
|
||||
type hostingerPurchaseRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
Period int `json:"period"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
Privacy bool `json:"privacy_protection"`
|
||||
PaymentMethodID string `json:"payment_method_id,omitempty"`
|
||||
}
|
||||
|
||||
// hostingerNameserversRequest is the body for updating nameservers.
|
||||
type hostingerNameserversRequest struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
}
|
||||
|
||||
// ListDomains retrieves all domains in the account portfolio.
|
||||
func (c *Client) ListDomains() ([]hosting.Domain, error) {
|
||||
var list hostingerDomainList
|
||||
if err := c.doRequest(http.MethodGet, "/api/domains/v1/portfolio", nil, &list); err != nil {
|
||||
return nil, fmt.Errorf("list domains: %w", err)
|
||||
}
|
||||
|
||||
domains := make([]hosting.Domain, 0, len(list.Domains))
|
||||
for _, d := range list.Domains {
|
||||
domains = append(domains, toSummaryDomain(d))
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// GetDomain retrieves details for a specific domain.
|
||||
func (c *Client) GetDomain(domain string) (*hosting.DomainDetail, error) {
|
||||
path := fmt.Sprintf("/api/domains/v1/portfolio/%s", url.PathEscape(domain))
|
||||
|
||||
var d hostingerDomain
|
||||
if err := c.doRequest(http.MethodGet, path, nil, &d); err != nil {
|
||||
return nil, fmt.Errorf("get domain %s: %w", domain, err)
|
||||
}
|
||||
|
||||
result := toDetailDomain(d)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CheckDomainAvailability checks whether the given domain is available for
|
||||
// registration across the specified TLDs. If tlds is empty, the domain string
|
||||
// is checked as-is.
|
||||
func (c *Client) CheckDomainAvailability(domain string, tlds []string) ([]hosting.DomainAvailability, error) {
|
||||
// Build the list of fully qualified domain names to check.
|
||||
var domains []string
|
||||
if len(tlds) == 0 {
|
||||
domains = []string{domain}
|
||||
} else {
|
||||
for _, tld := range tlds {
|
||||
tld = strings.TrimPrefix(tld, ".")
|
||||
domains = append(domains, domain+"."+tld)
|
||||
}
|
||||
}
|
||||
|
||||
req := hostingerAvailabilityRequest{Domains: domains}
|
||||
|
||||
var results []hostingerAvailabilityResult
|
||||
if err := c.doRequest(http.MethodPost, "/api/domains/v1/availability", req, &results); err != nil {
|
||||
return nil, fmt.Errorf("check domain availability: %w", err)
|
||||
}
|
||||
|
||||
avail := make([]hosting.DomainAvailability, 0, len(results))
|
||||
for _, r := range results {
|
||||
da := hosting.DomainAvailability{
|
||||
Domain: r.Domain,
|
||||
Available: r.Available,
|
||||
}
|
||||
// Extract TLD from the domain name.
|
||||
if idx := strings.Index(r.Domain, "."); idx >= 0 {
|
||||
da.TLD = r.Domain[idx+1:]
|
||||
}
|
||||
if r.Price != nil {
|
||||
da.Price = r.Price.Amount
|
||||
da.Currency = r.Price.Currency
|
||||
}
|
||||
avail = append(avail, da)
|
||||
}
|
||||
return avail, nil
|
||||
}
|
||||
|
||||
// PurchaseDomain registers a new domain.
|
||||
func (c *Client) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.OrderResult, error) {
|
||||
body := hostingerPurchaseRequest{
|
||||
Domain: req.Domain,
|
||||
Period: req.Years,
|
||||
AutoRenew: req.AutoRenew,
|
||||
Privacy: req.Privacy,
|
||||
PaymentMethodID: req.PaymentMethod,
|
||||
}
|
||||
|
||||
var d hostingerDomain
|
||||
if err := c.doRequest(http.MethodPost, "/api/domains/v1/portfolio", body, &d); err != nil {
|
||||
return nil, fmt.Errorf("purchase domain %s: %w", req.Domain, err)
|
||||
}
|
||||
|
||||
return &hosting.OrderResult{
|
||||
OrderID: d.Domain,
|
||||
Status: "completed",
|
||||
Message: fmt.Sprintf("domain %s registered", d.Domain),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetNameservers updates the nameservers for a domain.
|
||||
func (c *Client) SetNameservers(domain string, nameservers []string) error {
|
||||
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/nameservers", url.PathEscape(domain))
|
||||
body := hostingerNameserversRequest{Nameservers: nameservers}
|
||||
|
||||
if err := c.doRequest(http.MethodPut, path, body, nil); err != nil {
|
||||
return fmt.Errorf("set nameservers for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableDomainLock enables the registrar lock for a domain.
|
||||
func (c *Client) EnableDomainLock(domain string) error {
|
||||
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/domain-lock", url.PathEscape(domain))
|
||||
if err := c.doRequest(http.MethodPut, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("enable domain lock for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableDomainLock disables the registrar lock for a domain.
|
||||
func (c *Client) DisableDomainLock(domain string) error {
|
||||
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/domain-lock", url.PathEscape(domain))
|
||||
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("disable domain lock for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnablePrivacyProtection enables WHOIS privacy protection for a domain.
|
||||
func (c *Client) EnablePrivacyProtection(domain string) error {
|
||||
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/privacy-protection", url.PathEscape(domain))
|
||||
if err := c.doRequest(http.MethodPut, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("enable privacy protection for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisablePrivacyProtection disables WHOIS privacy protection for a domain.
|
||||
func (c *Client) DisablePrivacyProtection(domain string) error {
|
||||
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/privacy-protection", url.PathEscape(domain))
|
||||
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("disable privacy protection for %s: %w", domain, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toSummaryDomain converts a Hostinger domain to the summary Domain type.
|
||||
func toSummaryDomain(d hostingerDomain) hosting.Domain {
|
||||
expires, _ := time.Parse(time.RFC3339, d.ExpirationDate)
|
||||
return hosting.Domain{
|
||||
Name: d.Domain,
|
||||
Status: d.Status,
|
||||
ExpiresAt: expires,
|
||||
}
|
||||
}
|
||||
|
||||
// toDetailDomain converts a Hostinger domain to the full DomainDetail type.
|
||||
func toDetailDomain(d hostingerDomain) hosting.DomainDetail {
|
||||
expires, _ := time.Parse(time.RFC3339, d.ExpirationDate)
|
||||
return hosting.DomainDetail{
|
||||
Name: d.Domain,
|
||||
Status: d.Status,
|
||||
Registrar: "hostinger",
|
||||
ExpiresAt: expires,
|
||||
AutoRenew: d.AutoRenew,
|
||||
Locked: d.DomainLock,
|
||||
PrivacyProtection: d.PrivacyProtection,
|
||||
Nameservers: d.Nameservers,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package hostinger
|
||||
|
||||
import "setec-manager/internal/hosting"
|
||||
|
||||
func init() {
|
||||
hosting.Register(New(""))
|
||||
}
|
||||
219
services/setec-manager/internal/hosting/hostinger/vps.go
Normal file
219
services/setec-manager/internal/hosting/hostinger/vps.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package hostinger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/hosting"
|
||||
)
|
||||
|
||||
// hostingerVM is the Hostinger API representation of a virtual machine.
|
||||
type hostingerVM struct {
|
||||
ID int `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
Status string `json:"status"`
|
||||
Plan string `json:"plan"`
|
||||
DataCenter string `json:"data_center"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
OS string `json:"os"`
|
||||
CPUs int `json:"cpus"`
|
||||
RAMMB int `json:"ram_mb"`
|
||||
DiskGB int `json:"disk_gb"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// hostingerDataCenter is the Hostinger API representation of a data center.
|
||||
type hostingerDataCenter struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// hostingerSSHKey is the Hostinger API representation of an SSH key.
|
||||
type hostingerSSHKey struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// hostingerCreateVMRequest is the request body for creating a VM.
|
||||
type hostingerCreateVMRequest struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Plan string `json:"plan"`
|
||||
DataCenterID int `json:"data_center_id"`
|
||||
OS string `json:"template"`
|
||||
Password string `json:"password,omitempty"`
|
||||
SSHKeyID *int `json:"ssh_key_id,omitempty"`
|
||||
}
|
||||
|
||||
// hostingerCreateVMResponse is the response from the VM creation endpoint.
|
||||
type hostingerCreateVMResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// hostingerAddSSHKeyRequest is the request body for adding an SSH key.
|
||||
type hostingerAddSSHKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
// ListVMs retrieves all virtual machines in the account.
|
||||
func (c *Client) ListVMs() ([]hosting.VirtualMachine, error) {
|
||||
var vms []hostingerVM
|
||||
if err := c.doRequest(http.MethodGet, "/api/vps/v1/virtual-machines", nil, &vms); err != nil {
|
||||
return nil, fmt.Errorf("list VMs: %w", err)
|
||||
}
|
||||
|
||||
result := make([]hosting.VirtualMachine, 0, len(vms))
|
||||
for _, vm := range vms {
|
||||
result = append(result, toGenericVM(vm))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetVM retrieves a specific virtual machine by ID.
|
||||
func (c *Client) GetVM(id string) (*hosting.VirtualMachine, error) {
|
||||
path := fmt.Sprintf("/api/vps/v1/virtual-machines/%s", url.PathEscape(id))
|
||||
|
||||
var vm hostingerVM
|
||||
if err := c.doRequest(http.MethodGet, path, nil, &vm); err != nil {
|
||||
return nil, fmt.Errorf("get VM %s: %w", id, err)
|
||||
}
|
||||
|
||||
result := toGenericVM(vm)
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateVM provisions a new virtual machine.
|
||||
func (c *Client) CreateVM(req hosting.VMCreateRequest) (*hosting.OrderResult, error) {
|
||||
body := hostingerCreateVMRequest{
|
||||
Hostname: req.Hostname,
|
||||
Plan: req.Plan,
|
||||
OS: req.OS,
|
||||
Password: req.Password,
|
||||
}
|
||||
|
||||
// Parse data center ID from string to int for the Hostinger API.
|
||||
dcID, err := strconv.Atoi(req.DataCenterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid data center ID %q: must be numeric", req.DataCenterID)
|
||||
}
|
||||
body.DataCenterID = dcID
|
||||
|
||||
// Parse SSH key ID if provided.
|
||||
if req.SSHKeyID != "" {
|
||||
keyID, err := strconv.Atoi(req.SSHKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SSH key ID %q: must be numeric", req.SSHKeyID)
|
||||
}
|
||||
body.SSHKeyID = &keyID
|
||||
}
|
||||
|
||||
var resp hostingerCreateVMResponse
|
||||
if err := c.doRequest(http.MethodPost, "/api/vps/v1/virtual-machines", body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("create VM: %w", err)
|
||||
}
|
||||
|
||||
return &hosting.OrderResult{
|
||||
OrderID: resp.OrderID,
|
||||
Status: resp.Status,
|
||||
Message: resp.Message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListDataCenters retrieves all available data centers.
|
||||
func (c *Client) ListDataCenters() ([]hosting.DataCenter, error) {
|
||||
var dcs []hostingerDataCenter
|
||||
if err := c.doRequest(http.MethodGet, "/api/vps/v1/data-centers", nil, &dcs); err != nil {
|
||||
return nil, fmt.Errorf("list data centers: %w", err)
|
||||
}
|
||||
|
||||
result := make([]hosting.DataCenter, 0, len(dcs))
|
||||
for _, dc := range dcs {
|
||||
result = append(result, hosting.DataCenter{
|
||||
ID: strconv.Itoa(dc.ID),
|
||||
Name: dc.Name,
|
||||
Location: dc.Location,
|
||||
Country: dc.Country,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListSSHKeys retrieves all SSH keys in the account.
|
||||
func (c *Client) ListSSHKeys() ([]hosting.SSHKey, error) {
|
||||
var keys []hostingerSSHKey
|
||||
if err := c.doRequest(http.MethodGet, "/api/vps/v1/public-keys", nil, &keys); err != nil {
|
||||
return nil, fmt.Errorf("list SSH keys: %w", err)
|
||||
}
|
||||
|
||||
result := make([]hosting.SSHKey, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
created, _ := time.Parse(time.RFC3339, k.CreatedAt)
|
||||
result = append(result, hosting.SSHKey{
|
||||
ID: strconv.Itoa(k.ID),
|
||||
Name: k.Name,
|
||||
PublicKey: k.PublicKey,
|
||||
CreatedAt: created,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddSSHKey uploads a new SSH public key.
|
||||
func (c *Client) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) {
|
||||
body := hostingerAddSSHKeyRequest{
|
||||
Name: name,
|
||||
PublicKey: publicKey,
|
||||
}
|
||||
|
||||
var key hostingerSSHKey
|
||||
if err := c.doRequest(http.MethodPost, "/api/vps/v1/public-keys", body, &key); err != nil {
|
||||
return nil, fmt.Errorf("add SSH key: %w", err)
|
||||
}
|
||||
|
||||
created, _ := time.Parse(time.RFC3339, key.CreatedAt)
|
||||
return &hosting.SSHKey{
|
||||
ID: strconv.Itoa(key.ID),
|
||||
Name: key.Name,
|
||||
PublicKey: key.PublicKey,
|
||||
CreatedAt: created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteSSHKey removes an SSH key by ID.
|
||||
func (c *Client) DeleteSSHKey(id string) error {
|
||||
path := fmt.Sprintf("/api/vps/v1/public-keys/%s", url.PathEscape(id))
|
||||
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
|
||||
return fmt.Errorf("delete SSH key %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toGenericVM converts a Hostinger VM to the generic VirtualMachine type.
|
||||
func toGenericVM(vm hostingerVM) hosting.VirtualMachine {
|
||||
created, _ := time.Parse(time.RFC3339, vm.CreatedAt)
|
||||
|
||||
return hosting.VirtualMachine{
|
||||
ID: strconv.Itoa(vm.ID),
|
||||
Hostname: vm.Hostname,
|
||||
IPAddress: vm.IPv4,
|
||||
IPv6: vm.IPv6,
|
||||
Status: vm.Status,
|
||||
Plan: vm.Plan,
|
||||
DataCenter: vm.DataCenter,
|
||||
OS: vm.OS,
|
||||
CPUs: vm.CPUs,
|
||||
RAMBytes: int64(vm.RAMMB) * 1024 * 1024,
|
||||
DiskBytes: int64(vm.DiskGB) * 1024 * 1024 * 1024,
|
||||
CreatedAt: created,
|
||||
}
|
||||
}
|
||||
287
services/setec-manager/internal/hosting/provider.go
Normal file
287
services/setec-manager/internal/hosting/provider.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package hosting
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotSupported is returned when a provider does not support a given operation.
|
||||
var ErrNotSupported = errors.New("operation not supported by this provider")
|
||||
|
||||
// Provider is the interface all hosting service integrations must implement.
|
||||
// Not all providers support all features -- methods should return ErrNotSupported
|
||||
// for unsupported operations.
|
||||
type Provider interface {
|
||||
// Name returns the provider identifier (e.g. "hostinger", "digitalocean").
|
||||
Name() string
|
||||
|
||||
// DisplayName returns a human-readable provider name.
|
||||
DisplayName() string
|
||||
|
||||
// --- Authentication ---
|
||||
|
||||
// Configure applies the given configuration to the provider.
|
||||
Configure(cfg ProviderConfig) error
|
||||
|
||||
// TestConnection verifies that the provider credentials are valid.
|
||||
TestConnection() error
|
||||
|
||||
// --- DNS Management ---
|
||||
|
||||
// ListDNSRecords returns all DNS records for a domain.
|
||||
ListDNSRecords(domain string) ([]DNSRecord, error)
|
||||
|
||||
// CreateDNSRecord adds a single DNS record to a domain.
|
||||
CreateDNSRecord(domain string, record DNSRecord) error
|
||||
|
||||
// UpdateDNSRecords replaces DNS records for a domain. If overwrite is true,
|
||||
// all existing records are removed first.
|
||||
UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
|
||||
|
||||
// DeleteDNSRecord removes DNS records matching the filter.
|
||||
DeleteDNSRecord(domain string, filter DNSRecordFilter) error
|
||||
|
||||
// ResetDNSRecords restores the default DNS records for a domain.
|
||||
ResetDNSRecords(domain string) error
|
||||
|
||||
// --- Domain Management ---
|
||||
|
||||
// ListDomains returns all domains on the account.
|
||||
ListDomains() ([]Domain, error)
|
||||
|
||||
// GetDomain returns detailed information about a single domain.
|
||||
GetDomain(domain string) (*DomainDetail, error)
|
||||
|
||||
// CheckDomainAvailability checks registration availability across TLDs.
|
||||
CheckDomainAvailability(domain string, tlds []string) ([]DomainAvailability, error)
|
||||
|
||||
// PurchaseDomain registers a new domain.
|
||||
PurchaseDomain(req DomainPurchaseRequest) (*OrderResult, error)
|
||||
|
||||
// SetNameservers configures the nameservers for a domain.
|
||||
SetNameservers(domain string, nameservers []string) error
|
||||
|
||||
// EnableDomainLock enables the registrar lock on a domain.
|
||||
EnableDomainLock(domain string) error
|
||||
|
||||
// DisableDomainLock disables the registrar lock on a domain.
|
||||
DisableDomainLock(domain string) error
|
||||
|
||||
// EnablePrivacyProtection enables WHOIS privacy for a domain.
|
||||
EnablePrivacyProtection(domain string) error
|
||||
|
||||
// DisablePrivacyProtection disables WHOIS privacy for a domain.
|
||||
DisablePrivacyProtection(domain string) error
|
||||
|
||||
// --- VPS Management ---
|
||||
|
||||
// ListVMs returns all virtual machines on the account.
|
||||
ListVMs() ([]VirtualMachine, error)
|
||||
|
||||
// GetVM returns details for a single virtual machine.
|
||||
GetVM(id string) (*VirtualMachine, error)
|
||||
|
||||
// CreateVM provisions a new virtual machine.
|
||||
CreateVM(req VMCreateRequest) (*OrderResult, error)
|
||||
|
||||
// ListDataCenters returns available data center locations.
|
||||
ListDataCenters() ([]DataCenter, error)
|
||||
|
||||
// --- SSH Keys ---
|
||||
|
||||
// ListSSHKeys returns all SSH keys on the account.
|
||||
ListSSHKeys() ([]SSHKey, error)
|
||||
|
||||
// AddSSHKey uploads a new SSH public key.
|
||||
AddSSHKey(name, publicKey string) (*SSHKey, error)
|
||||
|
||||
// DeleteSSHKey removes an SSH key by ID.
|
||||
DeleteSSHKey(id string) error
|
||||
|
||||
// --- Billing ---
|
||||
|
||||
// ListSubscriptions returns all active subscriptions.
|
||||
ListSubscriptions() ([]Subscription, error)
|
||||
|
||||
// GetCatalog returns available products in a category.
|
||||
GetCatalog(category string) ([]CatalogItem, error)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ProviderConfig holds the credentials and settings needed to connect to a
|
||||
// hosting provider.
|
||||
type ProviderConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// DNSRecord represents a single DNS record.
|
||||
type DNSRecord struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // A, AAAA, CNAME, MX, TXT, etc.
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl,omitempty"` // seconds; 0 means provider default
|
||||
Priority int `json:"priority,omitempty"` // used by MX, SRV
|
||||
}
|
||||
|
||||
// DNSRecordFilter identifies DNS records to match for deletion or lookup.
|
||||
type DNSRecordFilter struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Domain is a summary of a domain on the account.
|
||||
type Domain struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// DomainDetail contains full information about a domain registration.
|
||||
type DomainDetail struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Registrar string `json:"registrar"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
Locked bool `json:"locked"`
|
||||
PrivacyProtection bool `json:"privacy_protection"`
|
||||
Nameservers []string `json:"nameservers"`
|
||||
}
|
||||
|
||||
// DomainAvailability reports whether a domain + TLD combination can be
|
||||
// registered and its price.
|
||||
type DomainAvailability struct {
|
||||
Domain string `json:"domain"`
|
||||
TLD string `json:"tld"`
|
||||
Available bool `json:"available"`
|
||||
Price float64 `json:"price"` // in the provider's default currency
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// DomainPurchaseRequest contains everything needed to register a domain.
|
||||
type DomainPurchaseRequest struct {
|
||||
Domain string `json:"domain"`
|
||||
Years int `json:"years"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
Privacy bool `json:"privacy"`
|
||||
PaymentMethod string `json:"payment_method,omitempty"`
|
||||
}
|
||||
|
||||
// OrderResult is returned after a purchase or provisioning request.
|
||||
type OrderResult struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Status string `json:"status"` // e.g. "pending", "completed", "failed"
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// VirtualMachine represents a VPS instance.
|
||||
type VirtualMachine struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
Status string `json:"status"` // running, stopped, provisioning, etc.
|
||||
Plan string `json:"plan"`
|
||||
DataCenter string `json:"data_center"`
|
||||
OS string `json:"os"`
|
||||
CPUs int `json:"cpus"`
|
||||
RAMBytes int64 `json:"ram_bytes"`
|
||||
DiskBytes int64 `json:"disk_bytes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// VMCreateRequest contains everything needed to provision a new VPS.
|
||||
type VMCreateRequest struct {
|
||||
Hostname string `json:"hostname"`
|
||||
Plan string `json:"plan"`
|
||||
DataCenterID string `json:"data_center_id"`
|
||||
OS string `json:"os"`
|
||||
SSHKeyID string `json:"ssh_key_id,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// DataCenter represents a physical hosting location.
|
||||
type DataCenter struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"` // city or region
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
// SSHKey is a stored SSH public key.
|
||||
type SSHKey struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Subscription represents a billing subscription.
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // active, cancelled, expired
|
||||
RenewsAt time.Time `json:"renews_at"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
// CatalogItem is a purchasable product or plan.
|
||||
type CatalogItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
Features map[string]string `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
providers = map[string]Provider{}
|
||||
)
|
||||
|
||||
// Register adds a provider to the global registry. It panics if a provider
|
||||
// with the same name is already registered.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
name := p.Name()
|
||||
if _, exists := providers[name]; exists {
|
||||
panic("hosting: provider already registered: " + name)
|
||||
}
|
||||
providers[name] = p
|
||||
}
|
||||
|
||||
// Get returns a registered provider by name.
|
||||
func Get(name string) (Provider, bool) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
p, ok := providers[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// List returns the names of all registered providers, sorted alphabetically.
|
||||
func List() []string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
names := make([]string, 0, len(providers))
|
||||
for name := range providers {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
201
services/setec-manager/internal/nginx/config.go
Normal file
201
services/setec-manager/internal/nginx/config.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"setec-manager/internal/config"
|
||||
"setec-manager/internal/db"
|
||||
)
|
||||
|
||||
const reverseProxyTemplate = `# Managed by Setec App Manager — do not edit manually
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{.CertbotWebroot}};
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
{{if .SSLEnabled}}server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
|
||||
|
||||
ssl_certificate {{.SSLCertPath}};
|
||||
ssl_certificate_key {{.SSLKeyPath}};
|
||||
include snippets/ssl-params.conf;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{{.AppPort}};
|
||||
include snippets/proxy-params.conf;
|
||||
}
|
||||
|
||||
# WebSocket / SSE support
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:{{.AppPort}};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
include snippets/proxy-params.conf;
|
||||
}
|
||||
}{{end}}
|
||||
`
|
||||
|
||||
const staticSiteTemplate = `# Managed by Setec App Manager — do not edit manually
|
||||
server {
|
||||
listen 80;
|
||||
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root {{.CertbotWebroot}};
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
{{if .SSLEnabled}}server {
|
||||
listen 443 ssl http2;
|
||||
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
|
||||
root {{.AppRoot}};
|
||||
index index.html;
|
||||
|
||||
ssl_certificate {{.SSLCertPath}};
|
||||
ssl_certificate_key {{.SSLKeyPath}};
|
||||
include snippets/ssl-params.conf;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}{{else}}server {
|
||||
listen 80;
|
||||
server_name {{.Domain}}{{if .Aliases}} {{.Aliases}}{{end}};
|
||||
root {{.AppRoot}};
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}{{end}}
|
||||
`
|
||||
|
||||
type configData struct {
|
||||
Domain string
|
||||
Aliases string
|
||||
AppRoot string
|
||||
AppPort int
|
||||
SSLEnabled bool
|
||||
SSLCertPath string
|
||||
SSLKeyPath string
|
||||
CertbotWebroot string
|
||||
}
|
||||
|
||||
func GenerateConfig(cfg *config.Config, site *db.Site) error {
|
||||
data := configData{
|
||||
Domain: site.Domain,
|
||||
Aliases: site.Aliases,
|
||||
AppRoot: site.AppRoot,
|
||||
AppPort: site.AppPort,
|
||||
SSLEnabled: site.SSLEnabled,
|
||||
SSLCertPath: site.SSLCertPath,
|
||||
SSLKeyPath: site.SSLKeyPath,
|
||||
CertbotWebroot: cfg.Nginx.CertbotWebroot,
|
||||
}
|
||||
|
||||
var tmplStr string
|
||||
switch site.AppType {
|
||||
case "static":
|
||||
tmplStr = staticSiteTemplate
|
||||
default:
|
||||
tmplStr = reverseProxyTemplate
|
||||
}
|
||||
|
||||
tmpl, err := template.New("nginx").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(cfg.Nginx.SitesAvailable, site.Domain)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create config: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return tmpl.Execute(f, data)
|
||||
}
|
||||
|
||||
func EnableSite(cfg *config.Config, domain string) error {
|
||||
src := filepath.Join(cfg.Nginx.SitesAvailable, domain)
|
||||
dst := filepath.Join(cfg.Nginx.SitesEnabled, domain)
|
||||
|
||||
// Remove existing symlink
|
||||
os.Remove(dst)
|
||||
|
||||
return os.Symlink(src, dst)
|
||||
}
|
||||
|
||||
func DisableSite(cfg *config.Config, domain string) error {
|
||||
dst := filepath.Join(cfg.Nginx.SitesEnabled, domain)
|
||||
return os.Remove(dst)
|
||||
}
|
||||
|
||||
func Reload() error {
|
||||
return exec.Command("systemctl", "reload", "nginx").Run()
|
||||
}
|
||||
|
||||
func Restart() error {
|
||||
return exec.Command("systemctl", "restart", "nginx").Run()
|
||||
}
|
||||
|
||||
func Test() (string, error) {
|
||||
out, err := exec.Command("nginx", "-t").CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func Status() (string, bool) {
|
||||
out, err := exec.Command("systemctl", "is-active", "nginx").Output()
|
||||
status := strings.TrimSpace(string(out))
|
||||
return status, err == nil && status == "active"
|
||||
}
|
||||
|
||||
func InstallSnippets(cfg *config.Config) error {
|
||||
os.MkdirAll(cfg.Nginx.Snippets, 0755)
|
||||
|
||||
sslParams := `# SSL params — managed by Setec App Manager
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
`
|
||||
|
||||
proxyParams := `# Proxy params — managed by Setec App Manager
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(cfg.Nginx.Snippets, "ssl-params.conf"), []byte(sslParams), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(cfg.Nginx.Snippets, "proxy-params.conf"), []byte(proxyParams), 0644)
|
||||
}
|
||||
280
services/setec-manager/internal/scheduler/cron.go
Normal file
280
services/setec-manager/internal/scheduler/cron.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CronExpr represents a parsed 5-field cron expression.
|
||||
// Each field is expanded into a sorted slice of valid integer values.
|
||||
type CronExpr struct {
|
||||
Minutes []int // 0-59
|
||||
Hours []int // 0-23
|
||||
DaysOfMonth []int // 1-31
|
||||
Months []int // 1-12
|
||||
DaysOfWeek []int // 0-6 (0 = Sunday)
|
||||
}
|
||||
|
||||
// fieldBounds defines the min/max for each cron field.
|
||||
var fieldBounds = [5][2]int{
|
||||
{0, 59}, // minute
|
||||
{0, 23}, // hour
|
||||
{1, 31}, // day of month
|
||||
{1, 12}, // month
|
||||
{0, 6}, // day of week
|
||||
}
|
||||
|
||||
// ParseCron parses a standard 5-field cron expression into a CronExpr.
|
||||
//
|
||||
// Supported syntax per field:
|
||||
// - * all values in range
|
||||
// - N single number
|
||||
// - N-M range from N to M inclusive
|
||||
// - N-M/S range with step S
|
||||
// - */S full range with step S
|
||||
// - N,M,O list of values (each element can be a number or range)
|
||||
func ParseCron(expr string) (*CronExpr, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(expr))
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron: expected 5 fields, got %d in %q", len(fields), expr)
|
||||
}
|
||||
|
||||
ce := &CronExpr{}
|
||||
targets := []*[]int{&ce.Minutes, &ce.Hours, &ce.DaysOfMonth, &ce.Months, &ce.DaysOfWeek}
|
||||
|
||||
for i, field := range fields {
|
||||
vals, err := parseField(field, fieldBounds[i][0], fieldBounds[i][1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cron field %d (%q): %w", i+1, field, err)
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return nil, fmt.Errorf("cron field %d (%q): produced no values", i+1, field)
|
||||
}
|
||||
*targets[i] = vals
|
||||
}
|
||||
|
||||
return ce, nil
|
||||
}
|
||||
|
||||
// parseField parses a single cron field into a sorted slice of ints.
|
||||
func parseField(field string, min, max int) ([]int, error) {
|
||||
// Handle lists: "1,3,5" or "1-3,7,10-12"
|
||||
parts := strings.Split(field, ",")
|
||||
seen := make(map[int]bool)
|
||||
|
||||
for _, part := range parts {
|
||||
vals, err := parsePart(part, min, max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range vals {
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Collect and sort.
|
||||
result := make([]int, 0, len(seen))
|
||||
for v := range seen {
|
||||
result = append(result, v)
|
||||
}
|
||||
sortInts(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parsePart parses a single element that may be *, a number, a range, or have a step.
|
||||
func parsePart(part string, min, max int) ([]int, error) {
|
||||
// Split on "/" for step.
|
||||
var stepStr string
|
||||
base := part
|
||||
if idx := strings.Index(part, "/"); idx >= 0 {
|
||||
base = part[:idx]
|
||||
stepStr = part[idx+1:]
|
||||
}
|
||||
|
||||
// Determine the range.
|
||||
var lo, hi int
|
||||
if base == "*" {
|
||||
lo, hi = min, max
|
||||
} else if idx := strings.Index(base, "-"); idx >= 0 {
|
||||
var err error
|
||||
lo, err = strconv.Atoi(base[:idx])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: %w", base[:idx], err)
|
||||
}
|
||||
hi, err = strconv.Atoi(base[idx+1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: %w", base[idx+1:], err)
|
||||
}
|
||||
} else {
|
||||
n, err := strconv.Atoi(base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: %w", base, err)
|
||||
}
|
||||
if stepStr == "" {
|
||||
// Single value, no step.
|
||||
if n < min || n > max {
|
||||
return nil, fmt.Errorf("value %d out of range [%d, %d]", n, min, max)
|
||||
}
|
||||
return []int{n}, nil
|
||||
}
|
||||
// e.g., "5/10" means starting at 5, step 10, up to max.
|
||||
lo, hi = n, max
|
||||
}
|
||||
|
||||
// Validate bounds.
|
||||
if lo < min || lo > max {
|
||||
return nil, fmt.Errorf("value %d out of range [%d, %d]", lo, min, max)
|
||||
}
|
||||
if hi < min || hi > max {
|
||||
return nil, fmt.Errorf("value %d out of range [%d, %d]", hi, min, max)
|
||||
}
|
||||
if lo > hi {
|
||||
return nil, fmt.Errorf("range start %d > end %d", lo, hi)
|
||||
}
|
||||
|
||||
step := 1
|
||||
if stepStr != "" {
|
||||
var err error
|
||||
step, err = strconv.Atoi(stepStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid step %q: %w", stepStr, err)
|
||||
}
|
||||
if step < 1 {
|
||||
return nil, fmt.Errorf("step must be >= 1, got %d", step)
|
||||
}
|
||||
}
|
||||
|
||||
var vals []int
|
||||
for v := lo; v <= hi; v += step {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
// NextRun computes the next run time for a cron expression after the given time.
|
||||
// It searches up to 2 years ahead before giving up.
|
||||
func NextRun(schedule string, from time.Time) (time.Time, error) {
|
||||
ce, err := ParseCron(schedule)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return ce.Next(from)
|
||||
}
|
||||
|
||||
// Next finds the earliest time after "from" that matches the cron expression.
|
||||
func (ce *CronExpr) Next(from time.Time) (time.Time, error) {
|
||||
// Start from the next whole minute.
|
||||
t := from.Truncate(time.Minute).Add(time.Minute)
|
||||
|
||||
// Search limit: 2 years of minutes (~1,051,200). We iterate by
|
||||
// advancing fields intelligently rather than minute-by-minute.
|
||||
deadline := t.Add(2 * 365 * 24 * time.Hour)
|
||||
|
||||
for t.Before(deadline) {
|
||||
// Check month.
|
||||
if !contains(ce.Months, int(t.Month())) {
|
||||
// Advance to next valid month.
|
||||
t = advanceMonth(t, ce.Months)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check day of month.
|
||||
dom := t.Day()
|
||||
domOk := contains(ce.DaysOfMonth, dom)
|
||||
dowOk := contains(ce.DaysOfWeek, int(t.Weekday()))
|
||||
if !domOk || !dowOk {
|
||||
// Advance one day.
|
||||
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
|
||||
// Check hour.
|
||||
if !contains(ce.Hours, t.Hour()) {
|
||||
// Advance to next valid hour today.
|
||||
nextH := nextVal(ce.Hours, t.Hour())
|
||||
if nextH == -1 {
|
||||
// No more valid hours today, go to next day.
|
||||
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
||||
} else {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), nextH, 0, 0, 0, t.Location())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check minute.
|
||||
if !contains(ce.Minutes, t.Minute()) {
|
||||
nextM := nextVal(ce.Minutes, t.Minute())
|
||||
if nextM == -1 {
|
||||
// No more valid minutes this hour, advance hour.
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
|
||||
} else {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextM, 0, 0, t.Location())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// All fields match.
|
||||
return t, nil
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("cron: no matching time found within 2 years for %q", ce.String())
|
||||
}
|
||||
|
||||
// String reconstructs a human-readable representation of the cron expression.
|
||||
func (ce *CronExpr) String() string {
|
||||
return fmt.Sprintf("%v %v %v %v %v",
|
||||
ce.Minutes, ce.Hours, ce.DaysOfMonth, ce.Months, ce.DaysOfWeek)
|
||||
}
|
||||
|
||||
// contains checks if val is in the sorted slice.
|
||||
func contains(vals []int, val int) bool {
|
||||
for _, v := range vals {
|
||||
if v == val {
|
||||
return true
|
||||
}
|
||||
if v > val {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nextVal returns the smallest value in vals that is > current, or -1.
|
||||
func nextVal(vals []int, current int) int {
|
||||
for _, v := range vals {
|
||||
if v > current {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// advanceMonth jumps to day 1, hour 0, minute 0 of the next valid month.
|
||||
func advanceMonth(t time.Time, months []int) time.Time {
|
||||
cur := int(t.Month())
|
||||
year := t.Year()
|
||||
|
||||
// Find next valid month in this year.
|
||||
for _, m := range months {
|
||||
if m > cur {
|
||||
return time.Date(year, time.Month(m), 1, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
}
|
||||
// Wrap to first valid month of next year.
|
||||
return time.Date(year+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// sortInts performs an insertion sort on a small slice.
|
||||
func sortInts(a []int) {
|
||||
for i := 1; i < len(a); i++ {
|
||||
key := a[i]
|
||||
j := i - 1
|
||||
for j >= 0 && a[j] > key {
|
||||
a[j+1] = a[j]
|
||||
j--
|
||||
}
|
||||
a[j+1] = key
|
||||
}
|
||||
}
|
||||
279
services/setec-manager/internal/scheduler/scheduler.go
Normal file
279
services/setec-manager/internal/scheduler/scheduler.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/db"
|
||||
)
|
||||
|
||||
// Job type constants.
|
||||
const (
|
||||
JobSSLRenew = "ssl_renew"
|
||||
JobBackup = "backup"
|
||||
JobGitPull = "git_pull"
|
||||
JobRestart = "restart"
|
||||
JobCleanup = "cleanup"
|
||||
)
|
||||
|
||||
// Job represents a scheduled job stored in the cron_jobs table.
|
||||
type Job struct {
|
||||
ID int64 `json:"id"`
|
||||
SiteID *int64 `json:"site_id"`
|
||||
JobType string `json:"job_type"`
|
||||
Schedule string `json:"schedule"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
NextRun *time.Time `json:"next_run"`
|
||||
}
|
||||
|
||||
// HandlerFunc is the signature for job handler functions.
|
||||
// siteID may be nil for global jobs (e.g., cleanup).
|
||||
type HandlerFunc func(siteID *int64) error
|
||||
|
||||
// Scheduler manages cron-like scheduled jobs backed by a SQLite database.
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
handlers map[string]HandlerFunc
|
||||
mu sync.RWMutex
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// New creates a new Scheduler attached to the given database.
|
||||
func New(database *db.DB) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandler registers a function to handle a given job type.
|
||||
// Must be called before Start.
|
||||
func (s *Scheduler) RegisterHandler(jobType string, fn HandlerFunc) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handlers[jobType] = fn
|
||||
log.Printf("[scheduler] registered handler for job type %q", jobType)
|
||||
}
|
||||
|
||||
// Start begins the scheduler's ticker goroutine that fires every minute.
|
||||
func (s *Scheduler) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
log.Printf("[scheduler] already running")
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.stop = make(chan struct{})
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[scheduler] starting — checking for due jobs every 60s")
|
||||
go s.loop()
|
||||
}
|
||||
|
||||
// Stop shuts down the scheduler ticker.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
close(s.stop)
|
||||
s.running = false
|
||||
log.Printf("[scheduler] stopped")
|
||||
}
|
||||
|
||||
// loop runs the main ticker. It fires immediately on start, then every minute.
|
||||
func (s *Scheduler) loop() {
|
||||
// Run once immediately on start.
|
||||
s.tick()
|
||||
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.tick()
|
||||
case <-s.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick queries for all enabled jobs whose next_run <= now and executes them.
|
||||
func (s *Scheduler) tick() {
|
||||
now := time.Now().UTC()
|
||||
|
||||
rows, err := s.db.Conn().Query(`
|
||||
SELECT id, site_id, job_type, schedule, enabled, last_run, next_run
|
||||
FROM cron_jobs
|
||||
WHERE enabled = TRUE AND next_run IS NOT NULL AND next_run <= ?
|
||||
ORDER BY next_run ASC`, now)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] error querying due jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var due []Job
|
||||
for rows.Next() {
|
||||
var j Job
|
||||
var siteID sql.NullInt64
|
||||
var lastRun, nextRun sql.NullTime
|
||||
if err := rows.Scan(&j.ID, &siteID, &j.JobType, &j.Schedule, &j.Enabled, &lastRun, &nextRun); err != nil {
|
||||
log.Printf("[scheduler] error scanning job row: %v", err)
|
||||
continue
|
||||
}
|
||||
if siteID.Valid {
|
||||
id := siteID.Int64
|
||||
j.SiteID = &id
|
||||
}
|
||||
if lastRun.Valid {
|
||||
j.LastRun = &lastRun.Time
|
||||
}
|
||||
if nextRun.Valid {
|
||||
j.NextRun = &nextRun.Time
|
||||
}
|
||||
due = append(due, j)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(due) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[scheduler] %d job(s) due", len(due))
|
||||
|
||||
for _, job := range due {
|
||||
s.executeJob(job, now)
|
||||
}
|
||||
}
|
||||
|
||||
// executeJob runs a single job's handler and updates the database.
|
||||
func (s *Scheduler) executeJob(job Job, now time.Time) {
|
||||
s.mu.RLock()
|
||||
handler, ok := s.handlers[job.JobType]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
log.Printf("[scheduler] no handler for job type %q (job %d), skipping", job.JobType, job.ID)
|
||||
// Still advance next_run so we don't re-fire every minute.
|
||||
s.advanceJob(job, now)
|
||||
return
|
||||
}
|
||||
|
||||
siteLabel := "global"
|
||||
if job.SiteID != nil {
|
||||
siteLabel = fmt.Sprintf("site %d", *job.SiteID)
|
||||
}
|
||||
log.Printf("[scheduler] executing job %d: type=%s %s schedule=%s", job.ID, job.JobType, siteLabel, job.Schedule)
|
||||
|
||||
if err := handler(job.SiteID); err != nil {
|
||||
log.Printf("[scheduler] job %d (%s) failed: %v", job.ID, job.JobType, err)
|
||||
} else {
|
||||
log.Printf("[scheduler] job %d (%s) completed successfully", job.ID, job.JobType)
|
||||
}
|
||||
|
||||
s.advanceJob(job, now)
|
||||
}
|
||||
|
||||
// advanceJob updates last_run to now and computes the next next_run.
|
||||
func (s *Scheduler) advanceJob(job Job, now time.Time) {
|
||||
next, err := NextRun(job.Schedule, now)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] cannot compute next run for job %d (%q): %v — disabling", job.ID, job.Schedule, err)
|
||||
_, _ = s.db.Conn().Exec(`UPDATE cron_jobs SET enabled = FALSE, last_run = ? WHERE id = ?`, now, job.ID)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.db.Conn().Exec(
|
||||
`UPDATE cron_jobs SET last_run = ?, next_run = ? WHERE id = ?`,
|
||||
now, next, job.ID)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] error updating job %d: %v", job.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AddJob inserts a new scheduled job and returns its ID.
|
||||
// siteID may be nil for global jobs.
|
||||
func (s *Scheduler) AddJob(siteID *int64, jobType, schedule string) (int64, error) {
|
||||
// Validate the schedule before inserting.
|
||||
next, err := NextRun(schedule, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid schedule %q: %w", schedule, err)
|
||||
}
|
||||
|
||||
var sid sql.NullInt64
|
||||
if siteID != nil {
|
||||
sid = sql.NullInt64{Int64: *siteID, Valid: true}
|
||||
}
|
||||
|
||||
res, err := s.db.Conn().Exec(
|
||||
`INSERT INTO cron_jobs (site_id, job_type, schedule, enabled, next_run) VALUES (?, ?, ?, TRUE, ?)`,
|
||||
sid, jobType, schedule, next)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert cron job: %w", err)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get insert id: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[scheduler] added job %d: type=%s schedule=%s next_run=%s", id, jobType, schedule, next.Format(time.RFC3339))
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RemoveJob deletes a scheduled job by ID.
|
||||
func (s *Scheduler) RemoveJob(id int64) error {
|
||||
res, err := s.db.Conn().Exec(`DELETE FROM cron_jobs WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete cron job %d: %w", id, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("cron job %d not found", id)
|
||||
}
|
||||
log.Printf("[scheduler] removed job %d", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListJobs returns all cron jobs with their current state.
|
||||
func (s *Scheduler) ListJobs() ([]Job, error) {
|
||||
rows, err := s.db.Conn().Query(`
|
||||
SELECT id, site_id, job_type, schedule, enabled, last_run, next_run
|
||||
FROM cron_jobs
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list cron jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []Job
|
||||
for rows.Next() {
|
||||
var j Job
|
||||
var siteID sql.NullInt64
|
||||
var lastRun, nextRun sql.NullTime
|
||||
if err := rows.Scan(&j.ID, &siteID, &j.JobType, &j.Schedule, &j.Enabled, &lastRun, &nextRun); err != nil {
|
||||
return nil, fmt.Errorf("scan cron job: %w", err)
|
||||
}
|
||||
if siteID.Valid {
|
||||
id := siteID.Int64
|
||||
j.SiteID = &id
|
||||
}
|
||||
if lastRun.Valid {
|
||||
j.LastRun = &lastRun.Time
|
||||
}
|
||||
if nextRun.Valid {
|
||||
j.NextRun = &nextRun.Time
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
114
services/setec-manager/internal/server/auth.go
Normal file
114
services/setec-manager/internal/server/auth.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.DB.AuthenticateUser(req.Username, req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenStr, err := token.SignedString(s.JWTKey)
|
||||
if err != nil {
|
||||
http.Error(w, "Token generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set cookie
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "setec_token",
|
||||
Value: tokenStr,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: s.Config.Server.TLS,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: tokenStr,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "setec_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
MaxAge: -1,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
claims := getClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
http.Error(w, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user_id": claims.UserID,
|
||||
"username": claims.Username,
|
||||
"role": claims.Role,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderTemplate(w, "login.html", nil)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
135
services/setec-manager/internal/server/middleware.go
Normal file
135
services/setec-manager/internal/server/middleware.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
// authRequired validates JWT from cookie or Authorization header.
|
||||
func (s *Server) authRequired(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := ""
|
||||
|
||||
// Try cookie first
|
||||
if cookie, err := r.Cookie("setec_token"); err == nil {
|
||||
tokenStr = cookie.Value
|
||||
}
|
||||
|
||||
// Fall back to Authorization header
|
||||
if tokenStr == "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
}
|
||||
|
||||
if tokenStr == "" {
|
||||
// If HTML request, redirect to login
|
||||
if acceptsHTML(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
return s.JWTKey, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
if acceptsHTML(r) {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// adminRequired checks that the authenticated user has admin role.
|
||||
func (s *Server) adminRequired(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := getClaimsFromContext(r.Context())
|
||||
if claims == nil || claims.Role != "admin" {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(r.Context()))
|
||||
})
|
||||
}
|
||||
|
||||
func getClaimsFromContext(ctx context.Context) *Claims {
|
||||
claims, _ := ctx.Value(claimsKey).(*Claims)
|
||||
return claims
|
||||
}
|
||||
|
||||
func acceptsHTML(r *http.Request) bool {
|
||||
return strings.Contains(r.Header.Get("Accept"), "text/html")
|
||||
}
|
||||
|
||||
// ── Rate Limiter ────────────────────────────────────────────────────
|
||||
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string][]time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
attempts: make(map[string][]time.Time),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) Allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-rl.window)
|
||||
|
||||
// Remove expired entries
|
||||
var valid []time.Time
|
||||
for _, t := range rl.attempts[key] {
|
||||
if t.After(cutoff) {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(valid) >= rl.limit {
|
||||
rl.attempts[key] = valid
|
||||
return false
|
||||
}
|
||||
|
||||
rl.attempts[key] = append(valid, now)
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) loginRateLimit(next http.Handler) http.Handler {
|
||||
limiter := newRateLimiter(5, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.RemoteAddr
|
||||
if !limiter.Allow(ip) {
|
||||
http.Error(w, "Too many login attempts. Try again in a minute.", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
152
services/setec-manager/internal/server/routes.go
Normal file
152
services/setec-manager/internal/server/routes.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"setec-manager/internal/handlers"
|
||||
"setec-manager/web"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
h := handlers.New(s.Config, s.DB, s.HostingConfigs)
|
||||
|
||||
// Static assets (embedded)
|
||||
staticFS, _ := fs.Sub(web.StaticFS, "static")
|
||||
s.Router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public routes
|
||||
s.Router.Group(func(r chi.Router) {
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.With(s.loginRateLimit).Post("/login", s.handleLogin)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
})
|
||||
|
||||
// Authenticated routes
|
||||
s.Router.Group(func(r chi.Router) {
|
||||
r.Use(s.authRequired)
|
||||
|
||||
// Dashboard
|
||||
r.Get("/", h.Dashboard)
|
||||
r.Get("/api/system/info", h.SystemInfo)
|
||||
|
||||
// Auth status
|
||||
r.Get("/api/auth/status", s.handleAuthStatus)
|
||||
|
||||
// Sites
|
||||
r.Get("/sites", h.SiteList)
|
||||
r.Get("/sites/new", h.SiteNewForm)
|
||||
r.Post("/sites", h.SiteCreate)
|
||||
r.Get("/sites/{id}", h.SiteDetail)
|
||||
r.Put("/sites/{id}", h.SiteUpdate)
|
||||
r.Delete("/sites/{id}", h.SiteDelete)
|
||||
r.Post("/sites/{id}/deploy", h.SiteDeploy)
|
||||
r.Post("/sites/{id}/restart", h.SiteRestart)
|
||||
r.Post("/sites/{id}/stop", h.SiteStop)
|
||||
r.Post("/sites/{id}/start", h.SiteStart)
|
||||
r.Get("/sites/{id}/logs", h.SiteLogs)
|
||||
r.Get("/sites/{id}/logs/stream", h.SiteLogStream)
|
||||
|
||||
// AUTARCH
|
||||
r.Get("/autarch", h.AutarchStatus)
|
||||
r.Post("/autarch/install", h.AutarchInstall)
|
||||
r.Post("/autarch/update", h.AutarchUpdate)
|
||||
r.Get("/autarch/status", h.AutarchStatusAPI)
|
||||
r.Post("/autarch/start", h.AutarchStart)
|
||||
r.Post("/autarch/stop", h.AutarchStop)
|
||||
r.Post("/autarch/restart", h.AutarchRestart)
|
||||
r.Get("/autarch/config", h.AutarchConfig)
|
||||
r.Put("/autarch/config", h.AutarchConfigUpdate)
|
||||
r.Post("/autarch/dns/build", h.AutarchDNSBuild)
|
||||
|
||||
// SSL
|
||||
r.Get("/ssl", h.SSLOverview)
|
||||
r.Post("/ssl/{domain}/issue", h.SSLIssue)
|
||||
r.Post("/ssl/{domain}/renew", h.SSLRenew)
|
||||
r.Get("/api/ssl/status", h.SSLStatus)
|
||||
|
||||
// Nginx
|
||||
r.Get("/nginx", h.NginxStatus)
|
||||
r.Post("/nginx/reload", h.NginxReload)
|
||||
r.Post("/nginx/restart", h.NginxRestart)
|
||||
r.Get("/nginx/config/{domain}", h.NginxConfigView)
|
||||
r.Post("/nginx/test", h.NginxTest)
|
||||
|
||||
// Firewall
|
||||
r.Get("/firewall", h.FirewallList)
|
||||
r.Post("/firewall/rules", h.FirewallAddRule)
|
||||
r.Delete("/firewall/rules/{id}", h.FirewallDeleteRule)
|
||||
r.Post("/firewall/enable", h.FirewallEnable)
|
||||
r.Post("/firewall/disable", h.FirewallDisable)
|
||||
r.Get("/api/firewall/status", h.FirewallStatus)
|
||||
|
||||
// System users
|
||||
r.Get("/users", h.UserList)
|
||||
r.Post("/users", h.UserCreate)
|
||||
r.Delete("/users/{id}", h.UserDelete)
|
||||
|
||||
// Panel users
|
||||
r.Get("/panel/users", h.PanelUserList)
|
||||
r.Post("/panel/users", h.PanelUserCreate)
|
||||
r.Put("/panel/users/{id}", h.PanelUserUpdate)
|
||||
r.Delete("/panel/users/{id}", h.PanelUserDelete)
|
||||
|
||||
// Backups
|
||||
r.Get("/backups", h.BackupList)
|
||||
r.Post("/backups/site/{id}", h.BackupSite)
|
||||
r.Post("/backups/full", h.BackupFull)
|
||||
r.Delete("/backups/{id}", h.BackupDelete)
|
||||
r.Get("/backups/{id}/download", h.BackupDownload)
|
||||
|
||||
// Hosting Provider Management
|
||||
r.Get("/hosting", h.HostingProviders)
|
||||
r.Get("/hosting/{provider}", h.HostingProviderConfig)
|
||||
r.Post("/hosting/{provider}/config", h.HostingProviderSave)
|
||||
r.Post("/hosting/{provider}/test", h.HostingProviderTest)
|
||||
// DNS
|
||||
r.Get("/hosting/{provider}/dns/{domain}", h.HostingDNSList)
|
||||
r.Put("/hosting/{provider}/dns/{domain}", h.HostingDNSUpdate)
|
||||
r.Delete("/hosting/{provider}/dns/{domain}", h.HostingDNSDelete)
|
||||
r.Post("/hosting/{provider}/dns/{domain}/reset", h.HostingDNSReset)
|
||||
// Domains
|
||||
r.Get("/hosting/{provider}/domains", h.HostingDomainsList)
|
||||
r.Post("/hosting/{provider}/domains/check", h.HostingDomainsCheck)
|
||||
r.Post("/hosting/{provider}/domains/purchase", h.HostingDomainsPurchase)
|
||||
r.Put("/hosting/{provider}/domains/{domain}/nameservers", h.HostingDomainNameservers)
|
||||
r.Put("/hosting/{provider}/domains/{domain}/lock", h.HostingDomainLock)
|
||||
r.Put("/hosting/{provider}/domains/{domain}/privacy", h.HostingDomainPrivacy)
|
||||
// VPS
|
||||
r.Get("/hosting/{provider}/vms", h.HostingVMsList)
|
||||
r.Get("/hosting/{provider}/vms/{id}", h.HostingVMGet)
|
||||
r.Post("/hosting/{provider}/vms", h.HostingVMCreate)
|
||||
r.Get("/hosting/{provider}/datacenters", h.HostingDataCenters)
|
||||
// SSH Keys
|
||||
r.Get("/hosting/{provider}/ssh-keys", h.HostingSSHKeys)
|
||||
r.Post("/hosting/{provider}/ssh-keys", h.HostingSSHKeyAdd)
|
||||
r.Delete("/hosting/{provider}/ssh-keys/{id}", h.HostingSSHKeyDelete)
|
||||
// Billing
|
||||
r.Get("/hosting/{provider}/subscriptions", h.HostingSubscriptions)
|
||||
r.Get("/hosting/{provider}/catalog", h.HostingCatalog)
|
||||
|
||||
// Monitoring
|
||||
r.Get("/monitor", h.MonitorPage)
|
||||
r.Get("/api/monitor/cpu", h.MonitorCPU)
|
||||
r.Get("/api/monitor/memory", h.MonitorMemory)
|
||||
r.Get("/api/monitor/disk", h.MonitorDisk)
|
||||
r.Get("/api/monitor/services", h.MonitorServices)
|
||||
|
||||
// Logs
|
||||
r.Get("/logs", h.LogsPage)
|
||||
r.Get("/api/logs/system", h.LogsSystem)
|
||||
r.Get("/api/logs/nginx", h.LogsNginx)
|
||||
r.Get("/api/logs/stream", h.LogsStream)
|
||||
|
||||
// Float Mode
|
||||
r.Post("/float/register", h.FloatRegister)
|
||||
r.Get("/float/sessions", h.FloatSessions)
|
||||
r.Delete("/float/sessions/{id}", h.FloatDisconnect)
|
||||
r.Get("/float/ws", s.FloatBridge.HandleWebSocket)
|
||||
})
|
||||
}
|
||||
199
services/setec-manager/internal/server/security.go
Normal file
199
services/setec-manager/internal/server/security.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ── Security Headers Middleware ──────────────────────────────────────
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data:; "+
|
||||
"font-src 'self'; "+
|
||||
"connect-src 'self'; "+
|
||||
"frame-ancestors 'none'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Request Body Limit ──────────────────────────────────────────────
|
||||
|
||||
func maxBodySize(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSRF Protection ─────────────────────────────────────────────────
|
||||
|
||||
const csrfTokenLength = 32
|
||||
const csrfCookieName = "setec_csrf"
|
||||
const csrfHeaderName = "X-CSRF-Token"
|
||||
const csrfFormField = "csrf_token"
|
||||
|
||||
func generateCSRFToken() string {
|
||||
b := make([]byte, csrfTokenLength)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func csrfProtection(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Safe methods don't need CSRF validation
|
||||
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
// Ensure a CSRF cookie exists for forms to use
|
||||
if _, err := r.Cookie(csrfCookieName); err != nil {
|
||||
token := generateCSRFToken()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: false, // JS needs to read this
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: 86400,
|
||||
})
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// For mutating requests, validate CSRF token
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil {
|
||||
http.Error(w, "CSRF token missing", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check header first, then form field
|
||||
token := r.Header.Get(csrfHeaderName)
|
||||
if token == "" {
|
||||
token = r.FormValue(csrfFormField)
|
||||
}
|
||||
|
||||
// API requests with JSON Content-Type + Bearer auth skip CSRF
|
||||
// (they're not vulnerable to CSRF since browsers don't send custom headers)
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.Contains(contentType, "application/json") && strings.HasPrefix(authHeader, "Bearer ") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if token != cookie.Value {
|
||||
http.Error(w, "CSRF token invalid", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Password Policy ─────────────────────────────────────────────────
|
||||
|
||||
type passwordPolicy struct {
|
||||
MinLength int
|
||||
RequireUpper bool
|
||||
RequireLower bool
|
||||
RequireDigit bool
|
||||
}
|
||||
|
||||
var defaultPasswordPolicy = passwordPolicy{
|
||||
MinLength: 8,
|
||||
RequireUpper: true,
|
||||
RequireLower: true,
|
||||
RequireDigit: true,
|
||||
}
|
||||
|
||||
func validatePassword(password string) error {
|
||||
p := defaultPasswordPolicy
|
||||
|
||||
if len(password) < p.MinLength {
|
||||
return fmt.Errorf("password must be at least %d characters", p.MinLength)
|
||||
}
|
||||
|
||||
hasUpper, hasLower, hasDigit := false, false, false
|
||||
for _, c := range password {
|
||||
if unicode.IsUpper(c) {
|
||||
hasUpper = true
|
||||
}
|
||||
if unicode.IsLower(c) {
|
||||
hasLower = true
|
||||
}
|
||||
if unicode.IsDigit(c) {
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
|
||||
if p.RequireUpper && !hasUpper {
|
||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||
}
|
||||
if p.RequireLower && !hasLower {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
if p.RequireDigit && !hasDigit {
|
||||
return fmt.Errorf("password must contain at least one digit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Persistent JWT Key ──────────────────────────────────────────────
|
||||
|
||||
func LoadOrCreateJWTKey(dataDir string) ([]byte, error) {
|
||||
keyPath := filepath.Join(dataDir, ".jwt_key")
|
||||
|
||||
// Try to load existing key
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(data) == 32 {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Save with restrictive permissions
|
||||
os.MkdirAll(dataDir, 0700)
|
||||
if err := os.WriteFile(keyPath, key, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ── Audit Logger ────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) logAudit(r *http.Request, action, detail string) {
|
||||
claims := getClaimsFromContext(r.Context())
|
||||
username := "anonymous"
|
||||
if claims != nil {
|
||||
username = claims.Username
|
||||
}
|
||||
ip := r.RemoteAddr
|
||||
|
||||
// Insert into audit log table
|
||||
s.DB.Conn().Exec(`INSERT INTO audit_log (username, ip, action, detail) VALUES (?, ?, ?, ?)`,
|
||||
username, ip, action, detail)
|
||||
}
|
||||
81
services/setec-manager/internal/server/server.go
Normal file
81
services/setec-manager/internal/server/server.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/config"
|
||||
"setec-manager/internal/db"
|
||||
"setec-manager/internal/float"
|
||||
"setec-manager/internal/hosting"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Config *config.Config
|
||||
DB *db.DB
|
||||
Router *chi.Mux
|
||||
http *http.Server
|
||||
JWTKey []byte
|
||||
FloatBridge *float.Bridge
|
||||
HostingConfigs *hosting.ProviderConfigStore
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, database *db.DB, jwtKey []byte) *Server {
|
||||
// Initialize hosting provider config store.
|
||||
hostingDir := filepath.Join(filepath.Dir(cfg.Database.Path), "hosting")
|
||||
hostingConfigs := hosting.NewConfigStore(hostingDir)
|
||||
|
||||
s := &Server{
|
||||
Config: cfg,
|
||||
DB: database,
|
||||
Router: chi.NewRouter(),
|
||||
JWTKey: jwtKey,
|
||||
FloatBridge: float.NewBridge(database),
|
||||
HostingConfigs: hostingConfigs,
|
||||
}
|
||||
|
||||
s.setupMiddleware()
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) setupMiddleware() {
|
||||
s.Router.Use(chiMiddleware.RequestID)
|
||||
s.Router.Use(chiMiddleware.RealIP)
|
||||
s.Router.Use(chiMiddleware.Logger)
|
||||
s.Router.Use(chiMiddleware.Recoverer)
|
||||
s.Router.Use(chiMiddleware.Timeout(60 * time.Second))
|
||||
s.Router.Use(securityHeaders)
|
||||
s.Router.Use(maxBodySize(10 << 20)) // 10MB max request body
|
||||
s.Router.Use(csrfProtection)
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port)
|
||||
|
||||
s.http = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: s.Router,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("[setec] Starting on %s (TLS=%v)", addr, s.Config.Server.TLS)
|
||||
|
||||
if s.Config.Server.TLS {
|
||||
return s.http.ListenAndServeTLS(s.Config.Server.Cert, s.Config.Server.Key)
|
||||
}
|
||||
return s.http.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.http.Shutdown(ctx)
|
||||
}
|
||||
93
services/setec-manager/internal/server/templates.go
Normal file
93
services/setec-manager/internal/server/templates.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"setec-manager/web"
|
||||
)
|
||||
|
||||
var (
|
||||
tmplOnce sync.Once
|
||||
tmpl *template.Template
|
||||
)
|
||||
|
||||
func (s *Server) getTemplates() *template.Template {
|
||||
tmplOnce.Do(func() {
|
||||
funcMap := template.FuncMap{
|
||||
"eq": func(a, b interface{}) bool { return a == b },
|
||||
"ne": func(a, b interface{}) bool { return a != b },
|
||||
"default": func(val, def interface{}) interface{} {
|
||||
if val == nil || val == "" || val == 0 || val == false {
|
||||
return def
|
||||
}
|
||||
return val
|
||||
},
|
||||
}
|
||||
|
||||
var err error
|
||||
tmpl, err = template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, "templates/*.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse templates: %v", err)
|
||||
}
|
||||
})
|
||||
return tmpl
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
Title string
|
||||
Claims *Claims
|
||||
Data interface{}
|
||||
Flash string
|
||||
Config interface{}
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
td := templateData{
|
||||
Data: data,
|
||||
Config: s.Config,
|
||||
}
|
||||
|
||||
t := s.getTemplates().Lookup(name)
|
||||
if t == nil {
|
||||
http.Error(w, "Template not found: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.Execute(w, td); err != nil {
|
||||
log.Printf("Template render error (%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplateWithClaims(w http.ResponseWriter, r *http.Request, name string, data interface{}) {
|
||||
td := templateData{
|
||||
Claims: getClaimsFromContext(r.Context()),
|
||||
Data: data,
|
||||
Config: s.Config,
|
||||
}
|
||||
|
||||
t := s.getTemplates().Lookup(name)
|
||||
if t == nil {
|
||||
http.Error(w, "Template not found: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.Execute(w, td); err != nil {
|
||||
log.Printf("Template render error (%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderError sends an error response - HTML for browsers, JSON for API calls.
|
||||
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||
if acceptsHTML(r) {
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, message)
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
243
services/setec-manager/internal/system/firewall.go
Normal file
243
services/setec-manager/internal/system/firewall.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
type UFWRule struct {
|
||||
Direction string `json:"direction"` // "in" or "out"
|
||||
Protocol string `json:"protocol"` // "tcp", "udp", or "" for both
|
||||
Port string `json:"port"` // e.g. "22", "80:90"
|
||||
Source string `json:"source"` // IP/CIDR or "any"/"Anywhere"
|
||||
Action string `json:"action"` // "allow", "deny", "reject", "limit"
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// ── Firewall (UFW) ──────────────────────────────────────────────────
|
||||
|
||||
// Status parses `ufw status verbose` and returns the enable state, parsed rules,
|
||||
// and the raw command output.
|
||||
func FirewallStatus() (enabled bool, rules []UFWRule, raw string, err error) {
|
||||
out, cmdErr := exec.Command("ufw", "status", "verbose").CombinedOutput()
|
||||
raw = string(out)
|
||||
if cmdErr != nil {
|
||||
// ufw may return non-zero when inactive; check output
|
||||
if strings.Contains(raw, "Status: inactive") {
|
||||
return false, nil, raw, nil
|
||||
}
|
||||
err = fmt.Errorf("ufw status failed: %w (%s)", cmdErr, raw)
|
||||
return
|
||||
}
|
||||
|
||||
enabled = strings.Contains(raw, "Status: active")
|
||||
|
||||
// Parse rule lines. After the header block, rules look like:
|
||||
// 22/tcp ALLOW IN Anywhere # SSH
|
||||
// 80/tcp ALLOW IN 192.168.1.0/24 # Web
|
||||
// We find lines after the "---" separator.
|
||||
lines := strings.Split(raw, "\n")
|
||||
pastSeparator := false
|
||||
// Match: port/proto (or port) ACTION DIRECTION source # optional comment
|
||||
ruleRegex := regexp.MustCompile(
|
||||
`^(\S+)\s+(ALLOW|DENY|REJECT|LIMIT)\s+(IN|OUT|FWD)?\s*(.+?)(?:\s+#\s*(.*))?$`,
|
||||
)
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "---") {
|
||||
pastSeparator = true
|
||||
continue
|
||||
}
|
||||
if !pastSeparator || trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := ruleRegex.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
portProto := matches[1]
|
||||
action := strings.ToLower(matches[2])
|
||||
direction := strings.ToLower(matches[3])
|
||||
source := strings.TrimSpace(matches[4])
|
||||
comment := strings.TrimSpace(matches[5])
|
||||
|
||||
if direction == "" {
|
||||
direction = "in"
|
||||
}
|
||||
|
||||
// Split port/protocol
|
||||
var port, proto string
|
||||
if strings.Contains(portProto, "/") {
|
||||
parts := strings.SplitN(portProto, "/", 2)
|
||||
port = parts[0]
|
||||
proto = parts[1]
|
||||
} else {
|
||||
port = portProto
|
||||
}
|
||||
|
||||
// Normalize source
|
||||
if source == "Anywhere" || source == "Anywhere (v6)" {
|
||||
source = "any"
|
||||
}
|
||||
|
||||
rules = append(rules, UFWRule{
|
||||
Direction: direction,
|
||||
Protocol: proto,
|
||||
Port: port,
|
||||
Source: source,
|
||||
Action: action,
|
||||
Comment: comment,
|
||||
})
|
||||
}
|
||||
|
||||
return enabled, rules, raw, nil
|
||||
}
|
||||
|
||||
// FirewallEnable enables UFW with --force to skip the interactive prompt.
|
||||
func FirewallEnable() error {
|
||||
out, err := exec.Command("ufw", "--force", "enable").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ufw enable failed: %w (%s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirewallDisable disables UFW.
|
||||
func FirewallDisable() error {
|
||||
out, err := exec.Command("ufw", "disable").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ufw disable failed: %w (%s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirewallAddRule constructs and executes a ufw command from the given rule struct.
|
||||
func FirewallAddRule(rule UFWRule) error {
|
||||
if rule.Port == "" {
|
||||
return fmt.Errorf("port is required")
|
||||
}
|
||||
if rule.Action == "" {
|
||||
rule.Action = "allow"
|
||||
}
|
||||
if rule.Protocol == "" {
|
||||
rule.Protocol = "tcp"
|
||||
}
|
||||
if rule.Source == "" || rule.Source == "any" {
|
||||
rule.Source = ""
|
||||
}
|
||||
|
||||
// Validate action
|
||||
switch rule.Action {
|
||||
case "allow", "deny", "reject", "limit":
|
||||
// valid
|
||||
default:
|
||||
return fmt.Errorf("invalid action %q: must be allow, deny, reject, or limit", rule.Action)
|
||||
}
|
||||
|
||||
// Validate protocol
|
||||
switch rule.Protocol {
|
||||
case "tcp", "udp":
|
||||
// valid
|
||||
default:
|
||||
return fmt.Errorf("invalid protocol %q: must be tcp or udp", rule.Protocol)
|
||||
}
|
||||
|
||||
// Validate direction
|
||||
if rule.Direction != "" && rule.Direction != "in" && rule.Direction != "out" {
|
||||
return fmt.Errorf("invalid direction %q: must be in or out", rule.Direction)
|
||||
}
|
||||
|
||||
// Build argument list
|
||||
args := []string{rule.Action}
|
||||
|
||||
// Direction
|
||||
if rule.Direction == "out" {
|
||||
args = append(args, "out")
|
||||
}
|
||||
|
||||
// Source filter
|
||||
if rule.Source != "" {
|
||||
args = append(args, "from", rule.Source)
|
||||
}
|
||||
|
||||
args = append(args, "to", "any", "port", rule.Port, "proto", rule.Protocol)
|
||||
|
||||
// Comment
|
||||
if rule.Comment != "" {
|
||||
args = append(args, "comment", rule.Comment)
|
||||
}
|
||||
|
||||
out, err := exec.Command("ufw", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ufw add rule failed: %w (%s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirewallDeleteRule constructs and executes a ufw delete command for the given rule.
|
||||
func FirewallDeleteRule(rule UFWRule) error {
|
||||
if rule.Port == "" {
|
||||
return fmt.Errorf("port is required")
|
||||
}
|
||||
if rule.Action == "" {
|
||||
rule.Action = "allow"
|
||||
}
|
||||
|
||||
// Build the rule specification that matches what was added
|
||||
args := []string{"delete", rule.Action}
|
||||
|
||||
if rule.Direction == "out" {
|
||||
args = append(args, "out")
|
||||
}
|
||||
|
||||
if rule.Source != "" && rule.Source != "any" {
|
||||
args = append(args, "from", rule.Source)
|
||||
}
|
||||
|
||||
portSpec := rule.Port
|
||||
if rule.Protocol != "" {
|
||||
portSpec = rule.Port + "/" + rule.Protocol
|
||||
}
|
||||
args = append(args, portSpec)
|
||||
|
||||
out, err := exec.Command("ufw", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ufw delete rule failed: %w (%s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirewallSetDefaults sets the default incoming and outgoing policies.
|
||||
// Valid values are "allow", "deny", "reject".
|
||||
func FirewallSetDefaults(incoming, outgoing string) error {
|
||||
validPolicy := map[string]bool{"allow": true, "deny": true, "reject": true}
|
||||
|
||||
if incoming != "" {
|
||||
if !validPolicy[incoming] {
|
||||
return fmt.Errorf("invalid incoming policy %q: must be allow, deny, or reject", incoming)
|
||||
}
|
||||
out, err := exec.Command("ufw", "default", incoming, "incoming").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting default incoming policy failed: %w (%s)", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
if outgoing != "" {
|
||||
if !validPolicy[outgoing] {
|
||||
return fmt.Errorf("invalid outgoing policy %q: must be allow, deny, or reject", outgoing)
|
||||
}
|
||||
out, err := exec.Command("ufw", "default", outgoing, "outgoing").CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting default outgoing policy failed: %w (%s)", err, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
511
services/setec-manager/internal/system/info.go
Normal file
511
services/setec-manager/internal/system/info.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
type CoreUsage struct {
|
||||
Core int `json:"core"`
|
||||
User float64 `json:"user"`
|
||||
System float64 `json:"system"`
|
||||
Idle float64 `json:"idle"`
|
||||
IOWait float64 `json:"iowait"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
type CPUInfo struct {
|
||||
Overall float64 `json:"overall"`
|
||||
Idle float64 `json:"idle"`
|
||||
Cores []CoreUsage `json:"cores"`
|
||||
}
|
||||
|
||||
type MemInfo struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
AvailableBytes uint64 `json:"available_bytes"`
|
||||
BuffersBytes uint64 `json:"buffers_bytes"`
|
||||
CachedBytes uint64 `json:"cached_bytes"`
|
||||
SwapTotalBytes uint64 `json:"swap_total_bytes"`
|
||||
SwapUsedBytes uint64 `json:"swap_used_bytes"`
|
||||
SwapFreeBytes uint64 `json:"swap_free_bytes"`
|
||||
Total string `json:"total"`
|
||||
Used string `json:"used"`
|
||||
Free string `json:"free"`
|
||||
Available string `json:"available"`
|
||||
Buffers string `json:"buffers"`
|
||||
Cached string `json:"cached"`
|
||||
SwapTotal string `json:"swap_total"`
|
||||
SwapUsed string `json:"swap_used"`
|
||||
SwapFree string `json:"swap_free"`
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
Filesystem string `json:"filesystem"`
|
||||
Size string `json:"size"`
|
||||
Used string `json:"used"`
|
||||
Available string `json:"available"`
|
||||
UsePercent string `json:"use_percent"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
}
|
||||
|
||||
type NetInfo struct {
|
||||
Interface string `json:"interface"`
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
RxPackets uint64 `json:"rx_packets"`
|
||||
RxErrors uint64 `json:"rx_errors"`
|
||||
RxDropped uint64 `json:"rx_dropped"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
TxPackets uint64 `json:"tx_packets"`
|
||||
TxErrors uint64 `json:"tx_errors"`
|
||||
TxDropped uint64 `json:"tx_dropped"`
|
||||
RxHuman string `json:"rx_human"`
|
||||
TxHuman string `json:"tx_human"`
|
||||
}
|
||||
|
||||
type UptimeInfo struct {
|
||||
Seconds float64 `json:"seconds"`
|
||||
IdleSeconds float64 `json:"idle_seconds"`
|
||||
HumanReadable string `json:"human_readable"`
|
||||
}
|
||||
|
||||
type LoadInfo struct {
|
||||
Load1 float64 `json:"load_1"`
|
||||
Load5 float64 `json:"load_5"`
|
||||
Load15 float64 `json:"load_15"`
|
||||
RunningProcs int `json:"running_procs"`
|
||||
TotalProcs int `json:"total_procs"`
|
||||
}
|
||||
|
||||
type ProcessInfo struct {
|
||||
PID int `json:"pid"`
|
||||
User string `json:"user"`
|
||||
CPU float64 `json:"cpu"`
|
||||
Mem float64 `json:"mem"`
|
||||
RSS int64 `json:"rss"`
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
// ── CPU ─────────────────────────────────────────────────────────────
|
||||
|
||||
// readCPUStats reads /proc/stat and returns a map of cpu label to field slices.
|
||||
func readCPUStats() (map[string][]uint64, error) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening /proc/stat: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
result := make(map[string][]uint64)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "cpu") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
label := fields[0]
|
||||
var vals []uint64
|
||||
for _, field := range fields[1:] {
|
||||
v, _ := strconv.ParseUint(field, 10, 64)
|
||||
vals = append(vals, v)
|
||||
}
|
||||
result[label] = vals
|
||||
}
|
||||
return result, scanner.Err()
|
||||
}
|
||||
|
||||
// parseCPUFields converts raw jiffie counts into a CoreUsage.
|
||||
// Fields: user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice
|
||||
func parseCPUFields(core int, before, after []uint64) CoreUsage {
|
||||
cu := CoreUsage{Core: core}
|
||||
if len(before) < 5 || len(after) < 5 {
|
||||
return cu
|
||||
}
|
||||
|
||||
// Sum all fields for total jiffies
|
||||
var totalBefore, totalAfter uint64
|
||||
for _, v := range before {
|
||||
totalBefore += v
|
||||
}
|
||||
for _, v := range after {
|
||||
totalAfter += v
|
||||
}
|
||||
|
||||
totalDelta := float64(totalAfter - totalBefore)
|
||||
if totalDelta == 0 {
|
||||
return cu
|
||||
}
|
||||
|
||||
userDelta := float64((after[0] + after[1]) - (before[0] + before[1]))
|
||||
systemDelta := float64(after[2] - before[2])
|
||||
idleDelta := float64(after[3] - before[3])
|
||||
var iowaitDelta float64
|
||||
if len(after) > 4 && len(before) > 4 {
|
||||
iowaitDelta = float64(after[4] - before[4])
|
||||
}
|
||||
|
||||
cu.User = userDelta / totalDelta * 100
|
||||
cu.System = systemDelta / totalDelta * 100
|
||||
cu.Idle = idleDelta / totalDelta * 100
|
||||
cu.IOWait = iowaitDelta / totalDelta * 100
|
||||
cu.Percent = 100 - cu.Idle
|
||||
|
||||
return cu
|
||||
}
|
||||
|
||||
// GetCPUUsage samples /proc/stat twice with a brief interval to compute usage.
|
||||
func GetCPUUsage() (CPUInfo, error) {
|
||||
info := CPUInfo{}
|
||||
|
||||
before, err := readCPUStats()
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
after, err := readCPUStats()
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
// Overall CPU (the "cpu" aggregate line)
|
||||
if bv, ok := before["cpu"]; ok {
|
||||
if av, ok := after["cpu"]; ok {
|
||||
overall := parseCPUFields(-1, bv, av)
|
||||
info.Overall = overall.Percent
|
||||
info.Idle = overall.Idle
|
||||
}
|
||||
}
|
||||
|
||||
// Per-core
|
||||
for i := 0; ; i++ {
|
||||
label := fmt.Sprintf("cpu%d", i)
|
||||
bv, ok1 := before[label]
|
||||
av, ok2 := after[label]
|
||||
if !ok1 || !ok2 {
|
||||
break
|
||||
}
|
||||
info.Cores = append(info.Cores, parseCPUFields(i, bv, av))
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ── Memory ──────────────────────────────────────────────────────────
|
||||
|
||||
func GetMemory() (MemInfo, error) {
|
||||
info := MemInfo{}
|
||||
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("opening /proc/meminfo: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
vals := make(map[string]uint64)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
valStr := strings.TrimSpace(parts[1])
|
||||
valStr = strings.TrimSuffix(valStr, " kB")
|
||||
valStr = strings.TrimSpace(valStr)
|
||||
v, err := strconv.ParseUint(valStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vals[key] = v * 1024 // convert kB to bytes
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
info.TotalBytes = vals["MemTotal"]
|
||||
info.FreeBytes = vals["MemFree"]
|
||||
info.AvailableBytes = vals["MemAvailable"]
|
||||
info.BuffersBytes = vals["Buffers"]
|
||||
info.CachedBytes = vals["Cached"]
|
||||
info.SwapTotalBytes = vals["SwapTotal"]
|
||||
info.SwapFreeBytes = vals["SwapFree"]
|
||||
info.SwapUsedBytes = info.SwapTotalBytes - info.SwapFreeBytes
|
||||
info.UsedBytes = info.TotalBytes - info.FreeBytes - info.BuffersBytes - info.CachedBytes
|
||||
if info.UsedBytes > info.TotalBytes {
|
||||
// Overflow guard: if buffers+cached > total-free, use simpler calculation
|
||||
info.UsedBytes = info.TotalBytes - info.AvailableBytes
|
||||
}
|
||||
|
||||
info.Total = humanBytes(info.TotalBytes)
|
||||
info.Used = humanBytes(info.UsedBytes)
|
||||
info.Free = humanBytes(info.FreeBytes)
|
||||
info.Available = humanBytes(info.AvailableBytes)
|
||||
info.Buffers = humanBytes(info.BuffersBytes)
|
||||
info.Cached = humanBytes(info.CachedBytes)
|
||||
info.SwapTotal = humanBytes(info.SwapTotalBytes)
|
||||
info.SwapUsed = humanBytes(info.SwapUsedBytes)
|
||||
info.SwapFree = humanBytes(info.SwapFreeBytes)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ── Disk ────────────────────────────────────────────────────────────
|
||||
|
||||
func GetDisk() ([]DiskInfo, error) {
|
||||
// Try with filesystem type filters first for real block devices
|
||||
out, err := exec.Command("df", "-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=ext3").CombinedOutput()
|
||||
if err != nil {
|
||||
// Fallback: exclude pseudo filesystems
|
||||
out, err = exec.Command("df", "-h", "--exclude-type=tmpfs", "--exclude-type=devtmpfs", "--exclude-type=squashfs").CombinedOutput()
|
||||
if err != nil {
|
||||
// Last resort: all filesystems
|
||||
out, err = exec.Command("df", "-h").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("df command failed: %w (%s)", err, string(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var disks []DiskInfo
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 || strings.TrimSpace(line) == "" {
|
||||
continue // skip header
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
disks = append(disks, DiskInfo{
|
||||
Filesystem: fields[0],
|
||||
Size: fields[1],
|
||||
Used: fields[2],
|
||||
Available: fields[3],
|
||||
UsePercent: fields[4],
|
||||
MountPoint: fields[5],
|
||||
})
|
||||
}
|
||||
|
||||
return disks, nil
|
||||
}
|
||||
|
||||
// ── Network ─────────────────────────────────────────────────────────
|
||||
|
||||
func GetNetwork() ([]NetInfo, error) {
|
||||
f, err := os.Open("/proc/net/dev")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening /proc/net/dev: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var interfaces []NetInfo
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
if lineNum <= 2 {
|
||||
continue // skip the two header lines
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
// Format: " iface: rx_bytes rx_packets rx_errs rx_drop ... tx_bytes tx_packets tx_errs tx_drop ..."
|
||||
colonIdx := strings.Index(line, ":")
|
||||
if colonIdx < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
iface := strings.TrimSpace(line[:colonIdx])
|
||||
rest := strings.TrimSpace(line[colonIdx+1:])
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
rxBytes, _ := strconv.ParseUint(fields[0], 10, 64)
|
||||
rxPackets, _ := strconv.ParseUint(fields[1], 10, 64)
|
||||
rxErrors, _ := strconv.ParseUint(fields[2], 10, 64)
|
||||
rxDropped, _ := strconv.ParseUint(fields[3], 10, 64)
|
||||
txBytes, _ := strconv.ParseUint(fields[8], 10, 64)
|
||||
txPackets, _ := strconv.ParseUint(fields[9], 10, 64)
|
||||
txErrors, _ := strconv.ParseUint(fields[10], 10, 64)
|
||||
txDropped, _ := strconv.ParseUint(fields[11], 10, 64)
|
||||
|
||||
interfaces = append(interfaces, NetInfo{
|
||||
Interface: iface,
|
||||
RxBytes: rxBytes,
|
||||
RxPackets: rxPackets,
|
||||
RxErrors: rxErrors,
|
||||
RxDropped: rxDropped,
|
||||
TxBytes: txBytes,
|
||||
TxPackets: txPackets,
|
||||
TxErrors: txErrors,
|
||||
TxDropped: txDropped,
|
||||
RxHuman: humanBytes(rxBytes),
|
||||
TxHuman: humanBytes(txBytes),
|
||||
})
|
||||
}
|
||||
|
||||
return interfaces, scanner.Err()
|
||||
}
|
||||
|
||||
// ── Uptime ──────────────────────────────────────────────────────────
|
||||
|
||||
func GetUptime() (UptimeInfo, error) {
|
||||
info := UptimeInfo{}
|
||||
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("reading /proc/uptime: %w", err)
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.TrimSpace(string(data)))
|
||||
if len(fields) < 2 {
|
||||
return info, fmt.Errorf("unexpected /proc/uptime format")
|
||||
}
|
||||
|
||||
info.Seconds, _ = strconv.ParseFloat(fields[0], 64)
|
||||
info.IdleSeconds, _ = strconv.ParseFloat(fields[1], 64)
|
||||
|
||||
// Build human readable string
|
||||
totalSec := int(info.Seconds)
|
||||
days := totalSec / 86400
|
||||
hours := (totalSec % 86400) / 3600
|
||||
minutes := (totalSec % 3600) / 60
|
||||
seconds := totalSec % 60
|
||||
|
||||
parts := []string{}
|
||||
if days > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d day%s", days, plural(days)))
|
||||
}
|
||||
if hours > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d hour%s", hours, plural(hours)))
|
||||
}
|
||||
if minutes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d minute%s", minutes, plural(minutes)))
|
||||
}
|
||||
if len(parts) == 0 || (days == 0 && hours == 0 && minutes == 0) {
|
||||
parts = append(parts, fmt.Sprintf("%d second%s", seconds, plural(seconds)))
|
||||
}
|
||||
|
||||
info.HumanReadable = strings.Join(parts, ", ")
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ── Load Average ────────────────────────────────────────────────────
|
||||
|
||||
func GetLoadAvg() (LoadInfo, error) {
|
||||
info := LoadInfo{}
|
||||
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("reading /proc/loadavg: %w", err)
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.TrimSpace(string(data)))
|
||||
if len(fields) < 4 {
|
||||
return info, fmt.Errorf("unexpected /proc/loadavg format")
|
||||
}
|
||||
|
||||
info.Load1, _ = strconv.ParseFloat(fields[0], 64)
|
||||
info.Load5, _ = strconv.ParseFloat(fields[1], 64)
|
||||
info.Load15, _ = strconv.ParseFloat(fields[2], 64)
|
||||
|
||||
// fields[3] is "running/total" format
|
||||
procParts := strings.SplitN(fields[3], "/", 2)
|
||||
if len(procParts) == 2 {
|
||||
info.RunningProcs, _ = strconv.Atoi(procParts[0])
|
||||
info.TotalProcs, _ = strconv.Atoi(procParts[1])
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ── Top Processes ───────────────────────────────────────────────────
|
||||
|
||||
func GetTopProcesses(n int) ([]ProcessInfo, error) {
|
||||
if n <= 0 {
|
||||
n = 10
|
||||
}
|
||||
|
||||
// ps aux --sort=-%mem gives us processes sorted by memory usage descending
|
||||
out, err := exec.Command("ps", "aux", "--sort=-%mem").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ps command failed: %w (%s)", err, string(out))
|
||||
}
|
||||
|
||||
var procs []ProcessInfo
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for i, line := range lines {
|
||||
if i == 0 {
|
||||
continue // skip header
|
||||
}
|
||||
if len(procs) >= n {
|
||||
break
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 11 {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, _ := strconv.Atoi(fields[1])
|
||||
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
||||
mem, _ := strconv.ParseFloat(fields[3], 64)
|
||||
rss, _ := strconv.ParseInt(fields[5], 10, 64)
|
||||
// Command is everything from field 10 onward (may contain spaces)
|
||||
command := strings.Join(fields[10:], " ")
|
||||
|
||||
procs = append(procs, ProcessInfo{
|
||||
PID: pid,
|
||||
User: fields[0],
|
||||
CPU: cpu,
|
||||
Mem: mem,
|
||||
RSS: rss,
|
||||
Command: command,
|
||||
})
|
||||
}
|
||||
|
||||
return procs, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
func humanBytes(b uint64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
|
||||
if exp >= len(suffixes) {
|
||||
exp = len(suffixes) - 1
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), suffixes[exp])
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
213
services/setec-manager/internal/system/packages.go
Normal file
213
services/setec-manager/internal/system/packages.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
type PackageInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Size int64 `json:"size"` // installed size in bytes
|
||||
SizeStr string `json:"size_str"` // human-readable
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ── APT Operations ──────────────────────────────────────────────────
|
||||
|
||||
// PackageUpdate runs `apt-get update` to refresh the package index.
|
||||
func PackageUpdate() (string, error) {
|
||||
out, err := exec.Command("apt-get", "update", "-qq").CombinedOutput()
|
||||
output := string(out)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("apt-get update failed: %w (%s)", err, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// PackageInstall installs one or more packages with apt-get install -y.
|
||||
// Package names are passed as separate arguments to avoid shell injection.
|
||||
func PackageInstall(packages ...string) (string, error) {
|
||||
if len(packages) == 0 {
|
||||
return "", fmt.Errorf("no packages specified")
|
||||
}
|
||||
|
||||
for _, pkg := range packages {
|
||||
if err := validatePackageName(pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
args := append([]string{"install", "-y"}, packages...)
|
||||
out, err := exec.Command("apt-get", args...).CombinedOutput()
|
||||
output := string(out)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("apt-get install failed: %w (%s)", err, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// PackageRemove removes one or more packages with apt-get remove -y.
|
||||
func PackageRemove(packages ...string) (string, error) {
|
||||
if len(packages) == 0 {
|
||||
return "", fmt.Errorf("no packages specified")
|
||||
}
|
||||
|
||||
for _, pkg := range packages {
|
||||
if err := validatePackageName(pkg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
args := append([]string{"remove", "-y"}, packages...)
|
||||
out, err := exec.Command("apt-get", args...).CombinedOutput()
|
||||
output := string(out)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("apt-get remove failed: %w (%s)", err, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// PackageListInstalled returns all installed packages via dpkg-query.
|
||||
func PackageListInstalled() ([]PackageInfo, error) {
|
||||
// dpkg-query format: name\tversion\tinstalled-size (in kB)
|
||||
out, err := exec.Command(
|
||||
"dpkg-query",
|
||||
"--show",
|
||||
"--showformat=${Package}\t${Version}\t${Installed-Size}\n",
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dpkg-query failed: %w (%s)", err, string(out))
|
||||
}
|
||||
|
||||
var packages []PackageInfo
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Installed-Size from dpkg is in kibibytes
|
||||
sizeKB, _ := strconv.ParseInt(strings.TrimSpace(fields[2]), 10, 64)
|
||||
sizeBytes := sizeKB * 1024
|
||||
|
||||
packages = append(packages, PackageInfo{
|
||||
Name: fields[0],
|
||||
Version: fields[1],
|
||||
Size: sizeBytes,
|
||||
SizeStr: humanBytes(uint64(sizeBytes)),
|
||||
})
|
||||
}
|
||||
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// PackageIsInstalled checks if a single package is installed using dpkg -l.
|
||||
func PackageIsInstalled(pkg string) bool {
|
||||
if err := validatePackageName(pkg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
out, err := exec.Command("dpkg", "-l", pkg).CombinedOutput()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// dpkg -l output has lines starting with "ii" for installed packages
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == "ii" && fields[1] == pkg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// PackageUpgrade runs `apt-get upgrade -y` to upgrade all packages.
|
||||
func PackageUpgrade() (string, error) {
|
||||
out, err := exec.Command("apt-get", "upgrade", "-y").CombinedOutput()
|
||||
output := string(out)
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("apt-get upgrade failed: %w (%s)", err, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// PackageSecurityUpdates returns a list of packages with available security updates.
|
||||
func PackageSecurityUpdates() ([]PackageInfo, error) {
|
||||
// apt list --upgradable outputs lines like:
|
||||
// package/suite version arch [upgradable from: old-version]
|
||||
out, err := exec.Command("apt", "list", "--upgradable").CombinedOutput()
|
||||
if err != nil {
|
||||
// apt list may return exit code 1 even with valid output
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("apt list --upgradable failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var securityPkgs []PackageInfo
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
for _, line := range lines {
|
||||
// Skip header/warning lines
|
||||
if strings.HasPrefix(line, "Listing") || strings.HasPrefix(line, "WARNING") || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter for security updates: look for "-security" in the suite name
|
||||
if !strings.Contains(line, "-security") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse: "name/suite version arch [upgradable from: old]"
|
||||
slashIdx := strings.Index(line, "/")
|
||||
if slashIdx < 0 {
|
||||
continue
|
||||
}
|
||||
name := line[:slashIdx]
|
||||
|
||||
// Get version from the fields after the suite
|
||||
rest := line[slashIdx+1:]
|
||||
fields := strings.Fields(rest)
|
||||
var version string
|
||||
if len(fields) >= 2 {
|
||||
version = fields[1]
|
||||
}
|
||||
|
||||
securityPkgs = append(securityPkgs, PackageInfo{
|
||||
Name: name,
|
||||
Version: version,
|
||||
Status: "security-update",
|
||||
})
|
||||
}
|
||||
|
||||
return securityPkgs, nil
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// validatePackageName does basic validation to prevent obvious injection attempts.
|
||||
// Package names in Debian must consist of lowercase alphanumerics, +, -, . and
|
||||
// must be at least 2 characters long.
|
||||
func validatePackageName(pkg string) error {
|
||||
if len(pkg) < 2 {
|
||||
return fmt.Errorf("invalid package name %q: too short", pkg)
|
||||
}
|
||||
if len(pkg) > 128 {
|
||||
return fmt.Errorf("invalid package name %q: too long", pkg)
|
||||
}
|
||||
for _, c := range pkg {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.' || c == ':') {
|
||||
return fmt.Errorf("invalid character %q in package name %q", c, pkg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
292
services/setec-manager/internal/system/users.go
Normal file
292
services/setec-manager/internal/system/users.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
type SystemUser struct {
|
||||
Username string `json:"username"`
|
||||
UID int `json:"uid"`
|
||||
GID int `json:"gid"`
|
||||
Comment string `json:"comment"`
|
||||
HomeDir string `json:"home_dir"`
|
||||
Shell string `json:"shell"`
|
||||
}
|
||||
|
||||
type QuotaInfo struct {
|
||||
Username string `json:"username"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
UsedHuman string `json:"used_human"`
|
||||
HomeDir string `json:"home_dir"`
|
||||
}
|
||||
|
||||
// ── Protected accounts ──────────────────────────────────────────────
|
||||
|
||||
var protectedUsers = map[string]bool{
|
||||
"root": true,
|
||||
"autarch": true,
|
||||
}
|
||||
|
||||
// ── User Management ─────────────────────────────────────────────────
|
||||
|
||||
// ListUsers reads /etc/passwd and returns all users with UID >= 1000 and < 65534.
|
||||
func ListUsers() ([]SystemUser, error) {
|
||||
f, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening /etc/passwd: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var users []SystemUser
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format: username:x:uid:gid:comment:home:shell
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
|
||||
uid, err := strconv.Atoi(fields[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only normal user accounts (UID 1000-65533)
|
||||
if uid < 1000 || uid >= 65534 {
|
||||
continue
|
||||
}
|
||||
|
||||
gid, _ := strconv.Atoi(fields[3])
|
||||
|
||||
users = append(users, SystemUser{
|
||||
Username: fields[0],
|
||||
UID: uid,
|
||||
GID: gid,
|
||||
Comment: fields[4],
|
||||
HomeDir: fields[5],
|
||||
Shell: fields[6],
|
||||
})
|
||||
}
|
||||
|
||||
return users, scanner.Err()
|
||||
}
|
||||
|
||||
// CreateUser creates a new system user with the given username, password, and shell.
|
||||
func CreateUser(username, password, shell string) error {
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
if shell == "" {
|
||||
shell = "/bin/bash"
|
||||
}
|
||||
|
||||
// Sanitize: only allow alphanumeric, underscore, hyphen, and dot
|
||||
for _, c := range username {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') {
|
||||
return fmt.Errorf("invalid character %q in username", c)
|
||||
}
|
||||
}
|
||||
if len(username) > 32 {
|
||||
return fmt.Errorf("username too long (max 32 characters)")
|
||||
}
|
||||
|
||||
// Verify the shell exists
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
return fmt.Errorf("shell %q does not exist: %w", shell, err)
|
||||
}
|
||||
|
||||
// Create the user
|
||||
out, err := exec.Command("useradd", "--create-home", "--shell", shell, username).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("useradd failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
// Set the password via chpasswd
|
||||
if err := setPasswordViaChpasswd(username, password); err != nil {
|
||||
// Attempt cleanup on password failure
|
||||
exec.Command("userdel", "--remove", username).Run()
|
||||
return fmt.Errorf("user created but password set failed (user removed): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a system user and their home directory.
|
||||
func DeleteUser(username string) error {
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
|
||||
if protectedUsers[username] {
|
||||
return fmt.Errorf("cannot delete protected account %q", username)
|
||||
}
|
||||
|
||||
// Verify the user actually exists before attempting deletion
|
||||
_, err := exec.Command("id", username).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %q does not exist", username)
|
||||
}
|
||||
|
||||
// Kill any running processes owned by the user (best effort)
|
||||
exec.Command("pkill", "-u", username).Run()
|
||||
|
||||
out, err := exec.Command("userdel", "--remove", username).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("userdel failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPassword changes the password for an existing user.
|
||||
func SetPassword(username, password string) error {
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
_, err := exec.Command("id", username).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("user %q does not exist", username)
|
||||
}
|
||||
|
||||
return setPasswordViaChpasswd(username, password)
|
||||
}
|
||||
|
||||
// setPasswordViaChpasswd pipes "user:password" to chpasswd.
|
||||
func setPasswordViaChpasswd(username, password string) error {
|
||||
cmd := exec.Command("chpasswd")
|
||||
cmd.Stdin = strings.NewReader(fmt.Sprintf("%s:%s", username, password))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("chpasswd failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSSHKey appends a public key to the user's ~/.ssh/authorized_keys file.
|
||||
func AddSSHKey(username, pubkey string) error {
|
||||
if username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if pubkey == "" {
|
||||
return fmt.Errorf("public key is required")
|
||||
}
|
||||
|
||||
// Basic validation: SSH keys should start with a recognized prefix
|
||||
pubkey = strings.TrimSpace(pubkey)
|
||||
validPrefixes := []string{"ssh-rsa", "ssh-ed25519", "ssh-dss", "ecdsa-sha2-", "sk-ssh-ed25519", "sk-ecdsa-sha2-"}
|
||||
valid := false
|
||||
for _, prefix := range validPrefixes {
|
||||
if strings.HasPrefix(pubkey, prefix) {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("invalid SSH public key format")
|
||||
}
|
||||
|
||||
// Look up the user's home directory from /etc/passwd
|
||||
homeDir, err := getUserHome(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshDir := filepath.Join(homeDir, ".ssh")
|
||||
authKeysPath := filepath.Join(sshDir, "authorized_keys")
|
||||
|
||||
// Create .ssh directory if it doesn't exist
|
||||
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||
return fmt.Errorf("creating .ssh directory: %w", err)
|
||||
}
|
||||
|
||||
// Check for duplicate keys
|
||||
if existing, err := os.ReadFile(authKeysPath); err == nil {
|
||||
for _, line := range strings.Split(string(existing), "\n") {
|
||||
if strings.TrimSpace(line) == pubkey {
|
||||
return fmt.Errorf("SSH key already exists in authorized_keys")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append the key
|
||||
f, err := os.OpenFile(authKeysPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening authorized_keys: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := fmt.Fprintf(f, "%s\n", pubkey); err != nil {
|
||||
return fmt.Errorf("writing authorized_keys: %w", err)
|
||||
}
|
||||
|
||||
// Fix ownership: chown user:user .ssh and authorized_keys
|
||||
exec.Command("chown", "-R", username+":"+username, sshDir).Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserQuota returns disk usage for a user's home directory.
|
||||
func GetUserQuota(username string) (QuotaInfo, error) {
|
||||
info := QuotaInfo{Username: username}
|
||||
|
||||
homeDir, err := getUserHome(username)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.HomeDir = homeDir
|
||||
|
||||
// Use du -sb for total bytes used in home directory
|
||||
out, err := exec.Command("du", "-sb", homeDir).CombinedOutput()
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("du failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
fields := strings.Fields(strings.TrimSpace(string(out)))
|
||||
if len(fields) >= 1 {
|
||||
info.UsedBytes, _ = strconv.ParseUint(fields[0], 10, 64)
|
||||
}
|
||||
|
||||
info.UsedHuman = humanBytes(info.UsedBytes)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// getUserHome looks up a user's home directory from /etc/passwd.
|
||||
func getUserHome(username string) (string, error) {
|
||||
f, err := os.Open("/etc/passwd")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opening /etc/passwd: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Split(scanner.Text(), ":")
|
||||
if len(fields) >= 6 && fields[0] == username {
|
||||
return fields[5], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("user %q not found in /etc/passwd", username)
|
||||
}
|
||||
BIN
services/setec-manager/setec-manager
Normal file
BIN
services/setec-manager/setec-manager
Normal file
Binary file not shown.
BIN
services/setec-manager/setec-manager.exe
Normal file
BIN
services/setec-manager/setec-manager.exe
Normal file
Binary file not shown.
9
services/setec-manager/web/embed.go
Normal file
9
services/setec-manager/web/embed.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*.html
|
||||
var TemplateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var StaticFS embed.FS
|
||||
370
services/setec-manager/web/static/css/style.css
Normal file
370
services/setec-manager/web/static/css/style.css
Normal 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; }
|
||||
}
|
||||
13
services/setec-manager/web/static/img/favicon.svg
Normal file
13
services/setec-manager/web/static/img/favicon.svg
Normal 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 |
63
services/setec-manager/web/static/img/icons.svg
Normal file
63
services/setec-manager/web/static/img/icons.svg
Normal 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 |
19
services/setec-manager/web/static/img/logo-full.svg
Normal file
19
services/setec-manager/web/static/img/logo-full.svg
Normal 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 |
28
services/setec-manager/web/static/img/logo.svg
Normal file
28
services/setec-manager/web/static/img/logo.svg
Normal 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 |
18
services/setec-manager/web/static/img/sidebar-logo.svg
Normal file
18
services/setec-manager/web/static/img/sidebar-logo.svg
Normal 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 |
109
services/setec-manager/web/static/js/app.js
Normal file
109
services/setec-manager/web/static/js/app.js
Normal 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);
|
||||
})();
|
||||
50
services/setec-manager/web/templates/autarch.html
Normal file
50
services/setec-manager/web/templates/autarch.html
Normal 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}}
|
||||
31
services/setec-manager/web/templates/backups.html
Normal file
31
services/setec-manager/web/templates/backups.html
Normal 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}}
|
||||
39
services/setec-manager/web/templates/base.html
Normal file
39
services/setec-manager/web/templates/base.html
Normal 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>
|
||||
53
services/setec-manager/web/templates/dashboard.html
Normal file
53
services/setec-manager/web/templates/dashboard.html
Normal 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}}
|
||||
40
services/setec-manager/web/templates/firewall.html
Normal file
40
services/setec-manager/web/templates/firewall.html
Normal 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}}
|
||||
40
services/setec-manager/web/templates/float.html
Normal file
40
services/setec-manager/web/templates/float.html
Normal 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}}
|
||||
783
services/setec-manager/web/templates/hosting.html
Normal file
783
services/setec-manager/web/templates/hosting.html
Normal 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 ? ' — ' + (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}}
|
||||
49
services/setec-manager/web/templates/login.html
Normal file
49
services/setec-manager/web/templates/login.html
Normal 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>
|
||||
38
services/setec-manager/web/templates/logs.html
Normal file
38
services/setec-manager/web/templates/logs.html
Normal 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}}
|
||||
50
services/setec-manager/web/templates/monitor.html
Normal file
50
services/setec-manager/web/templates/monitor.html
Normal 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}}
|
||||
58
services/setec-manager/web/templates/nginx.html
Normal file
58
services/setec-manager/web/templates/nginx.html
Normal 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}}
|
||||
63
services/setec-manager/web/templates/site_detail.html
Normal file
63
services/setec-manager/web/templates/site_detail.html
Normal 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}}
|
||||
31
services/setec-manager/web/templates/site_new.html
Normal file
31
services/setec-manager/web/templates/site_new.html
Normal 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}}
|
||||
44
services/setec-manager/web/templates/sites.html
Normal file
44
services/setec-manager/web/templates/sites.html
Normal 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}}
|
||||
31
services/setec-manager/web/templates/ssl.html
Normal file
31
services/setec-manager/web/templates/ssl.html
Normal 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}}
|
||||
56
services/setec-manager/web/templates/users.html
Normal file
56
services/setec-manager/web/templates/users.html
Normal 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}}
|
||||
Reference in New Issue
Block a user