feat: user roles - admins and users limited to the machines given to them, user editor with machines, password sign-in and its lock; direct address without Cloudflare as the agents' fallback and in the installer; version 0.1.2

This commit is contained in:
redtea committed 2026-10-15 10:56:19 +03:00
1 parent 36dc12b69d
commit 021da93229
29 files changed
+844 -97

No files matched your search

+48 -7
View File
@@ -121,15 +121,20 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
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": s.currentAdmin(r),
"Agents": rows,
"CSRF": csrfToken(r),
"Admin": me,
"IsAdmin": me.IsAdmin(),
"Agents": rows,
"CSRF": csrfToken(r),
})
}
@@ -158,8 +163,12 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
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)})
}
@@ -207,6 +216,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
}
data := map[string]any{
"Agent": a,
"IsAdmin": s.currentAdmin(r).IsAdmin(),
"CSRF": csrfToken(r),
"ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != Version,
@@ -409,12 +419,15 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
admins, _ := s.db.ListAdmins()
keyCounts, passOff := map[int64]int{}, map[int64]bool{}
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()
@@ -422,10 +435,16 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
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),
@@ -444,6 +463,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"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 выключен или интеграция с ним отключена.",
@@ -517,9 +537,20 @@ func (s *Server) handleSetHost(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -556,12 +587,21 @@ func (s *Server) handleAddAdmin(w http.ResponseWriter, r *http.Request) {
}
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 {
_ = s.db.CreateAdmin(username, hash)
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", http.StatusSeeOther)
http.Redirect(w, r, "/settings#users", http.StatusSeeOther)
}
func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
@@ -571,7 +611,7 @@ func (s *Server) handleDeleteAdmin(w http.ResponseWriter, r *http.Request) {
}
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
// Never delete the last admin.
if n, _ := s.db.CountAdmins(); n > 1 {
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)
@@ -654,6 +694,7 @@ 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)