Files
lpra/internal/server/handlers_panel.go
T
redtea bcfe909aaa feat: tunnel host and port in the panel settings, ufw kept in sync
The direct tunnel port (8446 by default) and the host agents dial for
it can be set in Settings. The server moves its listeners at once and
agents pick the address up from the panel: on connect and with every
poll, unless TUNNEL_ADDR / TUNNEL_PORT pin it locally.

ufw: run with a C locale (the output is translated), do not adopt rules
that existed before the panel, skip loopback-only listeners, re-open
what saved forwards need on start and when the integration is turned
on, move the shared tunnel rule with the port and drop it when no
direct forward is left. The last ufw error is shown in Settings.

The forwards page shows the real tunnel and panel ports.
2026-10-13 14:50:50 +03:00

537 lines
16 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// --- first-run setup ---
func (s *Server) handleSetupForm(w http.ResponseWriter, r *http.Request) {
if s.db.SetupDone() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.render(w, "setup.html", map[string]any{})
}
func (s *Server) handleSetupSubmit(w http.ResponseWriter, r *http.Request) {
if s.db.SetupDone() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
// Guard against a race where two requests create the first admin.
if n, _ := s.db.CountAdmins(); n > 0 {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.FormValue("username"))
pw := r.FormValue("password")
pw2 := r.FormValue("password2")
if username == "" || len(pw) < 8 || pw != pw2 {
s.render(w, "setup.html", map[string]any{
"Error": "Введите имя и пароль (мин. 8 символов), пароли должны совпадать.",
})
return
}
hash, err := hashPassword(pw)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := s.db.CreateAdmin(username, hash); err != nil {
s.render(w, "setup.html", map[string]any{"Error": "Не удалось создать админа: " + err.Error()})
return
}
_ = s.db.MarkSetupDone()
admin, _ := s.db.AdminByUsername(username)
if admin != nil {
_ = s.setSession(w, admin.ID)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- login / logout ---
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
if !s.db.SetupDone() {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
if s.currentAdmin(r) != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, "login.html", map[string]any{})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if s.limiter.blocked(ip) {
s.render(w, "login.html", map[string]any{"Error": "Слишком много попыток. Подождите немного."})
return
}
username := strings.TrimSpace(r.FormValue("username"))
pw := r.FormValue("password")
admin, _ := s.db.AdminByUsername(username)
if admin == nil || !checkPassword(admin.PassHash, pw) {
s.limiter.fin(ip, false)
s.render(w, "login.html", map[string]any{"Error": "Неверный логин или пароль."})
return
}
s.limiter.fin(ip, true)
if err := s.setSession(w, admin.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.clearSession(w, r)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// --- dashboard ---
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
agents, err := s.db.ListAgents()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
type row struct {
Agent
Online bool
Seen string
}
rows := make([]row, 0, len(agents))
for _, a := range agents {
rows = append(rows, row{Agent: a, Online: a.Online(), Seen: humanSince(a.LastSeen)})
}
s.render(w, "dashboard.html", map[string]any{
"Admin": s.currentAdmin(r),
"Agents": rows,
"CSRF": csrfToken(r),
})
}
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
s.render(w, "terminal.html", map[string]any{
"Agent": a,
"CSRF": csrfToken(r),
})
}
func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
_ = s.db.DeleteAgent(id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
data := map[string]any{
"Agent": a,
"CSRF": csrfToken(r),
"ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1",
"UserOK": r.URL.Query().Get("uok"),
"UserErr": r.URL.Query().Get("uerr"),
}
// The user list lives on the machine, so it is only known while it is online.
if a.Online() {
if users, err := s.agentUsers(id); err == nil {
data["Users"] = users
} else {
data["UsersErr"] = err.Error()
}
}
s.render(w, "edit.html", data)
}
// usersTaskTimeout bounds how long the edit page waits for the agent.
const usersTaskTimeout = 8 * time.Second
// agentUsers asks the agent who it runs as and which users it can log in as.
func (s *Server) agentUsers(agentID string) (*proto.SysUsersResult, error) {
taskID, ch := s.hub.CreateTask(agentID, proto.TaskSysUsers, nil)
defer s.hub.DiscardTask(taskID)
select {
case res := <-ch:
if !res.OK {
// Agents older than 0.0.2 don't know this task.
return nil, errors.New("клиент не поддерживает выбор пользователя — обновите его")
}
var out proto.SysUsersResult
if err := json.Unmarshal(res.Data, &out); err != nil {
return nil, errors.New("некорректный ответ клиента")
}
return &out, nil
case <-time.After(usersTaskTimeout):
return nil, errors.New("таймаут ожидания клиента")
}
}
// editUserRedirect returns to the edit page with a result banner.
func editUserRedirect(w http.ResponseWriter, r *http.Request, id, key, msg string) {
http.Redirect(w, r, "/agents/"+id+"/edit?"+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
}
// handleAgentSetUser picks the OS user terminals on this machine run as.
func (s *Server) handleAgentSetUser(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
name := strings.TrimSpace(r.FormValue("user"))
if !a.Online() {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — сменить пользователя нельзя.")
return
}
// Only accept a user the machine itself reports, so a typo (or an agent too
// old to honour the choice) can't silently leave the terminal as root.
users, err := s.agentUsers(id)
if err != nil {
editUserRedirect(w, r, id, "uerr", err.Error())
return
}
if !slices.Contains(users.Users, name) {
editUserRedirect(w, r, id, "uerr", "Пользователя "+name+" нет на машине.")
return
}
if name == users.Current {
name = "" // follow the agent's own user
}
if err := s.db.SetAgentLoginUser(id, name); err != nil {
editUserRedirect(w, r, id, "uerr", "Не удалось сохранить.")
return
}
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, id, "set_user:"+firstNonEmpty(name, users.Current))
}
editUserRedirect(w, r, id, "uok", "Терминал будет открываться от "+firstNonEmpty(name, users.Current)+".")
}
// handleAgentAddUser creates a new OS user on the machine and switches the
// terminal to it.
func (s *Server) handleAgentAddUser(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
if !a.Online() {
editUserRedirect(w, r, id, "uerr", "Машина офлайн — создать пользователя нельзя.")
return
}
name := strings.TrimSpace(r.FormValue("name"))
args, _ := json.Marshal(proto.SysUserAddArgs{
Name: name,
Password: r.FormValue("password"),
Sudo: r.FormValue("sudo") == "1",
})
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUserAdd, args)
defer s.hub.DiscardTask(taskID)
select {
case res := <-ch:
if !res.OK {
editUserRedirect(w, r, id, "uerr", "Не удалось создать пользователя: "+res.Error)
return
}
case <-time.After(30 * time.Second):
editUserRedirect(w, r, id, "uerr", "Таймаут ожидания клиента.")
return
}
_ = s.db.SetAgentLoginUser(id, name)
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, id, "create_user:"+name)
}
editUserRedirect(w, r, id, "uok", "Пользователь "+name+" создан и выбран для терминала.")
}
// handleAgentUpdate asks the agent to download the latest binary and restart.
func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
if !a.Online() {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
return
}
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
defer s.hub.DiscardTask(taskID)
select {
case <-ch:
case <-time.After(30 * time.Second):
}
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
}
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id := r.PathValue("id")
name := strings.TrimSpace(r.FormValue("name"))
if name != "" {
_ = s.db.RenameAgent(id, name)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --- settings ---
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
admins, _ := s.db.ListAdmins()
host := s.db.HostAddress()
secret := s.db.EnrollSecret()
code := s.db.InstallCode()
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
ufwRules, _ := s.db.ListUfwRules()
s.render(w, "settings.html", map[string]any{
"Admin": s.currentAdmin(r),
"Host": host,
"Secret": secret,
"Admins": admins,
"CSRF": csrfToken(r),
"InstallShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", host, code),
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"ServerVersion": Version,
"UfwInstalled": ufwInstalled,
"UfwActive": ufwActive,
"UfwRunnable": ufwRunnable,
"UfwEnabled": s.db.UfwEnabled(),
"UfwRules": ufwRules,
"UfwError": s.ufw.LastError(),
"TunnelPort": s.TunnelPort(),
"TunnelDefault": s.tunnelDefault,
"TunnelHost": s.db.TunnelHost(),
"TunnelCustom": s.db.TunnelPort() > 0,
"Error": map[string]string{
"tunnel_host": "Некорректный хост туннеля: нужен IP или имя без схемы и порта.",
"tunnel_port": "Некорректный порт туннеля.",
"tunnel_listen": "Не удалось занять новый порт туннеля на хосте (возможно, уже используется). Порт не изменён.",
}[r.URL.Query().Get("err")],
})
}
// POST /settings/tunnel — host and port agents use for the direct tunnel.
func (s *Server) handleSetTunnel(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
if s.tunnelDefault == 0 {
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
}
host := strings.TrimSpace(r.FormValue("tunnel_host"))
host = strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
if !validTunnelHost(host) {
http.Redirect(w, r, "/settings?err=tunnel_host", http.StatusSeeOther)
return
}
// An empty port goes back to the one the server was started with.
port, custom := s.tunnelDefault, ""
if v := strings.TrimSpace(r.FormValue("tunnel_port")); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 65535 {
http.Redirect(w, r, "/settings?err=tunnel_port", http.StatusSeeOther)
return
}
port = n
if n != s.tunnelDefault {
custom = v
}
}
if err := s.setTunnelPort(port); err != nil {
log.Printf("tunnel: move to port %d: %v", port, err)
http.Redirect(w, r, "/settings?err=tunnel_listen", http.StatusSeeOther)
return
}
_ = s.db.SetConfig(cfgTunnelPort, custom)
_ = s.db.SetConfig(cfgTunnelHost, host)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// validTunnelHost accepts an empty value, an IP, or a bare host name.
func validTunnelHost(host string) bool {
if host == "" || net.ParseIP(host) != nil {
return true
}
if len(host) > 253 {
return false
}
for _, c := range host {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '_':
default:
return false
}
}
return true
}
func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
host := strings.TrimRight(strings.TrimSpace(r.FormValue("host")), "/")
if host != "" {
_ = s.db.SetConfig(cfgHostAddress, host)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleRotateSecret(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
_ = s.db.SetConfig(cfgEnrollSecret, randToken())
// Rotate the short install code too so old /i/<code> links stop working.
_ = s.db.SetConfig(cfgInstallCode, shortCode())
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
username := strings.TrimSpace(r.FormValue("username"))
pw := r.FormValue("password")
if username != "" && len(pw) >= 8 {
if hash, err := hashPassword(pw); err == nil {
_ = s.db.CreateAdmin(username, hash)
}
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
// Never delete the last admin.
if n, _ := s.db.CountAdmins(); n > 1 {
_ = s.db.DeleteAdmin(id)
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleUfwToggle(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
on := r.FormValue("enabled") == "1"
_ = s.db.SetUfwEnabled(on)
if on {
// Open what forwards created while the integration was off need.
s.ufw.sync()
}
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
func (s *Server) handleUfwRevoke(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
s.ufw.revoke(id)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
}
// --- install script + downloads ---
// handleInstallScript serves the generic installer; the secret is NOT baked in,
// so it must be supplied via env or the interactive prompt.
func (s *Server) handleInstallScript(w http.ResponseWriter, r *http.Request) {
s.renderInstall(w, "")
}
// handleShortInstall serves the installer behind the short /i/<code> link with
// HOST and ENROLL_SECRET already embedded, so the one-liner is just
//
// wget -qO- https://host/i/<code> | sudo sh
func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("code")
want := s.db.InstallCode()
if want == "" || !constantTimeEqual(code, want) {
http.NotFound(w, r)
return
}
s.renderInstall(w, s.db.EnrollSecret())
}
// renderInstall writes install.sh with the current host and an optional secret.
func (s *Server) renderInstall(w http.ResponseWriter, secret string) {
w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8")
if err := s.installTmpl.Execute(w, map[string]any{
"Host": s.db.HostAddress(),
"Secret": secret,
}); err != nil {
log.Printf("install.sh render: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func (s *Server) downloadHandler() http.Handler {
// Agent binaries carry no secrets and are intentionally public so the
// install one-liner works before the machine is enrolled.
return http.FileServer(http.Dir(s.distDir))
}