teg/backend/admin_milestone.go
2026-05-22 14:11:18 +02:00

1156 lines
28 KiB
Go

// 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 "<empty>"
}
const maxLength = 1000
if len(value) > maxLength {
return value[:maxLength] + "...<truncated>"
}
return value
}
func (s *Server) milestoneEncryptionKey() string {
value := strings.TrimSpace(os.Getenv("MILESTONE_SETTINGS_KEY"))
if value != "" {
return value
}
return string(s.jwtSecret)
}