233 lines
7.7 KiB
Go
233 lines
7.7 KiB
Go
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
|
|
}
|