feat: initial commit
This commit is contained in:
1 parent
353f62e257
commit
c6852c6f1b
62 files changed
+5911
-2
No files matched your search
@@ -0,0 +1,337 @@
|
||||
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/<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
|
||||
}
|
||||
_ = 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/<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))
|
||||
}
|
||||
Reference in new issue
Block a user