package server import ( "fmt" "log" "net/http" "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 } s.render(w, "edit.html", map[string]any{ "Agent": a, "CSRF": csrfToken(r), "ServerVersion": Version, "Outdated": a.Version != "" && a.Version != Version, "Updated": r.URL.Query().Get("updated") == "1", }) } // 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, }) } 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/ 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 } _ = s.db.SetUfwEnabled(r.FormValue("enabled") == "1") 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/ 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) { 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)) }