// backend\admin_milestone.go package main import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "strings" "time" "github.com/jackc/pgx/v5" ) type milestoneSettingsResponse struct { Host string `json:"host"` Username string `json:"username"` PasswordConfigured bool `json:"passwordConfigured"` SkipTLSVerify bool `json:"skipTlsVerify"` TokenConfigured bool `json:"tokenConfigured"` TokenType string `json:"tokenType"` TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` TokenUpdatedAt *time.Time `json:"tokenUpdatedAt,omitempty"` ServerID string `json:"serverId"` ServerName string `json:"serverName"` ServerVersion string `json:"serverVersion"` ServerUpdatedAt *time.Time `json:"serverUpdatedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` } type updateMilestoneSettingsRequest struct { Host string `json:"host"` Username string `json:"username"` Password string `json:"password"` SkipTLSVerify bool `json:"skipTlsVerify"` } type milestoneCredentials struct { Host string Username string Password string SkipTLSVerify bool } type milestoneSyncResult struct { TokenReceived bool `json:"tokenReceived"` TokenType string `json:"tokenType"` TokenExpiresAt time.Time `json:"tokenExpiresAt"` RecordingServerID string `json:"recordingServerId"` RecordingServerName string `json:"recordingServerName"` RecordingServerVersion string `json:"recordingServerVersion"` HardwareCount int `json:"hardwareCount"` } type milestoneSyncEvent struct { Type string `json:"type"` Message string `json:"message,omitempty"` TokenType string `json:"tokenType,omitempty"` TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` RecordingServerID string `json:"recordingServerId,omitempty"` RecordingServerName string `json:"recordingServerName,omitempty"` RecordingServerVersion string `json:"recordingServerVersion,omitempty"` HardwareCount int `json:"hardwareCount,omitempty"` Settings *milestoneSettingsResponse `json:"settings,omitempty"` } type milestoneTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } type milestoneRecordingServersResponse struct { Array []milestoneRecordingServer `json:"array"` } type milestoneRecordingServer struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` Version string `json:"version"` ProductVersion string `json:"productVersion"` RecordingServerVersion string `json:"recordingServerVersion"` } type milestoneHardwareResponse struct { Array []milestoneHardware `json:"array"` } type milestoneHardware struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` DeviceName string `json:"deviceName"` Address string `json:"address"` Enabled *bool `json:"enabled"` Disabled *bool `json:"disabled"` Relations milestoneRelations `json:"relations"` } type milestoneRelations struct { Parent milestoneRelationRef `json:"parent"` Self milestoneRelationRef `json:"self"` } type milestoneRelationRef struct { Type string `json:"type"` ID string `json:"id"` } type milestoneHardwareDriverSettingsResponse struct { Array []milestoneHardwareDriverSettingsItem `json:"array"` } type milestoneHardwareDriverSettingsItem struct { DisplayName string `json:"displayName"` HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"` Relations milestoneRelations `json:"relations"` } type milestoneHardwareDriverSettings struct { DetectedModelName string `json:"detectedModelName"` ProductID string `json:"productID"` MacAddress string `json:"macAddress"` IPAddress string `json:"ipAddress"` Address string `json:"address"` HostName string `json:"hostName"` DeviceModelName string `json:"deviceModelName"` DeviceManufacturer string `json:"deviceManufacturer"` FirmwareVersion string `json:"firmwareVersion"` SerialNumber string `json:"serialNumber"` } type milestoneHardwareDevice struct { HardwareID string RecordingServerID string DisplayName string DeviceName string Manufacturer string MacAddress string IPAddress string DeviceModelName string SerialNumber string FirmwareVersion string Enabled bool } func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) { settings, err := s.getMilestoneSettings(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeJSON(w, http.StatusOK, map[string]any{ "settings": milestoneSettingsResponse{ Host: "", Username: "", PasswordConfigured: false, SkipTLSVerify: false, TokenConfigured: false, }, }) return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "settings": settings, }) } func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Request) { var input updateMilestoneSettingsRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } host := normalizeMilestoneHost(input.Host) username := strings.TrimSpace(input.Username) password := strings.TrimSpace(input.Password) skipTLSVerify := input.SkipTLSVerify if host == "" { writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") return } if username == "" { writeError(w, http.StatusBadRequest, "Benutzername ist erforderlich") return } currentSettings, err := s.getMilestoneSettings(r.Context()) passwordAlreadyConfigured := false if err == nil { passwordAlreadyConfigured = currentSettings.PasswordConfigured } else if !errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geprüft werden") return } if password == "" && !passwordAlreadyConfigured { writeError(w, http.StatusBadRequest, "Kennwort ist erforderlich") return } encryptionKey := s.milestoneEncryptionKey() if password != "" { _, err = s.db.Exec( r.Context(), ` INSERT INTO milestone_settings ( id, host, username, password_encrypted, skip_tls_verify, access_token_encrypted, token_type, token_expires_at, token_updated_at ) VALUES ( 1, $1, $2, pgp_sym_encrypt($3, $4), $5, NULL, '', NULL, NULL ) ON CONFLICT (id) DO UPDATE SET host = EXCLUDED.host, username = EXCLUDED.username, password_encrypted = EXCLUDED.password_encrypted, skip_tls_verify = EXCLUDED.skip_tls_verify, access_token_encrypted = NULL, token_type = '', token_expires_at = NULL, token_updated_at = NULL, updated_at = now() `, host, username, password, encryptionKey, skipTLSVerify, ) } else { _, err = s.db.Exec( r.Context(), ` UPDATE milestone_settings SET host = $1, username = $2, skip_tls_verify = $3, access_token_encrypted = NULL, token_type = '', token_expires_at = NULL, token_updated_at = NULL, updated_at = now() WHERE id = 1 `, host, username, skipTLSVerify, ) } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht gespeichert werden") return } if r.URL.Query().Get("sync") == "false" { settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "settings": settings, }) return } syncWarning := "" var syncResult milestoneSyncResult credentials, err := s.getMilestoneCredentials(r.Context()) if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" { syncResult, err = s.refreshMilestoneTokenAndServerID(r.Context(), credentials) if err != nil { log.Printf("Milestone sync after settings save failed: %v", err) syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Hardware konnten nicht vollständig abgerufen werden." } } else { syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden." } settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") return } response := map[string]any{ "settings": settings, } if syncWarning != "" { response["warning"] = syncWarning } else if syncResult.TokenReceived { response["sync"] = syncResult } writeJSON(w, http.StatusOK, response) } func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Request) { credentials, err := s.getMilestoneCredentials(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden") return } if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig") return } syncResult, err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials) if err != nil { log.Printf("Milestone token/server sync failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden") return } settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "settings": settings, "sync": syncResult, }) } func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http.Request) { credentials, err := s.getMilestoneCredentials(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden") return } if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig") return } w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") _, err = s.refreshMilestoneTokenAndServerIDWithProgress( r.Context(), credentials, func(event milestoneSyncEvent) { writeMilestoneSyncEvent(w, event) }, ) if err != nil { log.Printf("Milestone token/server sync stream failed: %v", err) writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "error", Message: "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden", }) return } settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "error", Message: "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden", }) return } writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "done", Settings: &settings, }) } func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) { if err := json.NewEncoder(w).Encode(event); err != nil { log.Printf("Milestone sync stream event could not be written: %v", err) return } if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } } func emitMilestoneSyncEvent( progress func(milestoneSyncEvent), event milestoneSyncEvent, ) { if progress != nil { progress(event) } } func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) { var settings milestoneSettingsResponse var updatedAt time.Time var tokenExpiresAt *time.Time var tokenUpdatedAt *time.Time var serverUpdatedAt *time.Time err := s.db.QueryRow( ctx, ` SELECT host, username, password_encrypted IS NOT NULL, skip_tls_verify, access_token_encrypted IS NOT NULL, token_type, token_expires_at, token_updated_at, COALESCE(server_id, ''), COALESCE(server_name, ''), COALESCE(server_version, ''), server_updated_at, updated_at FROM milestone_settings WHERE id = 1 LIMIT 1 `, ).Scan( &settings.Host, &settings.Username, &settings.PasswordConfigured, &settings.SkipTLSVerify, &settings.TokenConfigured, &settings.TokenType, &tokenExpiresAt, &tokenUpdatedAt, &settings.ServerID, &settings.ServerName, &settings.ServerVersion, &serverUpdatedAt, &updatedAt, ) if err != nil { return settings, err } settings.TokenExpiresAt = tokenExpiresAt settings.TokenUpdatedAt = tokenUpdatedAt settings.ServerUpdatedAt = serverUpdatedAt settings.UpdatedAt = &updatedAt return settings, nil } func (s *Server) refreshMilestoneTokenAndServerID( ctx context.Context, credentials milestoneCredentials, ) (milestoneSyncResult, error) { return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil) } func (s *Server) refreshMilestoneTokenAndServerIDWithProgress( ctx context.Context, credentials milestoneCredentials, progress func(milestoneSyncEvent), ) (milestoneSyncResult, error) { var syncResult milestoneSyncResult tokenResponse, err := s.requestMilestoneToken(ctx, credentials) if err != nil { return syncResult, err } accessToken := strings.TrimSpace(tokenResponse.AccessToken) if accessToken == "" { return syncResult, errors.New("milestone returned empty access token") } tokenType := strings.TrimSpace(tokenResponse.TokenType) if tokenType == "" { tokenType = "Bearer" } expiresIn := tokenResponse.ExpiresIn if expiresIn <= 0 { expiresIn = 3600 } tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) syncResult.TokenReceived = true syncResult.TokenType = tokenType syncResult.TokenExpiresAt = tokenExpiresAt emitMilestoneSyncEvent(progress, milestoneSyncEvent{ Type: "token", TokenType: tokenType, TokenExpiresAt: &tokenExpiresAt, }) serverID, serverName, serverVersion, err := s.requestMilestoneServerID( ctx, credentials.Host, tokenType, accessToken, credentials.SkipTLSVerify, ) if err != nil { return syncResult, err } syncResult.RecordingServerID = serverID syncResult.RecordingServerName = serverName syncResult.RecordingServerVersion = serverVersion emitMilestoneSyncEvent(progress, milestoneSyncEvent{ Type: "recordingServer", RecordingServerID: serverID, RecordingServerName: serverName, RecordingServerVersion: serverVersion, }) hardwareDevices, err := s.requestMilestoneHardwareDevices( ctx, credentials.Host, serverID, accessToken, credentials.SkipTLSVerify, ) if err != nil { return syncResult, err } tx, err := s.db.Begin(ctx) if err != nil { return syncResult, err } defer tx.Rollback(ctx) encryptionKey := s.milestoneEncryptionKey() _, err = tx.Exec( ctx, ` UPDATE milestone_settings SET access_token_encrypted = pgp_sym_encrypt($1, $2), token_type = $3, token_expires_at = $4, token_updated_at = now(), server_id = $5, server_name = $6, server_version = $7, server_updated_at = now(), updated_at = now() WHERE id = 1 `, accessToken, encryptionKey, tokenType, tokenExpiresAt, serverID, serverName, serverVersion, ) if err != nil { return syncResult, err } syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices) if err != nil { return syncResult, err } syncResult.HardwareCount = syncedHardwareCount if err := tx.Commit(ctx); err != nil { return syncResult, err } emitMilestoneSyncEvent(progress, milestoneSyncEvent{ Type: "hardware", HardwareCount: syncResult.HardwareCount, }) log.Printf( "Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d", serverID, syncedHardwareCount, ) return syncResult, nil } func (s *Server) requestMilestoneServerID( ctx context.Context, host string, tokenType string, accessToken string, skipTLSVerify bool, ) (string, string, string, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers" log.Printf( "Requesting Milestone recording server id: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) request, err := http.NewRequestWithContext( ctx, http.MethodGet, requestURL, nil, ) if err != nil { return "", "", "", err } request.Header.Set("Authorization", "Bearer "+accessToken) request.Header.Set("Accept", "application/json") client := milestoneHTTPClient(skipTLSVerify) response, err := client.Do(request) if err != nil { return "", "", "", err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return "", "", "", err } if response.StatusCode < 200 || response.StatusCode >= 300 { return "", "", "", fmt.Errorf( "milestone recording servers request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } var recordingServersResponse milestoneRecordingServersResponse if err := json.Unmarshal(body, &recordingServersResponse); err != nil { return "", "", "", fmt.Errorf( "milestone recording servers response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } if len(recordingServersResponse.Array) == 0 { return "", "", "", fmt.Errorf( "milestone returned no recording servers: body=%s", truncateMilestoneLogBody(body), ) } recordingServer := recordingServersResponse.Array[0] recordingServerID := strings.TrimSpace(recordingServer.ID) recordingServerName := strings.TrimSpace(recordingServer.DisplayName) if recordingServerName == "" { recordingServerName = strings.TrimSpace(recordingServer.Name) } recordingServerVersion := strings.TrimSpace(recordingServer.Version) if recordingServerVersion == "" { recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion) } if recordingServerVersion == "" { recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion) } if recordingServerID == "" { return "", "", "", fmt.Errorf( "milestone returned recording server without id: body=%s", truncateMilestoneLogBody(body), ) } log.Printf( "Milestone recording server received successfully: id=%s displayName=%q version=%q", recordingServerID, recordingServerName, recordingServerVersion, ) return recordingServerID, recordingServerName, recordingServerVersion, nil } func (s *Server) requestMilestoneHardwareDevices( ctx context.Context, host string, recordingServerID string, accessToken string, skipTLSVerify bool, ) ([]milestoneHardwareDevice, error) { hardwareItems, err := s.requestMilestoneHardware( ctx, host, recordingServerID, accessToken, skipTLSVerify, ) if err != nil { return nil, err } result := make([]milestoneHardwareDevice, 0, len(hardwareItems)) for _, hardware := range hardwareItems { hardwareID := strings.TrimSpace(hardware.ID) if hardwareID == "" { hardwareID = strings.TrimSpace(hardware.Relations.Self.ID) } if hardwareID == "" { log.Printf("Milestone hardware skipped: missing hardware id") continue } settings, err := s.requestMilestoneHardwareDriverSettings( ctx, host, hardwareID, accessToken, skipTLSVerify, ) if err != nil { return nil, err } displayName := strings.TrimSpace(hardware.DisplayName) if displayName == "" { displayName = strings.TrimSpace(hardware.Name) } model := strings.TrimSpace(settings.DeviceModelName) if model == "" { model = strings.TrimSpace(settings.DetectedModelName) } manufacturer := normalizeMilestoneManufacturer( settings.DeviceManufacturer, model, ) if manufacturer == "" { manufacturer = normalizeMilestoneManufacturer( settings.ProductID, model, ) } if manufacturer == "" { manufacturer = extractMilestoneManufacturerFromName(model) } deviceName := strings.TrimSpace(hardware.DeviceName) if deviceName == "" { deviceName = displayName } enabled := true if hardware.Enabled != nil { enabled = *hardware.Enabled } if hardware.Disabled != nil { enabled = !*hardware.Disabled } ipAddress := extractMilestoneHardwareIPAddress(hardware.Address) result = append(result, milestoneHardwareDevice{ HardwareID: hardwareID, RecordingServerID: recordingServerID, DisplayName: displayName, DeviceName: deviceName, Manufacturer: manufacturer, MacAddress: strings.TrimSpace(settings.MacAddress), IPAddress: ipAddress, DeviceModelName: model, SerialNumber: strings.TrimSpace(settings.SerialNumber), FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), Enabled: enabled, }) } log.Printf( "Milestone hardware loaded successfully: recordingServerId=%s count=%d", recordingServerID, len(result), ) return result, nil } func extractMilestoneHardwareIPAddress(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } parsedURL, err := url.Parse(value) if err == nil && parsedURL.Hostname() != "" { return parsedURL.Hostname() } value = strings.TrimPrefix(value, "http://") value = strings.TrimPrefix(value, "https://") value = strings.TrimRight(value, "/") if host, _, found := strings.Cut(value, ":"); found { return strings.TrimSpace(host) } return strings.TrimSpace(value) } func (s *Server) requestMilestoneHardware( ctx context.Context, host string, recordingServerID string, accessToken string, skipTLSVerify bool, ) ([]milestoneHardware, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers/" + url.PathEscape(recordingServerID) + "/hardware?disabled" log.Printf( "Requesting Milestone hardware: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) body, err := requestMilestoneJSON( ctx, requestURL, accessToken, skipTLSVerify, ) if err != nil { return nil, err } var hardwareResponse milestoneHardwareResponse if err := json.Unmarshal(body, &hardwareResponse); err != nil { return nil, fmt.Errorf( "milestone hardware response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } return hardwareResponse.Array, nil } func (s *Server) requestMilestoneHardwareDriverSettings( ctx context.Context, host string, hardwareID string, accessToken string, skipTLSVerify bool, ) (milestoneHardwareDriverSettings, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/hardware/" + url.PathEscape(hardwareID) + "/hardwareDriverSettings" log.Printf( "Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) body, err := requestMilestoneJSON( ctx, requestURL, accessToken, skipTLSVerify, ) if err != nil { return milestoneHardwareDriverSettings{}, err } var settingsResponse milestoneHardwareDriverSettingsResponse if err := json.Unmarshal(body, &settingsResponse); err != nil { return milestoneHardwareDriverSettings{}, fmt.Errorf( "milestone hardware driver settings response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } if len(settingsResponse.Array) == 0 { return milestoneHardwareDriverSettings{}, fmt.Errorf( "milestone returned no hardware driver settings: hardwareId=%s body=%s", hardwareID, truncateMilestoneLogBody(body), ) } return settingsResponse.Array[0].HardwareDriverSettings, nil } func requestMilestoneJSON( ctx context.Context, requestURL string, accessToken string, skipTLSVerify bool, ) ([]byte, error) { request, err := http.NewRequestWithContext( ctx, http.MethodGet, requestURL, nil, ) if err != nil { return nil, err } request.Header.Set("Authorization", "Bearer "+accessToken) request.Header.Set("Accept", "application/json") client := milestoneHTTPClient(skipTLSVerify) response, err := client.Do(request) if err != nil { return nil, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf( "milestone request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } return body, nil } func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) { var credentials milestoneCredentials err := s.db.QueryRow( ctx, ` SELECT host, username, COALESCE(pgp_sym_decrypt(password_encrypted, $1), ''), skip_tls_verify FROM milestone_settings WHERE id = 1 LIMIT 1 `, s.milestoneEncryptionKey(), ).Scan( &credentials.Host, &credentials.Username, &credentials.Password, &credentials.SkipTLSVerify, ) return credentials, err } func milestoneHTTPClient(skipTLSVerify bool) *http.Client { client := &http.Client{ Timeout: 15 * time.Second, } if skipTLSVerify { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, } } return client } func (s *Server) requestMilestoneToken(ctx context.Context, credentials milestoneCredentials) (milestoneTokenResponse, error) { var tokenResponse milestoneTokenResponse form := url.Values{} form.Set("grant_type", "password") form.Set("username", credentials.Username) form.Set("password", credentials.Password) form.Set("client_id", "GrantValidatorClient") requestURL := strings.TrimRight(credentials.Host, "/") + "/API/IDP/connect/token" log.Printf("Requesting Milestone token: url=%s username=%s", requestURL, credentials.Username) request, err := http.NewRequestWithContext( ctx, http.MethodPost, requestURL, strings.NewReader(form.Encode()), ) if err != nil { return tokenResponse, err } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := milestoneHTTPClient(credentials.SkipTLSVerify) response, err := client.Do(request) if err != nil { return tokenResponse, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return tokenResponse, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return tokenResponse, fmt.Errorf( "milestone token request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } if err := json.Unmarshal(body, &tokenResponse); err != nil { return tokenResponse, err } log.Printf( "Milestone token received successfully: url=%s tokenType=%s expiresIn=%d hasAccessToken=%t", requestURL, tokenResponse.TokenType, tokenResponse.ExpiresIn, strings.TrimSpace(tokenResponse.AccessToken) != "", ) return tokenResponse, nil } func normalizeMilestoneHost(value string) string { value = strings.TrimSpace(value) value = strings.TrimRight(value, "/") if value == "" { return "" } if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { return value } return "https://" + value } func truncateMilestoneLogBody(body []byte) string { value := strings.TrimSpace(string(body)) if value == "" { return "" } const maxLength = 1000 if len(value) > maxLength { return value[:maxLength] + "..." } return value } func (s *Server) milestoneEncryptionKey() string { value := strings.TrimSpace(os.Getenv("MILESTONE_SETTINGS_KEY")) if value != "" { return value } return string(s.jwtSecret) }