Files
lpra/internal/server/server.go
T

368 lines
13 KiB
Go

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
}
// 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/", staticHandler(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}/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/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
}
// staticHandler serves the embedded assets with a content hash as ETag. The
// browser asks each time whether a file changed (no-cache) and gets a 304
// when it did not, so a new release reaches it at once without reloading
// what stayed the same.
func staticHandler(fsys fs.FS) http.Handler {
files := http.FileServer(http.FS(fsys))
var mu sync.Mutex
etags := map[string]string{}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/")
mu.Lock()
tag, ok := etags[name]
mu.Unlock()
if !ok {
if data, err := fs.ReadFile(fsys, name); err == nil {
sum := sha256.Sum256(data)
tag = `"` + hex.EncodeToString(sum[:8]) + `"`
}
mu.Lock()
etags[name] = tag
mu.Unlock()
}
if tag != "" {
w.Header().Set("ETag", tag)
}
w.Header().Set("Cache-Control", "no-cache")
files.ServeHTTP(w, r)
})
}