448 lines
17 KiB
Go
448 lines
17 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
|
|
ship shipping // files on their way from one machine to another
|
|
challenges challenges // passkey challenges handed out
|
|
wsAgents wsAgents // agents on their WebSocket now
|
|
pinMisses pinMisses // wrong PINs per session
|
|
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
|
|
}
|
|
mainHost = func() string { return directHost(opts.DB.HostAddress()) }
|
|
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 the one set in Settings, else the direct address's (the main one may be
|
|
// behind a proxy that carries no raw TCP), else empty: agents then use the
|
|
// panel's host.
|
|
func (s *Server) tunnelAdvert() string {
|
|
port := s.TunnelPort()
|
|
if port == 0 {
|
|
return ""
|
|
}
|
|
host := s.db.TunnelHost()
|
|
if host == "" {
|
|
host = directHost(s.db.DirectAddress())
|
|
}
|
|
return net.JoinHostPort(host, 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}/transport", s.requireAdmin(s.handleAgentTransport))
|
|
mux.Handle("POST /agents/{id}/users/add", s.requireAdmin(s.handleAgentAddUser))
|
|
mux.Handle("POST /agents/{id}/delete", s.adminsOnly(s.handleAgentDelete))
|
|
mux.Handle("GET /settings", s.adminsOnly(s.handleSettings))
|
|
mux.Handle("POST /settings/host", s.adminsOnly(s.handleSetHost))
|
|
mux.Handle("POST /settings/tunnel", s.adminsOnly(s.handleSetTunnel))
|
|
mux.Handle("POST /settings/secret/rotate", s.adminsOnly(s.handleRotateSecret))
|
|
mux.Handle("POST /settings/install/code", s.adminsOnly(s.handleInstallCode))
|
|
mux.Handle("POST /settings/admins/add", s.adminsOnly(s.handleAddAdmin))
|
|
mux.Handle("POST /settings/admins/delete", s.adminsOnly(s.handleDeleteAdmin))
|
|
mux.Handle("GET /api/panel/users/{id}/agents", s.adminsOnly(s.handleUserAgents))
|
|
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
|
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(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 /api/panel/account/password", s.requireAdmin(s.handlePasswordToggle))
|
|
mux.Handle("POST /api/panel/account/pin", s.requireAdmin(s.handlePinSet))
|
|
mux.Handle("GET /api/panel/pin", s.requireAdmin(s.handlePinState))
|
|
mux.Handle("POST /api/panel/pin/check", s.requireAdmin(s.handlePinCheck))
|
|
mux.Handle("POST /api/panel/account/password/change", s.requireAdmin(s.handlePasswordChange))
|
|
mux.Handle("POST /settings/ufw/toggle", s.adminsOnly(s.handleUfwToggle))
|
|
mux.Handle("POST /settings/ufw/add", s.adminsOnly(s.handleUfwAdd))
|
|
mux.Handle("POST /settings/ufw/revoke", s.adminsOnly(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("GET /api/panel/term/{sid}/ws", s.requireAdmin(s.handlePanelTermWS))
|
|
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/ship", s.requireAdmin(s.handleFSShip))
|
|
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
|
|
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
|
|
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/ws", s.handleAgentWS)
|
|
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)
|
|
mux.HandleFunc("POST /api/agent/fs/job/{jid}/progress", s.handleShipProgress)
|
|
mux.HandleFunc("POST /api/agent/fs/relay/{rid}/{i}", s.handleRelayPost)
|
|
mux.HandleFunc("GET /api/agent/fs/relay/{rid}/{i}", s.handleRelayGet)
|
|
|
|
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
|
|
}
|
|
// A user who is not an admin uses only the machines given to them:
|
|
// any other is as if it were not there.
|
|
if !admin.IsAdmin() {
|
|
p := r.URL.Path
|
|
if strings.HasPrefix(p, "/agents/") || strings.HasPrefix(p, "/api/panel/agents/") || strings.HasPrefix(p, "/api/panel/term/") {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
id = r.PathValue("agentID")
|
|
}
|
|
if id != "" && id != "presence" && !s.db.CanUseAgent(admin, id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
h(w, r)
|
|
})
|
|
}
|
|
|
|
// adminsOnly lets only admins through: the settings, users, the firewall.
|
|
func (s *Server) adminsOnly(h http.HandlerFunc) http.Handler {
|
|
return s.requireAdmin(func(w http.ResponseWriter, r *http.Request) {
|
|
if !s.currentAdmin(r).IsAdmin() {
|
|
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Error(w, "только для администраторов", http.StatusForbidden)
|
|
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)
|
|
})
|
|
}
|