mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
a3edee5e6a
* feat: add weekly UTC maintenance windows to mute rules
Let mute rules suppress only during recurring UTC windows, normalize
replica node identity, and fail-open when remotes lack schedule support
so older nodes never keep an all-day scheduled mute after a successful cleanup DELETE.
* fix: fail closed on corrupt mute schedules and clean invalid replicas
Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
* fix: require explicit repair before clearing a corrupt mute schedule
The suppression engine already fails closed on an unreadable stored
schedule (scheduleInvalid), but the frontend never surfaced that flag:
a corrupt rule looked identical to an ordinary unscheduled one, and
opening Edit then clicking Update sent an explicit schedule: null,
silently turning the corruption into a valid all-day mute. Add the
flag to the rule type, show an Invalid schedule badge on the card, and
block saving in the edit form until the operator explicitly touches
the weekly window (configures a new one, or toggles it to confirm the
clear).
* fix: correct contradictory toggle-sequence copy in schedule-repair toast
The blocking toast told operators to toggle the weekly window "off then
on" to confirm clearing a corrupt schedule, but the toggle starts off
for a corrupt rule, so that sequence leaves it on and trips the
no-selected-day validation instead. The correct, tested sequence is on
then off, matching the inline hint below the toggle. Also add a
regression test confirming the invalid-schedule save gate resets
cleanly across edit sessions on different rules.
* fix: enforce replica node_id and guard fleet sync against stale writes
Two hardenings to the suppression-rule fleet sync path found during
review: the /replica endpoint trusted the payload's node_id instead of
forcing it to null server-side, so a direct proxy-authenticated caller
could persist a scoped replica; and upsertNotificationSuppressionRuleReplica
overwrote unconditionally with no ordering check, so a delayed older
POST arriving after a newer one could downgrade the stored rule. Force
node_id to null on every replica write, and skip (with a warning log)
any incoming write whose updated_at is not newer than what's stored.
* test: assert the exact-tie updated_at case in the fleet sync stale-write guard
The staleness guard added in c31458a1 uses >= (ties are ignored, not
just strictly older writes); add the missing assertion for that
boundary and make the comment explicit about it.
* fix: bump vulnerable transitive backend dependencies
npm audit flagged body-parser, fast-uri, and protobufjs (one high
severity: fast-uri host confusion via failed IDN canonicalization).
All three have patch/minor fixes within existing semver ranges;
npm audit fix resolves all three with no package.json changes.
* fix: sanitize suppression replica fields before logging
Log entries built from fleet-sync replica payloads embedded rule id
and timestamp values directly, allowing a compromised peer to forge
log lines via control characters.
* fix: prevent delayed replica writes from resurrecting deleted mute rules
A network-reordered replica POST arriving after a DELETE fell into the
insert-when-absent branch with no protection, since the staleness guard
only compares against a row that still exists. Add a permanent
per-id tombstone (safe because rule ids are AUTOINCREMENT and never
reused): every delete records one, and the replica upsert refuses to
recreate a tombstoned id regardless of the incoming updated_at.
230 lines
8.6 KiB
TypeScript
230 lines
8.6 KiB
TypeScript
import axios from 'axios';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import semver from 'semver';
|
|
import { SENCHO_VERSION } from '../generated/version';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import type { ImagePinKind } from '../helpers/selfUpdateCompose';
|
|
|
|
const IMAGE_PIN_KINDS: readonly ImagePinKind[] = ['floating', 'semver', 'digest', 'unknown'];
|
|
|
|
/** Coerce an untrusted /api/meta value to a known pin kind, or null. */
|
|
function parseImagePinKind(value: unknown): ImagePinKind | null {
|
|
return typeof value === 'string' && (IMAGE_PIN_KINDS as readonly string[]).includes(value)
|
|
? (value as ImagePinKind)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Static registry of capabilities supported by THIS Sencho instance.
|
|
* Append-only: when a new feature ships, add its capability string here.
|
|
* The frontend uses these flags (not semver comparisons) to gate features
|
|
* on nodes that may be running older versions.
|
|
*/
|
|
export const CAPABILITIES = [
|
|
'stacks',
|
|
'containers',
|
|
'resources',
|
|
'templates',
|
|
'global-logs',
|
|
'system-stats',
|
|
'fleet',
|
|
'auto-updates',
|
|
'labels',
|
|
'webhooks',
|
|
'network-topology',
|
|
'notifications',
|
|
'notification-routing',
|
|
'notification-suppression',
|
|
'notification-suppression-schedule',
|
|
'host-console',
|
|
'container-exec',
|
|
'audit-log',
|
|
'scheduled-ops',
|
|
'sso',
|
|
'api-tokens',
|
|
'users',
|
|
'registries',
|
|
'self-update',
|
|
'vulnerability-scanning',
|
|
'compose-doctor',
|
|
'update-guard',
|
|
'compose-networking',
|
|
'env-inventory',
|
|
'container-label-inventory',
|
|
'project-env-files',
|
|
'compose-storage',
|
|
'cross-node-rbac',
|
|
'stack-down-remove-volumes',
|
|
'guided-external-network-preflight',
|
|
'service-scoped-update',
|
|
] as const;
|
|
|
|
/**
|
|
* Advertised by instances that enforce the proxied actor's role (instead of
|
|
* treating every node-to-node request as admin) and honor the exact-stack
|
|
* allowlist on stop-by-label. The control instance refuses to forward a
|
|
* non-admin's request, or a confirmed stop, to a remote lacking this flag so a
|
|
* mixed-version fleet cannot escalate or over-stop on an un-upgraded node.
|
|
*/
|
|
export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac';
|
|
|
|
export type Capability = (typeof CAPABILITIES)[number];
|
|
|
|
/** Remotes that evaluate weekly maintenance windows on mute/suppression replicas. */
|
|
export const NOTIFICATION_SUPPRESSION_SCHEDULE_CAPABILITY =
|
|
'notification-suppression-schedule' as const satisfies Capability;
|
|
|
|
/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */
|
|
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
|
|
|
/** Capability for the nested per-service update/restore routes and the `effective-services` model they read. */
|
|
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
|
|
|
/** Returns true when the string is a usable semver version. */
|
|
export function isValidVersion(v: string | null | undefined): v is string {
|
|
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v);
|
|
}
|
|
|
|
// Resolved once per process at import time, then cached.
|
|
function resolveVersion(): string | null {
|
|
// Primary: walk up to find the root package.json (always authoritative).
|
|
// The generated SENCHO_VERSION constant can be stale when a branch falls
|
|
// behind a release-please version bump, so we prefer the live value.
|
|
let dir = __dirname;
|
|
for (let i = 0; i < 5; i++) {
|
|
const candidate = path.join(dir, 'package.json');
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
if (pkg.name === 'sencho') return pkg.version;
|
|
} catch { /* not found, keep walking */ }
|
|
dir = path.dirname(dir);
|
|
}
|
|
// Fallback: build-time constant (may be stale in dev, but correct in Docker)
|
|
if (SENCHO_VERSION !== '0.0.0-dev') return SENCHO_VERSION;
|
|
console.warn('[CapabilityRegistry] Could not resolve Sencho version from any source');
|
|
return null;
|
|
}
|
|
|
|
const cachedVersion = resolveVersion();
|
|
|
|
export function getSenchoVersion(): string | null {
|
|
return cachedVersion;
|
|
}
|
|
|
|
export interface RemoteMeta {
|
|
version: string | null;
|
|
capabilities: string[];
|
|
startedAt: number | null;
|
|
/** Error message from a failed self-update attempt on the remote node. */
|
|
updateError: string | null;
|
|
/** True when the /api/meta request succeeded (node is reachable). */
|
|
online: boolean;
|
|
/**
|
|
* How the remote pins its Sencho image, when it advertises it. Null for an
|
|
* older remote that predates this field or one that could not classify its
|
|
* pin. The hub uses only this safe subset (no full image ref) for remote rows.
|
|
*/
|
|
imagePinKind: ImagePinKind | null;
|
|
/** True when the remote reports its update is blocked (digest/unknown pin). */
|
|
updateBlocked: boolean;
|
|
/**
|
|
* Coarse image channel from the remote public meta. Null when the remote is
|
|
* older than this field or offline. Safe to expose (no private repository path).
|
|
*/
|
|
imageChannel: 'community' | 'hardened' | 'unknown' | null;
|
|
}
|
|
|
|
// Runtime capability overrides; services call disableCapability() during init.
|
|
const disabledCapabilities = new Set<Capability>();
|
|
|
|
export function disableCapability(c: Capability): void {
|
|
disabledCapabilities.add(c);
|
|
}
|
|
|
|
export function enableCapability(c: Capability): void {
|
|
disabledCapabilities.delete(c);
|
|
}
|
|
|
|
/** Returns capabilities this instance actually supports at runtime. */
|
|
export function getActiveCapabilities(): readonly string[] {
|
|
if (disabledCapabilities.size === 0) return CAPABILITIES;
|
|
return CAPABILITIES.filter(c => !disabledCapabilities.has(c));
|
|
}
|
|
|
|
/**
|
|
* Capabilities a pilot-agent process should hide from its own /api/meta because
|
|
* the central->pilot path for them is not yet wired through the reverse tunnel.
|
|
* Surfacing them would let the frontend offer a tab whose click silently falls
|
|
* through to central's local handler.
|
|
*
|
|
* `self-update` is intentionally NOT here: a pilot deployed via Docker Compose
|
|
* picks up the compose labels SelfUpdateService.initialize() needs and toggles
|
|
* the capability on locally; the Fleet Update flow then routes through
|
|
* NodeRegistry.getProxyTarget() so the tunnel carries the trigger.
|
|
*/
|
|
const PILOT_DISABLED_CAPABILITIES: readonly Capability[] = [
|
|
'host-console',
|
|
];
|
|
|
|
/** Disable capabilities that require a central->pilot path that is not yet wired. */
|
|
export function applyPilotModeCapabilityFilter(): void {
|
|
for (const cap of PILOT_DISABLED_CAPABILITIES) disableCapability(cap);
|
|
}
|
|
|
|
/** Shared offline shape returned when a remote node is unreachable. */
|
|
export const OFFLINE_META: RemoteMeta = {
|
|
version: null,
|
|
capabilities: [],
|
|
startedAt: null,
|
|
updateError: null,
|
|
online: false,
|
|
imagePinKind: null,
|
|
updateBlocked: false,
|
|
imageChannel: null,
|
|
};
|
|
|
|
function parseImageChannel(value: unknown): RemoteMeta['imageChannel'] {
|
|
if (value === 'community' || value === 'hardened' || value === 'unknown') return value;
|
|
return null;
|
|
}
|
|
|
|
/** Strip any `user:pass@` userinfo from a URL so credentials never reach the logs. */
|
|
function redactUrlCredentials(url: string): string {
|
|
return url.replace(/(\/\/)[^/@]*@/, '$1');
|
|
}
|
|
|
|
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
|
|
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
|
|
const safeUrl = redactUrlCredentials(baseUrl);
|
|
try {
|
|
const res = await axios.get(`${baseUrl.replace(/\/$/, '')}/api/meta`, {
|
|
headers: apiToken ? { Authorization: `Bearer ${apiToken}` } : {},
|
|
timeout: 5000,
|
|
});
|
|
const rawVersion: string | undefined = res.data.version;
|
|
const meta: RemoteMeta = {
|
|
version: isValidVersion(rawVersion) ? rawVersion : null,
|
|
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
|
|
startedAt: typeof res.data.startedAt === 'number' ? res.data.startedAt : null,
|
|
updateError: typeof res.data.updateError === 'string' ? res.data.updateError : null,
|
|
online: true,
|
|
imagePinKind: parseImagePinKind(res.data.imagePinKind),
|
|
updateBlocked: res.data.updateBlocked === true,
|
|
imageChannel: parseImageChannel(res.data.imageChannel),
|
|
};
|
|
if (isDebugEnabled()) {
|
|
// Diagnostic aid for "why is this feature gated?": log the resolved version
|
|
// and capability count (not the full list) at the one boundary that decides
|
|
// gating. The URL is logged with any userinfo credentials stripped.
|
|
console.log(
|
|
`[CapabilityRegistry:diag] meta ok from ${safeUrl}: version=${meta.version ?? 'null'} capabilities=${meta.capabilities.length}`,
|
|
);
|
|
}
|
|
return meta;
|
|
} catch (err) {
|
|
console.warn(`[CapabilityRegistry] Failed to fetch meta from ${safeUrl}:`, (err as Error).message);
|
|
return { ...OFFLINE_META };
|
|
}
|
|
}
|