218 lines
7.0 KiB
Go
218 lines
7.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"rmm-openwrt/server/internal/authn"
|
|
"rmm-openwrt/server/internal/model"
|
|
"rmm-openwrt/server/internal/store"
|
|
)
|
|
|
|
var (
|
|
usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,64}$`)
|
|
dnsLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
|
|
)
|
|
|
|
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := a.store.ListUsers(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list users")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
|
}
|
|
|
|
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
var req createUserRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
req.Username = strings.TrimSpace(req.Username)
|
|
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
|
if req.Role == "" {
|
|
req.Role = "user"
|
|
}
|
|
if !usernamePattern.MatchString(req.Username) {
|
|
writeError(w, http.StatusBadRequest, "username must be 3-64 characters and contain only letters, digits, '.', '_' or '-'")
|
|
return
|
|
}
|
|
if req.Role != "user" && req.Role != "admin" {
|
|
writeError(w, http.StatusBadRequest, "role must be user or admin")
|
|
return
|
|
}
|
|
passwordHash, err := authn.HashPassword(req.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
user, err := a.store.CreateUser(r.Context(), req.Username, passwordHash, req.Role)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
writeError(w, http.StatusConflict, "username is already in use")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create user")
|
|
return
|
|
}
|
|
principal, _ := principalFromContext(r.Context())
|
|
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "user.create", "", "", mustJSON(map[string]any{
|
|
"created_user_id": user.ID,
|
|
"created_username": user.Username,
|
|
"role": user.Role,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusCreated, user)
|
|
}
|
|
|
|
func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
if len(parts) != 3 || parts[0] != "api" || parts[1] != "users" || parts[2] == "" {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
var req updateUserRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Disabled == nil && req.Password == "" {
|
|
writeError(w, http.StatusBadRequest, "disabled or password is required")
|
|
return
|
|
}
|
|
principal, _ := principalFromContext(r.Context())
|
|
if parts[2] == principal.User.ID && req.Disabled != nil && *req.Disabled {
|
|
writeError(w, http.StatusBadRequest, "you cannot disable your current account")
|
|
return
|
|
}
|
|
passwordHash := ""
|
|
var err error
|
|
if req.Password != "" {
|
|
passwordHash, err = authn.HashPassword(req.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
user, found, err := a.store.UpdateUserSecurity(r.Context(), parts[2], req.Disabled, passwordHash)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "last active") {
|
|
writeError(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update user")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "user.security_update", "", "", mustJSON(map[string]any{
|
|
"updated_user_id": user.ID,
|
|
"disabled": req.Disabled,
|
|
"password_reset": passwordHash != "",
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusOK, user)
|
|
}
|
|
|
|
func (a *App) handleCreateEnrollmentGrant(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
var req enrollmentGrantRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
label := strings.ToLower(strings.TrimSpace(req.DNSLabel))
|
|
if label != "" && !dnsLabelPattern.MatchString(label) {
|
|
writeError(w, http.StatusBadRequest, "dns_label must be a valid single DNS label")
|
|
return
|
|
}
|
|
duration := 15 * time.Minute
|
|
if req.ExpiresSeconds != 0 {
|
|
if req.ExpiresSeconds < 60 || req.ExpiresSeconds > 24*60*60 {
|
|
writeError(w, http.StatusBadRequest, "expires_seconds must be between 60 and 86400")
|
|
return
|
|
}
|
|
duration = time.Duration(req.ExpiresSeconds) * time.Second
|
|
}
|
|
token, err := randomToken(32)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create enrollment grant")
|
|
return
|
|
}
|
|
principal, _ := principalFromContext(r.Context())
|
|
grant, err := a.store.CreateEnrollmentGrant(r.Context(), principal.User.ID, label, store.TokenHash(token), time.Now().UTC().Add(duration))
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "limit") {
|
|
writeError(w, http.StatusTooManyRequests, "active enrollment grant limit reached")
|
|
return
|
|
}
|
|
if strings.Contains(strings.ToLower(err.Error()), "already") || strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
writeError(w, http.StatusConflict, "dns label is already reserved")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create enrollment grant")
|
|
return
|
|
}
|
|
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "enrollment_grant.create", "", "", mustJSON(map[string]any{
|
|
"grant_id": grant.ID,
|
|
"dns_label": label,
|
|
"expires_at": grant.ExpiresAt,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"grant": grant,
|
|
"enrollment_token": token,
|
|
})
|
|
}
|
|
|
|
func (a *App) handleUpdateDeviceDNS(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
if len(parts) != 4 {
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
var req dnsLabelRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
label := strings.ToLower(strings.TrimSpace(req.DNSLabel))
|
|
if !dnsLabelPattern.MatchString(label) {
|
|
writeError(w, http.StatusBadRequest, "dns_label must be a valid single DNS label")
|
|
return
|
|
}
|
|
device, found, err := a.store.UpdateDeviceDNSLabel(r.Context(), parts[2], label)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") || strings.Contains(strings.ToLower(err.Error()), "reserved") {
|
|
writeError(w, http.StatusConflict, "dns label is already in use")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update dns label")
|
|
return
|
|
}
|
|
if !found {
|
|
writeError(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
a.decorateDevice(&device)
|
|
principal, _ := principalFromContext(r.Context())
|
|
_, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "device.dns_update", device.ID, "", mustJSON(map[string]string{
|
|
"dns_label": label,
|
|
"request_id": requestID(r.Context()),
|
|
}))
|
|
writeJSON(w, http.StatusOK, device)
|
|
}
|
|
|
|
func (a *App) decorateDevices(devices []model.Device) {
|
|
for index := range devices {
|
|
a.decorateDevice(&devices[index])
|
|
}
|
|
}
|
|
|
|
func (a *App) decorateDevice(device *model.Device) {
|
|
if a.deviceDomain != "" && device.DNSLabel != "" {
|
|
device.DomainName = device.DNSLabel + "." + a.deviceDomain
|
|
}
|
|
}
|