Deleting a machine left its forward rules behind: the host ports stayed bound and "busy" with no page to remove them from. The rules, their listeners and ufw rules now go with the agent, and rules orphaned by older versions are dropped on start. A host port equal to the direct tunnel port gets its own message.
309 lines
10 KiB
Go
309 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"embed"
|
|
"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
|
|
// (default ":8446"). 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
|
|
}
|
|
|
|
// 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),
|
|
tun: tun,
|
|
udp: udp,
|
|
tmpl: tmpl,
|
|
limiter: newLoginLimiter(),
|
|
distDir: opts.DistDir,
|
|
secureCookies: opts.SecureCookies,
|
|
installTmpl: installTmpl,
|
|
}
|
|
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/", 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}/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/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/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)
|
|
|
|
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
|
|
}
|