package server import ( "encoding/json" "errors" "fmt" "log" "net" "net/http" "net/url" "slices" "strconv" "strings" "sync" "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": "Неверный логин или пароль.", "Username": username}) return } // An admin who turned the password off signs in with a passkey only. if s.db.PasswordOff(admin.ID) { s.render(w, "login.html", map[string]any{"Error": "Для этого аккаунта вход только по ключу доступа.", "Username": username}) 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) { s.renderPage(w, r, "dashboard", s.dashboardData(r)) } // dashboardData is the machines list as the user may see it. func (s *Server) dashboardData(r *http.Request) map[string]any { agents, _ := s.db.ListAgents() type row struct { Agent Online bool Seen string } me := s.currentAdmin(r) rows := make([]row, 0, len(agents)) for _, a := range agents { if !s.db.CanUseAgent(me, a.ID) { continue } on := s.online(&a) rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)}) } return map[string]any{ "Admin": me, "IsAdmin": me.IsAdmin(), "Agents": rows, "CSRF": csrfToken(r), } } // seenText is the "activity" cell: a connected agent is active now, whatever // the time of its last recorded poll. func seenText(online bool, lastSeen time.Time) string { if online { return "сейчас" } return humanSince(lastSeen) } // GET /api/panel/agents/presence - who is online, for the dashboard to keep // its status column live without reloading. func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) { agents, err := s.db.ListAgents() if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } type item struct { ID string `json:"id"` Name string `json:"name"` Hostname string `json:"hostname"` User string `json:"user"` // who a terminal opens as Online bool `json:"online"` Seen string `json:"seen"` } me := s.currentAdmin(r) out := make([]item, 0, len(agents)) for _, a := range agents { if !s.db.CanUseAgent(me, a.ID) { continue } on := s.online(&a) out = append(out, item{ID: a.ID, Name: a.Name, Hostname: a.Hostname, User: a.TerminalUser(), Online: on, Seen: seenText(on, a.LastSeen)}) } writeJSON(w, out) } 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) s.stats.drop(id) s.relays.drop(id, nil) s.hub.Forget(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, "IsAdmin": s.currentAdmin(r).IsAdmin(), "CSRF": csrfToken(r), "ServerVersion": Version, "AI": s.aiConfig(), "Outdated": a.Version != "" && a.Version != Version, "Updated": r.URL.Query().Get("updated") == "1", "UpdateMsg": r.URL.Query().Get("umsg"), "UpdateErr": r.URL.Query().Get("uperr"), "UserOK": r.URL.Query().Get("uok"), "UserErr": r.URL.Query().Get("uerr"), "Transport": firstNonEmpty(a.Transport, proto.TransportAuto), "OnWS": s.wsAgents.On(a.ID), "ViaDirect": viaDirectOf(&s.viaDirectA, a.ID), "Online": s.online(a), } // The user list lives on the machine, so it is only known while it is online. if s.online(a) { if users, err := s.agentUsers(id); err == nil { data["Users"] = users } else { data["UsersErr"] = err.Error() } } s.renderPage(w, r, "edit", data) } // POST /agents/{id}/transport - how the agent is to reach the server: auto, // ws or lp. A connected agent picks it up within a poll round. func (s *Server) handleAgentTransport(w http.ResponseWriter, r *http.Request) { if !checkCSRF(r) { http.Error(w, "bad csrf", http.StatusForbidden) return } id, t := r.PathValue("id"), r.FormValue("transport") switch t { case proto.TransportAuto, proto.TransportWS, proto.TransportLP: _ = s.db.SetAgentTransport(id, t) } http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther) } // 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 !s.online(a) { 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 !s.online(a) { 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 } back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) } if !s.online(a) { back(url.Values{"uperr": {"машина не в сети"}}) return } if admin := s.currentAdmin(r); admin != nil { s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version) } // The download can take a while on a slow link; under a proxy's timeout. res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second) switch { case err != nil: back(url.Values{"uperr": {"агент не ответил: " + err.Error()}}) case !res.OK: back(url.Values{"uperr": {res.Error}}) default: var d struct { Status string `json:"status"` } _ = json.Unmarshal(res.Data, &d) back(url.Values{"updated": {"1"}, "umsg": {d.Status}}) } } 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) } // Back to «Изменить», with the new name: the machine dialog stays open. http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther) } // --- settings --- func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { admins, _ := s.db.ListAdmins() keyCounts, passOff, agentCounts := map[int64]int{}, map[int64]bool{}, map[int64]int{} for _, a := range admins { keyCounts[a.ID] = s.db.CountAdminKeys(a.ID) passOff[a.ID] = s.db.PasswordOff(a.ID) agentCounts[a.ID] = len(s.db.UserAgents(a.ID)) } allAgents, _ := s.db.ListAgents() host := s.db.HostAddress() direct := s.db.DirectAddress() secret := s.db.EnrollSecret() code := s.db.InstallCode() ufwInstalled, ufwActive, ufwRunnable := s.ufw.status() ufwRules, _ := s.db.ListUfwRules() s.renderPage(w, r, "settings", map[string]any{ "Admin": s.currentAdmin(r), "Host": host, "Direct": direct, "DirectShort": fmt.Sprintf("wget -qO- %s/i/%s | sudo sh", direct, code), "DirectCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s FALLBACK_HOST=%s ENROLL_SECRET=%s sh", direct, direct, host, secret), "Secret": secret, "CodeLen": s.db.InstallCodeLen(), "Admins": admins, "KeyCounts": keyCounts, "AgentCounts": agentCounts, "AllAgents": allAgents, "Me": s.currentAdmin(r), "PassOff": passOff, "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 или имя без схемы и порта.", "host": "Некорректный адрес: нужен URL вида https://panel.example.com, без пути.", "tunnel_port": "Некорректный порт туннеля.", "ufw_port": "Некорректное правило ufw: нужен порт 1-65535 и протокол tcp или udp.", "ufw_off": "Правило ufw не добавлено: ufw выключен или интеграция с ним отключена.", "ufw_failed": "Правило ufw не добавлено: команда ufw завершилась ошибкой.", "ufw_exists": "Этот порт уже открыт в ufw не панелью - правило осталось как было, панель его не отслеживает.", "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")), "/") direct := strings.TrimRight(strings.TrimSpace(r.FormValue("direct")), "/") for _, v := range []string{host, direct} { if v == "" { continue } if u, err := url.Parse(v); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || (u.Path != "" && u.Path != "/") { http.Redirect(w, r, "/settings?err=host", http.StatusSeeOther) return } } if host != "" { _ = s.db.SetConfig(cfgHostAddress, host) } _ = s.db.SetConfig(cfgDirectAddr, direct) 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/ links stop working. _ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen())) http.Redirect(w, r, "/settings", http.StatusSeeOther) } // POST /settings/install/code - a new install code, of the length given: // the old /i/ link stops working. func (s *Server) handleInstallCode(w http.ResponseWriter, r *http.Request) { if !checkCSRF(r) { http.Error(w, "bad csrf", http.StatusForbidden) return } if n, err := strconv.Atoi(r.FormValue("len")); err == nil { n = min(installCodeMax, max(installCodeMin, n)) _ = s.db.SetConfig(cfgInstallLen, strconv.Itoa(n)) } _ = s.db.SetConfig(cfgInstallCode, shortCode(s.db.InstallCodeLen())) 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") role := RoleAdmin if r.FormValue("role") == RoleUser { role = RoleUser } if username != "" && len(pw) >= 8 { if hash, err := hashPassword(pw); err == nil { if id, err := s.db.CreateUser(username, hash, role); err == nil && role == RoleUser { _ = s.db.SetUserAgents(id, r.Form["agents"]) } if me := s.currentAdmin(r); me != nil { s.db.Audit(me.ID, "", "user_add:"+username+":"+role) } } } http.Redirect(w, r, "/settings#users", 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 u, _ := s.db.AdminByID(id); u != nil && (!u.IsAdmin() || s.db.CountRole(RoleAdmin) > 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) handleUfwAdd(w http.ResponseWriter, r *http.Request) { if !checkCSRF(r) { http.Error(w, "bad csrf", http.StatusForbidden) return } port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port"))) proto := r.FormValue("proto") if port < 1 || port > 65535 || (proto != "tcp" && proto != "udp") { http.Redirect(w, r, "/settings?err=ufw_port", http.StatusSeeOther) return } target := "/settings" if key := s.ufw.allowManual(port, proto); key != "" { target += "?err=" + key } http.Redirect(w, r, target, 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, r, "") } // handleShortInstall serves the installer behind the short /i/ link with // HOST and ENROLL_SECRET already embedded, so the one-liner is just // // wget -qO- https://host/i/ | sudo sh func (s *Server) handleShortInstall(w http.ResponseWriter, r *http.Request) { // A short code can be guessed: wrong ones count against the address // like wrong passwords do. ip := clientIP(r) if s.limiter.blocked(ip) { http.Error(w, "too many attempts", http.StatusTooManyRequests) return } code := r.PathValue("code") want := s.db.InstallCode() if want == "" || !constantTimeEqual(code, want) { s.limiter.fin(ip, false) http.NotFound(w, r) return } s.renderInstall(w, r, s.db.EnrollSecret()) } // viaDirect reports whether the request came to the direct address. func (s *Server) viaDirect(r *http.Request) bool { d := directHost(s.db.DirectAddress()) if d == "" { return false } host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } return strings.EqualFold(host, d) } // renderInstall writes install.sh with the server's addresses and an // optional secret. Fetched from the direct address, it makes that the // agent's address and the main one its fallback; otherwise the other way. func (s *Server) renderInstall(w http.ResponseWriter, r *http.Request, secret string) { host, fallback := s.db.HostAddress(), s.db.DirectAddress() if s.viaDirect(r) { host, fallback = fallback, host } w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") if err := s.installTmpl.Execute(w, map[string]any{ "Host": host, "Fallback": fallback, "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)) } // viaDirectOf tells whether the agent last reached the server at the direct // address. func viaDirectOf(m *sync.Map, id string) bool { v, _ := m.Load(id) b, _ := v.(bool) return b }