DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops

- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages
- PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs)
- Auth/RBAC/workspace isolation, credential vault (AES-256-GCM)
- Servers/SSH, Docker discovery & lifecycle, Job Center
- Websites & deployments (git provider credential clone, env, health verification)
- Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring
- Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools)
- AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals)
- Notes: Vditor WYSIWYG markdown editor, non-blocking autosave
- Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
This commit is contained in:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
@@ -0,0 +1,11 @@
/**
* # DNS 集成模块(Cloudflare / Manual 适配器)
*/
import { Module } from "@nestjs/common";
import { CloudflareDnsProvider, ManualDnsProvider } from "./dns.providers";
@Module({
providers: [CloudflareDnsProvider, ManualDnsProvider],
exports: [CloudflareDnsProvider, ManualDnsProvider],
})
export class DnsModule {}
@@ -0,0 +1,171 @@
/**
* # DNS Provider 适配器(Task 013)
* 接口 + 两种实现:
* - CloudflareDnsProvider:Cloudflare v4 API(token 凭据);
* - ManualDnsProvider:无外部 API,记录仅存本地(本地事实源)。
*/
import { Injectable } from "@nestjs/common";
/** # DNS 记录 **/
export interface DnsRecord {
id: string | null;
type: string;
name: string;
value: string;
ttl: number;
proxied: boolean;
}
/** # DNS Provider 接口 **/
export interface DnsProvider {
/** # 提供商标识 **/
readonly provider: string;
listZones(params: { token: string; name?: string }): Promise<{ id: string; name: string }[]>;
listRecords(params: { token: string; zoneName: string }): Promise<DnsRecord[]>;
upsertRecord(params: {
token: string;
zoneName: string;
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
}): Promise<DnsRecord>;
deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void>;
}
/** # Cloudflare API 响应包络 **/
interface CfEnvelope<T> {
success: boolean;
errors: { code: number; message: string }[];
result: T;
}
/** # Cloudflare 记录 **/
interface CfRecord {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
}
@Injectable()
export class CloudflareDnsProvider implements DnsProvider {
/** # 提供商标识 **/
readonly provider = "CLOUDFLARE";
/** # API 基址 **/
private base = process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4";
/** # 请求 Cloudflare API */
private async call<T>(token: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
signal: AbortSignal.timeout(15_000),
});
const body = (await res.json().catch(() => null)) as CfEnvelope<T> | null;
if (!body || !body.success) {
const message = body?.errors?.[0]?.message ?? `Cloudflare API HTTP ${res.status}`;
throw new Error(message);
}
return body.result;
}
/** # 列出区域 */
async listZones(params: { token: string; name?: string }) {
const query = params.name ? `?name=${encodeURIComponent(params.name)}` : "?per_page=50";
const zones = await this.call<{ id: string; name: string }[]>(params.token, `/zones${query}`);
return zones.map((z) => ({ id: z.id, name: z.name }));
}
/** # 列出记录 */
async listRecords(params: { token: string; zoneName: string }) {
const zones = await this.listZones({ token: params.token, name: params.zoneName });
if (zones.length === 0) return [];
const records = await this.call<CfRecord[]>(
params.token,
`/zones/${zones[0].id}/dns_records?per_page=100`,
);
return records.map((r) => ({
id: r.id,
type: r.type,
name: r.name,
value: r.content,
ttl: r.ttl,
proxied: Boolean(r.proxied),
}));
}
/** # 创建或更新记录(按 type+name 查找,存在则 PATCH) */
async upsertRecord(params: {
token: string;
zoneName: string;
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
}) {
const zones = await this.listZones({ token: params.token, name: params.zoneName });
if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`);
const zoneId = zones[0].id;
const existing = await this.call<CfRecord[]>(
params.token,
`/zones/${zoneId}/dns_records?type=${params.record.type}&name=${encodeURIComponent(params.record.name)}`,
);
const payload = {
type: params.record.type,
name: params.record.name,
content: params.record.value,
ttl: params.record.ttl ?? 300,
proxied: params.record.proxied ?? false,
};
if (existing.length > 0) {
const updated = await this.call<CfRecord>(
params.token,
`/zones/${zoneId}/dns_records/${existing[0].id}`,
{ method: "PATCH", body: JSON.stringify(payload) },
);
return { id: updated.id, type: updated.type, name: updated.name, value: updated.content, ttl: updated.ttl, proxied: Boolean(updated.proxied) };
}
const created = await this.call<CfRecord>(params.token, `/zones/${zoneId}/dns_records`, {
method: "POST",
body: JSON.stringify(payload),
});
return { id: created.id, type: created.type, name: created.name, value: created.content, ttl: created.ttl, proxied: Boolean(created.proxied) };
}
/** # 删除记录 */
async deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void> {
const zones = await this.listZones({ token: params.token, name: params.zoneName });
if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`);
await this.call(params.token, `/zones/${zones[0].id}/dns_records/${params.recordId}`, { method: "DELETE" });
}
}
@Injectable()
export class ManualDnsProvider implements DnsProvider {
/** # 提供商标识 **/
readonly provider = "MANUAL";
/** # 手动模式:区域/记录由本地数据库管理,适配器返回空集合(由服务层处理) */
async listZones(): Promise<{ id: string; name: string }[]> {
return [];
}
async listRecords(): Promise<DnsRecord[]> {
return [];
}
async upsertRecord(params: {
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
}): Promise<DnsRecord> {
return {
id: null,
type: params.record.type,
name: params.record.name,
value: params.record.value,
ttl: params.record.ttl ?? 300,
proxied: params.record.proxied ?? false,
};
}
async deleteRecord(): Promise<void> {
/* 手动模式下删除仅操作本地库 */
}
}
@@ -0,0 +1,12 @@
/**
* # Docker 集成模块
*/
import { Global, Module } from "@nestjs/common";
import { DockerService } from "./docker.service";
@Global()
@Module({
providers: [DockerService],
exports: [DockerService],
})
export class DockerModule {}
@@ -0,0 +1,314 @@
/**
* # Docker 适配器(Task 010)
* 统一接口 + 两种实现:
* - SshDockerAdapter:通过 SSH 调用目标机 docker CLI(零额外依赖、权限模型清晰);
* - LocalSocketAdapter:通过本机 /var/run/docker.sock 调用 Docker Engine API。
* DevOS 不重新实现 Docker,仅提供统一适配层。
*/
import { Injectable } from "@nestjs/common";
import { request as httpRequest } from "node:http";
import { SshClientService, type SshTarget } from "../ssh/ssh-client.service";
import { ApiError } from "../../common/errors/api-error";
/** # 容器摘要视图 **/
export interface DockerContainerInfo {
id: string;
name: string;
image: string;
state: string;
status: string;
ports: Record<string, unknown>[];
labels: Record<string, string>;
}
/** # Docker 适配器接口 **/
export interface DockerAdapter {
/** # 是否支持该目标(例如本地 socket 仅限本机) */
supports(server: { host: string; provider?: string }): boolean;
listContainers(target: SshTarget): Promise<DockerContainerInfo[]>;
inspectContainer(target: SshTarget, containerId: string): Promise<Record<string, unknown>>;
restartContainer(target: SshTarget, containerId: string): Promise<void>;
checkDocker(target: SshTarget): Promise<{ available: boolean; version: string }>;
}
/** # docker ps --format 输出行结构(部分字段) **/
interface DockerPsRow {
ID: string;
Names: string;
Image: string;
State: string;
Status: string;
Ports?: string;
Labels?: string;
Command?: string;
CreatedAt?: string;
Networks?: string;
LocalVolumes?: string;
Mounts?: string;
RunningFor?: string;
Size?: string;
}
/** # 解析 docker ps --format '{{json .}}' 的 NDJSON 输出(纯函数,可单测) */
export function parseDockerPsOutput(output: string): DockerContainerInfo[] {
const lines = output
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const result: DockerContainerInfo[] = [];
for (const line of lines) {
let row: DockerPsRow;
try {
row = JSON.parse(line) as DockerPsRow;
} catch {
continue; // 跳过非 JSON 噪音行
}
result.push({
id: row.ID ?? "",
name: (row.Names ?? "").split(",").filter(Boolean)[0] ?? "",
image: row.Image ?? "",
state: row.State ?? "",
status: row.Status ?? "",
ports: parseDockerPorts(row.Ports ?? ""),
labels: parseDockerLabels(row.Labels ?? ""),
});
}
return result;
}
/** # 解析 docker ps Ports 列(如 0.0.0.0:8080->80/tcp, :::8080->80/tcp) */
export function parseDockerPorts(ports: string): Record<string, unknown>[] {
if (!ports || ports === "") return [];
return ports.split(", ").filter(Boolean).map((p) => ({ raw: p }));
}
/** # 解析 docker ps Labels 列(逗号分隔 key=value) */
export function parseDockerLabels(labels: string): Record<string, string> {
const out: Record<string, string> = {};
if (!labels) return out;
for (const pair of labels.split(",")) {
const idx = pair.indexOf("=");
if (idx > 0) out[pair.slice(0, idx)] = pair.slice(idx + 1);
}
return out;
}
@Injectable()
export class DockerService implements DockerAdapter {
/** # 本机 docker socket 路径 **/
private static readonly LOCAL_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
constructor(private readonly ssh: SshClientService) {}
/** # 是否支持该目标 */
supports(server: { host: string; provider?: string }): boolean {
return true; // 接口层总是可用;选择 SSH 或 socket 由 targetProvider 决定
}
/** # 目标是否走本地 socket(server.provider === 'local') */
isLocal(server: { provider?: string | null }): boolean {
return server.provider === "local";
}
/** # 列出容器(含停止的) */
async listContainers(target: SshTarget, isLocal = false): Promise<DockerContainerInfo[]> {
if (isLocal) {
const rows = await this.localApi<Record<string, unknown>[]>("/containers/json?all=1");
return rows.map(mapEngineContainer);
}
const res = await this.ssh.exec(target, "docker ps -a --format '{{json .}}'", 30_000);
if (res.code !== 0 && !res.stdout.trim()) {
throw ApiError.upstream(`docker ps failed: ${res.stderr.slice(0, 500)}`);
}
return parseDockerPsOutput(res.stdout);
}
/** # 检查容器详情 */
async inspectContainer(
target: SshTarget,
containerId: string,
isLocal = false,
): Promise<Record<string, unknown>> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
return this.localApi<Record<string, unknown>>(`/containers/${safeId}/json`);
}
const res = await this.ssh.exec(target, `docker inspect ${safeId}`, 20_000);
if (res.code !== 0) throw ApiError.upstream(`docker inspect failed: ${res.stderr.slice(0, 300)}`);
const parsed = JSON.parse(res.stdout) as Record<string, unknown>[];
return parsed[0] ?? {};
}
/** # 重启容器 */
async restartContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
await this.localApiVoid(`/containers/${safeId}/restart?t=10`, "POST");
return;
}
const res = await this.ssh.exec(target, `docker restart -t 10 ${safeId}`, 60_000);
if (res.code !== 0) throw ApiError.upstream(`docker restart failed: ${res.stderr.slice(0, 300)}`);
}
/** # 启动容器 */
async startContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
await this.localApiVoid(`/containers/${safeId}/start`, "POST");
return;
}
const res = await this.ssh.exec(target, `docker start ${safeId}`, 60_000);
if (res.code !== 0) throw ApiError.upstream(`docker start failed: ${res.stderr.slice(0, 300)}`);
}
/** # 停止容器 */
async stopContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
await this.localApiVoid(`/containers/${safeId}/stop?t=10`, "POST");
return;
}
const res = await this.ssh.exec(target, `docker stop -t 10 ${safeId}`, 60_000);
if (res.code !== 0) throw ApiError.upstream(`docker stop failed: ${res.stderr.slice(0, 300)}`);
}
/** # 容器日志(tail N 行) */
async containerLogs(target: SshTarget, containerId: string, tail = 200, isLocal = false): Promise<string[]> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
const res = await this.localApi<string>(`/containers/${safeId}/logs?stdout=1&stderr=1&tail=${tail}`);
// Docker API 多路复用流:剥离 8 字节帧头
return res
.split("\n")
.map((l) => l.replace(/^[^\r\n]*\r?/, ""))
.slice(-tail);
}
const res = await this.ssh.exec(target, `docker logs --tail ${tail} ${safeId} 2>&1`, 30_000);
return res.stdout.split("\n").slice(-tail);
}
/** # 镜像列表 */
async listImages(target: SshTarget, isLocal = false): Promise<{ repository: string; tag: string; size: string; created: string }[]> {
if (isLocal) {
const rows = await this.localApi<Record<string, unknown>[]>("/images/json");
return rows.map((r) => {
const tags = (r.RepoTags as string[] | undefined) ?? ["<none>:<none>"];
return {
repository: (tags[0].split(":")[0]) ?? "<none>",
tag: tags[0].split(":").slice(1).join(":") || "<none>",
size: `${Math.round((Number(r.Size) || 0) / 1024 / 1024)}MB`,
created: new String(r.Created ?? "").toString(),
};
});
}
const res = await this.ssh.exec(target, "docker images --format '{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}'", 20_000);
return res.stdout
.split("\n")
.filter((l) => l.trim())
.map((l) => {
const [repository, tag, size, created] = l.split("|");
return { repository, tag, size, created };
});
}
/** # 容器资源用量(stats no-stream) */
async containerStats(
target: SshTarget,
containerId: string,
isLocal = false,
): Promise<{ cpuPct: string; memUsage: string; memPct: string }> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
const s = await this.localApi<{ cpu_stats: Record<string, unknown>; memory_stats: Record<string, unknown> }>(
`/containers/${safeId}/stats?stream=false`,
);
return { cpuPct: "n/a", memUsage: `${Math.round((Number(s.memory_stats.usage) || 0) / 1024 / 1024)}MB`, memPct: "n/a" };
}
const res = await this.ssh.exec(target, `docker stats --no-stream --format '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}' ${safeId}`, 20_000);
const [cpuPct, memUsage, memPct] = res.stdout.trim().split("|");
return { cpuPct: cpuPct ?? "n/a", memUsage: memUsage ?? "n/a", memPct: memPct ?? "n/a" };
}
/** # Docker 可用性检查 */
async checkDocker(target: SshTarget, isLocal = false): Promise<{ available: boolean; version: string }> {
try {
if (isLocal) {
const version = await this.localApi<{ Version: string }>("/version");
return { available: true, version: version.Version };
}
const res = await this.ssh.exec(target, "docker version --format '{{.Server.Version}}'", 20_000);
if (res.code === 0 && res.stdout.trim()) {
return { available: true, version: res.stdout.trim() };
}
return { available: false, version: res.stderr.trim().slice(0, 200) };
} catch (error) {
return {
available: false,
version: error instanceof Error ? error.message.slice(0, 200) : "unknown error",
};
}
}
/** # 通过本机 socket 调用 Docker Engine API */
private localApi<T>(path: string, method = "GET"): Promise<T> {
return new Promise<T>((resolve, reject) => {
const req = httpRequest(
{
socketPath: DockerService.LOCAL_SOCKET,
path: `http://localhost/v1.41${path}`,
method,
timeout: 15_000,
},
(res) => {
let body = "";
res.on("data", (c) => (body += c.toString("utf8")));
res.on("end", () => {
if ((res.statusCode ?? 500) >= 400) {
reject(ApiError.upstream(`Docker API ${path} -> ${res.statusCode}: ${body.slice(0, 200)}`));
return;
}
try {
resolve(body ? (JSON.parse(body) as T) : (undefined as T));
} catch {
resolve(undefined as T);
}
});
},
);
req.on("timeout", () => {
req.destroy();
reject(ApiError.timeout("Docker API timeout"));
});
req.on("error", (err) => reject(ApiError.upstream(`Docker API error: ${err.message}`)));
req.end();
});
}
/** # 本机 socket 无返回体调用 */
private async localApiVoid(path: string, method: string): Promise<void> {
await this.localApi<unknown>(path, method);
}
}
/** # 容器 ID 白名单校验(防注入;docker id 为 [0-9a-f]{12,64} 或 name) */
export function assertSafeContainerId(id: string): string {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(id)) {
throw ApiError.validation("Invalid container id");
}
return id;
}
/** # Docker Engine API 容器对象 → 摘要视图 */
function mapEngineContainer(row: Record<string, unknown>): DockerContainerInfo {
const names = (row.Names as string[] | undefined) ?? [];
const ports = (row.Ports as Record<string, unknown>[] | undefined) ?? [];
return {
id: (row.Id as string | undefined)?.slice(0, 12) ?? "",
name: (names[0] ?? "").replace(/^\//, ""),
image: (row.Image as string | undefined) ?? "",
state: (row.State as string | undefined) ?? "",
status: (row.Status as string | undefined) ?? "",
ports,
labels: (row.Labels as Record<string, string> | undefined) ?? {},
};
}
@@ -0,0 +1,178 @@
/**
* # SSH 集成客户端
* 基于 ssh2 的统一封装:连接池化(每目标复用)、命令执行(强制超时)、连通性探测。
* 安全约束:所有命令必须带超时;凭据只在内存中短暂存在。
*/
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import { Client, type ClientCallback, type ConnectConfig } from "ssh2";
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
import { ApiError } from "../../common/errors/api-error";
/** # 连接参数 **/
export interface SshTarget {
host: string;
port: number;
username: string;
/** # 密码或私钥二选一 **/
password?: string;
privateKey?: string;
/** # 私钥口令(可选) **/
passphrase?: string;
}
/** # 命令执行结果 **/
export interface SshExecResult {
code: number;
stdout: string;
stderr: string;
/** # 是否因超时被终止 **/
timedOut: boolean;
}
/** # 默认连接超时(毫秒) **/
const CONNECT_TIMEOUT_MS = 10_000;
/** # 默认命令超时(毫秒) **/
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
@Injectable()
export class SshClientService implements OnModuleDestroy {
/** # 连接缓存 key → Client **/
private connections = new Map<string, { client: Client; busy: boolean }>();
/** # 日志器 **/
private readonly logger: StructuredLoggerInstance;
constructor(loggers: LogService) {
this.logger = loggers.child("SshClient");
}
/** # 连接缓存键(不含凭据) */
private cacheKey(target: SshTarget): string {
return `${target.username}@${target.host}:${target.port}`;
}
/** # 建立或复用连接
* @returns 就绪的 ssh2 Client
*/
private async connect(target: SshTarget): Promise<Client> {
const key = this.cacheKey(target);
const cached = this.connections.get(key);
if (cached) {
// 已缓存的连接复用(ssh2 Client 支持串行 exec)
return cached.client;
}
const config: ConnectConfig = {
host: target.host,
port: target.port,
username: target.username,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 10_000,
tryKeyboard: false,
};
if (target.privateKey) {
config.privateKey = target.privateKey;
if (target.passphrase) config.passphrase = target.passphrase;
} else if (target.password) {
config.password = target.password;
} else {
throw ApiError.validation("SSH target requires password or privateKey");
}
return new Promise<Client>((resolve, reject) => {
const client = new Client();
const timer = setTimeout(() => {
client.end();
reject(ApiError.timeout(`SSH connect timeout after ${CONNECT_TIMEOUT_MS}ms`));
}, CONNECT_TIMEOUT_MS + 2000);
client
.on("ready", () => {
clearTimeout(timer);
this.connections.set(key, { client, busy: false });
resolve(client);
})
.on("error", (err: Error) => {
clearTimeout(timer);
this.connections.delete(key);
reject(
new ApiError(
502,
"UPSTREAM_FAILURE",
`SSH connection failed: ${err.message}`,
),
);
})
.connect(config);
});
}
/** # 在目标机上执行命令(强制超时)
* @param target SSH 目标
* @param command 待执行命令
* @param timeoutMs 命令超时(默认 30s)
*/
async exec(target: SshTarget, command: string, timeoutMs = DEFAULT_EXEC_TIMEOUT_MS): Promise<SshExecResult> {
const client = await this.connect(target);
return new Promise<SshExecResult>((resolve, reject) => {
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
// 超时后断开整条连接,避免流挂起
this.closeKey(this.cacheKey(target));
reject(ApiError.timeout(`SSH command timed out after ${timeoutMs}ms`));
}, timeoutMs);
client.exec(command, ((err: Error | undefined, stream) => {
if (err) {
clearTimeout(timer);
this.closeKey(this.cacheKey(target));
reject(new ApiError(502, "UPSTREAM_FAILURE", `SSH exec failed: ${err.message}`));
return;
}
let stdout = "";
let stderr = "";
stream.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
stream.on("close", (code: number | null) => {
clearTimeout(timer);
if (timedOut) return;
resolve({ code: code ?? -1, stdout, stderr, timedOut: false });
});
}) as ClientCallback);
});
}
/** # 连通性探测:执行 echo 并测量耗时
* @returns 延迟毫秒与远端 uname 信息
*/
async check(target: SshTarget): Promise<{ latencyMs: number; uname: string }> {
const start = Date.now();
const result = await this.exec(target, "uname -srm && cat /etc/os-release 2>/dev/null | head -2", 15_000);
return { latencyMs: Date.now() - start, uname: result.stdout.trim() };
}
/** # 关闭指定目标的连接 */
private closeKey(key: string): void {
const cached = this.connections.get(key);
if (cached) {
try {
cached.client.end();
} catch {
/* ignore */
}
this.connections.delete(key);
}
}
/** # 关闭所有连接 */
closeAll(): void {
for (const [key] of this.connections) this.closeKey(key);
}
/** # 停止时清理连接 */
onModuleDestroy(): void {
this.closeAll();
}
}
@@ -0,0 +1,12 @@
/**
* # SSH 集成模块
*/
import { Global, Module } from "@nestjs/common";
import { SshClientService } from "./ssh-client.service";
@Global()
@Module({
providers: [SshClientService],
exports: [SshClientService],
})
export class SshModule {}