package app import ( "context" "database/sql" "embed" "encoding/json" "errors" "fmt" "html/template" "io" "log" "net" "net/http" "net/url" "strconv" "strings" "time" _ "modernc.org/sqlite" ) //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 CustomerLabel string User string Hostname string PackageSlug string SourceIP string Details string CreatedAt time.Time } type auditFilters struct { TimeRange string EventType string CustomerLabel string User string Hostname string PackageSlug string SourceIP string Details string } type pageData struct { Title string Technician *technician CSRFToken string Authorizations []authorizationRecord AuditEvents []auditRecord AuditFilters auditFilters AuditEventTypes []string Packages []packageRecord Actions []actionRecord FleetHosts []fleetHostRecord NewCode string DefaultHostLimit int DefaultDuration string Error string Notice string GiteaLoginURL string CurrentView string } func New(cfg Config) (*Server, error) { db, err := sql.Open("sqlite", cfg.DatabaseDSN) if err != nil { return nil, err } db.SetConnMaxLifetime(5 * time.Minute) // A single writer connection avoids SQLITE_BUSY errors while WAL mode still // permits concurrent readers. This service is intentionally single-node. db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) 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(cfg.DisplayTimeZone) 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(displayTimeZone *time.Location) (*template.Template, error) { if displayTimeZone == nil { displayTimeZone = time.UTC } return template.New("").Funcs(template.FuncMap{ "formatTime": func(value time.Time) string { return value.In(displayTimeZone).Format("Jan 2, 2006 3:04 PM MST") }, "isActive": func(record authorizationRecord) bool { return !record.RevokedAt.Valid && time.Now().Before(record.ExpiresAt) }, "isStale": func(value time.Time) bool { return value.Before(time.Now().Add(-30 * 24 * time.Hour)) }, "shortInstallationID": shortInstallationID, "shortCommit": shortCommit, }).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("GET /portal/packages", s.requireTechnician(s.handlePackagesPortal)) mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal)) mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal)) 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("POST /api/v1/hosts/register", s.handleFleetRegister) mux.HandleFunc("POST /api/v1/hosts/events", s.handleFleetEvent) 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) }