No One Can Stop Me Now
This commit is contained in:
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})
|
||||
}
|
||||
Reference in New Issue
Block a user