Files
lpra/internal/server/handlers_panel.go
T

710 lines
23 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": "Неверный логин или пароль.", "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) {
agents, err := s.db.ListAgents()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
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)})
}
s.render(w, "dashboard.html", 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) 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)
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,
"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),
"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.render(w, "edit.html", 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)
}
http.Redirect(w, r, "/", 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.render(w, "settings.html", 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, host, direct, 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/<code> 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/<code> 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, "")
}
// 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) {
// 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, 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(),
"Direct": s.db.DirectAddress(),
"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))
}