fix: support pmg connection tests (#551)
This commit is contained in:
parent
b79183ac76
commit
5f5d746caf
2 changed files with 245 additions and 3 deletions
|
|
@ -53,6 +53,7 @@ type ConfigHandlers struct {
|
||||||
wsHub *websocket.Hub
|
wsHub *websocket.Hub
|
||||||
guestMetadataHandler *GuestMetadataHandler
|
guestMetadataHandler *GuestMetadataHandler
|
||||||
setupCodes map[string]*SetupCode // Map of code hash -> setup code details
|
setupCodes map[string]*SetupCode // Map of code hash -> setup code details
|
||||||
|
recentSetupTokens map[string]time.Time // Temporary map for recently used setup tokens (grace period)
|
||||||
codeMutex sync.RWMutex // Mutex for thread-safe code access
|
codeMutex sync.RWMutex // Mutex for thread-safe code access
|
||||||
clusterDetectMutex sync.Mutex
|
clusterDetectMutex sync.Mutex
|
||||||
lastClusterDetection map[string]time.Time
|
lastClusterDetection map[string]time.Time
|
||||||
|
|
@ -71,6 +72,7 @@ func NewConfigHandlers(cfg *config.Config, monitor *monitoring.Monitor, reloadFu
|
||||||
wsHub: wsHub,
|
wsHub: wsHub,
|
||||||
guestMetadataHandler: guestMetadataHandler,
|
guestMetadataHandler: guestMetadataHandler,
|
||||||
setupCodes: make(map[string]*SetupCode),
|
setupCodes: make(map[string]*SetupCode),
|
||||||
|
recentSetupTokens: make(map[string]time.Time),
|
||||||
lastClusterDetection: make(map[string]time.Time),
|
lastClusterDetection: make(map[string]time.Time),
|
||||||
recentAutoRegistered: make(map[string]time.Time),
|
recentAutoRegistered: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
|
@ -100,10 +102,41 @@ func (h *ConfigHandlers) cleanupExpiredCodes() {
|
||||||
log.Debug().Bool("was_used", code.Used).Msg("Cleaned up setup code")
|
log.Debug().Bool("was_used", code.Used).Msg("Cleaned up setup code")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for tokenHash, expiresAt := range h.recentSetupTokens {
|
||||||
|
if now.After(expiresAt) {
|
||||||
|
delete(h.recentSetupTokens, tokenHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
h.codeMutex.Unlock()
|
h.codeMutex.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateSetupToken checks whether the provided temporary setup token is still valid.
|
||||||
|
func (h *ConfigHandlers) ValidateSetupToken(token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenHash := internalauth.HashAPIToken(token)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
h.codeMutex.RLock()
|
||||||
|
defer h.codeMutex.RUnlock()
|
||||||
|
|
||||||
|
if code, exists := h.setupCodes[tokenHash]; exists {
|
||||||
|
// Allow tokens while they are valid or within a short grace period after use.
|
||||||
|
if now.Before(code.ExpiresAt.Add(2 * time.Minute)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expiresAt, ok := h.recentSetupTokens[tokenHash]; ok && now.Before(expiresAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ConfigHandlers) markAutoRegistered(nodeType, nodeName string) {
|
func (h *ConfigHandlers) markAutoRegistered(nodeType, nodeName string) {
|
||||||
if nodeType == "" || nodeName == "" {
|
if nodeType == "" || nodeName == "" {
|
||||||
return
|
return
|
||||||
|
|
@ -1240,7 +1273,7 @@ func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Req
|
||||||
req.Name = host
|
req.Name = host
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Type != "pve" && req.Type != "pbs" {
|
if req.Type != "pve" && req.Type != "pbs" && req.Type != "pmg" {
|
||||||
http.Error(w, "Invalid node type", http.StatusBadRequest)
|
http.Error(w, "Invalid node type", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1316,7 +1349,7 @@ func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Req
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
} else {
|
} else if req.Type == "pbs" {
|
||||||
// Ensure host has protocol for PBS
|
// Ensure host has protocol for PBS
|
||||||
host := req.Host
|
host := req.Host
|
||||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||||
|
|
@ -1395,6 +1428,81 @@ func (h *ConfigHandlers) HandleTestConnection(w http.ResponseWriter, r *http.Req
|
||||||
"datastoreCount": len(datastores),
|
"datastoreCount": len(datastores),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
} else {
|
||||||
|
host := req.Host
|
||||||
|
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||||
|
host = "https://" + host
|
||||||
|
}
|
||||||
|
protocolEnd := 0
|
||||||
|
if strings.HasPrefix(host, "https://") {
|
||||||
|
protocolEnd = 8
|
||||||
|
} else if strings.HasPrefix(host, "http://") {
|
||||||
|
protocolEnd = 7
|
||||||
|
}
|
||||||
|
if protocolEnd > 0 && !strings.Contains(host[protocolEnd:], ":") {
|
||||||
|
host = host + ":8006"
|
||||||
|
}
|
||||||
|
|
||||||
|
clientConfig := config.CreatePMGConfigFromFields(host, req.User, req.Password, req.TokenName, req.TokenValue, req.Fingerprint, req.VerifySSL)
|
||||||
|
|
||||||
|
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
|
||||||
|
if clientConfig.User != "" && !strings.Contains(clientConfig.User, "@") {
|
||||||
|
clientConfig.User = clientConfig.User + "@pmg"
|
||||||
|
}
|
||||||
|
} else if req.TokenName != "" && req.TokenValue != "" {
|
||||||
|
if user != "" {
|
||||||
|
normalizedUser := user
|
||||||
|
if !strings.Contains(normalizedUser, "@") {
|
||||||
|
normalizedUser = normalizedUser + "@pmg"
|
||||||
|
}
|
||||||
|
clientConfig.User = normalizedUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tempClient, err := pmg.NewClient(clientConfig)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, sanitizeErrorMessage(err, "create_client"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
version, err := tempClient.GetVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, sanitizeErrorMessage(err, "connection"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionLabel := ""
|
||||||
|
if version != nil && strings.TrimSpace(version.Version) != "" {
|
||||||
|
versionLabel = strings.TrimSpace(version.Version)
|
||||||
|
if strings.TrimSpace(version.Release) != "" {
|
||||||
|
versionLabel = versionLabel + "-" + strings.TrimSpace(version.Release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "Connected to PMG instance"
|
||||||
|
if versionLabel != "" {
|
||||||
|
message = fmt.Sprintf("Connected to PMG instance (version %s)", versionLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if version != nil {
|
||||||
|
if version.Version != "" {
|
||||||
|
response["version"] = strings.TrimSpace(version.Version)
|
||||||
|
}
|
||||||
|
if version.Release != "" {
|
||||||
|
response["release"] = strings.TrimSpace(version.Release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(response)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
@ -2272,6 +2380,63 @@ func (h *ConfigHandlers) HandleTestNode(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if nodeType == "pmg" && index < len(h.config.PMGInstances) {
|
||||||
|
pmgInstance := h.config.PMGInstances[index]
|
||||||
|
|
||||||
|
clientConfig := config.CreatePMGConfig(&pmgInstance)
|
||||||
|
if pmgInstance.Password != "" && pmgInstance.TokenName == "" && pmgInstance.TokenValue == "" {
|
||||||
|
if clientConfig.User != "" && !strings.Contains(clientConfig.User, "@") {
|
||||||
|
clientConfig.User = clientConfig.User + "@pmg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := pmg.NewClient(clientConfig)
|
||||||
|
if err != nil {
|
||||||
|
testResult = map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"message": sanitizeErrorMessage(err, "create_client"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startTime := time.Now()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if version, err := client.GetVersion(ctx); err != nil {
|
||||||
|
testResult = map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"message": sanitizeErrorMessage(err, "connection"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
latency := time.Since(startTime).Milliseconds()
|
||||||
|
versionLabel := ""
|
||||||
|
if version != nil && strings.TrimSpace(version.Version) != "" {
|
||||||
|
versionLabel = strings.TrimSpace(version.Version)
|
||||||
|
if strings.TrimSpace(version.Release) != "" {
|
||||||
|
versionLabel = versionLabel + "-" + strings.TrimSpace(version.Release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := "Connected to PMG instance"
|
||||||
|
if versionLabel != "" {
|
||||||
|
message = fmt.Sprintf("Connected to PMG instance (version %s)", versionLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
testResult = map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"message": message,
|
||||||
|
"latency": latency,
|
||||||
|
}
|
||||||
|
|
||||||
|
if version != nil {
|
||||||
|
if version.Version != "" {
|
||||||
|
testResult["version"] = strings.TrimSpace(version.Version)
|
||||||
|
}
|
||||||
|
if version.Release != "" {
|
||||||
|
testResult["release"] = strings.TrimSpace(version.Release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
testResult = map[string]interface{}{
|
testResult = map[string]interface{}{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|
@ -4447,6 +4612,13 @@ func (h *ConfigHandlers) HandleAutoRegister(w http.ResponseWriter, r *http.Reque
|
||||||
// what's entered in the UI and what's provided in the setup script URL
|
// what's entered in the UI and what's provided in the setup script URL
|
||||||
if setupCode.NodeType == req.Type {
|
if setupCode.NodeType == req.Type {
|
||||||
setupCode.Used = true // Mark as used immediately
|
setupCode.Used = true // Mark as used immediately
|
||||||
|
// Allow the token to be reused for a brief grace period so the setup
|
||||||
|
// script can complete follow-up actions (temperature verification, etc).
|
||||||
|
graceExpiry := time.Now().Add(5 * time.Minute)
|
||||||
|
if setupCode.ExpiresAt.After(graceExpiry) {
|
||||||
|
graceExpiry = setupCode.ExpiresAt
|
||||||
|
}
|
||||||
|
h.recentSetupTokens[codeHash] = graceExpiry
|
||||||
authenticated = true
|
authenticated = true
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("type", req.Type).
|
Str("type", req.Type).
|
||||||
|
|
|
||||||
|
|
@ -835,7 +835,7 @@ func (r *Router) setupRoutes() {
|
||||||
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings)
|
r.systemSettingsHandler = NewSystemSettingsHandler(r.config, r.persistence, r.wsHub, r.monitor, r.reloadSystemSettings)
|
||||||
r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings)
|
r.mux.HandleFunc("/api/system/settings", r.systemSettingsHandler.HandleGetSystemSettings)
|
||||||
r.mux.HandleFunc("/api/system/settings/update", r.systemSettingsHandler.HandleUpdateSystemSettings)
|
r.mux.HandleFunc("/api/system/settings/update", r.systemSettingsHandler.HandleUpdateSystemSettings)
|
||||||
r.mux.HandleFunc("/api/system/verify-temperature-ssh", RequireAuth(r.config, r.configHandlers.HandleVerifyTemperatureSSH))
|
r.mux.HandleFunc("/api/system/verify-temperature-ssh", r.handleVerifyTemperatureSSH)
|
||||||
// Old API token endpoints removed - now using /api/security/regenerate-token
|
// Old API token endpoints removed - now using /api/security/regenerate-token
|
||||||
|
|
||||||
// Docker agent download endpoints
|
// Docker agent download endpoints
|
||||||
|
|
@ -857,6 +857,69 @@ func (r *Router) setupRoutes() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleVerifyTemperatureSSH(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if r.configHandlers == nil {
|
||||||
|
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if token := extractSetupToken(req); token != "" {
|
||||||
|
if r.configHandlers.ValidateSetupToken(token) {
|
||||||
|
r.configHandlers.HandleVerifyTemperatureSSH(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if CheckAuth(r.config, w, req) {
|
||||||
|
r.configHandlers.HandleVerifyTemperatureSSH(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Warn().
|
||||||
|
Str("ip", req.RemoteAddr).
|
||||||
|
Str("path", req.URL.Path).
|
||||||
|
Str("method", req.Method).
|
||||||
|
Msg("Unauthorized access attempt (verify-temperature-ssh)")
|
||||||
|
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/api/") || strings.Contains(req.Header.Get("Accept"), "application/json") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":"Authentication required"}`))
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSetupToken(req *http.Request) string {
|
||||||
|
if token := strings.TrimSpace(req.Header.Get("X-Setup-Token")); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if token := extractBearerToken(req.Header.Get("Authorization")); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if token := strings.TrimSpace(req.URL.Query().Get("auth_token")); token != "" {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBearerToken(header string) string {
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(header)
|
||||||
|
if len(trimmed) < 7 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.ToLower(trimmed), "bearer ") {
|
||||||
|
return strings.TrimSpace(trimmed[7:])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Handler returns the router wrapped with middleware.
|
// Handler returns the router wrapped with middleware.
|
||||||
func (r *Router) Handler() http.Handler {
|
func (r *Router) Handler() http.Handler {
|
||||||
if r.wrapped != nil {
|
if r.wrapped != nil {
|
||||||
|
|
@ -1044,6 +1107,13 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
isPublic = true
|
isPublic = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow temperature verification endpoint when a setup token is provided
|
||||||
|
if normalizedPath == "/api/system/verify-temperature-ssh" && r.configHandlers != nil {
|
||||||
|
if token := extractSetupToken(req); token != "" && r.configHandlers.ValidateSetupToken(token) {
|
||||||
|
isPublic = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-register endpoint needs to be public (validates tokens internally)
|
// Auto-register endpoint needs to be public (validates tokens internally)
|
||||||
// BUT the tokens must be generated by authenticated users via setup-script-url
|
// BUT the tokens must be generated by authenticated users via setup-script-url
|
||||||
if normalizedPath == "/api/auto-register" {
|
if normalizedPath == "/api/auto-register" {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue