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.
543 lines
17 KiB
Go
543 lines
17 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")
|
|
// Its forward rules go with it: left behind they would keep their host
|
|
// ports bound and busy with no page to remove them from.
|
|
rules, _ := s.db.ListForwardRulesByAgent(id)
|
|
for _, rule := range rules {
|
|
s.removeForward(rule.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))
|
|
}
|