mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e79bf9d0d | ||
|
|
03029cd372 | ||
|
|
446596989e | ||
|
|
b7f749cc9d |
No files matched your search
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
||||||
- API keys can be limited to reading or running actions
|
- API keys can be limited to reading or running actions, when an admin makes them
|
||||||
|
- A click on a host can open its host page, when a plugin offers one
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
|||||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||||
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
||||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||||
|
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
|
||||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.25
|
||||||
|
|
||||||
|
Plugin API 1.14.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Host click behavior `hostPage` opens a host page when a plugin offers one
|
||||||
|
- `createApiKey` takes scopes, to limit a key to actions
|
||||||
|
|
||||||
## 1.0.24
|
## 1.0.24
|
||||||
|
|
||||||
Plugin API 1.14.0.
|
Plugin API 1.14.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.24 | Plugin API 1.14.0
|
SDK 1.0.25 | Plugin API 1.14.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.24",
|
"version": "1.0.25",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
|
|||||||
clearLocalAdaptivePreferences (value): () => void
|
clearLocalAdaptivePreferences (value): () => void
|
||||||
cn (value): (...inputs: ClassValue[]) => string
|
cn (value): (...inputs: ClassValue[]) => string
|
||||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
|
||||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
|
|||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
/** JSON list, or null for full access. */
|
||||||
|
scopes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiKeyRepository {
|
export class ApiKeyRepository {
|
||||||
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
|
|||||||
expiresAt: apiKeys.expiresAt,
|
expiresAt: apiKeys.expiresAt,
|
||||||
lastUsedAt: apiKeys.lastUsedAt,
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
isActive: apiKeys.isActive,
|
isActive: apiKeys.isActive,
|
||||||
|
scopes: apiKeys.scopes,
|
||||||
})
|
})
|
||||||
.from(apiKeys)
|
.from(apiKeys)
|
||||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RequestHandler, Router } from "express";
|
import type { RequestHandler, Router } from "express";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
* description: Optional expiration date. Null means the key never expires.
|
* description: Optional expiration date. Null means the key never expires.
|
||||||
|
* scopes:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* enum: [operations:read, operations:run]
|
||||||
|
* description: Limits the key to actions only. Leave out for full access.
|
||||||
* responses:
|
* responses:
|
||||||
* 201:
|
* 201:
|
||||||
* description: API key created. Contains the full token (shown only once).
|
* description: API key created. Contains the full token (shown only once).
|
||||||
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
*/
|
*/
|
||||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
|
||||||
const apiKeyRepository = createCurrentApiKeyRepository();
|
const apiKeyRepository = createCurrentApiKeyRepository();
|
||||||
const userRepository = createCurrentUserRepository();
|
const userRepository = createCurrentUserRepository();
|
||||||
|
|
||||||
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
|
|||||||
expiresAtValue = parsed.toISOString();
|
expiresAtValue = parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scopesValue: string[] | null = null;
|
||||||
|
if (scopes !== undefined && scopes !== null) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(scopes) ||
|
||||||
|
scopes.some(
|
||||||
|
(scope) =>
|
||||||
|
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Unknown API key scope" });
|
||||||
|
}
|
||||||
|
scopesValue =
|
||||||
|
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
|
||||||
|
}
|
||||||
|
|
||||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||||
const tokenPrefix = rawToken.substring(0, 12);
|
const tokenPrefix = rawToken.substring(0, 12);
|
||||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||||
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
expiresAt: expiresAtValue,
|
expiresAt: expiresAtValue,
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actorId = (req as AuthenticatedRequest).userId;
|
const actorId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
details: JSON.stringify({
|
details: JSON.stringify({
|
||||||
targetUserId,
|
targetUserId,
|
||||||
targetUsername: targetUser.username,
|
targetUsername: targetUser.username,
|
||||||
|
scopes: scopesValue,
|
||||||
}),
|
}),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
tokenPrefix,
|
tokenPrefix,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: expiresAtValue,
|
expiresAt: expiresAtValue,
|
||||||
|
scopes: scopesValue,
|
||||||
token: rawToken,
|
token: rawToken,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ export function clearApprovalPolicies(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
|
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
|
||||||
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
|
// approved: a person approved this exact request in the plugin's own UI.
|
||||||
|
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]);
|
||||||
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
|
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
|
||||||
|
|
||||||
/** A person is the check when the request came from something else. */
|
/** A person is the check when the request came from something else. */
|
||||||
|
|||||||
@@ -451,6 +451,15 @@ describe("policy", () => {
|
|||||||
expect(outcome.status).toBe("success");
|
expect(outcome.status).toBe("success");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lets a request a person approved in a plugin go by RBAC", async () => {
|
||||||
|
register();
|
||||||
|
const outcome = await runOperation({
|
||||||
|
...base,
|
||||||
|
origin: { kind: "plugin", pluginId: "x", via: "approved" },
|
||||||
|
});
|
||||||
|
expect(outcome.status).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
it("treats a community read as a change", async () => {
|
it("treats a community read as a change", async () => {
|
||||||
state.plugin.source = "user";
|
state.plugin.source = "user";
|
||||||
state.plugin.signedTier = "community";
|
state.plugin.signedTier = "community";
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
|||||||
|
|
||||||
/** What clicking a host does when it already has an open tab. */
|
/** What clicking a host does when it already has an open tab. */
|
||||||
export type HostClickBehavior =
|
export type HostClickBehavior =
|
||||||
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
|
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
|
||||||
|
|
||||||
export interface HostSidebarFilterState {
|
export interface HostSidebarFilterState {
|
||||||
status: ("online" | "offline" | "pinned")[];
|
status: ("online" | "offline" | "pinned")[];
|
||||||
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
|
|||||||
"newTab",
|
"newTab",
|
||||||
"focusExisting",
|
"focusExisting",
|
||||||
"focusExistingDoubleClickNew",
|
"focusExistingDoubleClickNew",
|
||||||
|
"hostPage",
|
||||||
];
|
];
|
||||||
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
||||||
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ export interface ApiKey {
|
|||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
/** Null for full access. */
|
||||||
|
scopes?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatedApiKey extends ApiKey {
|
export interface CreatedApiKey extends ApiKey {
|
||||||
@@ -97,12 +99,14 @@ export async function createApiKey(
|
|||||||
name: string,
|
name: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
expiresAt?: string,
|
expiresAt?: string,
|
||||||
|
scopes?: string[] | null,
|
||||||
): Promise<CreatedApiKey> {
|
): Promise<CreatedApiKey> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/users/api-keys", {
|
const response = await authApi.post("/users/api-keys", {
|
||||||
name,
|
name,
|
||||||
userId,
|
userId,
|
||||||
expiresAt: expiresAt ?? null,
|
expiresAt: expiresAt ?? null,
|
||||||
|
scopes: scopes?.length ? scopes : null,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -609,6 +609,7 @@
|
|||||||
"hostClickBehavior": "Clicking a host",
|
"hostClickBehavior": "Clicking a host",
|
||||||
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
|
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
|
||||||
"hostClickNewTab": "Always open a new tab",
|
"hostClickNewTab": "Always open a new tab",
|
||||||
|
"hostClickHostPage": "Open the host page, when a plugin offers one",
|
||||||
"hostClickFocusExisting": "Switch to the open tab",
|
"hostClickFocusExisting": "Switch to the open tab",
|
||||||
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
|
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
|
||||||
"openInNewTab": "Open in new tab",
|
"openInNewTab": "Open in new tab",
|
||||||
|
|||||||
Reference in new issue
Block a user