No One Can Stop Me Now

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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