рабочий вариант, но скороть 10 МБит
build / backend (push) Has been cancelled
build / node-agent (push) Has been cancelled
build / worker (push) Has been cancelled

This commit is contained in:
2026-05-22 21:46:49 +03:00
parent 469fa0e860
commit 20d361a886
280 changed files with 954890 additions and 18524 deletions
+3
View File
@@ -28,6 +28,9 @@ func (m *Module) RegisterRoutes(router chi.Router) {
r.Post("/bootstrap-owner", m.handleBootstrapOwner)
})
router.Route("/auth", func(r chi.Router) {
r.Get("/ui/login", m.handleLoginHTMLPage)
r.Post("/ui/login", m.handleLoginHTML)
r.Get("/ui/vpn-download", m.handleVPNDownloadHTML)
r.Post("/login", m.handleLogin)
r.Post("/refresh", m.handleRefresh)
r.Post("/sessions/revoke", m.handleRevokeAuthSession)
@@ -0,0 +1,226 @@
package auth
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/example/remote-access-platform/backend/internal/platform/authority"
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
)
type loginHTMLPage struct {
Email string
Error string
}
type vpnDownloadHTMLPage struct {
ActorUserID string
AndroidAPK string
AndroidAPI string
BuildInfo string
Version string
VersionCode string
BuildType string
Size string
SHA256 string
PublishedAt string
}
func (m *Module) handleLoginHTMLPage(w http.ResponseWriter, r *http.Request) {
m.renderLoginHTML(w, loginHTMLPage{
Email: strings.TrimSpace(r.URL.Query().Get("email")),
Error: strings.TrimSpace(r.URL.Query().Get("error")),
})
}
func (m *Module) handleLoginHTML(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
m.renderLoginHTML(w, loginHTMLPage{Error: "Не удалось прочитать форму входа."})
return
}
email := strings.TrimSpace(r.FormValue("email"))
result, err := m.service.Login(r.Context(), LoginCommand{
Email: email,
Password: r.FormValue("password"),
DeviceFingerprint: "web-control-html5",
DeviceLabel: "HTML5 control panel",
TrustDevice: true,
})
if err != nil {
_, message := m.service.MapError(err)
m.renderLoginHTML(w, loginHTMLPage{Email: email, Error: message})
return
}
target := "/api/v1/auth/ui/vpn-download?actor_user_id=" + template.URLQueryEscaper(result.User.ID)
switch strings.TrimSpace(result.User.PlatformRole) {
case authority.PlatformRoleAdmin, authority.PlatformRoleRecoveryAdmin:
target = "/api/v1/ui/admin?actor_user_id=" + template.URLQueryEscaper(result.User.ID)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (m *Module) handleVPNDownloadHTML(w http.ResponseWriter, r *http.Request) {
page := vpnDownloadHTMLPage{
ActorUserID: strings.TrimSpace(r.URL.Query().Get("actor_user_id")),
AndroidAPK: "/downloads/rap-android-vpn-latest-debug.apk",
AndroidAPI: "/api/v1/downloads/rap-android-vpn-latest-debug.apk",
BuildInfo: "/downloads/rap-android-vpn-build.json",
}
page.loadAndroidBuildInfo()
page.applyAndroidDownloadPaths()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := vpnDownloadHTMLTemplate.Execute(w, page); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "render vpn download page")
}
}
func (m *Module) renderLoginHTML(w http.ResponseWriter, page loginHTMLPage) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := loginHTMLTemplate.Execute(w, page); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "render login page")
}
}
func (p *vpnDownloadHTMLPage) loadAndroidBuildInfo() {
var manifest struct {
Version struct {
Name string `json:"name"`
Code string `json:"code"`
} `json:"version"`
Build struct {
Type string `json:"type"`
} `json:"build"`
Published struct {
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"size_bytes"`
TimestampUTC string `json:"timestamp_utc"`
} `json:"published"`
}
releaseDir := strings.TrimSpace(os.Getenv("RAP_RELEASE_DIR"))
if releaseDir == "" {
releaseDir = "/tmp/rap-release"
}
data, err := os.ReadFile(filepath.Join(releaseDir, "rap-android-vpn-build.json"))
if err != nil || json.Unmarshal(data, &manifest) != nil {
return
}
p.Version = strings.TrimSpace(manifest.Version.Name)
p.VersionCode = strings.TrimSpace(manifest.Version.Code)
p.BuildType = strings.TrimSpace(manifest.Build.Type)
p.SHA256 = strings.TrimSpace(manifest.Published.SHA256)
p.PublishedAt = strings.TrimSpace(manifest.Published.TimestampUTC)
if manifest.Published.SizeBytes > 0 {
p.Size = fmt.Sprintf("%.1f MB", float64(manifest.Published.SizeBytes)/1024/1024)
}
}
func (p *vpnDownloadHTMLPage) applyAndroidDownloadPaths() {
version := strings.TrimSpace(p.Version)
buildType := strings.TrimSpace(p.BuildType)
if version == "" || buildType == "" {
return
}
fileName := "rap-android-vpn-" + version + "-" + buildType + ".apk"
p.AndroidAPK = "/downloads/releases/" + version + "/" + fileName
p.AndroidAPI = "/api/v1/downloads/releases/" + version + "/" + fileName
}
var loginHTMLTemplate = template.Must(template.New("login-html").Parse(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход в панель фермы</title>
<style>
:root{color-scheme:light;--bg:#eef2ec;--panel:#fffdf7;--ink:#102018;--muted:#66716a;--line:#dce2d8;--accent:#1f6b4c;--danger:#a9443b;--soft:#f6f4ea}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,Segoe UI,Arial,sans-serif;font-size:15px;line-height:1.45}
main{min-height:100vh;display:grid;place-items:center;padding:32px}
.shell{width:min(460px,100%)}
h1{margin:0 0 8px;font-size:26px;letter-spacing:0}.lead{margin:0 0 22px;color:var(--muted)}
form,.note{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:18px;box-shadow:0 14px 34px rgba(20,35,25,.08)}
label{display:block;margin:0 0 14px;font-weight:700;font-size:13px;color:#3d4a42}
input{width:100%;margin-top:7px;border:1px solid var(--line);border-radius:6px;background:#fff;color:var(--ink);padding:12px 13px;font:inherit}
input:focus{outline:2px solid rgba(31,107,76,.22);border-color:var(--accent)}
button{width:100%;border:0;border-radius:6px;background:var(--accent);color:white;padding:12px 14px;font:700 15px Inter,Segoe UI,Arial,sans-serif;cursor:pointer}
.error{margin:0 0 14px;padding:10px 12px;border-radius:6px;background:#fff0ed;color:var(--danger);font-weight:700}
.note{margin-top:10px;background:var(--soft);color:var(--muted);font-size:13px;box-shadow:none}
</style>
</head>
<body>
<main>
<section class="shell" aria-labelledby="title">
<h1 id="title">Вход в панель фермы</h1>
<p class="lead">Панель открывает администрирование только для роли администратора. Обычный пользователь получит страницу установки VPN.</p>
<form method="post" action="/api/v1/auth/ui/login">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<label>Email или логин
<input name="email" value="{{.Email}}" autocomplete="username" required autofocus>
</label>
<label>Пароль
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Войти</button>
</form>
<p class="note">Форма серверная HTML5: без клиентской авторизации и без тяжелой логики в браузере.</p>
</section>
</main>
</body>
</html>`))
var vpnDownloadHTMLTemplate = template.Must(template.New("vpn-download-html").Parse(`<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Установка VPN</title>
<style>
:root{color-scheme:light;--bg:#eef2ec;--panel:#fffdf7;--ink:#102018;--muted:#66716a;--line:#dce2d8;--accent:#1f6b4c;--soft:#f6f4ea}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,Segoe UI,Arial,sans-serif;font-size:15px;line-height:1.45}
main{min-height:100vh;display:grid;place-items:center;padding:32px}.shell{width:min(620px,100%)}
h1{margin:0 0 8px;font-size:26px;letter-spacing:0}.lead{margin:0 0 22px;color:var(--muted)}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:18px;box-shadow:0 14px 34px rgba(20,35,25,.08)}
.row{display:flex;gap:12px;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:7px;padding:14px;margin-top:12px;background:white}
.title{font-weight:800}.meta{color:var(--muted);font-size:13px;margin-top:2px}
a.button{display:inline-flex;align-items:center;justify-content:center;min-width:130px;border-radius:6px;background:var(--accent);color:white;text-decoration:none;padding:11px 13px;font-weight:800}
a.secondary{background:white;color:var(--accent);border:1px solid var(--accent)}
a.link{color:var(--accent);font-weight:700}.note{margin-top:12px;background:var(--soft);border:1px solid var(--line);border-radius:7px;padding:12px;color:var(--muted);font-size:13px}
.facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:12px}
.fact{border:1px solid var(--line);border-radius:7px;background:white;padding:10px;min-width:0}
.fact b{display:block;font-size:12px;color:#3d4a42}.fact span{display:block;margin-top:3px;overflow-wrap:anywhere;font-weight:800}
.actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}
@media(max-width:560px){.row{display:block}.actions{display:grid;margin-top:12px}a.button{width:100%}.facts{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<section class="shell" aria-labelledby="title">
<h1 id="title">Установка VPN</h1>
<p class="lead">Для вашей роли доступен установщик Android VPN. Узел Android после установки работает с фермой по протоколу фермы.</p>
<div class="panel">
<div class="row">
<div><div class="title">Android VPN{{if .Version}} {{.Version}}{{end}}</div><div class="meta">актуальный APK установщика{{if .BuildType}} · {{.BuildType}}{{end}}</div></div>
<div class="actions">
<a class="button" href="{{.AndroidAPK}}" type="application/vnd.android.package-archive">Скачать APK</a>
<a class="button secondary" href="{{.AndroidAPI}}" type="application/vnd.android.package-archive">Резерв</a>
</div>
</div>
<div class="facts">
<div class="fact"><b>Версия</b><span>{{if .Version}}{{.Version}}{{else}}не указана{{end}}</span></div>
<div class="fact"><b>Код</b><span>{{if .VersionCode}}{{.VersionCode}}{{else}}не указан{{end}}</span></div>
<div class="fact"><b>Размер</b><span>{{if .Size}}{{.Size}}{{else}}не указан{{end}}</span></div>
<div class="fact"><b>SHA256</b><span>{{if .SHA256}}{{.SHA256}}{{else}}не указан{{end}}</span></div>
<div class="fact"><b>Публикация</b><span>{{if .PublishedAt}}{{.PublishedAt}}{{else}}не указана{{end}}</span></div>
<div class="fact"><b>Путь</b><span>{{.AndroidAPK}}</span></div>
</div>
<div class="note">Данные сборки: <a class="link" href="{{.BuildInfo}}">manifest JSON</a>{{if .ActorUserID}}. Пользователь: {{.ActorUserID}}{{end}}</div>
</div>
</section>
</main>
</body>
</html>`))
+1 -1
View File
@@ -453,7 +453,7 @@ func (s *Service) installationStatusFromRecord(record *InstallationAuthorityStat
if record == nil {
record = &InstallationAuthorityState{AuthorityState: "unbootstrapped"}
}
mode := authority.ModeLegacy
mode := authority.ModeCompat
strict := false
rootFingerprint := ""
insecureAllowed := false
+340 -226
View File
@@ -53,10 +53,8 @@ const (
FabricServiceClassRemoteWorkspace = "remote_workspace"
FabricServiceClassFileTransfer = "file_transfer"
FabricServiceClassVideo = "video"
FabricServiceClassPlatformAdmin = "platform_admin"
FabricServiceClassClusterAdmin = "cluster_admin"
FabricServiceClassOrganization = "organization_portal"
FabricServiceClassUserPortal = "user_portal"
FabricServiceClassAdminIngress = "admin-ingress"
FabricServiceClassPublicIngress = "public-ingress"
FabricChannelControl = "control"
FabricChannelInteractive = "interactive"
@@ -66,27 +64,21 @@ const (
)
var allowedNodeRoles = map[string]struct{}{
"public-ingress": {},
"admin-ingress": {},
"global-admin-runtime": {},
"cluster-admin-runtime": {},
"organization-portal-runtime": {},
"user-portal-runtime": {},
"identity-runtime": {},
"policy-authority": {},
"audit-sink": {},
"entry-node": {},
"relay-node": {},
"core-mesh": {},
"rdp-worker": {},
"vnc-worker": {},
"vpn-exit": {},
"vpn-connector": {},
"vpn-client": {},
"ipv4-egress": {},
"file-storage-cache": {},
"update-cache": {},
"video-relay": {},
"public-ingress": {},
"admin-ingress": {},
"entry-node": {},
"relay-node": {},
"core-mesh": {},
"rdp-worker": {},
"vnc-worker": {},
"vpn-exit": {},
"vpn-connector": {},
"vpn-client": {},
"ipv4-ingress": {},
"ipv4-egress": {},
"file-storage-cache": {},
"update-cache": {},
"video-relay": {},
}
type Cluster struct {
@@ -163,8 +155,7 @@ type DockerInstallProfileRequest struct {
type DockerInstallProfile struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
BackendURL string `json:"backend_url"`
ControlPlaneEndpoints []string `json:"control_plane_endpoints,omitempty"`
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key,omitempty"`
ArtifactEndpoints []string `json:"artifact_endpoints,omitempty"`
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"`
DockerImageArtifact *DockerArtifact `json:"docker_image_artifact,omitempty"`
@@ -179,12 +170,12 @@ type DockerInstallProfile struct {
Replace bool `json:"replace"`
DockerVPNGatewayEnabled bool `json:"docker_vpn_gateway_enabled"`
WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"`
MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"`
FabricRuntimeEnabled bool `json:"fabric_runtime_enabled"`
MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"`
MeshListenAddr string `json:"mesh_listen_addr,omitempty"`
MeshListenPortMode string `json:"mesh_listen_port_mode,omitempty"`
MeshListenAutoPortStart int `json:"mesh_listen_auto_port_start,omitempty"`
MeshListenAutoPortEnd int `json:"mesh_listen_auto_port_end,omitempty"`
FabricListenAddr string `json:"fabric_listen_addr,omitempty"`
FabricListenPortMode string `json:"fabric_listen_port_mode,omitempty"`
FabricListenAutoPortStart int `json:"fabric_listen_auto_port_start,omitempty"`
FabricListenAutoPortEnd int `json:"fabric_listen_auto_port_end,omitempty"`
MeshAdvertiseEndpoint string `json:"mesh_advertise_endpoint,omitempty"`
MeshAdvertiseEndpointsJSON json.RawMessage `json:"mesh_advertise_endpoints_json,omitempty"`
MeshAdvertiseTransport string `json:"mesh_advertise_transport,omitempty"`
@@ -201,8 +192,7 @@ type DockerInstallProfile struct {
type WindowsInstallProfile struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
BackendURL string `json:"backend_url"`
ControlPlaneEndpoints []string `json:"control_plane_endpoints,omitempty"`
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key,omitempty"`
ArtifactEndpoints []string `json:"artifact_endpoints,omitempty"`
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"`
NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact,omitempty"`
@@ -212,12 +202,12 @@ type WindowsInstallProfile struct {
InstallDir string `json:"install_dir"`
StartupMode string `json:"startup_mode"`
WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"`
MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"`
FabricRuntimeEnabled bool `json:"fabric_runtime_enabled"`
MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"`
MeshListenAddr string `json:"mesh_listen_addr,omitempty"`
MeshListenPortMode string `json:"mesh_listen_port_mode,omitempty"`
MeshListenAutoPortStart int `json:"mesh_listen_auto_port_start,omitempty"`
MeshListenAutoPortEnd int `json:"mesh_listen_auto_port_end,omitempty"`
FabricListenAddr string `json:"fabric_listen_addr,omitempty"`
FabricListenPortMode string `json:"fabric_listen_port_mode,omitempty"`
FabricListenAutoPortStart int `json:"fabric_listen_auto_port_start,omitempty"`
FabricListenAutoPortEnd int `json:"fabric_listen_auto_port_end,omitempty"`
MeshAdvertiseEndpoint string `json:"mesh_advertise_endpoint,omitempty"`
MeshAdvertiseEndpointsJSON json.RawMessage `json:"mesh_advertise_endpoints_json,omitempty"`
MeshAdvertiseTransport string `json:"mesh_advertise_transport,omitempty"`
@@ -234,8 +224,7 @@ type WindowsInstallProfile struct {
type LinuxInstallProfile struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
BackendURL string `json:"backend_url"`
ControlPlaneEndpoints []string `json:"control_plane_endpoints,omitempty"`
ClusterAuthorityPublicKey string `json:"cluster_authority_public_key,omitempty"`
ArtifactEndpoints []string `json:"artifact_endpoints,omitempty"`
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"`
NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact,omitempty"`
@@ -245,12 +234,12 @@ type LinuxInstallProfile struct {
InstallDir string `json:"install_dir"`
StartupMode string `json:"startup_mode"`
WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"`
MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"`
FabricRuntimeEnabled bool `json:"fabric_runtime_enabled"`
MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"`
MeshListenAddr string `json:"mesh_listen_addr,omitempty"`
MeshListenPortMode string `json:"mesh_listen_port_mode,omitempty"`
MeshListenAutoPortStart int `json:"mesh_listen_auto_port_start,omitempty"`
MeshListenAutoPortEnd int `json:"mesh_listen_auto_port_end,omitempty"`
FabricListenAddr string `json:"fabric_listen_addr,omitempty"`
FabricListenPortMode string `json:"fabric_listen_port_mode,omitempty"`
FabricListenAutoPortStart int `json:"fabric_listen_auto_port_start,omitempty"`
FabricListenAutoPortEnd int `json:"fabric_listen_auto_port_end,omitempty"`
MeshAdvertiseEndpoint string `json:"mesh_advertise_endpoint,omitempty"`
MeshAdvertiseEndpointsJSON json.RawMessage `json:"mesh_advertise_endpoints_json,omitempty"`
MeshAdvertiseTransport string `json:"mesh_advertise_transport,omitempty"`
@@ -264,6 +253,19 @@ type LinuxInstallProfile struct {
Roles []string `json:"roles,omitempty"`
}
type InstallJoinBundle struct {
SchemaVersion string `json:"schema_version"`
BundleKind string `json:"bundle_kind"`
ClusterID string `json:"cluster_id"`
ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"`
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
IssuedAt time.Time `json:"issued_at"`
DockerInstallProfile *DockerInstallProfile `json:"docker_install_profile,omitempty"`
WindowsInstallProfile *WindowsInstallProfile `json:"windows_install_profile,omitempty"`
LinuxInstallProfile *LinuxInstallProfile `json:"linux_install_profile,omitempty"`
}
type DockerArtifact struct {
Kind string `json:"kind"`
Image string `json:"image,omitempty"`
@@ -324,15 +326,19 @@ type NodeUpdatePolicy struct {
}
type NodeUpdateHint struct {
SchemaVersion string `json:"schema_version"`
Generation string `json:"generation,omitempty"`
CheckNow bool `json:"check_now"`
Products []string `json:"products,omitempty"`
Reason string `json:"reason,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
SubscriptionStatus string `json:"subscription_status,omitempty"`
UpdateService *NodeUpdateServiceAssignment `json:"update_service,omitempty"`
FallbackPollSeconds int `json:"fallback_poll_seconds,omitempty"`
SchemaVersion string `json:"schema_version"`
Generation string `json:"generation,omitempty"`
CheckNow bool `json:"check_now"`
Products []string `json:"products,omitempty"`
TargetVersions map[string]string `json:"target_versions,omitempty"`
Reason string `json:"reason,omitempty"`
DeliveryMode string `json:"delivery_mode,omitempty"`
SubscriptionStatus string `json:"subscription_status,omitempty"`
UpdateService *NodeUpdateServiceAssignment `json:"update_service,omitempty"`
UpdateServiceCandidates []NodeUpdateServiceAssignment `json:"update_service_candidates,omitempty"`
RescuePollSeconds int `json:"rescue_poll_seconds,omitempty"`
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
}
type NodeUpdateServiceAssignment struct {
@@ -356,23 +362,75 @@ type NodeUpdateServiceCandidate struct {
}
type NodeUpdatePlan struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
NodeID string `json:"node_id"`
Product string `json:"product"`
CurrentVersion string `json:"current_version,omitempty"`
Action string `json:"action"`
Reason string `json:"reason"`
TargetVersion string `json:"target_version,omitempty"`
Channel string `json:"channel,omitempty"`
Strategy string `json:"strategy,omitempty"`
RollbackAllowed bool `json:"rollback_allowed"`
HealthWindowSec int `json:"health_window_seconds,omitempty"`
Artifact *ReleaseArtifact `json:"artifact,omitempty"`
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
AuthorityQuorum *QuorumEnvelope `json:"authority_quorum,omitempty"`
ProductionForwarding bool `json:"production_forwarding"`
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
NodeID string `json:"node_id"`
Product string `json:"product"`
CurrentVersion string `json:"current_version,omitempty"`
Action string `json:"action"`
Reason string `json:"reason"`
TargetVersion string `json:"target_version,omitempty"`
Channel string `json:"channel,omitempty"`
Strategy string `json:"strategy,omitempty"`
RollbackAllowed bool `json:"rollback_allowed"`
HealthWindowSec int `json:"health_window_seconds,omitempty"`
FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"`
UpdateIntent *NodeUpdateIntent `json:"update_intent,omitempty"`
RolloutLease *NodeUpdateLease `json:"rollout_lease,omitempty"`
Artifact *ReleaseArtifact `json:"artifact,omitempty"`
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
AuthorityQuorum *QuorumEnvelope `json:"authority_quorum,omitempty"`
ProductionForwarding bool `json:"production_forwarding"`
}
type NodeUpdateArtifactContent struct {
SchemaVersion string `json:"schema_version"`
ArtifactID string `json:"artifact_id"`
Product string `json:"product"`
Version string `json:"version"`
DataBase64 string `json:"data_base64"`
SHA256 string `json:"sha256,omitempty"`
ChunkSHA256 string `json:"chunk_sha256,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
Offset int64 `json:"offset,omitempty"`
ChunkSize int64 `json:"chunk_size,omitempty"`
Complete bool `json:"complete,omitempty"`
DistributorID string `json:"distributor_id,omitempty"`
}
type NodeUpdateIntent struct {
SchemaVersion string `json:"schema_version"`
IntentID string `json:"intent_id"`
ClusterID string `json:"cluster_id"`
NodeID string `json:"node_id,omitempty"`
Product string `json:"product"`
TargetVersion string `json:"target_version"`
Strategy string `json:"strategy"`
Generation string `json:"generation"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RollbackAllowed bool `json:"rollback_allowed"`
HealthWindowSec int `json:"health_window_seconds,omitempty"`
RequiredLease bool `json:"required_lease"`
AllowedMirrors []string `json:"allowed_mirrors,omitempty"`
}
type NodeUpdateLease struct {
SchemaVersion string `json:"schema_version"`
LeaseID string `json:"lease_id"`
IntentID string `json:"intent_id"`
ClusterID string `json:"cluster_id"`
NodeID string `json:"node_id"`
Product string `json:"product"`
TargetVersion string `json:"target_version"`
Strategy string `json:"strategy"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
MaxParallel int `json:"max_parallel"`
ActiveUpdateCnt int `json:"active_update_count"`
AcquiredAt time.Time `json:"acquired_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type NodeBridgeReplayProductPlan struct {
@@ -414,52 +472,87 @@ type NodeUpdateStatus struct {
}
type StaleNodeRiskReport struct {
ClusterID string `json:"cluster_id"`
GeneratedAt time.Time `json:"generated_at"`
HeartbeatStaleAfterSeconds int `json:"heartbeat_stale_after_seconds"`
LegacyRemovalAllowed bool `json:"legacy_removal_allowed"`
BridgeHoldRequired bool `json:"bridge_hold_required"`
BridgeHoldNodeIDs []string `json:"bridge_hold_node_ids,omitempty"`
BridgeHoldReasons []string `json:"bridge_hold_reasons,omitempty"`
BlockedOperations []string `json:"blocked_operations,omitempty"`
Nodes []StaleNodeRiskNode `json:"nodes"`
Summary StaleNodeRiskSummary `json:"summary"`
ClusterID string `json:"cluster_id"`
GeneratedAt time.Time `json:"generated_at"`
HeartbeatStaleAfterSeconds int `json:"heartbeat_stale_after_seconds"`
FabricStandardCleanupAllowed bool `json:"fabric_standard_cleanup_allowed"`
BridgeHoldRequired bool `json:"bridge_hold_required"`
BridgeHoldNodeIDs []string `json:"bridge_hold_node_ids,omitempty"`
BridgeHoldReasons []string `json:"bridge_hold_reasons,omitempty"`
BlockedOperations []string `json:"blocked_operations,omitempty"`
Nodes []StaleNodeRiskNode `json:"nodes"`
Summary StaleNodeRiskSummary `json:"summary"`
}
type StaleNodeRiskSummary struct {
TotalNodes int `json:"total_nodes"`
StaleNodes int `json:"stale_nodes"`
BlockedNodes int `json:"blocked_nodes"`
DirectPeerAlertNodes int `json:"direct_peer_alert_nodes"`
ArtifactGapNodes int `json:"artifact_gap_nodes"`
UnknownProfileNodes int `json:"unknown_profile_nodes"`
WaitingUpdateStatusNodes int `json:"waiting_update_status_nodes"`
UnknownVersionNodes int `json:"unknown_version_nodes"`
LegacyRecoveryContractNodes int `json:"legacy_recovery_contract_nodes"`
RecoveryBridgeRequiredNodes int `json:"recovery_bridge_required_nodes"`
RecoveryBridgeReplayReadyNodes int `json:"recovery_bridge_replay_ready_nodes"`
WaitingRecoveryHeartbeatNodes int `json:"waiting_recovery_heartbeat_nodes"`
TotalNodes int `json:"total_nodes"`
StaleNodes int `json:"stale_nodes"`
BlockedNodes int `json:"blocked_nodes"`
DirectPeerAlertNodes int `json:"direct_peer_alert_nodes"`
AreaDiversityAlertNodes int `json:"area_diversity_alert_nodes"`
IndependentIngressAlertNodes int `json:"independent_ingress_alert_nodes"`
DirectoryDisseminationAlertNodes int `json:"directory_dissemination_alert_nodes"`
UpdaterSubscriptionAlertNodes int `json:"updater_subscription_alert_nodes"`
UpdaterWakeUnsupportedNodes int `json:"updater_wake_unsupported_nodes"`
UpdaterRuntimeMissingNodes int `json:"updater_runtime_missing_nodes"`
StandardUpdaterLineNodes int `json:"standard_updater_line_nodes"`
StagedSelfUpdatePendingNodes int `json:"staged_self_update_pending_nodes"`
PostUpdateHeartbeatGapNodes int `json:"post_update_heartbeat_gap_nodes"`
ArtifactGapNodes int `json:"artifact_gap_nodes"`
StandardControlDependencyNodes int `json:"standard_control_dependency_nodes"`
RegistryCandidateOnlyNodes int `json:"registry_candidate_only_nodes"`
RegistryJoinContractMissingNodes int `json:"registry_join_missing_nodes"`
UnknownProfileNodes int `json:"unknown_profile_nodes"`
WaitingUpdateStatusNodes int `json:"waiting_update_status_nodes"`
UnknownVersionNodes int `json:"unknown_version_nodes"`
StandardRecoveryContractNodes int `json:"standard_recovery_contract_nodes"`
RecoveryBridgeRequiredNodes int `json:"recovery_bridge_required_nodes"`
RecoveryBridgeReplayReadyNodes int `json:"recovery_bridge_replay_ready_nodes"`
WaitingRecoveryHeartbeatNodes int `json:"waiting_recovery_heartbeat_nodes"`
}
type StaleNodeRiskNode struct {
NodeID string `json:"node_id"`
Name string `json:"name"`
RegistrationStatus string `json:"registration_status"`
HealthStatus string `json:"health_status"`
ReportedVersion *string `json:"reported_version,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
HeartbeatStale bool `json:"heartbeat_stale"`
Blocked bool `json:"blocked"`
DirectPeerAlert bool `json:"direct_peer_alert"`
DirectPeerReadyCount int `json:"direct_peer_ready_count,omitempty"`
DirectPeerTargetCount int `json:"direct_peer_target_count,omitempty"`
DirectPeerDeficit int `json:"direct_peer_deficit,omitempty"`
Alerts []string `json:"alerts,omitempty"`
RecoveryBridgeRequired bool `json:"recovery_bridge_required"`
RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"`
RecoveryBridgeActions []string `json:"recovery_bridge_actions,omitempty"`
Risks []string `json:"risks,omitempty"`
Products []StaleNodeRiskProduct `json:"products,omitempty"`
NodeID string `json:"node_id"`
Name string `json:"name"`
Area string `json:"area,omitempty"`
RegistrationStatus string `json:"registration_status"`
HealthStatus string `json:"health_status"`
ReportedVersion *string `json:"reported_version,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
HeartbeatStale bool `json:"heartbeat_stale"`
Blocked bool `json:"blocked"`
DirectPeerAlert bool `json:"direct_peer_alert"`
DirectPeerReadyCount int `json:"direct_peer_ready_count,omitempty"`
DirectPeerTargetCount int `json:"direct_peer_target_count,omitempty"`
DirectPeerDeficit int `json:"direct_peer_deficit,omitempty"`
DirectReadyAreas []string `json:"direct_ready_areas,omitempty"`
ExternalAreaReadyCount int `json:"external_area_ready_count,omitempty"`
RequiredExternalAreaCount int `json:"required_external_area_count,omitempty"`
AreaDiversityAlert bool `json:"area_diversity_alert"`
RequiredIndependentIngressCount int `json:"required_independent_ingress_count,omitempty"`
IndependentIngressAlert bool `json:"independent_ingress_alert"`
FullDirectoryExpected bool `json:"full_directory_expected"`
KnownPeerDirectoryCount int `json:"known_peer_directory_count,omitempty"`
ExpectedDirectoryCount int `json:"expected_directory_count,omitempty"`
DirectoryDisseminationAlert bool `json:"directory_dissemination_alert"`
UpdaterSubscriptionAlert bool `json:"updater_subscription_alert"`
UpdaterWakeUnsupported bool `json:"updater_wake_unsupported"`
UpdaterRuntimeMissing bool `json:"updater_runtime_missing"`
StandardUpdaterLine bool `json:"standard_updater_line"`
StagedSelfUpdatePending bool `json:"staged_self_update_pending"`
PostUpdateHeartbeatGap bool `json:"post_update_heartbeat_gap"`
StandardControlDependency bool `json:"standard_control_dependency"`
StandardControlURL string `json:"standard_control_url,omitempty"`
RegistryRuntimeStatus string `json:"registry_runtime_status,omitempty"`
RegistryJoinContractMissing bool `json:"registry_join_missing"`
ResolvedServiceCount int `json:"resolved_service_count,omitempty"`
IndependentIngressCount int `json:"independent_ingress_count,omitempty"`
Alerts []string `json:"alerts,omitempty"`
RecoveryBridgeRequired bool `json:"recovery_bridge_required"`
RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"`
RecoveryBridgeActions []string `json:"recovery_bridge_actions,omitempty"`
Risks []string `json:"risks,omitempty"`
Products []StaleNodeRiskProduct `json:"products,omitempty"`
}
type StaleNodeRiskProduct struct {
@@ -478,13 +571,15 @@ type StaleNodeRiskProduct struct {
LastStatusPhase string `json:"last_status_phase,omitempty"`
LastStatusValue string `json:"last_status_value,omitempty"`
LastStatusReason string `json:"last_status_reason,omitempty"`
StagedSelfUpdatePending bool `json:"staged_self_update_pending"`
PostUpdateHeartbeatGap bool `json:"post_update_heartbeat_gap"`
RecoveryBridgeRequired bool `json:"recovery_bridge_required"`
RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"`
RecoveryBridgeMode string `json:"recovery_bridge_mode,omitempty"`
Risks []string `json:"risks,omitempty"`
}
type NodeBootstrap struct {
type NodeJoinContract struct {
NodeID string `json:"node_id"`
ClusterID string `json:"cluster_id"`
IdentityStatus string `json:"identity_status"`
@@ -664,23 +759,23 @@ type FabricServiceChannelAdaptivePolicy struct {
}
type FabricServiceChannelPoolPolicy struct {
SchemaVersion string `json:"schema_version"`
Fingerprint string `json:"fingerprint,omitempty"`
EntryPoolNodeIDs []string `json:"entry_pool_node_ids,omitempty"`
ExitPoolNodeIDs []string `json:"exit_pool_node_ids,omitempty"`
PreferredEntryNodeID string `json:"preferred_entry_node_id,omitempty"`
PreferredExitNodeID string `json:"preferred_exit_node_id,omitempty"`
SelectionStrategy string `json:"selection_strategy"`
RouteRebuild string `json:"route_rebuild"`
EntryFailover string `json:"entry_failover"`
ExitFailover string `json:"exit_failover"`
BackendFallbackAllowed bool `json:"backend_fallback_allowed"`
StickySession bool `json:"sticky_session"`
Source string `json:"source"`
UpdatedByUserID *string `json:"updated_by_user_id,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ControlPlaneOnly bool `json:"control_plane_only"`
ProductionForwarding bool `json:"production_forwarding"`
SchemaVersion string `json:"schema_version"`
Fingerprint string `json:"fingerprint,omitempty"`
EntryPoolNodeIDs []string `json:"entry_pool_node_ids,omitempty"`
ExitPoolNodeIDs []string `json:"exit_pool_node_ids,omitempty"`
PreferredEntryNodeID string `json:"preferred_entry_node_id,omitempty"`
PreferredExitNodeID string `json:"preferred_exit_node_id,omitempty"`
SelectionStrategy string `json:"selection_strategy"`
RouteRebuild string `json:"route_rebuild"`
EntryFailover string `json:"entry_failover"`
ExitFailover string `json:"exit_failover"`
CompatFallbackAllowed bool `json:"degraded_route_allowed"`
StickySession bool `json:"sticky_session"`
Source string `json:"source"`
UpdatedByUserID *string `json:"updated_by_user_id,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
ControlPlaneOnly bool `json:"control_plane_only"`
ProductionForwarding bool `json:"production_forwarding"`
}
type FabricServiceChannelBreadcrumbWindowPolicy struct {
@@ -851,12 +946,12 @@ type NodeSyntheticMeshConfig struct {
ServiceChannelFeedback *FabricServiceChannelRouteFeedbackReport `json:"service_channel_route_feedback,omitempty"`
ServiceChannelAdaptivePolicy *FabricServiceChannelAdaptivePolicy `json:"service_channel_adaptive_policy,omitempty"`
ServiceChannelRemediationCommands []FabricServiceChannelAccessRemediationCommand `json:"service_channel_remediation_commands,omitempty"`
MeshListener *NodeMeshListenerConfig `json:"mesh_listener,omitempty"`
FabricListener *NodeFabricListenerConfig `json:"fabric_listener,omitempty"`
Routes []SyntheticMeshRouteConfig `json:"routes"`
ProductionForwarding bool `json:"production_forwarding"`
}
type NodeMeshListenerConfig struct {
type NodeFabricListenerConfig struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
DesiredState string `json:"desired_state"`
@@ -1094,23 +1189,23 @@ type FabricServiceChannelLeaseRecord struct {
}
type FabricServiceChannelLeaseSummary struct {
ClusterID string `json:"cluster_id"`
ChannelID string `json:"channel_id"`
ResourceID string `json:"resource_id,omitempty"`
ServiceClass string `json:"service_class"`
Status string `json:"status"`
SelectedEntryNodeID string `json:"selected_entry_node_id,omitempty"`
SelectedExitNodeID string `json:"selected_exit_node_id,omitempty"`
AllowedChannels []string `json:"allowed_channels,omitempty"`
PrimaryRouteID string `json:"primary_route_id,omitempty"`
PrimaryRouteStatus string `json:"primary_route_status,omitempty"`
DataPlane FabricServiceChannelDataPlaneContract `json:"data_plane,omitempty"`
ForceBackendFallback bool `json:"force_backend_fallback"`
Expired bool `json:"expired"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClusterID string `json:"cluster_id"`
ChannelID string `json:"channel_id"`
ResourceID string `json:"resource_id,omitempty"`
ServiceClass string `json:"service_class"`
Status string `json:"status"`
SelectedEntryNodeID string `json:"selected_entry_node_id,omitempty"`
SelectedExitNodeID string `json:"selected_exit_node_id,omitempty"`
AllowedChannels []string `json:"allowed_channels,omitempty"`
PrimaryRouteID string `json:"primary_route_id,omitempty"`
PrimaryRouteStatus string `json:"primary_route_status,omitempty"`
DataPlane FabricServiceChannelDataPlaneContract `json:"data_plane,omitempty"`
ForceCompatFallback bool `json:"force_degraded_route"`
Expired bool `json:"expired"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FabricServiceChannelLeaseMaintenance struct {
@@ -1139,15 +1234,15 @@ type FabricServiceChannelAccessTelemetry struct {
TotalAccepted int `json:"total_accepted"`
SignedAccepted int `json:"signed_accepted"`
IntrospectionAccepted int `json:"introspection_accepted"`
LegacyUnsignedAccepted int `json:"legacy_unsigned_accepted"`
BackendFallbackCount int `json:"backend_fallback_count"`
BackendFallbackBlockedCount int `json:"backend_fallback_blocked_count,omitempty"`
CompatUnsignedAccepted int `json:"unsigned_accepted"`
CompatFallbackCount int `json:"degraded_route_use_count"`
CompatFallbackBlockedCount int `json:"degraded_route_blocked_count,omitempty"`
FabricRouteSendFailureCount int `json:"fabric_route_send_failure_count,omitempty"`
DataPlaneContractCount int `json:"data_plane_contract_count,omitempty"`
LastDataPlaneMode string `json:"last_data_plane_mode,omitempty"`
LastWorkingDataTransport string `json:"last_working_data_transport,omitempty"`
LastSteadyStateTransport string `json:"last_steady_state_transport,omitempty"`
LastBackendRelayPolicy string `json:"last_backend_relay_policy,omitempty"`
LastCompatRelayPolicy string `json:"last_degraded_route_policy,omitempty"`
LastLogicalFlowMode string `json:"last_logical_flow_mode,omitempty"`
LastDataPlaneViolationStatus string `json:"last_data_plane_violation_status,omitempty"`
LastDataPlaneViolationReason string `json:"last_data_plane_violation_reason,omitempty"`
@@ -1184,15 +1279,15 @@ type FabricServiceChannelAccessTelemetryNode struct {
TotalAccepted int `json:"total_accepted"`
SignedAccepted int `json:"signed_accepted"`
IntrospectionAccepted int `json:"introspection_accepted"`
LegacyUnsignedAccepted int `json:"legacy_unsigned_accepted"`
BackendFallbackCount int `json:"backend_fallback_count"`
BackendFallbackBlockedCount int `json:"backend_fallback_blocked_count,omitempty"`
CompatUnsignedAccepted int `json:"unsigned_accepted"`
CompatFallbackCount int `json:"degraded_route_use_count"`
CompatFallbackBlockedCount int `json:"degraded_route_blocked_count,omitempty"`
FabricRouteSendFailureCount int `json:"fabric_route_send_failure_count,omitempty"`
DataPlaneContractCount int `json:"data_plane_contract_count,omitempty"`
LastDataPlaneMode string `json:"last_data_plane_mode,omitempty"`
LastWorkingDataTransport string `json:"last_working_data_transport,omitempty"`
LastSteadyStateTransport string `json:"last_steady_state_transport,omitempty"`
LastBackendRelayPolicy string `json:"last_backend_relay_policy,omitempty"`
LastCompatRelayPolicy string `json:"last_degraded_route_policy,omitempty"`
LastLogicalFlowMode string `json:"last_logical_flow_mode,omitempty"`
LastDataPlaneViolationStatus string `json:"last_data_plane_violation_status,omitempty"`
LastDataPlaneViolationReason string `json:"last_data_plane_violation_reason,omitempty"`
@@ -1219,17 +1314,17 @@ type FabricServiceChannelAccessTelemetryChannel struct {
SelectedExitNodeID string `json:"selected_exit_node_id,omitempty"`
PrimaryRouteID string `json:"primary_route_id,omitempty"`
PrimaryRouteStatus string `json:"primary_route_status,omitempty"`
ForceBackendFallback bool `json:"force_backend_fallback"`
ForceCompatFallback bool `json:"force_degraded_route"`
EntryNodeTotalAccepted int `json:"entry_node_total_accepted"`
EntryNodeIntrospectionAccepted int `json:"entry_node_introspection_accepted"`
EntryNodeBackendFallbackCount int `json:"entry_node_backend_fallback_count"`
EntryNodeBackendFallbackBlockedCount int `json:"entry_node_backend_fallback_blocked_count,omitempty"`
EntryNodeCompatFallbackCount int `json:"entry_node_degraded_route_count"`
EntryNodeCompatFallbackBlockedCount int `json:"entry_node_degraded_route_blocked_count,omitempty"`
EntryNodeFabricRouteSendFailureCount int `json:"entry_node_fabric_route_send_failure_count,omitempty"`
EntryNodeDataPlaneContractCount int `json:"entry_node_data_plane_contract_count,omitempty"`
EntryNodeLastDataPlaneMode string `json:"entry_node_last_data_plane_mode,omitempty"`
EntryNodeLastWorkingDataTransport string `json:"entry_node_last_working_data_transport,omitempty"`
EntryNodeLastSteadyStateTransport string `json:"entry_node_last_steady_state_transport,omitempty"`
EntryNodeLastBackendRelayPolicy string `json:"entry_node_last_backend_relay_policy,omitempty"`
EntryNodeLastCompatRelayPolicy string `json:"entry_node_last_degraded_route_policy,omitempty"`
EntryNodeLastLogicalFlowMode string `json:"entry_node_last_logical_flow_mode,omitempty"`
EntryNodeLastDataPlaneViolationStatus string `json:"entry_node_last_data_plane_violation_status,omitempty"`
EntryNodeLastDataPlaneViolationReason string `json:"entry_node_last_data_plane_violation_reason,omitempty"`
@@ -1305,26 +1400,26 @@ type FabricServiceChannelAccessRemediationCommand struct {
}
type FabricServiceChannelLeaseIntrospection struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
ChannelID string `json:"channel_id"`
ResourceID string `json:"resource_id,omitempty"`
ServiceClass string `json:"service_class"`
Allowed bool `json:"allowed"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
AcceptedBy string `json:"accepted_by"`
SelectedEntryNodeID string `json:"selected_entry_node_id,omitempty"`
SelectedExitNodeID string `json:"selected_exit_node_id,omitempty"`
AllowedChannels []string `json:"allowed_channels,omitempty"`
PreferredRouteID string `json:"preferred_route_id,omitempty"`
ForceBackendFallback bool `json:"force_backend_fallback"`
LeaseStatus string `json:"lease_status,omitempty"`
PrimaryRoute FabricServiceChannelRoute `json:"primary_route,omitempty"`
DataPlane FabricServiceChannelDataPlaneContract `json:"data_plane,omitempty"`
RouteGeneration string `json:"route_generation,omitempty"`
FencingEpoch int64 `json:"fencing_epoch,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
ChannelID string `json:"channel_id"`
ResourceID string `json:"resource_id,omitempty"`
ServiceClass string `json:"service_class"`
Allowed bool `json:"allowed"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
AcceptedBy string `json:"accepted_by"`
SelectedEntryNodeID string `json:"selected_entry_node_id,omitempty"`
SelectedExitNodeID string `json:"selected_exit_node_id,omitempty"`
AllowedChannels []string `json:"allowed_channels,omitempty"`
PreferredRouteID string `json:"preferred_route_id,omitempty"`
ForceCompatFallback bool `json:"force_degraded_route"`
LeaseStatus string `json:"lease_status,omitempty"`
PrimaryRoute FabricServiceChannelRoute `json:"primary_route,omitempty"`
DataPlane FabricServiceChannelDataPlaneContract `json:"data_plane,omitempty"`
RouteGeneration string `json:"route_generation,omitempty"`
FencingEpoch int64 `json:"fencing_epoch,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
type FabricServiceChannelRouteFeedbackObservation struct {
@@ -1970,7 +2065,17 @@ type CreateJoinRequestInput struct {
RequestedRoles json.RawMessage
}
type GetJoinRequestBootstrapInput struct {
type RegisterFabricNodeInput struct {
ClusterID string
NodeKey string
Name string
OwnershipType string
OwnerOrganizationID *string
ReportedVersion *string
Metadata json.RawMessage
}
type GetJoinRequestJoinInput struct {
ClusterID string
JoinRequestID string
NodeFingerprint string
@@ -1988,14 +2093,14 @@ type ApproveJoinRequestInput struct {
}
type ApprovedJoinRequest struct {
JoinRequest NodeJoinRequest `json:"join_request"`
Bootstrap NodeBootstrap `json:"node_bootstrap"`
JoinRequest NodeJoinRequest `json:"join_request"`
JoinContract NodeJoinContract `json:"node_join"`
}
type JoinRequestBootstrapResult struct {
Status string `json:"status"`
JoinRequest NodeJoinRequest `json:"join_request"`
Bootstrap *NodeBootstrap `json:"node_bootstrap,omitempty"`
type JoinRequestJoinResult struct {
Status string `json:"status"`
JoinRequest NodeJoinRequest `json:"join_request"`
JoinContract *NodeJoinContract `json:"node_join,omitempty"`
}
type RejectJoinRequestInput struct {
@@ -2114,15 +2219,24 @@ type UpsertNodeUpdatePolicyInput struct {
}
type GetNodeUpdatePlanInput struct {
ClusterID string
NodeID string
Product string
CurrentVersion string
OS string
Arch string
InstallType string
Channel string
ArtifactOrigin string
ClusterID string
NodeID string
Product string
CurrentVersion string
OS string
Arch string
InstallType string
Channel string
ArtifactOrigin string
ExecutorCapabilities []string
}
type GetNodeUpdateArtifactContentInput struct {
ClusterID string
NodeID string
ArtifactID string
Offset int64
Length int64
}
type GetStaleNodeRiskReportInput struct {
@@ -2276,38 +2390,38 @@ type SetFabricEgressPoolNodeInput struct {
}
type IssueFabricServiceChannelLeaseInput struct {
ActorUserID string
ClusterID string
OrganizationID string
UserID string
ResourceID string
ServiceClass string
EntryNodeIDs []string
ExitNodeIDs []string
PreferredEntryNodeID string
PreferredExitNodeID string
RequiredRoles []string
AllowedChannels []string
QoS json.RawMessage
Failover json.RawMessage
Metadata json.RawMessage
TTL time.Duration
BackendFallbackAllowed *bool
ActorUserID string
ClusterID string
OrganizationID string
UserID string
ResourceID string
ServiceClass string
EntryNodeIDs []string
ExitNodeIDs []string
PreferredEntryNodeID string
PreferredExitNodeID string
RequiredRoles []string
AllowedChannels []string
QoS json.RawMessage
Failover json.RawMessage
Metadata json.RawMessage
TTL time.Duration
CompatFallbackAllowed *bool
}
type UpdateFabricServiceChannelPoolPolicyInput struct {
ActorUserID string
ClusterID string
EntryPoolNodeIDs []string
ExitPoolNodeIDs []string
PreferredEntryNodeID string
PreferredExitNodeID string
SelectionStrategy string
RouteRebuild string
EntryFailover string
ExitFailover string
BackendFallbackAllowed *bool
StickySession *bool
ActorUserID string
ClusterID string
EntryPoolNodeIDs []string
ExitPoolNodeIDs []string
PreferredEntryNodeID string
PreferredExitNodeID string
SelectionStrategy string
RouteRebuild string
EntryFailover string
ExitFailover string
CompatFallbackAllowed *bool
StickySession *bool
}
type UpdateFabricServiceChannelBreadcrumbWindowPolicyInput struct {
+155 -61
View File
@@ -60,13 +60,30 @@ func (m *Module) Name() string {
}
func (m *Module) RegisterRoutes(router chi.Router) {
router.Get("/ui/admin", m.renderAdminHTML)
router.Get("/ui/htmx-lite.js", m.renderHTMXLiteJS)
router.Get("/downloads/{fileName}", m.downloadReleaseFile)
router.Get("/downloads/releases/{version}/{fileName}", m.downloadVersionedReleaseFile)
router.Route("/clusters", func(r chi.Router) {
r.Get("/", m.listClusters)
r.Post("/", m.createCluster)
r.Get("/{clusterID}", m.getCluster)
r.Put("/{clusterID}", m.updateCluster)
r.Get("/{clusterID}/nodes", m.listClusterNodes)
r.Get("/{clusterID}/ui/overview", m.renderFarmOverviewHTML)
r.Get("/{clusterID}/ui/nodes", m.renderNodesHTML)
r.Get("/{clusterID}/ui/nodes/fragment", m.renderNodesHTMLFragment)
r.Get("/{clusterID}/ui/nodes/{nodeID}/details", m.renderNodeDetailsHTML)
r.Get("/{clusterID}/ui/updates", m.renderUpdatesHTML)
r.Get("/{clusterID}/ui/updates/fragment", m.renderUpdatesHTMLFragment)
r.Post("/{clusterID}/ui/updates/check-now", m.renderUpdateCheckNowAllHTML)
r.Post("/{clusterID}/ui/updates/{nodeID}/check-now", m.renderUpdateCheckNowHTML)
r.Get("/{clusterID}/ui/topology", m.renderTopologyHTML)
r.Get("/{clusterID}/ui/fabric", m.renderFabricConsoleHTML)
r.Post("/{clusterID}/ui/fabric/policy", m.renderFabricPolicyHTML)
r.Get("/{clusterID}/ui/web-control", m.renderWebControlHTML)
r.Post("/{clusterID}/ui/web-control/desired", m.renderWebControlDesiredHTML)
r.Get("/{clusterID}/ui/audit", m.renderAuditConsoleHTML)
r.Get("/{clusterID}/node-groups", m.listNodeGroups)
r.Post("/{clusterID}/node-groups", m.createNodeGroup)
r.Get("/{clusterID}/join-requests", m.listJoinRequests)
@@ -84,6 +101,7 @@ func (m *Module) RegisterRoutes(router chi.Router) {
r.Post("/{clusterID}/updates/releases", m.createReleaseVersion)
r.Put("/{clusterID}/nodes/{nodeID}/updates/policy", m.upsertNodeUpdatePolicy)
r.Get("/{clusterID}/nodes/{nodeID}/updates/plan", m.getNodeUpdatePlan)
r.Get("/{clusterID}/nodes/{nodeID}/updates/artifacts/{artifactID}/content", m.getNodeUpdateArtifactContent)
r.Get("/{clusterID}/nodes/{nodeID}/updates/bridge-replay-plan", m.getNodeBridgeReplayPlan)
r.Post("/{clusterID}/nodes/{nodeID}/updates/status", m.reportNodeUpdateStatus)
r.Get("/{clusterID}/nodes/{nodeID}/updates/statuses", m.listNodeUpdateStatuses)
@@ -191,9 +209,45 @@ func (m *Module) downloadReleaseFile(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
setReleaseDownloadHeaders(w, fileName)
http.ServeFile(w, r, path)
}
func (m *Module) downloadVersionedReleaseFile(w http.ResponseWriter, r *http.Request) {
version := filepath.Base(strings.TrimSpace(chi.URLParam(r, "version")))
fileName := filepath.Base(strings.TrimSpace(chi.URLParam(r, "fileName")))
if version == "" || version == "." || version != strings.TrimSpace(chi.URLParam(r, "version")) {
http.NotFound(w, r)
return
}
if fileName == "" || fileName == "." || fileName != strings.TrimSpace(chi.URLParam(r, "fileName")) {
http.NotFound(w, r)
return
}
releaseDir := strings.TrimSpace(os.Getenv("RAP_RELEASE_DIR"))
if releaseDir == "" {
releaseDir = "/tmp/rap-release"
}
path := filepath.Join(releaseDir, "releases", version, fileName)
if _, err := os.Stat(path); err != nil {
http.NotFound(w, r)
return
}
setReleaseDownloadHeaders(w, fileName)
http.ServeFile(w, r, path)
}
func setReleaseDownloadHeaders(w http.ResponseWriter, fileName string) {
switch strings.ToLower(filepath.Ext(fileName)) {
case ".apk":
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
case ".json":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
}
func (m *Module) listClusters(w http.ResponseWriter, r *http.Request) {
items, err := m.service.ListClusters(r.Context(), r.URL.Query().Get("actor_user_id"))
if writeServiceError(w, err) {
@@ -340,14 +394,10 @@ func adminRuntimeProjectionResponse(statusCode int, status string, reason string
func isAllowedAdminRuntimeProjectionScope(scope string, serviceClass string) bool {
switch serviceClass {
case FabricServiceClassPlatformAdmin:
return scope == "platform"
case FabricServiceClassClusterAdmin:
return scope == "cluster"
case FabricServiceClassOrganization:
return scope == "organization"
case FabricServiceClassUserPortal:
return scope == "user" || scope == "organization"
case FabricServiceClassAdminIngress:
return scope == "platform" || scope == "cluster"
case FabricServiceClassPublicIngress:
return scope == "organization" || scope == "user"
default:
return false
}
@@ -357,18 +407,22 @@ func adminRuntimeManifest(scope string, serviceClass string) map[string]any {
sections := []string{"status"}
actions := []string{"read_status"}
switch strings.TrimSpace(serviceClass) {
case FabricServiceClassPlatformAdmin:
case FabricServiceClassAdminIngress:
sections = []string{"clusters", "nodes", "roles", "fabric", "workloads", "audit"}
actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"}
case FabricServiceClassClusterAdmin:
sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"}
actions = []string{"read_cluster_summary", "read_node_status"}
case FabricServiceClassOrganization:
if scope == "cluster" {
sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"}
actions = []string{"read_cluster_summary", "read_node_status"}
} else {
actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"}
}
case FabricServiceClassPublicIngress:
sections = []string{"organization", "sessions", "resources", "audit"}
actions = []string{"read_organization_summary", "read_sessions"}
case FabricServiceClassUserPortal:
sections = []string{"profile", "sessions", "resources"}
actions = []string{"read_profile", "read_sessions"}
if scope == "user" {
sections = []string{"profile", "sessions", "resources"}
actions = []string{"read_profile", "read_sessions"}
} else {
actions = []string{"read_organization_summary", "read_sessions"}
}
}
return map[string]any{
"schema_version": adminRuntimeManifestSchema,
@@ -729,6 +783,10 @@ func (m *Module) getNodeUpdatePlan(w http.ResponseWriter, r *http.Request) {
InstallType: r.URL.Query().Get("install_type"),
Channel: r.URL.Query().Get("channel"),
ArtifactOrigin: requestOrigin(r),
ExecutorCapabilities: append(
r.URL.Query()["executor_capability"],
r.URL.Query()["executor_capabilities"]...,
),
})
if writeServiceError(w, err) {
return
@@ -736,6 +794,35 @@ func (m *Module) getNodeUpdatePlan(w http.ResponseWriter, r *http.Request) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_update_plan": item})
}
func (m *Module) getNodeUpdateArtifactContent(w http.ResponseWriter, r *http.Request) {
item, err := m.service.GetNodeUpdateArtifactContent(r.Context(), GetNodeUpdateArtifactContentInput{
ClusterID: chi.URLParam(r, "clusterID"),
NodeID: chi.URLParam(r, "nodeID"),
ArtifactID: chi.URLParam(r, "artifactID"),
Offset: parseInt64Query(r, "offset"),
Length: parseInt64Query(r, "length"),
})
if writeServiceError(w, err) {
return
}
httpx.WriteJSON(w, http.StatusOK, item)
}
func parseInt64Query(r *http.Request, key string) int64 {
if r == nil {
return 0
}
value := strings.TrimSpace(r.URL.Query().Get(key))
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func requestOrigin(r *http.Request) string {
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if proto == "" {
@@ -1732,35 +1819,35 @@ func (m *Module) getFabricServiceChannelPoolPolicy(w http.ResponseWriter, r *htt
func (m *Module) updateFabricServiceChannelPoolPolicy(w http.ResponseWriter, r *http.Request) {
var payload struct {
ActorUserID string `json:"actor_user_id"`
EntryPoolNodeIDs []string `json:"entry_pool_node_ids"`
ExitPoolNodeIDs []string `json:"exit_pool_node_ids"`
PreferredEntryNodeID string `json:"preferred_entry_node_id"`
PreferredExitNodeID string `json:"preferred_exit_node_id"`
SelectionStrategy string `json:"selection_strategy"`
RouteRebuild string `json:"route_rebuild"`
EntryFailover string `json:"entry_failover"`
ExitFailover string `json:"exit_failover"`
BackendFallbackAllowed *bool `json:"backend_fallback_allowed"`
StickySession *bool `json:"sticky_session"`
ActorUserID string `json:"actor_user_id"`
EntryPoolNodeIDs []string `json:"entry_pool_node_ids"`
ExitPoolNodeIDs []string `json:"exit_pool_node_ids"`
PreferredEntryNodeID string `json:"preferred_entry_node_id"`
PreferredExitNodeID string `json:"preferred_exit_node_id"`
SelectionStrategy string `json:"selection_strategy"`
RouteRebuild string `json:"route_rebuild"`
EntryFailover string `json:"entry_failover"`
ExitFailover string `json:"exit_failover"`
CompatFallbackAllowed *bool `json:"degraded_route_allowed"`
StickySession *bool `json:"sticky_session"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid pool policy payload")
return
}
item, err := m.service.UpdateFabricServiceChannelPoolPolicy(r.Context(), UpdateFabricServiceChannelPoolPolicyInput{
ActorUserID: payload.ActorUserID,
ClusterID: chi.URLParam(r, "clusterID"),
EntryPoolNodeIDs: payload.EntryPoolNodeIDs,
ExitPoolNodeIDs: payload.ExitPoolNodeIDs,
PreferredEntryNodeID: payload.PreferredEntryNodeID,
PreferredExitNodeID: payload.PreferredExitNodeID,
SelectionStrategy: payload.SelectionStrategy,
RouteRebuild: payload.RouteRebuild,
EntryFailover: payload.EntryFailover,
ExitFailover: payload.ExitFailover,
BackendFallbackAllowed: payload.BackendFallbackAllowed,
StickySession: payload.StickySession,
ActorUserID: payload.ActorUserID,
ClusterID: chi.URLParam(r, "clusterID"),
EntryPoolNodeIDs: payload.EntryPoolNodeIDs,
ExitPoolNodeIDs: payload.ExitPoolNodeIDs,
PreferredEntryNodeID: payload.PreferredEntryNodeID,
PreferredExitNodeID: payload.PreferredExitNodeID,
SelectionStrategy: payload.SelectionStrategy,
RouteRebuild: payload.RouteRebuild,
EntryFailover: payload.EntryFailover,
ExitFailover: payload.ExitFailover,
CompatFallbackAllowed: payload.CompatFallbackAllowed,
StickySession: payload.StickySession,
})
if writeServiceError(w, err) {
return
@@ -3411,7 +3498,7 @@ func writeServiceError(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
var legacyRemovalBlocked *LegacyRemovalBlockedError
var standardCleanupBlocked *FabricStandardCleanupBlockedError
switch {
case errors.Is(err, ErrAccessDenied):
httpx.WriteError(w, http.StatusForbidden, err.Error())
@@ -3419,11 +3506,11 @@ func writeServiceError(w http.ResponseWriter, err error) bool {
httpx.WriteError(w, http.StatusForbidden, err.Error())
case errors.Is(err, ErrClusterReadOnly):
httpx.WriteError(w, http.StatusConflict, err.Error())
case errors.As(err, &legacyRemovalBlocked):
case errors.As(err, &standardCleanupBlocked):
httpx.WriteErrorMessage(w, http.StatusConflict, httpx.ErrorResponse{
Error: httpx.NewErrorMessage(http.StatusConflict, err.Error(), legacyRemovalBlockedErrorDetails(*legacyRemovalBlocked), ""),
Error: httpx.NewErrorMessage(http.StatusConflict, err.Error(), FabricStandardCleanupBlockedErrorDetails(*standardCleanupBlocked), ""),
})
case errors.Is(err, ErrLegacyRemovalBlocked):
case errors.Is(err, ErrFabricStandardCleanupBlocked):
httpx.WriteError(w, http.StatusConflict, err.Error())
case errors.Is(err, ErrVPNLeaseAlreadyActive):
httpx.WriteError(w, http.StatusConflict, err.Error())
@@ -3437,24 +3524,31 @@ func writeServiceError(w http.ResponseWriter, err error) bool {
return true
}
func legacyRemovalBlockedErrorDetails(err LegacyRemovalBlockedError) map[string]any {
func FabricStandardCleanupBlockedErrorDetails(err FabricStandardCleanupBlockedError) map[string]any {
details := map[string]any{
"blocked_operation": err.BlockedOperation,
"legacy_removal_allowed": err.Report.LegacyRemovalAllowed,
"bridge_hold_required": err.Report.BridgeHoldRequired,
"bridge_hold_reasons": err.Report.BridgeHoldReasons,
"blocked_operations": err.Report.BlockedOperations,
"heartbeat_stale_after_seconds": err.Report.HeartbeatStaleAfterSeconds,
"stale_nodes": err.Report.Summary.StaleNodes,
"blocked_nodes": err.Report.Summary.BlockedNodes,
"artifact_gap_nodes": err.Report.Summary.ArtifactGapNodes,
"unknown_profile_nodes": err.Report.Summary.UnknownProfileNodes,
"waiting_update_status_nodes": err.Report.Summary.WaitingUpdateStatusNodes,
"unknown_version_nodes": err.Report.Summary.UnknownVersionNodes,
"legacy_recovery_contract_nodes": err.Report.Summary.LegacyRecoveryContractNodes,
"recovery_bridge_required_nodes": err.Report.Summary.RecoveryBridgeRequiredNodes,
"blocked_operation": err.BlockedOperation,
"fabric_standard_cleanup_allowed": err.Report.FabricStandardCleanupAllowed,
"bridge_hold_required": err.Report.BridgeHoldRequired,
"bridge_hold_reasons": err.Report.BridgeHoldReasons,
"blocked_operations": err.Report.BlockedOperations,
"heartbeat_stale_after_seconds": err.Report.HeartbeatStaleAfterSeconds,
"stale_nodes": err.Report.Summary.StaleNodes,
"blocked_nodes": err.Report.Summary.BlockedNodes,
"artifact_gap_nodes": err.Report.Summary.ArtifactGapNodes,
"area_diversity_alert_nodes": err.Report.Summary.AreaDiversityAlertNodes,
"independent_ingress_alert_nodes": err.Report.Summary.IndependentIngressAlertNodes,
"updater_wake_unsupported_nodes": err.Report.Summary.UpdaterWakeUnsupportedNodes,
"updater_runtime_missing_nodes": err.Report.Summary.UpdaterRuntimeMissingNodes,
"staged_self_update_pending_nodes": err.Report.Summary.StagedSelfUpdatePendingNodes,
"standard_control_dependency_nodes": err.Report.Summary.StandardControlDependencyNodes,
"registry_candidate_only_nodes": err.Report.Summary.RegistryCandidateOnlyNodes,
"unknown_profile_nodes": err.Report.Summary.UnknownProfileNodes,
"waiting_update_status_nodes": err.Report.Summary.WaitingUpdateStatusNodes,
"unknown_version_nodes": err.Report.Summary.UnknownVersionNodes,
"standard_recovery_contract_nodes": err.Report.Summary.StandardRecoveryContractNodes,
"recovery_bridge_required_nodes": err.Report.Summary.RecoveryBridgeRequiredNodes,
"recovery_bridge_replay_ready_nodes": err.Report.Summary.RecoveryBridgeReplayReadyNodes,
"waiting_recovery_heartbeat_nodes": err.Report.Summary.WaitingRecoveryHeartbeatNodes,
"waiting_recovery_heartbeat_nodes": err.Report.Summary.WaitingRecoveryHeartbeatNodes,
}
blockedNodeIDs := make([]string, 0, len(err.Report.Nodes))
for _, node := range err.Report.Nodes {
@@ -18,9 +18,9 @@ func TestProjectAdminRuntimeReturnsReadOnlyManifest(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
"method":"GET",
"path":"/platform-admin/ui-manifest",
"path":"/admin/ui-manifest",
"scope":"platform",
"service_class":"platform_admin"
"service_class":"admin-ingress"
}`)))
rec := httptest.NewRecorder()
@@ -72,9 +72,9 @@ func TestProjectAdminRuntimeRejectsMutations(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
"method":"POST",
"path":"/platform-admin/nodes",
"path":"/admin/nodes",
"scope":"platform",
"service_class":"platform_admin"
"service_class":"admin-ingress"
}`)))
rec := httptest.NewRecorder()
@@ -106,7 +106,7 @@ func TestProjectAdminRuntimeReturnsHealthProjection(t *testing.T) {
"method":"GET",
"path":"/readyz",
"scope":"platform",
"service_class":"platform_admin"
"service_class":"admin-ingress"
}`)))
rec := httptest.NewRecorder()
@@ -147,9 +147,9 @@ func TestProjectAdminRuntimeBlocksUnknownReadProjection(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
"method":"GET",
"path":"/platform-admin/nodes",
"path":"/admin/nodes",
"scope":"platform",
"service_class":"platform_admin"
"service_class":"admin-ingress"
}`)))
rec := httptest.NewRecorder()
@@ -181,9 +181,9 @@ func TestProjectAdminRuntimeRejectsScopeClassMismatch(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
"method":"GET",
"path":"/platform-admin/ui-manifest",
"path":"/admin/ui-manifest",
"scope":"organization",
"service_class":"platform_admin"
"service_class":"admin-ingress"
}`)))
rec := httptest.NewRecorder()
@@ -217,7 +217,7 @@ func TestProjectAdminRuntimeRejectsInvalidSchema(t *testing.T) {
"method":"GET",
"path":"/readyz",
"scope":"platform",
"service_class":"platform_admin"
"service_class":"admin-ingress"
}`)))
rec := httptest.NewRecorder()
File diff suppressed because one or more lines are too long
@@ -7,17 +7,17 @@ import (
"testing"
)
func TestWriteServiceErrorLegacyRemovalBlockedIncludesBreakdownDetails(t *testing.T) {
func TestWriteServiceErrorDisallowedRemovalBlockedIncludesBreakdownDetails(t *testing.T) {
recorder := httptest.NewRecorder()
handled := writeServiceError(recorder, &LegacyRemovalBlockedError{
handled := writeServiceError(recorder, &FabricStandardCleanupBlockedError{
BlockedOperation: "create_breaking_release",
Report: StaleNodeRiskReport{
HeartbeatStaleAfterSeconds: 900,
LegacyRemovalAllowed: false,
BridgeHoldRequired: true,
BridgeHoldNodeIDs: []string{"node-1"},
BridgeHoldReasons: []string{"legacy_contract_overlap"},
BlockedOperations: []string{"create_breaking_release", "target_breaking_update_policy", "remove_recovery_bridge_overlap"},
HeartbeatStaleAfterSeconds: 900,
FabricStandardCleanupAllowed: false,
BridgeHoldRequired: true,
BridgeHoldNodeIDs: []string{"node-1"},
BridgeHoldReasons: []string{"standard_contract_overlap"},
BlockedOperations: []string{"create_breaking_release", "target_breaking_update_policy", "remove_recovery_bridge_overlap"},
Nodes: []StaleNodeRiskNode{
{NodeID: "node-1", Blocked: true, RecoveryBridgeRequired: true},
{NodeID: "node-2", Blocked: false},
@@ -25,11 +25,13 @@ func TestWriteServiceErrorLegacyRemovalBlockedIncludesBreakdownDetails(t *testin
Summary: StaleNodeRiskSummary{
StaleNodes: 1,
BlockedNodes: 1,
UpdaterRuntimeMissingNodes: 1,
StagedSelfUpdatePendingNodes: 1,
ArtifactGapNodes: 0,
UnknownProfileNodes: 0,
WaitingUpdateStatusNodes: 0,
UnknownVersionNodes: 0,
LegacyRecoveryContractNodes: 0,
StandardRecoveryContractNodes: 0,
WaitingRecoveryHeartbeatNodes: 1,
},
},
@@ -54,6 +56,12 @@ func TestWriteServiceErrorLegacyRemovalBlockedIncludesBreakdownDetails(t *testin
if payload.Error.Details["waiting_recovery_heartbeat_nodes"] != float64(1) {
t.Fatalf("waiting_recovery_heartbeat_nodes = %v", payload.Error.Details["waiting_recovery_heartbeat_nodes"])
}
if payload.Error.Details["staged_self_update_pending_nodes"] != float64(1) {
t.Fatalf("staged_self_update_pending_nodes = %v", payload.Error.Details["staged_self_update_pending_nodes"])
}
if payload.Error.Details["updater_runtime_missing_nodes"] != float64(1) {
t.Fatalf("updater_runtime_missing_nodes = %v", payload.Error.Details["updater_runtime_missing_nodes"])
}
if payload.Error.Details["bridge_hold_required"] != true {
t.Fatalf("bridge_hold_required = %v", payload.Error.Details["bridge_hold_required"])
}
File diff suppressed because it is too large Load Diff
@@ -311,6 +311,69 @@ func (s *PostgresStore) ListClusterNodes(ctx context.Context, clusterID string)
return out, rows.Err()
}
func (s *PostgresStore) RegisterFabricNode(ctx context.Context, input RegisterFabricNodeInput) (ClusterNode, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return ClusterNode{}, err
}
defer tx.Rollback(ctx)
now := time.Now().UTC()
nodeID := uuid.NewString()
row := tx.QueryRow(ctx, `
INSERT INTO nodes (
id, owner_organization_id, node_key, name, ownership_type, registration_status, health_status,
version_state, partition_state, reported_version, metadata, created_at, updated_at
) VALUES ($1::uuid, $2::uuid, $3, $4, $5, 'active', 'healthy', 'current', 'healthy', $6, $7::jsonb, $8, $8)
ON CONFLICT (node_key) DO UPDATE SET
owner_organization_id = COALESCE(EXCLUDED.owner_organization_id, nodes.owner_organization_id),
name = EXCLUDED.name,
ownership_type = EXCLUDED.ownership_type,
registration_status = 'active',
health_status = 'healthy',
reported_version = COALESCE(EXCLUDED.reported_version, nodes.reported_version),
metadata = nodes.metadata || EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at
RETURNING id::text
`, nodeID, input.OwnerOrganizationID, input.NodeKey, input.Name, input.OwnershipType, input.ReportedVersion, []byte(input.Metadata), now)
if err := row.Scan(&nodeID); err != nil {
return ClusterNode{}, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO cluster_memberships (cluster_id, node_id, membership_status, joined_at, last_seen_at, metadata)
VALUES ($1::uuid, $2::uuid, 'active', $3, $3, $4::jsonb)
ON CONFLICT (cluster_id, node_id) DO UPDATE SET
membership_status = 'active',
last_seen_at = EXCLUDED.last_seen_at,
metadata = cluster_memberships.metadata || EXCLUDED.metadata
`, input.ClusterID, nodeID, now, []byte(`{"source":"fabric_control_register"}`)); err != nil {
return ClusterNode{}, err
}
itemRow := tx.QueryRow(ctx, `
SELECT n.id::text, n.owner_organization_id::text, n.node_key, n.name, n.ownership_type,
n.registration_status, n.health_status, n.version_state, n.partition_state,
n.reported_version, n.last_seen_at, cm.membership_status, cm.metadata,
ng.id::text, ng.name,
n.created_at, n.updated_at
FROM cluster_memberships cm
JOIN nodes n ON n.id = cm.node_id
LEFT JOIN cluster_node_group_memberships ngm ON ngm.cluster_id = cm.cluster_id AND ngm.node_id = cm.node_id
LEFT JOIN cluster_node_groups ng ON ng.cluster_id = ngm.cluster_id AND ng.id = ngm.group_id
WHERE cm.cluster_id = $1::uuid
AND cm.node_id = $2::uuid
`, input.ClusterID, nodeID)
item, err := scanClusterNode(itemRow)
if err != nil {
return ClusterNode{}, err
}
if err := tx.Commit(ctx); err != nil {
return ClusterNode{}, err
}
return item, nil
}
func (s *PostgresStore) ListNodeGroups(ctx context.Context, clusterID string) ([]ClusterNodeGroup, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, cluster_id::text, parent_group_id::text, name, description,
@@ -511,7 +574,7 @@ func (s *PostgresStore) CreateJoinRequest(ctx context.Context, input CreateJoinR
return item, nil
}
func (s *PostgresStore) GetJoinRequestForBootstrap(ctx context.Context, input GetJoinRequestBootstrapInput) (NodeJoinRequest, error) {
func (s *PostgresStore) GetJoinRequestForJoin(ctx context.Context, input GetJoinRequestJoinInput) (NodeJoinRequest, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, cluster_id::text, join_token_id::text, node_name, node_fingerprint, public_key,
reported_capabilities, reported_facts, requested_roles, status, reviewed_by_user_id::text,
@@ -666,7 +729,7 @@ func (s *PostgresStore) ApproveJoinRequest(ctx context.Context, input ApproveJoi
}
return ApprovedJoinRequest{
JoinRequest: updated,
Bootstrap: NodeBootstrap{
JoinContract: NodeJoinContract{
NodeID: nodeID,
ClusterID: input.ClusterID,
IdentityStatus: "active",
@@ -1310,6 +1373,17 @@ func (s *PostgresStore) listReleaseArtifacts(ctx context.Context, releaseID stri
return out, rows.Err()
}
func (s *PostgresStore) GetReleaseArtifact(ctx context.Context, clusterID, artifactID string) (ReleaseArtifact, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, release_id::text, cluster_id::text, product, version, os, arch,
install_type, kind, url, sha256, size_bytes, signature, metadata, created_at
FROM release_artifacts
WHERE cluster_id = $1::uuid
AND id = $2::uuid
`, clusterID, artifactID)
return scanReleaseArtifact(row)
}
func (s *PostgresStore) ListNodeUpdateServiceCandidates(ctx context.Context, clusterID string) ([]NodeUpdateServiceCandidate, error) {
rows, err := s.db.Query(ctx, `
SELECT n.id::text,
@@ -4922,9 +4996,6 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
item["tls_cert_sha256"] = certSHA256
item["peer_cert_sha256"] = certSHA256
}
if apiBaseURL := vpnEntryAPIBaseURL(address); apiBaseURL != "" {
item["api_base_url"] = apiBaseURL
}
out = append(out, item)
}
if len(out) == 0 {
@@ -4943,9 +5014,6 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
"status": "reported",
"source": "node_latest_heartbeat.mesh_endpoint_report.peer_endpoint",
}
if apiBaseURL := vpnEntryAPIBaseURL(address); apiBaseURL != "" {
item["api_base_url"] = apiBaseURL
}
out = append(out, item)
}
}
@@ -5072,17 +5140,6 @@ func heartbeatCapabilityEnabled(capabilities json.RawMessage, name string) bool
}
}
func vpnEntryAPIBaseURL(address string) string {
address = strings.TrimRight(strings.TrimSpace(address), "/")
if address == "" {
return ""
}
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
return ""
}
return address + "/api/v1"
}
func enrichVPNClientEntryEndpointCandidates(connection VPNClientConnection, endpoints map[string][]map[string]any) json.RawMessage {
var cfg map[string]any
if err := json.Unmarshal(connection.ClientConfig, &cfg); err != nil || cfg == nil {
@@ -5346,7 +5403,7 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
entryPool := dedupeStrings(append([]string{}, item.EntryNodeIDs...))
placementPolicy := jsonObjectFromRaw(item.PlacementPolicy)
entrySelector, _ := placementPolicy["entry_selector"].(string)
clientNodeEntry := strings.EqualFold(strings.TrimSpace(entrySelector), "client_node") || placementPolicy["android_node_agent_target"] == true
clientNodeEntry := strings.EqualFold(strings.TrimSpace(entrySelector), "client_node") || placementPolicy["ipv4_ingress_node_target"] == true || placementPolicy["android_node_agent_target"] == true
if len(entryPool) == 0 && !clientNodeEntry {
entryPool = dedupeStrings(append([]string{}, item.AllowedNodeIDs...))
}
@@ -5450,7 +5507,7 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
"queue_policy": "bounded_queue_then_route_failover",
"drop_policy": "drop_only_when_all_routes_unavailable_or_queue_full",
"bulk_and_realtime": "same_packet_path",
"flow_isolation": "hash_by_ip_protocol_and_ports",
"flow_isolation": "opaque_packet_hash_shards",
"target_dataplane": "fabric_farm_entry_to_exit_service_channel",
"temporary_fallback": "none",
},
@@ -5519,8 +5576,8 @@ func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selec
"role": role,
"priority": priority,
"status": "candidate",
"source_role": "vpn-client",
"route_scope": "client_node_to_exit_pool",
"source_role": "ipv4-ingress",
"route_scope": "ipv4_ingress_to_egress_pool",
}
if pair.entry != "" {
candidate["entry_node_id"] = pair.entry
@@ -18,10 +18,10 @@ func TestMeshLatestObservationKeySeparatesRouteHealthByRoute(t *testing.T) {
func TestMeshLatestObservationKeySeparatesConnectionManagerMode(t *testing.T) {
key := meshLatestObservationKey(json.RawMessage(`{
"observation_type":"peer_connection_manager",
"transport_mode":"relay_control",
"transport_mode":"relay_quic",
"relay_node_id":"node-r"
}`))
if key != "peer_connection_manager:relay_control:node-r" {
if key != "peer_connection_manager:relay_quic:node-r" {
t.Fatalf("key = %q", key)
}
}
@@ -192,7 +192,7 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedQUICEndpoint(t *testi
}
}
func TestVPNEntryEndpointCandidatesKeepsQUICEndpointsAndRejectsLegacyHTTP(t *testing.T) {
func TestVPNEntryEndpointCandidatesKeepsQUICEndpointsAndRejectsDisallowedHTTP(t *testing.T) {
heartbeatMetadata := json.RawMessage(`{
"mesh_endpoint_report": {
"transport": "direct_quic",
@@ -29,7 +29,8 @@ type Repository interface {
ExpireJoinTokens(ctx context.Context, clusterID string) error
CreateJoinRequest(ctx context.Context, input CreateJoinRequestInput, joinTokenID string) (NodeJoinRequest, error)
GetJoinRequestForBootstrap(ctx context.Context, input GetJoinRequestBootstrapInput) (NodeJoinRequest, error)
RegisterFabricNode(ctx context.Context, input RegisterFabricNodeInput) (ClusterNode, error)
GetJoinRequestForJoin(ctx context.Context, input GetJoinRequestJoinInput) (NodeJoinRequest, error)
ListJoinRequests(ctx context.Context, clusterID string) ([]NodeJoinRequest, error)
ApproveJoinRequest(ctx context.Context, input ApproveJoinRequestInput) (ApprovedJoinRequest, error)
SetJoinRequestApprovalAuthority(ctx context.Context, clusterID, joinRequestID string, payload json.RawMessage, signature ClusterSignature) (NodeJoinRequest, error)
@@ -43,6 +44,7 @@ type Repository interface {
ListNodeHeartbeats(ctx context.Context, clusterID, nodeID string, limit int) ([]NodeHeartbeat, error)
CreateReleaseVersion(ctx context.Context, input CreateReleaseVersionInput) (ReleaseVersion, error)
ListReleaseVersions(ctx context.Context, clusterID, product, channel string) ([]ReleaseVersion, error)
GetReleaseArtifact(ctx context.Context, clusterID, artifactID string) (ReleaseArtifact, error)
ListNodeUpdateServiceCandidates(ctx context.Context, clusterID string) ([]NodeUpdateServiceCandidate, error)
UpsertNodeUpdatePolicy(ctx context.Context, input UpsertNodeUpdatePolicyInput) (NodeUpdatePolicy, error)
GetNodeUpdatePolicy(ctx context.Context, clusterID, nodeID, product string) (NodeUpdatePolicy, error)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54 -291
View File
@@ -2,13 +2,9 @@ package nodeagent
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
clustermodule "github.com/example/remote-access-platform/backend/internal/modules/cluster"
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
@@ -17,7 +13,6 @@ import (
)
type Module struct {
db *pgxpool.Pool
cluster *clustermodule.Service
}
@@ -29,7 +24,6 @@ func NewModule(deps module.Dependencies) *Module {
}
}
return &Module{
db: deps.Infra.DB,
cluster: clustermodule.NewService(clusterStore),
}
}
@@ -40,62 +34,89 @@ func (m *Module) Name() string {
func (m *Module) RegisterRoutes(router chi.Router) {
router.Route("/node-agents", func(r chi.Router) {
r.Post("/docker-install-profile", m.dockerInstallProfile)
r.Post("/windows-install-profile", m.windowsInstallProfile)
r.Post("/linux-install-profile", m.linuxInstallProfile)
r.Post("/docker-join-bundle", m.dockerJoinBundle)
r.Post("/windows-join-bundle", m.windowsJoinBundle)
r.Post("/linux-join-bundle", m.linuxJoinBundle)
r.Post("/register", m.registerFabricNode)
r.Post("/enroll", m.enrollAgent)
r.Post("/enrollments/{requestID}/bootstrap", m.bootstrapEnrollment)
r.Post("/register", m.registerAgent)
r.Post("/{nodeID}/health", m.reportHealth)
r.Post("/{nodeID}/services/status", m.reportServiceStatus)
r.Post("/{nodeID}/update-manifest/request", m.requestUpdateManifest)
r.Post("/{nodeID}/update-result", m.acknowledgeUpdateResult)
r.Post("/{nodeID}/rollback-result", m.reportRollbackResult)
r.Get("/{nodeID}/clusters/{clusterID}/vpn-assignments/desired", m.listVPNAssignments)
r.Post("/{nodeID}/clusters/{clusterID}/vpn-assignments/{vpnConnectionID}/status", m.reportVPNAssignmentStatus)
r.Post("/enrollments/{requestID}/join", m.fetchEnrollmentJoinContract)
})
}
func (m *Module) linuxInstallProfile(w http.ResponseWriter, r *http.Request) {
func (m *Module) linuxJoinBundle(w http.ResponseWriter, r *http.Request) {
var payload clustermodule.DockerInstallProfileRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid linux install profile payload")
httpx.WriteError(w, http.StatusBadRequest, "invalid linux join bundle payload")
return
}
profile, err := m.cluster.GetLinuxInstallProfile(r.Context(), payload)
bundle, err := m.cluster.GetLinuxJoinBundle(r.Context(), payload)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"linux_install_profile": profile})
httpx.WriteJSON(w, http.StatusOK, map[string]any{"join_bundle": bundle})
}
func (m *Module) windowsInstallProfile(w http.ResponseWriter, r *http.Request) {
func (m *Module) windowsJoinBundle(w http.ResponseWriter, r *http.Request) {
var payload clustermodule.DockerInstallProfileRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid windows install profile payload")
httpx.WriteError(w, http.StatusBadRequest, "invalid windows join bundle payload")
return
}
profile, err := m.cluster.GetWindowsInstallProfile(r.Context(), payload)
bundle, err := m.cluster.GetWindowsJoinBundle(r.Context(), payload)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"windows_install_profile": profile})
httpx.WriteJSON(w, http.StatusOK, map[string]any{"join_bundle": bundle})
}
func (m *Module) dockerInstallProfile(w http.ResponseWriter, r *http.Request) {
func (m *Module) dockerJoinBundle(w http.ResponseWriter, r *http.Request) {
var payload clustermodule.DockerInstallProfileRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid docker install profile payload")
httpx.WriteError(w, http.StatusBadRequest, "invalid docker join bundle payload")
return
}
profile, err := m.cluster.GetDockerInstallProfile(r.Context(), payload)
bundle, err := m.cluster.GetDockerJoinBundle(r.Context(), payload)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"docker_install_profile": profile})
httpx.WriteJSON(w, http.StatusOK, map[string]any{"join_bundle": bundle})
}
func (m *Module) registerFabricNode(w http.ResponseWriter, r *http.Request) {
var payload struct {
ClusterID string `json:"cluster_id"`
NodeKey string `json:"node_key"`
Name string `json:"name"`
OwnershipType string `json:"ownership_type"`
OwnerOrganizationID *string `json:"owner_organization_id"`
ReportedVersion *string `json:"reported_version"`
Metadata json.RawMessage `json:"metadata"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric node registration payload")
return
}
item, err := m.cluster.RegisterFabricNode(r.Context(), clustermodule.RegisterFabricNodeInput{
ClusterID: payload.ClusterID,
NodeKey: payload.NodeKey,
Name: payload.Name,
OwnershipType: payload.OwnershipType,
OwnerOrganizationID: payload.OwnerOrganizationID,
ReportedVersion: payload.ReportedVersion,
Metadata: payload.Metadata,
})
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": "registered",
"node_id": item.ID,
"node": item,
})
}
func (m *Module) enrollAgent(w http.ResponseWriter, r *http.Request) {
@@ -133,17 +154,17 @@ func (m *Module) enrollAgent(w http.ResponseWriter, r *http.Request) {
})
}
func (m *Module) bootstrapEnrollment(w http.ResponseWriter, r *http.Request) {
func (m *Module) fetchEnrollmentJoinContract(w http.ResponseWriter, r *http.Request) {
var payload struct {
ClusterID string `json:"cluster_id"`
NodeFingerprint string `json:"node_fingerprint"`
PublicKey string `json:"public_key"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid enrollment bootstrap payload")
httpx.WriteError(w, http.StatusBadRequest, "invalid enrollment join payload")
return
}
result, err := m.cluster.GetJoinRequestBootstrap(r.Context(), clustermodule.GetJoinRequestBootstrapInput{
result, err := m.cluster.GetJoinRequestJoin(r.Context(), clustermodule.GetJoinRequestJoinInput{
ClusterID: payload.ClusterID,
JoinRequestID: chi.URLParam(r, "requestID"),
NodeFingerprint: payload.NodeFingerprint,
@@ -155,261 +176,3 @@ func (m *Module) bootstrapEnrollment(w http.ResponseWriter, r *http.Request) {
}
httpx.WriteJSON(w, http.StatusOK, result)
}
func (m *Module) registerAgent(w http.ResponseWriter, r *http.Request) {
var payload struct {
ClusterID string `json:"cluster_id"`
NodeKey string `json:"node_key"`
Name string `json:"name"`
OwnershipType string `json:"ownership_type"`
OwnerOrganizationID *string `json:"owner_organization_id"`
ReportedVersion *string `json:"reported_version"`
Metadata json.RawMessage `json:"metadata"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid agent registration payload")
return
}
if payload.NodeKey == "" || payload.Name == "" || payload.OwnershipType == "" {
httpx.WriteError(w, http.StatusBadRequest, "node_key, name, and ownership_type are required")
return
}
if len(payload.Metadata) == 0 {
payload.Metadata = json.RawMessage(`{}`)
}
now := time.Now().UTC()
nodeID := uuid.NewString()
if err := m.db.QueryRow(r.Context(), `
INSERT INTO nodes (
id, owner_organization_id, node_key, name, ownership_type, registration_status, health_status,
version_state, partition_state, desired_version, reported_version, last_seen_at, metadata, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, 'active', 'unknown', 'unknown', 'healthy', NULL, $6, $7, $8::jsonb, $9, $10)
ON CONFLICT (node_key) DO UPDATE SET
name = EXCLUDED.name,
ownership_type = EXCLUDED.ownership_type,
owner_organization_id = EXCLUDED.owner_organization_id,
registration_status = 'active',
reported_version = EXCLUDED.reported_version,
last_seen_at = EXCLUDED.last_seen_at,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at
RETURNING id
`, nodeID, payload.OwnerOrganizationID, payload.NodeKey, payload.Name, payload.OwnershipType, payload.ReportedVersion, now, []byte(payload.Metadata), now, now).Scan(&nodeID); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
if payload.ClusterID != "" {
if _, err := m.db.Exec(r.Context(), `
INSERT INTO cluster_memberships (cluster_id, node_id, membership_status, joined_at, last_seen_at, metadata)
VALUES ($1::uuid, $2::uuid, 'active', $3, $3, $4::jsonb)
ON CONFLICT (cluster_id, node_id) DO UPDATE SET
membership_status = 'active',
last_seen_at = EXCLUDED.last_seen_at,
metadata = cluster_memberships.metadata || EXCLUDED.metadata
`, payload.ClusterID, nodeID, now, []byte(`{"source":"fabric_control_candidate_registration"}`)); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"node_id": nodeID,
"status": "registered",
"legacy": true,
"warning": "direct node-agent registration is retained for compatibility; production enrollment must use /node-agents/enroll",
})
}
func (m *Module) reportHealth(w http.ResponseWriter, r *http.Request) {
var payload struct {
HealthStatus string `json:"health_status"`
ReportedVersion *string `json:"reported_version"`
Metadata json.RawMessage `json:"metadata"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid node health payload")
return
}
if payload.HealthStatus == "" {
payload.HealthStatus = "unknown"
}
if len(payload.Metadata) == 0 {
payload.Metadata = json.RawMessage(`{}`)
}
if _, err := m.db.Exec(r.Context(), `
UPDATE nodes
SET health_status = $2, reported_version = COALESCE($3, reported_version), last_seen_at = $4, metadata = $5::jsonb, updated_at = $4
WHERE id = $1
`, chi.URLParam(r, "nodeID"), payload.HealthStatus, payload.ReportedVersion, time.Now().UTC(), []byte(payload.Metadata)); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
}
func (m *Module) reportServiceStatus(w http.ResponseWriter, r *http.Request) {
var payload struct {
Services []struct {
ServiceType string `json:"service_type"`
ReportedState string `json:"reported_state"`
Metadata json.RawMessage `json:"metadata"`
} `json:"services"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid node service status payload")
return
}
now := time.Now().UTC()
for _, service := range payload.Services {
if len(service.Metadata) == 0 {
service.Metadata = json.RawMessage(`{}`)
}
if _, err := m.db.Exec(r.Context(), `
INSERT INTO node_services (
node_id, service_type, enabled, desired_state, reported_state, last_reported_at, metadata, updated_at
) VALUES ($1, $2, FALSE, 'disabled', $3, $4, $5::jsonb, $4)
ON CONFLICT (node_id, service_type) DO UPDATE SET
reported_state = EXCLUDED.reported_state,
last_reported_at = EXCLUDED.last_reported_at,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at
`, chi.URLParam(r, "nodeID"), service.ServiceType, service.ReportedState, now, []byte(service.Metadata)); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
}
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
}
func (m *Module) listVPNAssignments(w http.ResponseWriter, r *http.Request) {
items, err := m.cluster.ListNodeVPNAssignments(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
if writeClusterServiceError(w, err) {
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"vpn_assignments": items,
"runtime_execution_enabled": false,
})
}
func (m *Module) reportVPNAssignmentStatus(w http.ResponseWriter, r *http.Request) {
var payload struct {
ObservedStatus string `json:"observed_status"`
StatusPayload json.RawMessage `json:"status_payload"`
ObservedAt *time.Time `json:"observed_at"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn assignment status payload")
return
}
observedAt := time.Time{}
if payload.ObservedAt != nil {
observedAt = *payload.ObservedAt
}
item, err := m.cluster.ReportNodeVPNAssignmentStatus(r.Context(), clustermodule.ReportNodeVPNAssignmentStatusInput{
ClusterID: chi.URLParam(r, "clusterID"),
NodeID: chi.URLParam(r, "nodeID"),
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
ObservedStatus: payload.ObservedStatus,
StatusPayload: payload.StatusPayload,
ObservedAt: observedAt,
})
if writeClusterServiceError(w, err) {
return
}
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
"vpn_assignment_status": item,
"runtime_execution_enabled": false,
})
}
func (m *Module) requestUpdateManifest(w http.ResponseWriter, r *http.Request) {
nodeID := chi.URLParam(r, "nodeID")
var mode, channel string
var canary, automatic bool
var desiredVersion *string
if err := m.db.QueryRow(r.Context(), `
SELECT n.desired_version, p.mode, p.channel, p.canary, p.automatic_rollout
FROM nodes n
LEFT JOIN node_update_policies p ON p.node_id = n.id
WHERE n.id = $1
`, nodeID).Scan(&desiredVersion, &mode, &channel, &canary, &automatic); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"manifest": map[string]any{
"node_id": nodeID,
"desired_version": desiredVersion,
"mode": mode,
"channel": channel,
"canary": canary,
"automatic_rollout": automatic,
},
})
}
func (m *Module) acknowledgeUpdateResult(w http.ResponseWriter, r *http.Request) {
m.recordUpdateRun(w, r, "update")
}
func (m *Module) reportRollbackResult(w http.ResponseWriter, r *http.Request) {
m.recordUpdateRun(w, r, "rollback")
}
func (m *Module) recordUpdateRun(w http.ResponseWriter, r *http.Request, action string) {
var payload struct {
TargetVersion string `json:"target_version"`
Status string `json:"status"`
Payload json.RawMessage `json:"payload"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "invalid update result payload")
return
}
if payload.Status == "" {
payload.Status = "acknowledged"
}
if len(payload.Payload) == 0 {
payload.Payload = json.RawMessage(`{}`)
}
now := time.Now().UTC()
runID := uuid.NewString()
if _, err := m.db.Exec(r.Context(), `
INSERT INTO node_agent_update_runs (
id, node_id, action, target_version, status, requested_at, acknowledged_at, completed_at, payload
) VALUES ($1, $2, $3, $4, $5, $6, $6, CASE WHEN $5 IN ('succeeded', 'failed') THEN $6 ELSE NULL END, $7::jsonb)
`, runID, chi.URLParam(r, "nodeID"), action, payload.TargetVersion, payload.Status, now, []byte(payload.Payload)); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
if action == "update" && payload.Status == "succeeded" {
_, _ = m.db.Exec(r.Context(), `
UPDATE nodes
SET reported_version = $2, version_state = 'current', updated_at = $3
WHERE id = $1
`, chi.URLParam(r, "nodeID"), payload.TargetVersion, now)
}
if action == "rollback" && payload.Status == "succeeded" {
_, _ = m.db.Exec(r.Context(), `
UPDATE nodes
SET reported_version = $2, version_state = 'rollback', updated_at = $3
WHERE id = $1
`, chi.URLParam(r, "nodeID"), payload.TargetVersion, now)
}
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "run_id": runID})
}
func writeClusterServiceError(w http.ResponseWriter, err error) bool {
if err == nil {
return false
}
switch {
case errors.Is(err, clustermodule.ErrVPNLeaseOwnerNotAllowed), errors.Is(err, clustermodule.ErrVPNLeaseOwnerRoleRequired):
httpx.WriteError(w, http.StatusForbidden, err.Error())
case errors.Is(err, clustermodule.ErrInvalidPayload):
httpx.WriteError(w, http.StatusBadRequest, err.Error())
default:
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
}
return true
}
@@ -22,7 +22,7 @@ import (
const (
ModeStrict = "strict"
ModeLegacy = "legacy"
ModeCompat = "compat"
ActivationSchemaVersion = "rap.installation.activation.v1"
@@ -60,7 +60,7 @@ type Verifier struct {
func NewVerifier(cfg config.InstallationConfig) (*Verifier, error) {
mode := strings.ToLower(strings.TrimSpace(cfg.AuthorityMode))
if mode == "" {
mode = ModeLegacy
mode = ModeCompat
}
verifier := &Verifier{
mode: mode,
@@ -69,7 +69,7 @@ func NewVerifier(cfg config.InstallationConfig) (*Verifier, error) {
}
switch mode {
case ModeLegacy:
case ModeCompat:
return verifier, nil
case ModeStrict:
publicKey, err := decodeEd25519PublicKey(cfg.ProductRootPublicKeyBase64)
@@ -87,7 +87,7 @@ func NewVerifier(cfg config.InstallationConfig) (*Verifier, error) {
func (v *Verifier) Mode() string {
if v == nil || v.mode == "" {
return ModeLegacy
return ModeCompat
}
return v.mode
}
@@ -162,7 +162,7 @@ func EffectivePlatformRole(ctx context.Context, db postgresplatform.DBTX, verifi
return PlatformRoleUser, nil
}
if verifier == nil || !verifier.Strict() {
return legacyPlatformRole(ctx, db, userID)
return storedPlatformRole(ctx, db, userID)
}
var email string
@@ -220,7 +220,7 @@ END, prg.granted_at DESC
} else if ok {
return role, nil
}
return legacyPlatformRole(ctx, db, userID)
return storedPlatformRole(ctx, db, userID)
}
return bestRole, nil
}
@@ -257,7 +257,7 @@ WHERE u.id = $1::uuid
}
}
func legacyPlatformRole(ctx context.Context, db postgresplatform.DBTX, userID string) (string, error) {
func storedPlatformRole(ctx context.Context, db postgresplatform.DBTX, userID string) (string, error) {
var role string
if err := db.QueryRow(ctx, `SELECT platform_role FROM users WHERE id = $1::uuid`, userID).Scan(&role); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
+23 -13
View File
@@ -10,17 +10,18 @@ import (
)
type Config struct {
App AppConfig
HTTP HTTPConfig
Postgres PostgresConfig
Redis RedisConfig
Auth AuthConfig
Installation InstallationConfig
DataPlane DataPlaneConfig
Secret SecretConfig
Session SessionConfig
Worker WorkerConfig
WebSocket WebSocketConfig
App AppConfig
HTTP HTTPConfig
FabricControl FabricControlConfig
Postgres PostgresConfig
Redis RedisConfig
Auth AuthConfig
Installation InstallationConfig
DataPlane DataPlaneConfig
Secret SecretConfig
Session SessionConfig
Worker WorkerConfig
WebSocket WebSocketConfig
}
type AppConfig struct {
@@ -37,6 +38,11 @@ type HTTPConfig struct {
ShutdownTimeout time.Duration
}
type FabricControlConfig struct {
Enabled bool
ListenAddr string
}
type PostgresConfig struct {
DSN string
MaxConns int32
@@ -118,6 +124,10 @@ func Load() (Config, error) {
IdleTimeout: getDuration("HTTP_IDLE_TIMEOUT", 60*time.Second),
ShutdownTimeout: getDuration("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second),
},
FabricControl: FabricControlConfig{
Enabled: getBool("FABRIC_CONTROL_QUIC_ENABLED", false),
ListenAddr: getEnv("FABRIC_CONTROL_QUIC_LISTEN_ADDR", ":19191"),
},
Postgres: PostgresConfig{
DSN: getEnv("POSTGRES_DSN", ""),
MaxConns: int32(getInt("POSTGRES_MAX_CONNS", 20)),
@@ -235,13 +245,13 @@ func Load() (Config, error) {
func normalizeInstallationAuthorityMode(mode string, rootPublicKey string) string {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "strict", "legacy":
case "strict", "compat":
return mode
case "":
if strings.TrimSpace(rootPublicKey) != "" {
return "strict"
}
return "legacy"
return "compat"
default:
return mode
}
@@ -0,0 +1,78 @@
package fabriccontrol
import (
"encoding/binary"
"errors"
"fmt"
"io"
)
const (
frameMagic uint32 = 0x52415046
frameVersion uint8 = 1
frameHeaderSize = 32
maxPayload = 1024 * 1024
frameData uint8 = 5
trafficClassReliable uint8 = 4
controlForwardQUICStream uint64 = 3
)
type frame struct {
Type uint8
TrafficClass uint8
StreamID uint64
Sequence uint64
Payload []byte
}
var errInvalidFrame = errors.New("invalid fabric frame")
func readFrame(r io.Reader) (frame, error) {
header := make([]byte, frameHeaderSize)
if _, err := io.ReadFull(r, header); err != nil {
return frame{}, err
}
if binary.BigEndian.Uint32(header[0:4]) != frameMagic || header[4] != frameVersion {
return frame{}, errInvalidFrame
}
payloadLen := int(binary.BigEndian.Uint32(header[28:32]))
if payloadLen > maxPayload {
return frame{}, fmt.Errorf("%w: payload too large", errInvalidFrame)
}
out := frame{
Type: header[5],
TrafficClass: header[8],
StreamID: binary.BigEndian.Uint64(header[12:20]),
Sequence: binary.BigEndian.Uint64(header[20:28]),
}
if payloadLen > 0 {
out.Payload = make([]byte, payloadLen)
if _, err := io.ReadFull(r, out.Payload); err != nil {
return frame{}, err
}
}
return out, nil
}
func writeFrame(w io.Writer, f frame) error {
if len(f.Payload) > maxPayload {
return fmt.Errorf("%w: payload too large", errInvalidFrame)
}
header := make([]byte, frameHeaderSize)
binary.BigEndian.PutUint32(header[0:4], frameMagic)
header[4] = frameVersion
header[5] = f.Type
header[8] = f.TrafficClass
binary.BigEndian.PutUint64(header[12:20], f.StreamID)
binary.BigEndian.PutUint64(header[20:28], f.Sequence)
binary.BigEndian.PutUint32(header[28:32], uint32(len(f.Payload)))
if _, err := w.Write(header); err != nil {
return err
}
if len(f.Payload) == 0 {
return nil
}
_, err := w.Write(f.Payload)
return err
}
@@ -0,0 +1,195 @@
package fabriccontrol
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/quic-go/quic-go"
)
const nextProto = "rap-fabric-data-session-v1"
type Config struct {
Enabled bool
ListenAddr string
}
type Server struct {
cfg Config
router http.Handler
ln *quic.Listener
}
type rawControlRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Body json.RawMessage `json:"body,omitempty"`
}
type rawControlResponse struct {
StatusCode int `json:"status_code"`
Body json.RawMessage `json:"body,omitempty"`
}
type controlEnvelope struct {
Payload json.RawMessage `json:"payload,omitempty"`
Error string `json:"error,omitempty"`
}
func New(cfg Config, router http.Handler) *Server {
return &Server{cfg: cfg, router: router}
}
func (s *Server) ListenAndServe(ctx context.Context) error {
if s == nil || !s.cfg.Enabled {
return nil
}
listenAddr := strings.TrimSpace(s.cfg.ListenAddr)
if listenAddr == "" {
listenAddr = ":19191"
}
ln, err := quic.ListenAddr(listenAddr, selfSignedTLSConfig(), nil)
if err != nil {
return err
}
s.ln = ln
go func() {
<-ctx.Done()
_ = ln.Close()
}()
for {
conn, err := ln.Accept(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
go s.handleConn(ctx, conn)
}
}
func (s *Server) Close() error {
if s == nil || s.ln == nil {
return nil
}
return s.ln.Close()
}
func (s *Server) handleConn(ctx context.Context, conn *quic.Conn) {
for {
stream, err := conn.AcceptStream(ctx)
if err != nil {
return
}
go s.handleStream(ctx, stream)
}
}
func (s *Server) handleStream(ctx context.Context, stream *quic.Stream) {
defer stream.Close()
for {
reqFrame, err := readFrame(stream)
if err != nil {
return
}
if reqFrame.Type != frameData || reqFrame.StreamID != controlForwardQUICStream {
continue
}
payload, err := s.handlePayload(ctx, reqFrame.Payload)
envelope := controlEnvelope{Payload: payload}
if err != nil {
envelope = controlEnvelope{Error: err.Error()}
}
raw, _ := json.Marshal(envelope)
_ = writeFrame(stream, frame{
Type: frameData,
TrafficClass: trafficClassReliable,
StreamID: controlForwardQUICStream,
Sequence: reqFrame.Sequence,
Payload: raw,
})
}
}
func (s *Server) handlePayload(ctx context.Context, payload []byte) (json.RawMessage, error) {
var req rawControlRequest
if err := json.Unmarshal(payload, &req); err != nil {
return nil, fmt.Errorf("invalid fabric control request")
}
method := strings.ToUpper(strings.TrimSpace(req.Method))
if method == "" {
method = http.MethodGet
}
path := normalizeControlPath(req.Path)
if path == "" {
return nil, fmt.Errorf("fabric control path is not allowed")
}
httpReq := httptest.NewRequest(method, path, bytes.NewReader(req.Body)).WithContext(ctx)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-RAP-Fabric-Control", "quic")
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httpReq)
body := append(json.RawMessage(nil), rec.Body.Bytes()...)
raw, err := json.Marshal(rawControlResponse{StatusCode: rec.Code, Body: body})
if err != nil {
return nil, err
}
return raw, nil
}
func normalizeControlPath(path string) string {
path = strings.TrimSpace(path)
if path == "" || strings.Contains(path, "://") || strings.Contains(path, "..") {
return ""
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if strings.HasPrefix(path, "/api/v1/") {
return path
}
switch {
case strings.HasPrefix(path, "/clusters/"),
strings.HasPrefix(path, "/organizations/"),
strings.HasPrefix(path, "/node-agents/"),
strings.HasPrefix(path, "/auth/"):
return "/api/v1" + path
default:
return ""
}
}
func selfSignedTLSConfig() *tls.Config {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: "rap-fabric-control"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
panic(err)
}
cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
return &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{nextProto}}
}
+24 -7
View File
@@ -24,6 +24,7 @@ import (
"github.com/example/remote-access-platform/backend/internal/modules/worker"
"github.com/example/remote-access-platform/backend/internal/platform/authority"
"github.com/example/remote-access-platform/backend/internal/platform/config"
"github.com/example/remote-access-platform/backend/internal/platform/fabriccontrol"
"github.com/example/remote-access-platform/backend/internal/platform/httpserver"
"github.com/example/remote-access-platform/backend/internal/platform/logging"
"github.com/example/remote-access-platform/backend/internal/platform/module"
@@ -33,12 +34,13 @@ import (
)
type App struct {
cfg config.Config
logger *slog.Logger
httpServer *http.Server
workers []backgroundRunner
db closeFunc
redis closeFunc
cfg config.Config
logger *slog.Logger
httpServer *http.Server
fabricControl *fabriccontrol.Server
workers []backgroundRunner
db closeFunc
redis closeFunc
}
type closeFunc func() error
@@ -138,7 +140,11 @@ func NewApp(ctx context.Context) (*App, error) {
cfg: cfg,
logger: logger,
httpServer: httpserver.New(cfg.HTTP, router),
workers: []backgroundRunner{workerEvents.Run, leaseMonitor.Run},
fabricControl: fabriccontrol.New(fabriccontrol.Config{
Enabled: cfg.FabricControl.Enabled,
ListenAddr: cfg.FabricControl.ListenAddr,
}, router),
workers: []backgroundRunner{workerEvents.Run, leaseMonitor.Run},
db: func() error {
db.Close()
return nil
@@ -167,6 +173,14 @@ func (a *App) Run(ctx context.Context) error {
}
}()
}
if a.fabricControl != nil && a.cfg.FabricControl.Enabled {
go func() {
a.logger.Info("fabric control quic starting", "addr", a.cfg.FabricControl.ListenAddr, "service", a.cfg.App.Name)
if err := a.fabricControl.ListenAndServe(ctx); err != nil {
errCh <- err
}
}()
}
select {
case <-ctx.Done():
@@ -184,6 +198,9 @@ func (a *App) Run(ctx context.Context) error {
if err := a.httpServer.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown http server: %w", err)
}
if a.fabricControl != nil {
_ = a.fabricControl.Close()
}
if err := a.redis(); err != nil {
return fmt.Errorf("close redis: %w", err)