Files
TA-Deployment-Broker/internal/app/server.go
T
2026-07-25 14:55:41 -05:00

274 lines
7.4 KiB
Go

package app
import (
"context"
"database/sql"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
//go:embed templates/*.html static/*
var webFiles embed.FS
type Server struct {
cfg Config
db *sql.DB
templates *template.Template
client *http.Client
packageClient *http.Client
}
type technician struct {
Login string
DisplayName string
CSRFToken string
}
type packageRecord struct {
ID uint64
Slug string
DisplayName string
PackageName string
PackageVersion string
FileName string
SHA256 string
Enabled bool
}
type actionRecord struct {
Slug string
DisplayName string
Enabled bool
}
type authorizationRecord struct {
ID uint64
CodeHint string
CreatedBy string
CustomerLabel string
HostLimit int
HostCount int
CreatedAt time.Time
ExpiresAt time.Time
RevokedAt sql.NullTime
Packages string
Actions string
}
type auditRecord struct {
EventType string
Actor string
Hostname string
PackageSlug string
SourceIP string
Details string
CreatedAt time.Time
}
type pageData struct {
Title string
Technician *technician
CSRFToken string
Authorizations []authorizationRecord
AuditEvents []auditRecord
Packages []packageRecord
Actions []actionRecord
NewCode string
DefaultHostLimit int
DefaultDuration string
Error string
Notice string
GiteaLoginURL string
}
func New(cfg Config) (*Server, error) {
db, err := sql.Open("mysql", cfg.DatabaseDSN)
if err != nil {
return nil, err
}
db.SetConnMaxLifetime(5 * time.Minute)
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("database: %w", err)
}
templates, err := parseTemplates()
if err != nil {
_ = db.Close()
return nil, err
}
return &Server{
cfg: cfg,
db: db,
templates: templates,
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
},
packageClient: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return nil
},
},
}, nil
}
func parseTemplates() (*template.Template, error) {
return template.New("").Funcs(template.FuncMap{
"formatTime": func(value time.Time) string {
return value.Local().Format("Jan 2, 2006 3:04 PM")
},
"isActive": func(record authorizationRecord) bool {
return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt)
},
}).ParseFS(webFiles, "templates/*.html")
}
func (s *Server) Close() error {
return s.db.Close()
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health/live", s.handleLive)
mux.HandleFunc("GET /health/ready", s.handleReady)
mux.HandleFunc("GET /auth/login", s.handleLogin)
mux.HandleFunc("GET /auth/callback", s.handleCallback)
mux.HandleFunc("POST /auth/logout", s.handleLogout)
mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal))
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage))
mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
mux.HandleFunc("POST /api/v1/exchange", s.handleExchange)
mux.HandleFunc("GET /api/v1/packages/{slug}", s.handlePackageDownload)
mux.HandleFunc("GET /", s.handleHome)
mux.Handle("GET /static/", http.FileServerFS(webFiles))
return s.securityHeaders(s.requestLog(mux))
}
func (s *Server) handleLive(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "live"})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "database unavailable"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
if tech != nil {
http.Redirect(w, r, "/portal", http.StatusSeeOther)
return
}
s.render(w, "login.html", pageData{
Title: "TAPM Deployment Access",
GiteaLoginURL: "/auth/login",
})
}
func (s *Server) render(w http.ResponseWriter, name string, data pageData) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
log.Printf("render %s: %v", name, err)
}
}
func (s *Server) clientIP(r *http.Request) string {
if s.cfg.TrustProxyHeaders {
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
return forwarded
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
func (s *Server) requestLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s ip=%s duration=%s", r.Method, r.URL.Path, s.clientIP(r), time.Since(started).Round(time.Millisecond))
})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func parseUintPath(r *http.Request, name string) (uint64, error) {
return strconv.ParseUint(r.PathValue(name), 10, 64)
}
func joinURL(base *url.URL, path string) string {
result := *base
result.Path = strings.TrimRight(result.Path, "/") + "/" + strings.TrimLeft(path, "/")
return result.String()
}
func copyResponse(w http.ResponseWriter, response *http.Response) {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Disposition"} {
if value := response.Header.Get(header); value != "" {
w.Header().Set(header, value)
}
}
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}