chore: move plugins to their own repos and drop the plugins folder

This commit is contained in:
LukeGus committed 2026-10-04 22:40:06 -05:00
1 parent 29a946d02e
commit e02b5fe6e4
3167 files changed
+286 -440749

No files matched your search

+5 -15
View File
@@ -226,21 +226,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker, host-metrics, file-manager and tmux monitoring are
// deliberately absent: the ssh-terminal, docker, host-metrics,
// file-manager and tmux-monitor plugins start their own servers, so
// disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial, Homepage)
// is absent for the same reason: each one serves its routes under
// /plugin-api/<id>/ (or a WS route under /plugin-ws/<id>/) through ctx on
// activate, so disabling it answers 503 instead of leaving a dead import
// here. The dashboard's own uptime and recent-activity routes are core
// and are mounted on the main server in database.ts.
// Automations' scheduler and tunnel autostart also start from their own
// activate() rather than here.
// Plugins serve their routes under /plugin-api/<id>/ and /plugin-ws/<id>/
// through ctx on activate, and start their own schedulers there, so
// disabling one answers 503 instead of leaving a dead import here. The
// dashboard's own uptime and recent-activity routes are core and are
// mounted on the main server in database.ts.
// Initialize log level from database settings
const { getCurrentSettingValue } =
Binary file not shown.
@@ -1 +0,0 @@
d4 ssh recording
@@ -1 +0,0 @@
d4 rdp recording
-4
View File
@@ -1,4 +0,0 @@
JWT_SECRET=059e69f1248271031d958a76bd60280f09b8c99fa3414a03de84d0af886c1e4cef0ccbb4d5f1ef2d05c3607b89805134aa9cf985ebaddd3a01d7847a0772c2d7
DATABASE_KEY=a0e3e3b679a351d0daeb0f790ecfce906c3169d966e81d2e6be89873ab4b191a
ENCRYPTION_KEY=1c06c6a7605c71dc4724d4d39d17ea45afd44e3325115ac4e1e21e9d55f73030
INTERNAL_AUTH_TOKEN=d1ae0f2e46a0913996148702cf5a4decb1da7b68a39098ac3a75a00f0d1278e2
-922
View File
@@ -1,922 +0,0 @@
/**
* The rows in the committed 2.8 fixture (db.sqlite next to this file), as
* plain data. scripts/build-upgrade-fixture.ts writes them into the real 2.8
* schema, encrypting the columns 2.8 kept encrypted with each user's data
* key, and the upgrade test reads them back through 2.9.0's plugins.
*
* Every value a test looks for carries "d4" so it is easy to find.
*/
export type Row = Record<string, unknown>;
/** Every password user signs in with this. */
export const FIXTURE_PASSWORD = "d4-upgrade-password";
/** The TOTP user's secret (base32) and 2.8 backup codes. */
export const TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
export const TOTP_BACKUP_CODES = ["D4BACKUP1", "D4BACKUP2", "D4BACKUP3"];
export const USERS = {
admin: "d4-user-admin",
alice: "d4-user-alice",
totp: "d4-user-totp",
passkey: "d4-user-passkey",
sso: "d4-user-sso",
ldap: "d4-user-ldap",
} as const;
export const HOSTS = {
password: 1,
key: 2,
credential: 3,
none: 4,
agent: 5,
opkssh: 6,
stepca: 7,
tailscale: 8,
vault: 9,
warpgate: 10,
rdpOnly: 11,
plain: 12,
desktop: 13,
} as const;
/** The 2.8 auth_type each host was saved with. */
export const HOST_AUTH_TYPES: Record<number, string> = {
[HOSTS.password]: "password",
[HOSTS.key]: "key",
[HOSTS.credential]: "credential",
[HOSTS.none]: "none",
[HOSTS.agent]: "agent",
[HOSTS.opkssh]: "opkssh",
[HOSTS.stepca]: "stepca",
[HOSTS.tailscale]: "tailscale",
[HOSTS.vault]: "vault",
[HOSTS.warpgate]: "warpgate",
[HOSTS.rdpOnly]: "password",
[HOSTS.plain]: "password",
[HOSTS.desktop]: "password",
};
/**
* 2.8 stored recording paths absolute. The fixture writes this token instead,
* and the harness swaps in the DATA_DIR it boots from, where it also copies
* the files under upgrade/files.
*/
export const DATA_DIR_TOKEN = "{{DATA_DIR}}";
export const RECORDING_FILES = {
ssh: "session_logs/d4-user-admin/d4-session.cast",
rdp: "session_recordings/guacamole/d4-rdp.guac",
};
export const SSO_PROVIDER_ID = 1;
export const LDAP_PROVIDER_ID = 2;
/** 2.8's seeded user role, which the upgrade must leave as it was. */
export const USER_ROLE_PERMISSIONS_28 = [
"hosts.*",
"snippets.*",
"automations.*",
"credentials.*",
"ai.*",
];
export const OPERATORS_ROLE_ID = 10;
export const OPERATORS_PERMISSIONS = [
"hosts.view",
"credentials.view",
"snippets.*",
"automations.run",
"ai.use",
];
/** 2.8's electron/remote-sync-entities.cjs SYNCED_ENTITY_TYPES. */
export const SYNCED_ENTITY_TYPES_28 = [
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
];
const T = "2026-05-01T10:00:00.000Z";
const LATER = "2099-01-01T00:00:00.000Z";
/**
* Columns 2.8 stored encrypted with the owning user's data key, as
* [table, column, field name, column holding the user id]. The record id is
* the row id, except on users where it is the user id.
*/
export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [
["users", "totp_secret", "totpSecret", "id"],
["users", "totp_backup_codes", "totpBackupCodes", "id"],
["ssh_data", "password", "password", "user_id"],
["ssh_data", "key", "key", "user_id"],
["ssh_data", "key_password", "keyPassword", "user_id"],
["ssh_data", "sudo_password", "sudoPassword", "user_id"],
["ssh_data", "rdp_password", "rdpPassword", "user_id"],
["ssh_data", "vnc_password", "vncPassword", "user_id"],
["ssh_data", "telnet_password", "telnetPassword", "user_id"],
["ssh_credentials", "password", "password", "user_id"],
["ssh_credentials", "private_key", "privateKey", "user_id"],
["ssh_credentials", "key", "key", "user_id"],
["ssh_credentials", "public_key", "publicKey", "user_id"],
["notification_channels", "config", "config", "user_id"],
["opkssh_tokens", "ssh_cert", "sshCert", "user_id"],
["opkssh_tokens", "private_key", "privateKey", "user_id"],
["vault_tokens", "ssh_cert", "sshCert", "user_id"],
["vault_tokens", "private_key", "privateKey", "user_id"],
["termix_identity_ca", "private_key", "privateKey", "user_id"],
["ai_providers", "api_key", "apiKey", "user_id"],
["secret_sources", "token", "token", "user_id"],
];
const PUBLIC_KEY =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGd4ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0ZDRkNGQ0 d4";
const PRIVATE_KEY =
"-----BEGIN OPENSSH PRIVATE KEY-----\nd4-private-key\n-----END OPENSSH PRIVATE KEY-----";
function host(id: number, name: string, extra: Row = {}): Row {
return {
id,
user_id: USERS.admin,
name,
ip: `10.4.0.${id}`,
port: 22,
username: "root",
folder: "d4-folder",
tags: JSON.stringify(["d4"]),
auth_type: HOST_AUTH_TYPES[id],
sync_id: `d4-sync-host-${id}`,
created_at: T,
updated_at: T,
...extra,
};
}
const HOST_ROWS: Row[] = [
// Every plugin switch changed away from its 2.8 default.
host(HOSTS.password, "d4-password", {
password: "d4-host-password",
sudo_password: "d4-sudo",
enable_terminal: 1,
enable_command_history: 0,
enable_terminal_toolbar: 0,
enable_session_logging: 0,
allow_session_sharing: 0,
enable_file_manager: 0,
default_path: "/srv/d4",
scp_legacy: 1,
enable_tunnel: 1,
tunnel_connections: JSON.stringify([
{
sourcePort: 8080,
endpointPort: 80,
endpointHost: "d4-key",
autoStart: false,
},
]),
enable_web_ui: 1,
web_ui_config: JSON.stringify({
endpoints: [
{ id: "grafana", label: "d4-grafana", port: 3000, protocol: "http" },
],
}),
enable_tmux_monitor: 1,
enable_docker: 1,
docker_config: JSON.stringify({ runtime: "podman" }),
enable_ai_assistant: 1,
// Its own terminal look, behavior and SSH options, all in one JSON.
terminal_config: JSON.stringify({
theme: "dracula",
fontSize: 16,
cursorStyle: "block",
autoTmux: true,
sudoPasswordAutoFill: true,
localEcho: "on",
keepaliveInterval: 30,
keepaliveCountMax: 4,
allowLegacyAlgorithms: false,
agentForwarding: true,
environmentVariables: [{ key: "D4", value: "yes" }],
startupSnippetId: 1,
}),
quick_actions: JSON.stringify([{ name: "d4-uptime", snippetId: 1 }]),
mac_address: "AA:BB:CC:DD:EE:04",
wol_broadcast_address: "10.4.0.255",
stats_config: JSON.stringify({
statusCheckEnabled: false,
statusCheckInterval: 90,
metricsEnabled: false,
useGlobalMetricsInterval: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "memory"],
}),
}),
host(HOSTS.key, "d4-key", {
// Behavior only: this host kept following the user's look.
terminal_config: JSON.stringify({ passwordPromptAutoFill: false }),
key: PRIVATE_KEY,
key_password: "d4-key-pass",
key_type: "ssh-ed25519",
enable_proxmox: 1,
proxmox_config: JSON.stringify({
apiUrl: "https://pve.d4.example:8006",
tokenId: "root@pam!d4",
}),
enable_proxmox_stats: 1,
proxmox_stats_config: JSON.stringify({ node: "d4-node" }),
}),
host(HOSTS.credential, "d4-credential", { credential_id: 1 }),
host(HOSTS.none, "d4-none"),
host(HOSTS.agent, "d4-agent"),
host(HOSTS.opkssh, "d4-opkssh"),
host(HOSTS.stepca, "d4-stepca"),
host(HOSTS.tailscale, "d4-tailscale"),
host(HOSTS.vault, "d4-vault", { vault_profile_id: 1 }),
host(HOSTS.warpgate, "d4-warpgate"),
// A host from before the per-protocol switches: connection_type alone made
// it an RDP host with SSH off.
host(HOSTS.rdpOnly, "d4-rdp", {
connection_type: "rdp",
rdp_user: "d4-rdp-user",
rdp_password: "d4-rdp-password",
}),
host(HOSTS.desktop, "d4-desktop", {
enable_rdp: 1,
enable_vnc: 1,
enable_telnet: 1,
rdp_port: 3390,
vnc_port: 5901,
telnet_port: 2323,
rdp_user: "d4-rdp-user",
rdp_password: "d4-rdp-password",
rdp_domain: "D4-DOMAIN",
rdp_auth_type: "direct",
vnc_user: "d4-vnc-user",
vnc_password: "d4-vnc-password",
vnc_auth_type: "direct",
// Telnet through the saved credential, which has no password of its own.
telnet_auth_type: "credential",
telnet_credential_id: 1,
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ colorDepth: 24 }),
}),
// Touches nothing, so it has to keep reading 2.8's defaults.
host(HOSTS.plain, "d4-plain"),
];
export const ROWS: Record<string, Row[]> = {
users: [
{ id: USERS.admin, username: "d4-admin", is_admin: 1 },
{ id: USERS.alice, username: "d4-alice" },
{
id: USERS.totp,
username: "d4-totp",
totp_enabled: 1,
totp_secret: TOTP_SECRET,
totp_backup_codes: JSON.stringify(TOTP_BACKUP_CODES),
},
{ id: USERS.passkey, username: "d4-passkey" },
{
id: USERS.sso,
username: "d4-sso",
password_hash: "",
is_oidc: 1,
oidc_identifier: "d4-oidc-subject",
sso_provider_id: SSO_PROVIDER_ID,
},
{
id: USERS.ldap,
username: "d4-ldap",
password_hash: "",
is_oidc: 1,
oidc_identifier: `ldap:${LDAP_PROVIDER_ID}:d4-ldap-dn`,
sso_provider_id: LDAP_PROVIDER_ID,
},
].map((user) => ({ registered_at: T, ...user })),
roles: [
{
id: 1,
name: "admin",
display_name: "rbac.roles.admin",
description: "Administrator with full access",
is_system: 1,
permissions: JSON.stringify(["*"]),
},
{
id: 2,
name: "user",
display_name: "rbac.roles.user",
description: "Regular user",
is_system: 1,
permissions: JSON.stringify(USER_ROLE_PERMISSIONS_28),
},
{
id: OPERATORS_ROLE_ID,
name: "d4-operators",
display_name: "D4 operators",
is_system: 0,
permissions: JSON.stringify(OPERATORS_PERMISSIONS),
},
],
user_roles: [
{ user_id: USERS.admin, role_id: 1 },
...[USERS.alice, USERS.totp, USERS.passkey, USERS.sso, USERS.ldap].map(
(userId) => ({ user_id: userId, role_id: 2 }),
),
{
user_id: USERS.alice,
role_id: OPERATORS_ROLE_ID,
granted_by: USERS.admin,
},
],
ssh_credentials: [
{
id: 1,
user_id: USERS.admin,
name: "d4-credential",
auth_type: "key",
username: "deploy",
private_key: PRIVATE_KEY,
public_key: PUBLIC_KEY,
key_type: "ssh-ed25519",
sync_id: "d4-sync-credential",
},
],
ssh_folders: [
{
id: 1,
user_id: USERS.admin,
name: "d4-folder",
sync_id: "d4-sync-folder",
},
],
vault_profiles: [
{
id: 1,
user_id: USERS.admin,
name: "d4-vault",
vault_addr: "https://vault.d4.example",
ssh_mount: "ssh",
ssh_role: "admin",
shared: 0,
sync_id: "d4-sync-vault",
},
],
ssh_data: HOST_ROWS,
host_access: [
{
id: 1,
host_id: HOSTS.password,
user_id: USERS.alice,
granted_by: USERS.admin,
permission_level: "connect",
},
],
user_preferences: [
{
user_id: USERS.admin,
theme: "dark",
ai_assistant_enabled: 1,
ai_read_only_commands: 1,
rdp_defaults: JSON.stringify({
colorDepth: 16,
resizeMethod: "reconnect",
enableDrive: true,
}),
terminal_defaults: JSON.stringify({ fontSize: 18, cursorBlink: false }),
custom_themes: JSON.stringify([
{
id: "d4-theme",
name: "d4 theme",
colors: { background: "#101010", foreground: "#f0f0f0" },
},
]),
command_autocomplete: 1,
confirm_snippet_execution: 1,
folders_collapsed: 0,
terminal_macros: JSON.stringify([
{
id: "d4-macro",
name: "d4 macro",
steps: [{ id: "s1", type: "send", text: "uptime", pressEnter: true }],
},
]),
custom_keybindings: JSON.stringify([
{
id: "d4-kb",
combo: {
key: "u",
isCode: false,
ctrl: true,
alt: true,
shift: false,
meta: false,
},
action: { type: "runSnippet", snippetId: "1", appendEnter: true },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]),
},
],
sso_providers: [
{
id: SSO_PROVIDER_ID,
name: "d4-sso",
type: "oidc",
enabled: 1,
display_order: 0,
config: JSON.stringify({
issuerUrl: "https://id.d4.example",
clientId: "termix",
clientSecret: "d4-client-secret",
}),
},
{
id: LDAP_PROVIDER_ID,
name: "d4-ldap",
type: "ldap",
enabled: 1,
display_order: 1,
config: JSON.stringify({
url: "ldaps://ldap.d4.example",
bindDn: "cn=termix,dc=d4",
bindPassword: "d4-bind",
searchBase: "dc=d4",
}),
},
],
webauthn_credentials: [
{
id: "d4-passkey-id",
user_id: USERS.passkey,
name: "d4-passkey",
credential_id: "ZDQtY3JlZGVudGlhbA",
public_key: "ZDQtcHVibGljLWtleQ",
counter: 3,
transports: JSON.stringify(["internal"]),
},
],
notification_channels: [
{
id: 1,
user_id: USERS.admin,
name: "d4-channel",
type: "ntfy",
config: JSON.stringify({ url: "https://ntfy.d4.example/d4" }),
enabled: 1,
},
],
dismissed_alerts: [
{
id: 1,
user_id: USERS.admin,
alert_id: "d4-dismissed-announcement",
dismissed_at: T,
},
],
snippet_folders: [
{
id: 1,
user_id: USERS.admin,
name: "d4-snippet-folder",
sync_id: "d4-sync-snippet-folder",
},
],
snippets: [
{
id: 1,
user_id: USERS.admin,
name: "d4-snippet",
content: "uptime",
folder: "d4-snippet-folder",
sync_id: "d4-sync-snippet",
},
{
id: 2,
user_id: USERS.admin,
name: "d4-shared-snippet",
content: "df -h",
sync_id: "d4-sync-snippet-shared",
},
],
snippet_access: [
{
id: 1,
snippet_id: 2,
user_id: USERS.alice,
granted_by: USERS.admin,
permission_level: "view",
},
],
fleets: [{ id: 1, user_id: USERS.admin, name: "d4-fleet" }],
fleet_members: [
{ id: 1, fleet_id: 1, host_id: HOSTS.password },
{ id: 2, fleet_id: 1, host_id: HOSTS.key },
],
fleet_inventory: [
{
id: 1,
host_id: HOSTS.password,
user_id: USERS.admin,
os_pretty_name: "d4 Linux",
hostname: "d4-password",
},
],
automations: [
{
id: 1,
user_id: USERS.admin,
name: "d4-automation",
enabled: 0,
definition: JSON.stringify({
version: 1,
trigger: { type: "manual" },
steps: [],
}),
},
],
automation_trigger_state: [
{ id: 1, automation_id: 1, state_key: "d4-state", last_value: 1 },
],
automation_schedules: [
{ id: 1, automation_id: 1, cron: "0 4 * * *", timezone: "UTC" },
],
automation_runs: [
{
id: 1,
automation_id: 1,
user_id: USERS.admin,
trigger_type: "manual",
status: "success",
started_at: T,
finished_at: T,
},
],
automation_run_steps: [
{
id: 1,
run_id: 1,
step_index: 0,
step_id: "d4-step",
step_type: "command",
status: "success",
output: "d4-output",
},
],
automation_channels: [{ id: 1, automation_id: 1, channel_id: 1 }],
user_workspaces: [
{
id: 1,
user_id: USERS.admin,
name: "d4-workspace",
payload: JSON.stringify({}),
sync_id: "d4-sync-workspace",
},
],
network_topology: [
{
id: 1,
user_id: USERS.admin,
topology: JSON.stringify({ nodes: [{ id: "d4-node" }], edges: [] }),
},
],
homepage_items: [
{
id: 1,
user_id: USERS.admin,
type_id: "clock",
title: "d4-widget",
config: JSON.stringify({}),
sync_id: "d4-sync-homepage-item",
},
],
homepage_layouts: [
{
id: 1,
user_id: USERS.admin,
layout: JSON.stringify([{ i: "1", x: 0, y: 0, w: 2, h: 2 }]),
},
],
dashboard_service_links: [
{
id: 1,
user_id: USERS.admin,
label: "d4-link",
url: "https://d4.example",
sync_id: "d4-sync-service-link",
},
],
vault_tokens: [
{
id: 1,
user_id: USERS.admin,
profile_id: 1,
ssh_cert: "d4-vault-cert",
private_key: PRIVATE_KEY,
expires_at: LATER,
},
],
opkssh_tokens: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.opkssh,
ssh_cert: "d4-opkssh-cert",
private_key: PRIVATE_KEY,
email: "d4@example.com",
expires_at: LATER,
},
],
session_recordings: [
{
id: 1,
host_id: HOSTS.password,
user_id: USERS.admin,
username: "d4-admin",
started_at: T,
ended_at: T,
duration: 12,
recording_path: `${DATA_DIR_TOKEN}/${RECORDING_FILES.ssh}`,
protocol: "ssh",
format: "asciicast",
},
{
id: 2,
host_id: HOSTS.desktop,
user_id: USERS.admin,
username: "d4-admin",
started_at: T,
ended_at: T,
duration: 30,
recording_path: `${DATA_DIR_TOKEN}/${RECORDING_FILES.rdp}`,
protocol: "rdp",
format: "guacamole",
},
],
command_history: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
command: "d4-history-command",
executed_at: T,
},
],
c2s_tunnel_presets: [
{
id: 1,
user_id: USERS.admin,
name: "d4-preset",
config: JSON.stringify({ tunnels: [] }),
},
],
ai_providers: [
{
id: 1,
user_id: USERS.admin,
provider_type: "openai",
label: "d4-provider",
api_key: "sk-d4-provider-key",
api_key_prefix: "sk-d4",
default_model: "gpt-d4",
enabled: 1,
},
],
ai_conversations: [
{ id: 1, user_id: USERS.admin, title: "d4-conversation", provider_id: 1 },
],
ai_messages: [
{ id: 1, conversation_id: 1, role: "user", content: "d4-message" },
],
ai_proposals: [
{
id: 1,
conversation_id: 1,
user_id: USERS.admin,
kind: "run_command",
summary: "d4-proposal",
payload: JSON.stringify({ command: "uptime" }),
},
],
secret_sources: [
{
id: "d4-source",
user_id: USERS.admin,
name: "d4-source",
kind: "vaultwarden",
base_url: "https://vw.d4.example",
token: "d4-source-token",
},
],
termix_identities: [{ id: 1, user_id: USERS.admin, handle: "d4-handle" }],
termix_identity_keys: [
{
id: 1,
identity_id: 1,
user_id: USERS.admin,
public_key: PUBLIC_KEY,
key_type: "ssh-ed25519",
algorithm: "ed25519",
label: "d4-identity-key",
},
],
termix_identity_ca: [
{
id: 1,
identity_id: 1,
user_id: USERS.admin,
public_key: PUBLIC_KEY,
private_key: PRIVATE_KEY,
},
],
host_metrics_preferences: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
layout: JSON.stringify({ d4: true }),
},
],
host_health_checks: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
checks: JSON.stringify([{ id: "d4-check", type: "tcp", port: 22 }]),
},
],
host_health_history: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
check_id: "d4-check",
ts: T,
ok: 1,
latency_ms: 4,
},
],
host_metrics_history: [
{ id: 1, host_id: HOSTS.password, ts: T, cpu_percent: 44 },
],
proxmox_stats_preferences: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.key,
layout: JSON.stringify({ d4: true }),
},
],
proxmox_node_history: [{ id: 1, host_id: HOSTS.key, ts: T, cpu_percent: 4 }],
session_shares: [
{
id: "d4-share",
host_id: HOSTS.password,
owner_user_id: USERS.admin,
protocol: "ssh",
session_id: "d4-session",
share_type: "user",
target_user_id: USERS.alice,
permission_level: "read",
expires_at: LATER,
},
],
session_share_participants: [
{ id: 1, share_id: "d4-share", user_id: USERS.alice, joined_at: T },
],
collab_rooms: [
{
id: "d4-room",
name: "d4-room",
owner_user_id: USERS.admin,
persistent: 1,
},
],
collab_room_members: [
{ id: 1, room_id: "d4-room", user_id: USERS.admin, room_role: "owner" },
{ id: 2, room_id: "d4-room", user_id: USERS.alice, added_by: USERS.admin },
],
file_manager_recent: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
name: "d4-recent",
path: "/srv/d4/recent",
},
],
file_manager_pinned: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
name: "d4-pinned",
path: "/srv/d4/pinned",
},
],
file_manager_shortcuts: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
name: "d4-shortcut",
path: "/srv/d4/shortcut",
},
],
transfer_recent: [
{
id: 1,
user_id: USERS.admin,
source_host_id: HOSTS.password,
dest_host_id: HOSTS.key,
dest_path: "/srv/d4/transfer",
dest_path_label: "d4-transfer",
},
],
tmux_session_tags: [
{
id: 1,
user_id: USERS.admin,
host_id: HOSTS.password,
session_name: "d4-tmux",
tag: "d4-tag",
},
],
settings: [
["tailscale_api_key", "tskey-d4"],
["tailscale_api_base_url", "https://headscale.d4.example"],
["guac_enabled", "true"],
["guac_url", "guacd.d4.example:4822"],
["global_metrics_interval", "40"],
["metrics_history_retention_days", "14"],
[
"host_defaults",
JSON.stringify({
metricsEnabled: false,
enableCommandHistory: false,
fontSize: 20,
theme: "nord",
autoTmux: true,
}),
],
["ai_globally_enabled", "true"],
["ai_private_endpoint_allowlist", JSON.stringify(["10.4.0.0/24"])],
[
"acme_ssl_settings",
JSON.stringify({
enabled: true,
domain: "termix.d4.example",
email: "d4@example.com",
challengeType: "cloudflare",
cloudflareToken: "d4-cloudflare-token",
}),
],
["step_ca_url", "https://ca.d4.example"],
["step_ca_fingerprint", "d4fingerprint"],
["step_ca_provisioner", "d4-provisioner"],
["terminal_session_timeout_minutes", "45"],
["terminal_session_persistence_enabled", "false"],
["terminal_image_max_count", "7"],
["session_sharing_globally_enabled", "false"],
["analytics_enabled", "false"],
["analytics_instance_id", "d4-instance-id"],
// Long enough that the fixture's recordings never age out.
["session_recording_retention_days", "3650"],
].map(([key, value]) => ({ key, value })),
};
/** The 2.8 core tables the fixture fills that stay core in 2.9.0. */
export const CORE_TABLES = new Set([
"users",
"roles",
"user_roles",
"ssh_credentials",
"ssh_folders",
"ssh_data",
"host_access",
"user_preferences",
"settings",
]);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -297,12 +297,6 @@ describe("buildConnectConfig per auth type", () => {
expect(config.tryKeyboard).toBe(true);
});
// OPKSSH is a plugin-registered SSH auth provider now: see
// plugins/opkssh/tests/backend/provider.test.ts.
// Step CA is the step-ca plugin's provider: see
// plugins/step-ca/tests/backend/step-ca.test.ts.
it("a stepca host without the step-ca plugin names it", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "stepca", pluginId: "step-ca", pluginName: "Step CA" },
@@ -316,9 +310,6 @@ describe("buildConnectConfig per auth type", () => {
setSshAuthTypeOwnerSource(() => []);
});
// Vault is the vault plugin's provider: see
// plugins/vault/tests/backend/vault.test.ts.
it("a vault host without the vault plugin names it", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "vault", pluginId: "vault", pluginName: "HashiCorp Vault" },
@@ -332,9 +323,6 @@ describe("buildConnectConfig per auth type", () => {
setSshAuthTypeOwnerSource(() => []);
});
// Tailscale is a plugin-registered SSH auth provider now: see
// plugins/tailscale/tests/backend/ssh-auth-provider.test.ts.
it("a type nobody provides names the plugin that would", async () => {
setSshAuthTypeOwnerSource(() => [
{ type: "made-up", pluginId: "made", pluginName: "Made Up" },
-225
View File
@@ -1,225 +0,0 @@
/**
* Boots core the way starter.ts does, against a real SQLite database that
* starts in 2.8 shape, with the plugin loader pointed at the built bundles in
* dist/plugins rather than source. Shared by the boot and upgrade tests.
*
* Needs `npm run build` (or build:sdk and build:plugins) first.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
import { DATA_DIR_TOKEN } from "../fixtures/upgrade/rows.js";
import { remoteTestDialect, seedRemote28 } from "./remote-seed.js";
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
/** The committed 2.8 install: db.sqlite, the fixture.env it was keyed with and its files. */
export const UPGRADE_FIXTURE_DIR = path.join(__dirname, "../fixtures/upgrade");
/** Every plugin in the repo, by folder. */
export function sourcePluginIds(): string[] {
const root = path.join(REPO_ROOT, "plugins");
return fs
.readdirSync(root, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(root, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort();
}
/** Throws with the fix when dist/plugins is missing a plugin. */
export function assertPluginsBuilt(): void {
const missing = sourcePluginIds().filter(
(id) =>
!fs.existsSync(path.join(BUILT_PLUGINS_DIR, id, "dist", "backend.js")),
);
if (missing.length > 0) {
throw new Error(
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
);
}
}
export function builtManifest(id: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUILT_PLUGINS_DIR, id, "manifest.json"), "utf8"),
);
}
type Row = Record<string, unknown>;
/** A 2.8 database in memory, with a helper that ignores unknown columns. */
export function create28Database() {
const sqlite = new Database(":memory:");
sqlite.exec(fs.readFileSync(SCHEMA_28, "utf8"));
const columns = new Map<string, Set<string>>();
const insert = (table: string, row: Row): number => {
let known = columns.get(table);
if (!known) {
known = new Set(
(
sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
name: string;
}>
).map((column) => column.name),
);
columns.set(table, known);
}
const entries = Object.entries(row).filter(([key]) => known!.has(key));
const sql = `INSERT INTO "${table}" (${entries
.map(([key]) => `"${key}"`)
.join(", ")}) VALUES (${entries.map(() => "?").join(", ")})`;
const values = entries.map(([, value]) =>
typeof value === "boolean"
? value
? 1
: 0
: value !== null && typeof value === "object"
? JSON.stringify(value)
: value,
);
return Number(sqlite.prepare(sql).run(...values).lastInsertRowid);
};
return { sqlite, insert };
}
export interface BootedCore {
dataDir: string;
/** The live database core and the plugins write to; null on Postgres and MySQL. */
sqlite: Database.Database | null;
/** The 2.8 database file exactly as it was written to DATA_DIR, if one was. */
seed: Buffer | null;
/** keepDataDir writes the database to disk and leaves DATA_DIR for another boot. */
shutdown: (options?: { keepDataDir?: boolean }) => Promise<void>;
}
/** A 2.8 data directory to boot: a seeded database, or a folder holding one. */
export type BootSource =
Database.Database | { fixtureDir: string } | { reuseDataDir: string };
/**
* Lays a committed 2.8 install (fixtures/upgrade) into DATA_DIR: its key file
* and the files its rows point at, with the fixture's DATA_DIR token in those
* rows swapped for the real path, as 2.8 stored them absolute.
*/
function placeFixture(fixtureDir: string, dataDir: string): Buffer {
fs.copyFileSync(
path.join(fixtureDir, "fixture.env"),
path.join(dataDir, ".env"),
);
fs.cpSync(path.join(fixtureDir, "files"), dataDir, { recursive: true });
const sqlite = new Database(
fs.readFileSync(path.join(fixtureDir, "db.sqlite")),
);
try {
sqlite
.prepare(
"UPDATE session_recordings SET recording_path = replace(recording_path, ?, ?)",
)
.run(DATA_DIR_TOKEN, dataDir.split(path.sep).join("/"));
return sqlite.serialize();
} finally {
sqlite.close();
}
}
const SYSTEM_SECRETS = [
"JWT_SECRET",
"DATABASE_KEY",
"ENCRYPTION_KEY",
"INTERNAL_AUTH_TOKEN",
];
/**
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
* sequence up to, not including, opening the HTTP server: the pre-upgrade
* backup, the database, core's migrations, then the plugins and their data
* moves. Call vi.resetModules() before, so module state from other tests is
* gone.
*/
export async function bootCore(source: BootSource): Promise<BootedCore> {
const dataDir =
"reuseDataDir" in source
? source.reuseDataDir
: fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
// SystemCrypto prefers these over DATA_DIR/.env, and a previous boot in
// this worker set them.
for (const key of SYSTEM_SECRETS) delete process.env[key];
let seed: Buffer | null = null;
if (source instanceof Database) {
seed = source.serialize();
} else if ("fixtureDir" in source) {
seed = placeFixture(source.fixtureDir, dataDir);
}
// TEST_DIALECT=postgres|mysql puts the same 2.8 data on a real server.
const remote = remoteTestDialect();
if (remote) {
const url = process.env.TEST_DATABASE_URL;
if (!url) throw new Error(`TEST_DIALECT=${remote} needs TEST_DATABASE_URL`);
process.env.DATABASE_DIALECT = remote;
process.env.DATABASE_URL = url;
if (seed) await seedRemote28(remote, url, seed);
} else if (seed) {
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed);
}
const { SystemCrypto } = await import("../../utils/system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
const { backupBeforeUpgrade, runCoreBootMigrations } =
await import("../../boot.js");
await backupBeforeUpgrade({ dataDir, version: "2.9.0-test" });
const db = await import("../../database/db/index.js");
await db.initializeDatabase();
await runCoreBootMigrations();
const { primeKnownPermissions } =
await import("../../utils/known-permissions.js");
await primeKnownPermissions();
const { initializePlugins, shutdownPlugins } =
await import("../../plugins/index.js");
await initializePlugins();
const { runPluginDataMigrations } =
await import("../../upgrade/boot-migrations.js");
await runPluginDataMigrations();
return {
dataDir,
sqlite: remote ? null : db.getSqlite(),
seed,
shutdown: async ({ keepDataDir = false } = {}) => {
await shutdownPlugins();
if (keepDataDir) {
if (!remote) {
fs.writeFileSync(
path.join(dataDir, "db.sqlite"),
db.getSqlite().serialize(),
);
}
return;
}
fs.rmSync(dataDir, { recursive: true, force: true });
},
};
}
@@ -1,149 +0,0 @@
/**
* The built plugins really load: every bundle in dist/plugins activates on a
* database that started in 2.8 shape, in dependency order, with every
* service it needs provided and nothing registered twice.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
assertPluginsBuilt,
bootCore,
builtManifest,
create28Database,
sourcePluginIds,
type BootedCore,
} from "./built-harness.js";
const BOOT_TIMEOUT = 180_000;
let core: BootedCore;
const logged = { errors: [] as string[], warnings: [] as string[] };
beforeAll(async () => {
assertPluginsBuilt();
vi.resetModules();
const { sqlite } = create28Database();
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
logged.errors.push(args.map(String).join(" "));
});
const warn = vi.spyOn(console, "warn").mockImplementation((...args) => {
logged.warnings.push(args.map(String).join(" "));
});
try {
core = await bootCore(sqlite);
} finally {
error.mockRestore();
warn.mockRestore();
}
}, BOOT_TIMEOUT);
afterAll(async () => {
await core?.shutdown();
}, BOOT_TIMEOUT);
describe("the built plugins on a 2.8 database", () => {
it("boots without logging an error", () => {
expect(logged.errors).toEqual([]);
if (process.env.SHOW_BOOT_WARNINGS) {
console.log(logged.warnings.join("\n"));
}
});
it("activates every plugin, none failed or blocked", async () => {
const { getPluginRuntime } = await import("../../plugins/index.js");
const states = Object.fromEntries(
getPluginRuntime()
.loader.list()
.map((plugin) => [plugin.id, plugin.state]),
);
expect(Object.keys(states).sort()).toEqual(sourcePluginIds());
const notActive = Object.entries(states).filter(
([, state]) => state !== "active",
);
expect(notActive).toEqual([]);
});
it("gives every declared service a provider and meets every hard dependency", async () => {
const { listServices } = await import("../../plugins/service-registry.js");
const provided = new Set(
listServices().map((service) => `${service.pluginId}:${service.service}`),
);
const ids = new Set(sourcePluginIds());
for (const id of ids) {
const manifest = builtManifest(id) as {
provides?: Array<{ service: string }>;
requires?: Array<{ service: string; optional?: boolean }>;
dependencies?: Record<string, string>;
};
for (const { service } of manifest.provides ?? []) {
expect(provided, `${id} provides ${service}`).toContain(
`${id}:${service}`,
);
}
const available = new Set(
listServices().map((service) => service.service),
);
for (const requirement of manifest.requires ?? []) {
if (requirement.optional) continue;
expect(available, `${id} requires ${requirement.service}`).toContain(
requirement.service,
);
}
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
expect(ids, `${id} depends on ${dependency}`).toContain(dependency);
}
}
});
it("registers no route, socket or permission that clashes", async () => {
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toEqual([]);
const { getRegisteredWsRoutes } = await import("../../plugins/ws.js");
const sockets = getRegisteredWsRoutes();
expect(new Set(sockets).size).toBe(sockets.length);
// Plugin routes live under /plugin-api/<id>/, so a clash is two plugins
// claiming one id, which the loader refuses; a legacy path must start
// with its own plugin id.
for (const id of sourcePluginIds()) {
const legacy =
(
builtManifest(id) as {
contributes?: { http?: { legacyPaths?: string[] } };
}
).contributes?.http?.legacyPaths ?? [];
for (const legacyPath of legacy) {
expect(legacyPath.startsWith(`/${id}/`)).toBe(true);
}
}
const { getPermissionCatalog } =
await import("../../utils/permission-catalog.js");
const permissions = getPermissionCatalog().flatMap(
(entry) => entry.permissions,
);
expect(new Set(permissions).size).toBe(permissions.length);
});
it("records every plugin migration it ran", async () => {
const recorded = core
.sqlite!.prepare(
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
)
.all() as Array<{ plugin_id: string; n: number }>;
const byPlugin = new Map(recorded.map((row) => [row.plugin_id, row.n]));
const fs = await import("node:fs");
const path = await import("node:path");
const { BUILT_PLUGINS_DIR } = await import("./built-harness.js");
for (const id of sourcePluginIds()) {
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
const files = fs.existsSync(dir)
? fs.readdirSync(dir).filter((name) => name.endsWith(".sql"))
: [];
expect(byPlugin.get(id) ?? 0, `${id} migrations`).toBe(files.length);
}
});
});
@@ -1,153 +0,0 @@
/**
* The bundled plugins, as they ship.
*
* This reads plugins/ in the repo rather than a fixture: the manifests are
* checked-in artifacts, and a typo in one would otherwise only show up when
* the server tried to boot.
*/
import { describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { getBundledPluginsDir } from "../../plugins/paths.js";
import { parseManifest } from "../../plugins/manifest.js";
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const BUNDLED_DIR = getBundledPluginsDir();
function bundledIds(): string[] {
return fs
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
}
function readManifest(pluginId: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
);
}
const ids = bundledIds();
describe("bundled plugins", () => {
it("finds the bundled plugin directory", () => {
expect(ids.length).toBeGreaterThan(0);
});
it.each(ids)("%s has a manifest that parses", (id) => {
const { manifest, errors } = parseManifest(readManifest(id));
expect(errors).toEqual([]);
expect(manifest?.id).toBe(id);
});
it.each(ids)("%s declares only catalog capabilities", (id) => {
const { manifest } = parseManifest(readManifest(id));
for (const capability of manifest?.capabilities ?? []) {
expect(isKnownCapability(capability)).toBe(true);
}
});
it.each(ids)("%s ships the entry points its manifest names", (id) => {
const { manifest } = parseManifest(readManifest(id));
const dir = path.join(BUNDLED_DIR, id);
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
});
// process:transport-owner was the v1 marker for the in-process tier. There
// is one tier now, so nothing should still be asking for it.
it.each(ids)("%s does not declare a v1 permission", (id) => {
const raw = readManifest(id);
expect(raw.permissions).toBeUndefined();
expect(raw.sidecars).toBeUndefined();
expect(JSON.stringify(raw)).not.toContain("transport-owner");
});
});
describe("declared dependencies", () => {
it("names a plugin that is actually bundled", () => {
for (const id of ids) {
const { manifest } = parseManifest(readManifest(id));
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
expect(ids).toContain(dependency);
}
}
});
// ai reaches automations through the automations.access service, so it
// keeps working when automations is off.
it("keeps ai's use of automations optional", () => {
if (!ids.includes("ai")) return;
const { manifest } = parseManifest(readManifest("ai"));
expect(manifest?.dependencies?.automations).toBeUndefined();
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
});
});
describe("plugins that own a port", () => {
// Every one of these had its server created at module scope at some point.
// The rule is that it belongs inside activate, or disable leaves the port
// held and re-enable fails.
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
"%s creates no listener at module scope",
(id) => {
if (!ids.includes(id)) return;
const entry = path.join(BUNDLED_DIR, id, "src", "backend", "index.ts");
const source = fs.readFileSync(entry, "utf8");
const moduleScope = source.slice(
0,
source.indexOf("export async function activate"),
);
expect(moduleScope).not.toMatch(/\.listen\(/);
expect(moduleScope).not.toMatch(/new WebSocketServer/);
},
);
it("docker does not register a process signal handler", () => {
if (!ids.includes("docker")) return;
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
// every plugin's deactivate. A plugin calling process.exit() itself
// skipped the rest of that sequence.
for (const file of ["index.ts", "console.ts"]) {
const candidate = path.join(
BUNDLED_DIR,
"docker",
"src",
"backend",
file,
);
if (!fs.existsSync(candidate)) continue;
// Comments are stripped first: the rule is worth explaining in prose
// right where it applies, and that prose names the thing it forbids.
const code = fs
.readFileSync(candidate, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "");
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
expect(code).not.toMatch(/process\.exit\(/);
}
});
});
@@ -9,8 +9,6 @@
import type { PluginLoader } from "../../plugins/loader.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import type { Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
@@ -355,83 +353,6 @@ describe("the duplicate ai group is gone", () => {
});
});
describe("shipped manifests keep their permission ids", () => {
const root = path.resolve(import.meta.dirname, "../../../../plugins");
function idsFor(pluginId: string): string[] {
const raw = JSON.parse(
fs.readFileSync(path.join(root, pluginId, "manifest.json"), "utf8"),
);
const { manifest: parsed, errors } = parseManifest(raw);
expect(errors).toEqual([]);
return (parsed?.contributes?.permissions ?? []).map((entry) =>
qualifyPermission(pluginId, entry.name),
);
}
// A role holding these today must keep working, which is only true because
// <pluginId>.<name> reproduces the old id byte for byte.
it("ai", () => {
expect(idsFor("ai")).toEqual([
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
"ai.services.use",
"ai.secrets.share",
"ai.agents",
]);
});
it("automations", () => {
expect(idsFor("automations")).toEqual([
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
]);
});
it("tailscale", () => {
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
});
it("snippets", () => {
expect(idsFor("snippets")).toEqual([
"snippets.view",
"snippets.create",
"snippets.edit",
"snippets.delete",
"snippets.share",
]);
});
it("a role holding the old ids still validates once registered", () => {
for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) {
const ids = idsFor(pluginId);
registerPluginPermissions({
group: pluginId,
pluginId,
label: pluginId,
permissions: ids,
});
}
const stored = [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
"automations.run",
"automations.view",
"tailscale.devices.view",
"snippets.view",
"snippets.share",
];
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
});
});
describe("role defaults apply exactly once", () => {
let fixture: Fixture;
let loader: PluginLoader;
-146
View File
@@ -1,146 +0,0 @@
/**
* Puts a 2.8 install on a real Postgres or MySQL server for the upgrade test:
* wipes the database, applies release-2.8.0-tag's own drizzle migrations
* (UPGRADE_LEGACY_MIGRATIONS_DIR, which scripts/upgrade-check.sh extracts
* from git), then copies every row of the SQLite fixture across. After that
* the database is exactly what a 2.8 server left behind, and the normal boot
* takes it the rest of the way.
*/
import Database from "better-sqlite3";
import { sql, type SQL } from "drizzle-orm";
import { ROWS } from "../fixtures/upgrade/rows.js";
export type RemoteDialect = "postgres" | "mysql";
type Remote = { execute: (query: SQL) => Promise<unknown> };
/** TEST_DIALECT, the same switch the repository tests use. */
export function remoteTestDialect(
env: NodeJS.ProcessEnv = process.env,
): RemoteDialect | null {
const value = env.TEST_DIALECT?.trim().toLowerCase();
return value === "postgres" || value === "mysql" ? value : null;
}
async function rows<T>(db: Remote, query: SQL): Promise<T[]> {
const result = (await db.execute(query)) as { rows?: T[] } | T[];
if (Array.isArray(result)) {
return (Array.isArray(result[0]) ? result[0] : result) as T[];
}
return result.rows ?? [];
}
async function wipe(db: Remote, dialect: RemoteDialect): Promise<void> {
if (dialect === "postgres") {
await db.execute(sql.raw("DROP SCHEMA IF EXISTS drizzle CASCADE"));
await db.execute(sql.raw("DROP SCHEMA public CASCADE"));
await db.execute(sql.raw("CREATE SCHEMA public"));
return;
}
const [{ name }] = await rows<{ name: string }>(
db,
sql.raw("SELECT DATABASE() AS name"),
);
await db.execute(sql.raw(`DROP DATABASE \`${name}\``));
await db.execute(sql.raw(`CREATE DATABASE \`${name}\``));
await db.execute(sql.raw(`USE \`${name}\``));
}
async function columnTypes(
db: Remote,
dialect: RemoteDialect,
table: string,
): Promise<Map<string, string>> {
const schema =
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
const found = await rows<{ name: string; type: string }>(
db,
sql`SELECT column_name AS name, data_type AS type FROM information_schema.columns WHERE table_schema = ${schema} AND table_name = ${table}`,
);
return new Map(found.map((column) => [column.name, column.type]));
}
async function resyncSequence(db: Remote, table: string): Promise<void> {
const [sequence] = await rows<{ name: string | null }>(
db,
sql.raw(
`SELECT pg_get_serial_sequence('"${table}"', 'id') AS name FROM information_schema.columns ` +
`WHERE table_schema = current_schema() AND table_name = '${table}' AND column_name = 'id'`,
),
);
if (!sequence?.name) return;
await db.execute(
sql.raw(
`SELECT setval('${sequence.name}', COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`,
),
);
}
export async function seedRemote28(
dialect: RemoteDialect,
url: string,
fixture: Buffer,
): Promise<void> {
const legacyMigrations = process.env.UPGRADE_LEGACY_MIGRATIONS_DIR;
if (!legacyMigrations) {
throw new Error(
"UPGRADE_LEGACY_MIGRATIONS_DIR must point at release-2.8.0-tag's drizzle folder. Run scripts/upgrade-check.sh.",
);
}
const { drizzle } =
dialect === "postgres"
? await import("drizzle-orm/node-postgres")
: await import("drizzle-orm/mysql2");
const db = (
drizzle as unknown as (
config: unknown,
) => Remote & { $client: { end: () => Promise<void> } }
)(
dialect === "postgres"
? { connection: url }
: { connection: { uri: url, connectionLimit: 1 } },
);
const source = new Database(fixture, { readonly: true });
try {
await wipe(db, dialect);
const { runRemoteMigrations } =
await import("../../database/db/migrate.js");
await runRemoteMigrations(dialect, db as never, {
DRIZZLE_MIGRATIONS_DIR: legacyMigrations,
});
for (const table of Object.keys(ROWS)) {
const types = await columnTypes(db, dialect, table);
if (types.size === 0) {
throw new Error(`2.8's ${dialect} schema has no ${table} table`);
}
for (const row of source
.prepare(`SELECT * FROM "${table}"`)
.all() as Array<Record<string, unknown>>) {
const columns = Object.keys(row).filter(
(column) => types.has(column) && row[column] !== null,
);
const values = columns.map((column) =>
types.get(column) === "boolean" ? Boolean(row[column]) : row[column],
);
await db.execute(
sql`INSERT INTO ${sql.identifier(table)} (${sql.join(
columns.map((column) => sql.identifier(column)),
sql`, `,
)}) VALUES (${sql.join(
values.map((value) => sql`${value}`),
sql`, `,
)})`,
);
}
if (dialect === "postgres") await resyncSequence(db, table);
}
} finally {
source.close();
await db.$client.end();
}
}
@@ -30,7 +30,7 @@ const notes = adoptLegacyTable(
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
// The directory name is the plugin id, as it is in plugins/.
// The directory name is the plugin id.
pluginDir = path.join(root, "fleets");
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
recursive: true,
@@ -9,13 +9,11 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
collectOwnedIndexes,
findUnownedTableWrites,
} from "@termix/plugin-sdk/ddl";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
const state = vi.hoisted(() => ({
db: null as unknown,
@@ -155,39 +153,6 @@ describe("the migration checker", () => {
).not.toEqual([]);
});
it("passes every migration a bundled plugin ships", () => {
const plugins = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugins",
);
for (const id of fs.readdirSync(plugins)) {
const legacy = new Set(
Object.entries(LEGACY_TABLE_OWNERS)
.filter(([, owner]) => owner === id)
.map(([table]) => table),
);
for (const dialect of ["sqlite", "postgres", "mysql"]) {
const dir = path.join(plugins, id, "migrations", dialect);
if (!fs.existsSync(dir)) continue;
const files = fs
.readdirSync(dir)
.filter((file) => file.endsWith(".sql"))
.sort();
const sqls = files.map((file) =>
fs.readFileSync(path.join(dir, file), "utf8"),
);
// The runner lets a later migration drop an index an earlier one made.
const ownedIndexes = collectOwnedIndexes(id, sqls, legacy);
files.forEach((file, index) => {
expect(
findUnownedTableWrites(id, sqls[index], legacy, ownedIndexes),
`${id}/${dialect}/${file}`,
).toEqual([]);
});
}
}
});
it("gives a user plugin no legacy exemption at runtime", () => {
const adopt = 'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"';
expect(() =>
@@ -9,7 +9,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
import { parseManifest } from "../../../plugins/manifest.js";
import { RESERVED_PLUGIN_IDS } from "@termix/plugin-sdk/manifest";
@@ -95,22 +94,6 @@ describe("table prefixes", () => {
loader.load(path.join(root, "foo-bar"), "user"),
).rejects.toThrow(/overlaps "foo"/);
});
it("no two bundled plugins overlap", () => {
const plugins = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../plugins",
);
const ids = fs
.readdirSync(plugins, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
for (const a of ids) {
for (const b of ids) {
expect(prefixesOverlap(a, b), `${a} and ${b}`).toBe(false);
}
}
});
});
describe("paths stay inside the plugin", () => {
@@ -1,72 +0,0 @@
/**
* The committed 2.8 fixture still matches rows.ts and still fills every
* table a plugin adopts. A new legacy table in LEGACY_TABLE_OWNERS fails here
* until it gets fixture rows (npx tsx scripts/build-upgrade-fixture.ts).
*/
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { afterAll, describe, expect, it } from "vitest";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import { FieldCrypto } from "../../utils/field-crypto.js";
import { UPGRADE_FIXTURE_DIR } from "./built-harness.js";
import {
ENCRYPTED_COLUMNS,
RECORDING_FILES,
ROWS,
} from "../fixtures/upgrade/rows.js";
const sqlite = new Database(path.join(UPGRADE_FIXTURE_DIR, "db.sqlite"), {
readonly: true,
});
afterAll(() => sqlite.close());
const count = (table: string) =>
(
sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as {
n: number;
}
).n;
describe("the committed 2.8 upgrade fixture", () => {
it("fills every table a plugin adopts", () => {
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
expect(ROWS[table]?.length ?? 0, table).toBeGreaterThan(0);
expect(count(table), table).toBeGreaterThan(0);
}
});
it("holds exactly the rows in rows.ts", () => {
for (const [table, rows] of Object.entries(ROWS)) {
// Each user also has a wrapped data key in settings.
const extra = table === "settings" ? ROWS.users.length : 0;
expect(count(table), table).toBe(rows.length + extra);
}
});
it("stores the columns 2.8 encrypted as ciphertext", () => {
for (const [table, column] of ENCRYPTED_COLUMNS) {
const values = (
sqlite
.prepare(
`SELECT "${column}" AS value FROM "${table}" WHERE "${column}" IS NOT NULL AND "${column}" <> ''`,
)
.all() as Array<{ value: string }>
).map((row) => row.value);
for (const value of values) {
expect(FieldCrypto.isEncrypted(value), `${table}.${column}`).toBe(true);
}
}
});
it("ships the recording files its rows point at", () => {
for (const relative of Object.values(RECORDING_FILES)) {
expect(
fs.existsSync(path.join(UPGRADE_FIXTURE_DIR, "files", relative)),
relative,
).toBe(true);
}
});
});
@@ -1,741 +0,0 @@
/**
* The 2.8 to 2.9.0 upgrade, end to end. The committed 2.8 install in
* fixtures/upgrade boots through the pre-upgrade backup, core's migrations,
* every built plugin's migrations and the data moves, and everything it held
* reads back through the plugins' routes, their settings and their ctx. Then
* the same data directory boots again and nothing changes.
*
* TEST_DIALECT=postgres|mysql with TEST_DATABASE_URL runs it against a real
* server instead (scripts/upgrade-check.sh sets that up).
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import express from "express";
import request from "supertest";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import {
assertPluginsBuilt,
bootCore,
BUILT_PLUGINS_DIR,
UPGRADE_FIXTURE_DIR,
type BootedCore,
} from "./built-harness.js";
import { remoteTestDialect } from "./remote-seed.js";
import {
FIXTURE_PASSWORD,
HOST_AUTH_TYPES,
HOSTS,
LDAP_PROVIDER_ID,
OPERATORS_PERMISSIONS,
ROWS,
SSO_PROVIDER_ID,
SYNCED_ENTITY_TYPES_28,
TOTP_BACKUP_CODES,
TOTP_SECRET,
USER_ROLE_PERMISSIONS_28,
USERS,
} from "../fixtures/upgrade/rows.js";
const BOOT_TIMEOUT = 240_000;
type UserKey = keyof typeof USERS;
interface Booted {
core: BootedCore;
app: express.Express;
tokens: Record<UserKey, string>;
errors: string[];
}
/** legacy table -> the p_ table its plugin renamed it to. */
function adoptedNames(): Map<string, string> {
const names = new Map<string, string>();
for (const id of new Set(Object.values(LEGACY_TABLE_OWNERS))) {
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
for (const file of fs.readdirSync(dir)) {
const text = fs.readFileSync(path.join(dir, file), "utf8");
for (const match of text.matchAll(
/ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)"/g,
)) {
names.set(match[1], match[2]);
}
}
}
return names;
}
async function boot(dataDir?: string): Promise<Booted> {
vi.resetModules();
const errors: string[] = [];
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
errors.push(args.map(String).join(" "));
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
let core: BootedCore;
try {
core = await bootCore(
dataDir ? { reuseDataDir: dataDir } : { fixtureDir: UPGRADE_FIXTURE_DIR },
);
} finally {
error.mockRestore();
warn.mockRestore();
}
const { AuthManager } = await import("../../utils/auth-manager.js");
const auth = AuthManager.getInstance();
const tokens = {} as Record<UserKey, string>;
for (const [key, userId] of Object.entries(USERS)) {
await auth.unlockWithSystemKey(userId);
tokens[key as UserKey] = await auth.generateJWTToken(userId, {
deviceType: "web",
deviceInfo: "upgrade test",
});
}
const { mountPluginApi } =
await import("../../database/routes/plugin-api-routes.js");
const { default: pluginRoutes } =
await import("../../database/routes/plugins.js");
const { default: userRoutes } =
await import("../../database/routes/users.js");
const app = express();
app.use(express.json());
app.use("/users", userRoutes);
app.use("/plugins", pluginRoutes);
mountPluginApi(app);
return { core, app, tokens, errors };
}
function get(booted: Booted, url: string, as: UserKey = "admin") {
return request(booted.app)
.get(url)
.set("Authorization", `Bearer ${booted.tokens[as]}`);
}
async function settings(
booted: Booted,
pluginId: string,
scope: string,
as: UserKey = "admin",
): Promise<Record<string, unknown>> {
const response = await get(
booted,
`/plugins/${pluginId}/settings/${scope}`,
as,
);
expect(response.status, `${pluginId} ${scope} settings`).toBe(200);
return response.body.values as Record<string, unknown>;
}
/** A ctx for a running plugin, the one its own code gets. */
async function pluginCtx(pluginId: string) {
const { getPluginRuntime } = await import("../../plugins/index.js");
const { createPluginContext } = await import("../../plugins/ctx.js");
const loaded = getPluginRuntime().loader.get(pluginId);
if (!loaded?.handle) throw new Error(`${pluginId} is not running`);
return createPluginContext(loaded.manifest, loaded.handle);
}
async function asUser<T>(userId: string, fn: () => Promise<T>): Promise<T> {
const { runAsActor } = await import("../../plugins/actor.js");
return runAsActor(userId, "asUser", fn);
}
/** Every row of a plugin table, read through that plugin's ctx.db. */
async function pluginRows(
pluginId: string,
table: string,
): Promise<Array<Record<string, unknown>>> {
const ctx = await pluginCtx(pluginId);
/* eslint-disable @typescript-eslint/no-explicit-any */
const client = await ctx.db.client<any>();
const query = sql`SELECT * FROM ${sql.identifier(table)}`;
if (typeof client.all === "function") return client.all(query);
const result = await client.execute(query);
return Array.isArray(result) ? result[0] : result.rows;
/* eslint-enable @typescript-eslint/no-explicit-any */
}
/** Bookkeeping counts, compared between the first and second boot. */
async function counts(): Promise<Record<string, number>> {
const { selectRows } =
await import("../../utils/crypto-migration/raw-rows.js");
const out: Record<string, number> = {};
const tables = [
"plugin_migrations",
"plugin_settings",
"user_second_factors",
"user_external_identities",
"roles",
"user_roles",
"ssh_data",
...adoptedNames().values(),
];
for (const table of tables) {
const [row] = await selectRows<{ n: number | string }>(
sql`SELECT COUNT(*) AS n FROM ${sql.identifier(table)}`,
);
out[table] = Number(row.n);
}
return out;
}
function totpCode(secret: string, at = Date.now()): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = "";
for (const char of secret.replace(/=+$/, "")) {
bits += alphabet.indexOf(char).toString(2).padStart(5, "0");
}
const key = Buffer.from(
bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)),
);
const counter = Buffer.alloc(8);
counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000)));
const hmac = crypto.createHmac("sha1", key).update(counter).digest();
const offset = hmac[hmac.length - 1] & 0xf;
const value = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
return String(value).padStart(6, "0");
}
/** Everything a 2.8 install had, checked against a booted 2.9.0. */
function upgradeChecks(current: () => Booted, bootIndex: number) {
it("boots without logging an error", () => {
expect(current().errors).toEqual([]);
});
it("activates every plugin", async () => {
const { getPluginRuntime } = await import("../../plugins/index.js");
const notActive = getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state !== "active")
.map((plugin) => `${plugin.id}: ${plugin.state} ${plugin.lastError}`);
expect(notActive).toEqual([]);
});
it("keeps every row of every adopted table, readable by its plugin", async () => {
const names = adoptedNames();
for (const [legacy, pluginId] of Object.entries(LEGACY_TABLE_OWNERS)) {
const seeded = ROWS[legacy] ?? [];
expect(seeded.length, `the fixture fills ${legacy}`).toBeGreaterThan(0);
const table = names.get(legacy);
expect(table, `${pluginId} adopts ${legacy}`).toBeDefined();
// 2.8 kept LDAP directories in sso_providers; the ldap plugin takes them.
const expected =
legacy === "sso_providers"
? seeded.filter((row) => row.type !== "ldap")
: seeded;
const rows = await pluginRows(pluginId, table!);
const ids = rows.map((row) => String(row.id)).sort();
expect(ids, `${legacy} -> ${table}`).toEqual(
expected.map((row) => String(row.id)).sort(),
);
}
const ldap = await pluginRows("ldap", "p_ldap_providers");
expect(ldap.map((row) => String(row.id))).toEqual([
String(LDAP_PROVIDER_ID),
]);
});
it("reads the adopted rows back through each plugin's routes", async () => {
const checks: Array<[string, string, UserKey?]> = [
["/plugin-api/snippets/", "d4-snippet"],
["/plugin-api/snippets/shared", "d4-shared-snippet", "alice"],
["/plugin-api/fleets/", "d4-fleet"],
["/plugin-api/workspaces/", "d4-workspace"],
["/plugin-api/network-topology/", "d4-node"],
["/plugin-api/automations/", "d4-automation"],
["/plugin-api/homepage/items", "d4-widget"],
["/plugin-api/homepage/service-links", "d4-link"],
["/plugin-api/vault/profiles", "d4-vault"],
["/plugin-api/termix-identity/me", "d4-handle"],
["/plugin-api/sso/providers", "d4-sso"],
["/plugin-api/ldap/providers", "d4-ldap"],
[
`/plugin-api/ssh-terminal/command-history/${HOSTS.password}`,
"d4-history-command",
],
["/plugin-api/tunnels/presets", "d4-preset"],
["/plugin-api/ai/providers", "d4-provider"],
["/plugin-api/ai/conversations/1", "d4-message"],
["/plugin-api/secret-sources", "d4-source"],
["/plugin-api/session-sharing/rooms", "d4-room"],
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
["/plugin-api/alerts/channels", "d4-channel"],
];
const failures: string[] = [];
for (const [url, marker, as] of checks) {
const response = await get(current(), url, as);
if (
response.status !== 200 ||
!JSON.stringify(response.body).includes(marker)
) {
failures.push(
`${url} -> ${response.status} ${JSON.stringify(response.body).slice(0, 200)}`,
);
}
}
expect(failures).toEqual([]);
});
it("moves every host flag into its plugin's host settings", async () => {
const booted = current();
const host = (id: string) => settings(booted, id, `host/${HOSTS.password}`);
expect(await host("ssh-terminal")).toMatchObject({
enableTerminal: true,
enableCommandHistory: false,
enableTerminalToolbar: false,
// The look it saved in terminal_config, which took it off the user's.
inheritAppearance: false,
theme: "dracula",
fontSize: 16,
cursorStyle: "block",
autoTmux: true,
sudoPasswordAutoFill: true,
localEcho: "on",
});
expect(
await settings(booted, "ssh-terminal", `host/${HOSTS.key}`),
).toMatchObject({ inheritAppearance: true, passwordPromptAutoFill: false });
expect(await host("session-recording")).toMatchObject({
enableSessionRecording: false,
});
expect(await host("session-sharing")).toMatchObject({
allowSessionSharing: false,
});
expect(await host("file-manager")).toMatchObject({
enableFileManager: false,
defaultPath: "/srv/d4",
scpLegacy: true,
});
const tunnels = await host("tunnels");
expect(tunnels.enableTunnel).toBe(true);
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
const web = await host("web-endpoint");
expect(web.enableWebUi).toBe(true);
expect(JSON.stringify(web.webUiConfig)).toContain("d4-grafana");
expect(await host("tmux-monitor")).toMatchObject({
enableTmuxMonitor: true,
});
expect(await host("docker")).toMatchObject({
enableDocker: true,
containerRuntime: "podman",
});
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
expect(await host("snippets")).toMatchObject({
startupSnippetId: 1,
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
});
expect(await host("wake-on-lan")).toMatchObject({
macAddress: "AA:BB:CC:DD:EE:04",
broadcastAddress: "10.4.0.255",
});
expect(await host("host-metrics")).toMatchObject({
metricsEnabled: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "memory"],
});
const proxmox = await settings(booted, "proxmox", `host/${HOSTS.key}`);
expect(proxmox.enableProxmox).toBe(true);
expect(JSON.stringify(proxmox)).toContain("pve.d4.example");
expect(
await settings(booted, "remote-desktop", `host/${HOSTS.desktop}`),
).toMatchObject({
enableRdp: true,
enableVnc: true,
enableTelnet: true,
rdpPort: 3390,
vncPort: 5901,
telnetPort: 2323,
});
expect(
await settings(booted, "remote-desktop", `host/${HOSTS.rdpOnly}`),
).toMatchObject({ enableRdp: true });
expect(
await settings(booted, "vault", `host/${HOSTS.vault}`),
).toMatchObject({ profileId: 1 });
expect(
await settings(booted, "warpgate", `host/${HOSTS.warpgate}`),
).toMatchObject({ useWarpgate: true });
});
it("keeps 2.8's defaults on a host that never changed them", async () => {
const booted = current();
const host = (id: string) => settings(booted, id, `host/${HOSTS.plain}`);
expect(await host("file-manager")).toMatchObject({
enableFileManager: true,
});
expect(await host("ssh-terminal")).toMatchObject({
enableTerminal: true,
enableCommandHistory: true,
enableTerminalToolbar: true,
inheritAppearance: true,
autoTmux: false,
passwordPromptAutoFill: true,
});
expect(await host("session-recording")).toMatchObject({
enableSessionRecording: true,
});
expect(await host("session-sharing")).toMatchObject({
allowSessionSharing: true,
});
expect(await host("docker")).toMatchObject({ enableDocker: false });
expect(await host("tmux-monitor")).toMatchObject({
enableTmuxMonitor: false,
});
});
it("keeps each host's connection and secrets", async () => {
const { selectRows } =
await import("../../utils/crypto-migration/raw-rows.js");
const hosts = await selectRows<{
id: number;
auth_type: string;
enable_ssh: unknown;
}>(sql`SELECT id, auth_type, enable_ssh FROM ssh_data ORDER BY id`);
const byId = new Map(hosts.map((row) => [row.id, row]));
for (const [id, authType] of Object.entries(HOST_AUTH_TYPES)) {
// Warpgate became a plugin setting on a host with no SSH auth of its own.
const want = authType === "warpgate" ? "none" : authType;
expect(byId.get(Number(id))?.auth_type, `host ${id}`).toBe(want);
}
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
// The connection options in terminal_config moved to core's ssh_options.
const [options] = await selectRows<{ ssh_options: string | null }>(
sql`SELECT ssh_options FROM ssh_data WHERE id = ${HOSTS.password}`,
);
expect(JSON.parse(options.ssh_options ?? "{}")).toEqual({
keepaliveInterval: 30,
keepaliveCountMax: 4,
allowLegacyAlgorithms: false,
agentForwarding: true,
environmentVariables: [{ key: "D4", value: "yes" }],
});
// Every 2.8 RDP, VNC and Telnet login reads back through
// host_protocol_auth, the only place core reads them from now.
const ctx = await pluginCtx("remote-desktop");
const resolve = (hostId: number, protocol: string) =>
asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(hostId, protocol),
);
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
});
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
fields: { domain: "D4-DOMAIN" },
});
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
authType: "direct",
username: "d4-vnc-user",
password: "d4-vnc-password",
});
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
authType: "credential",
username: "deploy",
});
const logins = await selectRows<{ host_id: number; protocol: string }>(
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
);
expect(
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
).toEqual([
`${HOSTS.rdpOnly}:rdp`,
`${HOSTS.desktop}:rdp`,
`${HOSTS.desktop}:telnet`,
`${HOSTS.desktop}:vnc`,
]);
});
it("registers an SSH auth provider for every 2.8 auth type", async () => {
const { listSshAuthProviders } =
await import("../../hosts/connect/auth-provider-registry.js");
const types = new Set(listSshAuthProviders().map((p) => p.type));
for (const authType of new Set(Object.values(HOST_AUTH_TYPES))) {
if (authType === "warpgate") continue;
expect(types, authType).toContain(authType);
}
});
it("plays back every 2.8 recording", async () => {
const booted = current();
const list = await get(booted, "/plugin-api/session-recording/");
expect(
(list.body.logs as Array<{ id: number }>).map((log) => log.id).sort(),
).toEqual([1, 2]);
for (const [id, kind] of [
[1, "ssh"],
[2, "rdp"],
] as const) {
const content = await get(
booted,
`/plugin-api/session-recording/${id}/content`,
)
.buffer(true)
.parse((res, done) => {
let text = "";
res.on("data", (chunk: Buffer) => (text += chunk.toString()));
res.on("end", () => done(null, text));
});
expect(content.status, `recording ${id}`).toBe(200);
expect(content.body).toContain(`d4 ${kind} recording`);
}
});
it("puts every moved setting in its plugin's admin and user scope", async () => {
const booted = current();
expect(await settings(booted, "ssh-terminal", "admin")).toMatchObject({
sessionTimeoutMinutes: 45,
sessionPersistence: false,
commandHistoryForNewHosts: false,
imageMaxCount: 7,
newHostFontSize: 20,
newHostTheme: "nord",
newHostAutoTmux: true,
});
expect(await settings(booted, "ssh-terminal", "user")).toMatchObject({
terminalDefaults: { fontSize: 18, cursorBlink: false },
customThemes: [expect.objectContaining({ id: "d4-theme" })],
commandAutocomplete: true,
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
});
expect(await settings(booted, "snippets", "user")).toMatchObject({
confirmExecution: true,
foldersCollapsed: false,
});
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
caUrl: "https://ca.d4.example",
fingerprint: "d4fingerprint",
provisioner: "d4-provisioner",
});
expect(await settings(booted, "remote-desktop", "admin")).toMatchObject({
enabled: true,
guacdUrl: "guacd.d4.example:4822",
});
expect(await settings(booted, "host-metrics", "admin")).toMatchObject({
metricsInterval: 40,
historyRetentionDays: 14,
enabledForNewHosts: false,
});
expect(await settings(booted, "ai", "admin")).toMatchObject({
globallyEnabled: true,
});
expect(await settings(booted, "session-recording", "admin")).toMatchObject({
retentionDays: 3650,
});
expect(
JSON.stringify((await settings(booted, "ai", "admin")).privateEndpoints),
).toContain("10.4.0.0/24");
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
globallyEnabled: false,
});
expect(await settings(booted, "telemetry", "admin")).toMatchObject({
enabled: false,
instanceId: "d4-instance-id",
});
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
domain: "termix.d4.example",
email: "d4@example.com",
});
expect(await settings(booted, "tailscale", "admin")).toMatchObject({
apiBaseUrl: "https://headscale.d4.example",
});
expect(await settings(booted, "ai", "user")).toMatchObject({
enabled: true,
allowReadOnlyCommands: true,
});
expect(await settings(booted, "remote-desktop", "user")).toMatchObject({
colorDepth: "16",
resizeMethod: "reconnect",
enableDrive: "on",
});
});
it("keeps every secret readable by the plugin that owns it", async () => {
const tailscale = await pluginCtx("tailscale");
expect(await tailscale.settings.get("apiKey")).toBe("tskey-d4");
const acme = await pluginCtx("acme-ssl");
expect(JSON.stringify(await acme.settings.getAll("admin"))).toContain(
"d4-cloudflare-token",
);
const ai = await pluginCtx("ai");
expect(await asUser(USERS.admin, () => ai.secrets.get("provider:1"))).toBe(
"sk-d4-provider-key",
);
const sources = await pluginCtx("secret-sources");
expect(
await asUser(USERS.admin, () => sources.secrets.get("source:d4-source")),
).toBe("d4-source-token");
});
it("moves every notification channel into alerts, config readable", async () => {
const response = await get(current(), "/plugin-api/alerts/channels/1");
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
name: "d4-channel",
type: "ntfy",
usable: true,
config: { url: "https://ntfy.d4.example/d4" },
});
});
it("keeps every role's permissions", async () => {
const { PermissionManager } =
await import("../../utils/permission-manager.js");
const permissions = PermissionManager.getInstance();
for (const permission of OPERATORS_PERMISSIONS) {
expect(
await permissions.hasPermission(USERS.alice, permission),
`operators: ${permission}`,
).toBe(true);
}
for (const permission of USER_ROLE_PERMISSIONS_28) {
expect(
await permissions.hasPermission(USERS.passkey, permission),
`user: ${permission}`,
).toBe(true);
}
expect(await permissions.hasPermission(USERS.admin, "sso.manage")).toBe(
true,
);
});
it("still asks a 2.8 TOTP user for a code, and accepts one", async () => {
const booted = current();
const login = await request(booted.app)
.post("/users/login")
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
expect(login.status).toBe(200);
expect(login.body.requires_totp).toBe(true);
expect(JSON.stringify(login.body.second_factors)).toContain("totp");
const wrong = await request(booted.app)
.post("/users/totp/verify-login")
.send({ temp_token: login.body.temp_token, totp_code: "000000" });
expect(wrong.status).not.toBe(200);
const verify = await request(booted.app)
.post("/users/totp/verify-login")
.send({
temp_token: login.body.temp_token,
totp_code: totpCode(TOTP_SECRET),
});
expect(verify.status).toBe(200);
const again = await request(booted.app)
.post("/users/login")
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
const backup = await request(booted.app)
.post("/users/totp/verify-login")
.send({
temp_token: again.body.temp_token,
totp_code: TOTP_BACKUP_CODES[bootIndex],
});
expect(backup.status).toBe(200);
});
it("signs a password user without a second factor straight in", async () => {
const login = await request(current().app)
.post("/users/login")
.send({ username: "d4-alice", password: FIXTURE_PASSWORD });
expect(login.status).toBe(200);
expect(login.body.requires_totp).toBeFalsy();
});
it("links SSO and LDAP users to their providers", async () => {
const { selectRows } =
await import("../../utils/crypto-migration/raw-rows.js");
const links = await selectRows<{
user_id: string;
provider_id: unknown;
}>(sql`SELECT user_id, provider_id FROM user_external_identities`);
const byUser = new Map(
links.map((row) => [row.user_id, String(row.provider_id)]),
);
expect(byUser.get(USERS.sso)).toBe(String(SSO_PROVIDER_ID));
// The ldap plugin signs in with "ldap:<provider>".
expect(byUser.get(USERS.ldap)).toBe(`ldap:${LDAP_PROVIDER_ID}`);
});
it("syncs every entity type 2.8 synced, under the same name", async () => {
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
const { registerCoreSyncEntities } = await import("../../sync/entities.js");
registerCoreSyncEntities();
const known = new Set(listEntityTypes());
for (const type of SYNCED_ENTITY_TYPES_28) {
expect(known, type).toContain(type);
}
const snippets = await pluginRows("snippets", "p_snippets_snippets");
expect(snippets.map((row) => row.sync_id)).toContain("d4-sync-snippet");
const vault = await pluginRows("vault", "p_vault_profiles");
expect(vault.map((row) => row.sync_id)).toContain("d4-sync-vault");
});
}
let first: Booted;
let firstCounts: Record<string, number>;
let second: Booted;
beforeAll(async () => {
assertPluginsBuilt();
first = await boot();
}, BOOT_TIMEOUT);
afterAll(async () => {
await (second ?? first)?.core.shutdown();
}, BOOT_TIMEOUT);
describe("a 2.8 install upgraded to 2.9.0", () => {
// Only SQLite is backed up by Termix; a server database is the operator's.
it.skipIf(remoteTestDialect())(
"backed up the 2.8 database before anything touched it",
async () => {
const backups = path.join(first.core.dataDir, "backups");
const dirs = fs
.readdirSync(backups)
.filter((name) => name.startsWith("pre-plugin-runtime-"));
expect(dirs).toHaveLength(1);
const copy = fs.readFileSync(path.join(backups, dirs[0], "db.sqlite"));
expect(copy.equals(first.core.seed!)).toBe(true);
},
);
upgradeChecks(() => first, 0);
});
describe("booting the upgraded install again", () => {
beforeAll(async () => {
firstCounts = await counts();
const dataDir = first.core.dataDir;
await first.core.shutdown({ keepDataDir: true });
second = await boot(dataDir);
}, BOOT_TIMEOUT);
it("changes nothing", async () => {
expect(await counts()).toEqual(firstCounts);
});
it.skipIf(remoteTestDialect())("takes no second backup", () => {
const backups = fs.readdirSync(path.join(second.core.dataDir, "backups"));
expect(
backups.filter((name) => name.startsWith("pre-plugin-runtime-")),
).toHaveLength(1);
});
upgradeChecks(() => second, 1);
});
@@ -1,5 +1,4 @@
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
@@ -26,12 +25,8 @@ describe("snippet note column upgrade", () => {
);
repairSnippetsNoteColumn(sqlite);
if (table === "snippets") {
sqlite.exec(
readFileSync(
"plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql",
"utf8",
),
);
// What the plugin migration does to the legacy table.
sqlite.exec(`ALTER TABLE "snippets" RENAME TO "p_snippets_snippets"`);
}
expect(
sqlite
-43
View File
@@ -8,42 +8,6 @@ type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// dist/backend/backend/utils -> the repository.
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
/**
* A tag per plugin, named and described by its manifest, so the reference
* groups a plugin's routes without core naming any plugin.
*/
async function pluginTags(): Promise<{ name: string; description: string }[]> {
const pluginsDir = path.join(REPO_ROOT, "plugins");
const tags: { name: string; description: string }[] = [];
let entries: string[] = [];
try {
entries = await fs.readdir(pluginsDir);
} catch {
return tags;
}
for (const entry of entries.sort()) {
try {
const manifest = JSON.parse(
await fs.readFile(
path.join(pluginsDir, entry, "manifest.json"),
"utf8",
),
) as { name?: string; description?: string };
if (manifest.name) {
tags.push({
name: manifest.name,
description: manifest.description ?? "",
});
}
} catch {
// Not a plugin folder.
}
}
return tags;
}
const swaggerOptions: SwaggerJSDocOptions = {
definition: {
@@ -170,11 +134,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
// Plugin routes document themselves. Read from source, because a plugin's
// bundle drops comments.
path
.join(REPO_ROOT, "plugins", "*", "src", "backend", "**", "*.ts")
.replace(/\\/g, "/"),
],
};
@@ -184,8 +143,6 @@ async function generateOpenAPISpec() {
operation: "openapi_generate_start",
});
const definition = swaggerOptions.definition as { tags?: unknown[] };
definition.tags = [...(definition.tags ?? []), ...(await pluginTags())];
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
const outputPath = path.join(