package server import ( "crypto/sha256" "embed" "encoding/hex" "html/template" "io/fs" "log" "net" "net/http" "strconv" "strings" "sync" "sync/atomic" 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 // Empty disables direct forwarding. The port can be overridden from the // settings page. TunnelAddr string } // Server is the HTTP host: admin panel + agent rendezvous. type Server struct { db *DB hub *Hub fwd *forwardManager tun *tunnelHub udp *udpRelay ufw *ufwManager tmpl *template.Template limiter *loginLimiter distDir string secureCookies bool installTmpl *texttemplate.Template // Direct tunnel: bind host and port of the -tunnel address, and the port // currently listened on (the settings override, else the default). // tunnelDefault 0 means the tunnel is disabled. tunnelBind string tunnelDefault int tunnelMu sync.Mutex // serializes port changes tunnelPort atomic.Int64 stats statCache // last load sample per agent, for the status page relays speedRelays // speed tests that run through the browser meters speedMeters // byte counts of the speed tests under way xfers xfers // files on their way between a browser and an agent challenges challenges // passkey challenges handed out assets map[string]string // static file -> hash of its content } // New builds the server, parsing embedded templates. func New(opts Options) (*Server, error) { if err := opts.DB.ensureDefaults(); err != nil { return nil, err } staticFS, _ := fs.Sub(webFS, "web/static") assets := assetHashes(staticFS) funcs := template.FuncMap{ // asset is a static file's URL with its content hash, so a new // release is a new URL that no browser or proxy has cached. "asset": func(name string) string { return "/static/" + name + "?v=" + assets[name] }, } tmpl, err := template.New("").Funcs(funcs).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), tun: tun, udp: udp, tmpl: tmpl, limiter: newLoginLimiter(), distDir: opts.DistDir, secureCookies: opts.SecureCookies, installTmpl: installTmpl, assets: assets, } s.ufw = newUfwManager(opts.DB, s.TunnelPort) // Start the direct forward tunnel (TCP mux + UDP), on the port from the // settings page if one is set there. if opts.TunnelAddr != "" { s.tunnelBind, s.tunnelDefault = splitAddr(opts.TunnelAddr) } if s.tunnelDefault > 0 { port := s.tunnelDefault if p := s.db.TunnelPort(); p > 0 { port = p } s.tunnelPort.Store(int64(port)) addr := s.tunnelListenAddr(port) if err := tun.listen(addr); err != nil { log.Printf("tunnel tcp listen %s: %v", addr, err) } if err := udp.listen(addr); err != nil { log.Printf("tunnel udp listen %s: %v", addr, err) } } s.restoreForwards() go s.ufw.sync() return s, nil } // TunnelPort is the port the direct tunnel listens on (0 = disabled). func (s *Server) TunnelPort() int { return int(s.tunnelPort.Load()) } func (s *Server) tunnelListenAddr(port int) string { return net.JoinHostPort(s.tunnelBind, strconv.Itoa(port)) } // tunnelAdvert is the tunnel address handed to agents as "host:port". The host // is empty unless one is set in Settings; agents then use the panel's host. func (s *Server) tunnelAdvert() string { port := s.TunnelPort() if port == 0 { return "" } return net.JoinHostPort(s.db.TunnelHost(), strconv.Itoa(port)) } // setTunnelPort moves the direct tunnel to another port. Connected agents are // dropped and redial the new port once their next poll tells them about it. func (s *Server) setTunnelPort(port int) error { s.tunnelMu.Lock() defer s.tunnelMu.Unlock() old := s.TunnelPort() if old == 0 || port == old { return nil } addr := s.tunnelListenAddr(port) if err := s.tun.listen(addr); err != nil { return err } if err := s.udp.listen(addr); err != nil { // Keep TCP and UDP on the same port: go back to the old one. if rerr := s.tun.listen(s.tunnelListenAddr(old)); rerr != nil { log.Printf("tunnel tcp listen %s: %v", s.tunnelListenAddr(old), rerr) } return err } s.tunnelPort.Store(int64(port)) go s.ufw.sync() return 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 { // Rules of an agent deleted by an older version: drop them. if a, err := s.db.GetAgent(r.AgentID); err == nil && a == nil { log.Printf("forward rule %d: agent %s is gone, removing", r.ID, r.AgentID) s.removeForward(r.ID) continue } 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/", staticHandler(staticFS, s.assets))) // 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) mux.HandleFunc("POST /api/login/key/begin", s.handleKeyLoginBegin) mux.HandleFunc("POST /api/login/key/finish", s.handleKeyLoginFinish) // 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}/status", s.requireAdmin(s.handleStatusPage)) 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}/user", s.requireAdmin(s.handleAgentSetUser)) mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser)) 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/tunnel", s.requireAdmin(s.handleSetTunnel)) 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/admins/keys/reset", s.requireAdmin(s.handleResetAdminKeys)) mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete)) mux.Handle("POST /settings/ufw/toggle", s.requireAdmin(s.handleUfwToggle)) mux.Handle("POST /settings/ufw/add", s.requireAdmin(s.handleUfwAdd)) 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/upload", s.requireAdmin(s.handleFSUploadStart)) mux.Handle("PUT /api/panel/fs/xfer/{xid}/{i}", s.requireAdmin(s.handleXferPut)) mux.Handle("POST /api/panel/fs/xfer/{xid}/cancel", s.requireAdmin(s.handleXferCancel)) mux.Handle("GET /api/panel/fs/xfer/{xid}/result", s.requireAdmin(s.handleXferResult)) mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp)) // Machine status (admin). mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence)) mux.Handle("GET /api/panel/agents/{id}/stat", s.requireAdmin(s.handleAgentStat)) mux.Handle("POST /api/panel/agents/{id}/speed", s.requireAdmin(s.handleAgentSpeed)) mux.Handle("GET /api/panel/agents/{id}/speed/progress", s.requireAdmin(s.handleSpeedProgress)) mux.Handle("GET /api/panel/ping", s.requireAdmin(s.handlePanelPing)) mux.Handle("POST /api/panel/agents/{id}/speed/relay", s.requireAdmin(s.handleSpeedRelayOpen)) mux.Handle("POST /api/panel/agents/{id}/speed/down", s.requireAdmin(s.handleSpeedRelayFeed)) mux.Handle("GET /api/panel/agents/{id}/speed/up", s.requireAdmin(s.handleSpeedRelayDrain)) // 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/config", s.handleAgentConfig) 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) mux.HandleFunc("GET /api/agent/speed/down", s.handleSpeedDown) mux.HandleFunc("POST /api/agent/speed/up", s.handleSpeedUp) mux.HandleFunc("GET /api/agent/speed/relay/down", s.handleSpeedRelayDown) mux.HandleFunc("POST /api/agent/speed/relay/up", s.handleSpeedRelayUp) mux.HandleFunc("GET /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferGet) mux.HandleFunc("POST /api/agent/fs/xfer/{xid}/{i}", s.handleAgentXferPost) 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) } } // splitAddr splits a "host:port" or ":port" listen address. The port is 0 when // the address has none. func splitAddr(addr string) (host string, port int) { h, p, err := net.SplitHostPort(addr) if err != nil { return "", 0 } n, _ := strconv.Atoi(p) return h, 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 } // assetHashes hashes every static file, for asset URLs and ETags. func assetHashes(fsys fs.FS) map[string]string { out := map[string]string{} _ = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if data, err := fs.ReadFile(fsys, p); err == nil { sum := sha256.Sum256(data) out[p] = hex.EncodeToString(sum[:8]) } return nil }) return out } // staticHandler serves the embedded assets. Pages ask for them by URLs that // carry the content hash (?v=): such a file never changes and is kept for a // year, by the browser and by a proxy in front such as Cloudflare. Asked for // without it, a file is checked every time against its ETag. func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler { files := http.FileServer(http.FS(fsys)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := hashes[strings.TrimPrefix(r.URL.Path, "/")] if h != "" { w.Header().Set("ETag", `"`+h+`"`) } if v := r.URL.Query().Get("v"); h != "" && v == h { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "no-cache") } files.ServeHTTP(w, r) }) }