package server import ( "embed" "html/template" "io/fs" "log" "net" "net/http" "strconv" "strings" texttemplate "text/template" ) //go:embed web var webFS embed.FS // Options configures a Server. type Options struct { DB *DB // DistDir holds the compiled agent binaries served under /download/. DistDir string // SecureCookies marks session cookies Secure. True in production (behind // nginx TLS); set false only for plain-HTTP local testing. SecureCookies bool // TunnelAddr is the listen address for the direct TCP+UDP forward tunnel // (default ":8446"). Empty disables direct forwarding. TunnelAddr string } // Server is the HTTP host: admin panel + agent rendezvous. type Server struct { db *DB hub *Hub fwd *forwardManager ufw *ufwManager tmpl *template.Template limiter *loginLimiter distDir string secureCookies bool installTmpl *texttemplate.Template } // New builds the server, parsing embedded templates. func New(opts Options) (*Server, error) { if err := opts.DB.ensureDefaults(); err != nil { return nil, err } tmpl, err := template.ParseFS(webFS, "web/templates/*.html") if err != nil { return nil, err } installRaw, err := webFS.ReadFile("web/install.sh.tmpl") if err != nil { return nil, err } installTmpl, err := texttemplate.New("install").Parse(string(installRaw)) if err != nil { return nil, err } hub := NewHub() tun := newTunnelHub(opts.DB) udp := newUDPRelay(opts.DB) s := &Server{ db: opts.DB, hub: hub, fwd: newForwardManager(hub, tun, udp), ufw: newUfwManager(opts.DB, portFromAddr(opts.TunnelAddr)), tmpl: tmpl, limiter: newLoginLimiter(), distDir: opts.DistDir, secureCookies: opts.SecureCookies, installTmpl: installTmpl, } // Start the direct forward tunnel (TCP mux + UDP) on 8446. if opts.TunnelAddr != "" { if err := tun.listen(opts.TunnelAddr); err != nil { log.Printf("tunnel tcp listen %s: %v", opts.TunnelAddr, err) } if err := udp.listen(opts.TunnelAddr); err != nil { log.Printf("tunnel udp listen %s: %v", opts.TunnelAddr, err) } } s.restoreForwards() return s, nil } // restoreForwards re-opens the host listeners for all saved forward rules. func (s *Server) restoreForwards() { rules, err := s.db.ListForwardRules() if err != nil { log.Printf("restore forwards: %v", err) return } for _, r := range rules { if err := s.fwd.open(r); err != nil { log.Printf("restore forward rule %d (%s:%d): %v", r.ID, r.ListenAddr, r.HostPort, err) } } } // Handler returns the root http.Handler with all routes mounted. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // Static assets (xterm.js etc.) from the embedded FS. staticFS, _ := fs.Sub(webFS, "web/static") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // Public: install script + agent binary downloads. mux.HandleFunc("GET /install.sh", s.handleInstallScript) mux.HandleFunc("GET /i/{code}", s.handleShortInstall) mux.Handle("GET /download/", http.StripPrefix("/download/", s.downloadHandler())) // First-run setup wizard. mux.HandleFunc("GET /setup", s.handleSetupForm) mux.HandleFunc("POST /setup", s.handleSetupSubmit) // Auth. mux.HandleFunc("GET /login", s.handleLoginForm) mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /logout", s.handleLogout) // Panel (admin-only). mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard)) mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage)) mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage)) mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage)) mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit)) mux.Handle("POST /agents/{id}/rename", s.requireAdmin(s.handleAgentRename)) mux.Handle("POST /agents/{id}/update", s.requireAdmin(s.handleAgentUpdate)) mux.Handle("POST /agents/{id}/delete", s.requireAdmin(s.handleAgentDelete)) mux.Handle("GET /settings", s.requireAdmin(s.handleSettings)) mux.Handle("POST /settings/host", s.requireAdmin(s.handleSetHost)) mux.Handle("POST /settings/secret/rotate", s.requireAdmin(s.handleRotateSecret)) mux.Handle("POST /settings/admins/add", s.requireAdmin(s.handleAddAdmin)) mux.Handle("POST /settings/admins/delete", s.requireAdmin(s.handleDeleteAdmin)) mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/revoke", s.requireAdmin(s.handleUfwRevoke)) // Panel terminal over HTTP long-polling (admin-only). mux.Handle("POST /api/panel/term/{agentID}/open", s.requireAdmin(s.handlePanelOpen)) mux.Handle("GET /api/panel/term/{sid}/out", s.requireAdmin(s.handlePanelOut)) mux.Handle("POST /api/panel/term/{sid}/in", s.requireAdmin(s.handlePanelIn)) mux.Handle("POST /api/panel/term/{sid}/close", s.requireAdmin(s.handlePanelClose)) // Panel file manager (admin-only). mux.Handle("GET /api/panel/agents/{id}/fs/download", s.requireAdmin(s.handleFSDownload)) mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp)) // Panel port forwarding (admin-only). mux.Handle("POST /agents/{id}/forwards/add", s.requireAdmin(s.handleForwardAdd)) mux.Handle("POST /forwards/{rid}/delete", s.requireAdmin(s.handleForwardDelete)) // Agent API (token/secret auth inside handlers). mux.HandleFunc("POST /api/agent/enroll", s.handleEnroll) mux.HandleFunc("GET /api/agent/poll", s.handlePoll) mux.HandleFunc("GET /api/agent/term/{sid}/in", s.handleTermIn) mux.HandleFunc("POST /api/agent/term/{sid}/out", s.handleTermOut) mux.HandleFunc("GET /api/agent/task/{id}", s.handleTaskFetch) mux.HandleFunc("POST /api/agent/task/{id}/result", s.handleTaskResult) return s.logRequests(mux) } // requireAdmin is middleware that redirects anonymous users appropriately. func (s *Server) requireAdmin(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.db.SetupDone() { http.Redirect(w, r, "/setup", http.StatusSeeOther) return } admin := s.currentAdmin(r) if admin == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } h(w, r) }) } func (s *Server) logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Keep terminal/poll paths quiet; they are frequent and long-lived. if !strings.Contains(r.URL.Path, "/api/agent/") && !strings.Contains(r.URL.Path, "/api/panel/") { log.Printf("%s %s %s", clientIP(r), r.Method, r.URL.Path) } next.ServeHTTP(w, r) }) } func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") // Panel pages carry inline JS that changes between releases; never let a // browser or proxy serve a stale copy. w.Header().Set("Cache-Control", "no-store") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { log.Printf("template %s: %v", name, err) http.Error(w, "internal error", http.StatusInternalServerError) } } // portFromAddr extracts the numeric port from a "host:port" or ":port" address. func portFromAddr(addr string) int { if addr == "" { return 0 } _, p, err := net.SplitHostPort(addr) if err != nil { return 0 } n, _ := strconv.Atoi(p) return n } func clientIP(r *http.Request) string { // nginx sets X-Forwarded-For / X-Real-IP; trust the first hop. if v := r.Header.Get("X-Real-IP"); v != "" { return v } if v := r.Header.Get("X-Forwarded-For"); v != "" { if i := strings.IndexByte(v, ','); i >= 0 { return strings.TrimSpace(v[:i]) } return strings.TrimSpace(v) } host := r.RemoteAddr if i := strings.LastIndexByte(host, ':'); i >= 0 { host = host[:i] } return host }