@fabioforest/openclaw 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/bin/openclaw.js +152 -0
- package/lib/channels.js +84 -0
- package/lib/cli/debug.js +12 -0
- package/lib/cli/doctor.js +266 -0
- package/lib/cli/init.js +145 -0
- package/lib/cli/orchestrate.js +43 -0
- package/lib/cli/status.js +128 -0
- package/lib/cli/update.js +122 -0
- package/lib/config.js +68 -0
- package/lib/detect.js +49 -0
- package/lib/ops/audit.js +156 -0
- package/lib/ops/enroll.js +133 -0
- package/lib/ops/exec.js +140 -0
- package/lib/ops/healthcheck.js +167 -0
- package/lib/ops/policy.js +153 -0
- package/lib/ops/transfer.js +152 -0
- package/lib/ops/update-safe.js +173 -0
- package/lib/ops/vpn.js +131 -0
- package/lib/security.js +48 -0
- package/lib/setup/config_wizard.js +186 -0
- package/package.json +47 -0
- package/templates/.agent/agents/setup-specialist.md +24 -0
- package/templates/.agent/agents/sysadmin-proativo.md +31 -0
- package/templates/.agent/hooks/pre-tool-use.js +109 -0
- package/templates/.agent/rules/SECURITY.md +7 -0
- package/templates/.agent/skills/openclaw-installation-debugger/SKILL.md +37 -0
- package/templates/.agent/skills/openclaw-installation-debugger/scripts/debug.js +165 -0
- package/templates/.agent/skills/openclaw-ops/01-openclaw-vpn-wireguard/SKILL.md +20 -0
- package/templates/.agent/skills/openclaw-ops/02-openclaw-enroll-host/SKILL.md +14 -0
- package/templates/.agent/skills/openclaw-ops/03-openclaw-policy-baseline/SKILL.md +17 -0
- package/templates/.agent/skills/openclaw-ops/04-openclaw-remote-exec-runbooks/SKILL.md +13 -0
- package/templates/.agent/skills/openclaw-ops/05-openclaw-file-transfer-safe/SKILL.md +10 -0
- package/templates/.agent/skills/openclaw-ops/06-openclaw-audit-logging/SKILL.md +13 -0
- package/templates/.agent/skills/openclaw-ops/07-openclaw-safe-update/SKILL.md +9 -0
- package/templates/.agent/skills/openclaw-ops/08-openclaw-healthchecks/SKILL.md +10 -0
- package/templates/.agent/skills/universal-setup/SKILL.md +26 -0
- package/templates/.agent/workflows/doctor.md +20 -0
- package/templates/.agent/workflows/healthcheck.md +22 -0
- package/templates/.agent/workflows/healthcheck.runbook.md +9 -0
- package/templates/.agent/workflows/restart.md +20 -0
- package/templates/.agent/workflows/restart.runbook.md +8 -0
- package/templates/.agent/workflows/setup.md +18 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: Policy Baseline
|
|
5
|
+
*
|
|
6
|
+
* Define RBAC e allowlists (deny-by-default) para execução remota
|
|
7
|
+
* via VPN com break-glass expirável.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/03-openclaw-policy-baseline/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { readJsonSafe, writeJsonSafe } = require("../config");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Perfis RBAC disponíveis com suas permissões.
|
|
16
|
+
*/
|
|
17
|
+
const PROFILES = {
|
|
18
|
+
viewer: {
|
|
19
|
+
description: "Somente leitura — pode consultar status e logs",
|
|
20
|
+
permissions: ["read:status", "read:logs", "read:config"],
|
|
21
|
+
},
|
|
22
|
+
operator: {
|
|
23
|
+
description: "Pode executar runbooks permitidos",
|
|
24
|
+
permissions: ["read:status", "read:logs", "read:config", "exec:runbook", "write:transfer"],
|
|
25
|
+
},
|
|
26
|
+
admin: {
|
|
27
|
+
description: "Ações elevadas com confirmação extra e auditoria",
|
|
28
|
+
permissions: [
|
|
29
|
+
"read:status", "read:logs", "read:config",
|
|
30
|
+
"exec:runbook", "exec:command", "write:transfer",
|
|
31
|
+
"write:config", "manage:hosts", "manage:policy",
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verifica se um perfil tem uma determinada permissão.
|
|
38
|
+
* @param {string} profile — nome do perfil (viewer, operator, admin)
|
|
39
|
+
* @param {string} permission — permissão a verificar (ex: "exec:runbook")
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function hasPermission(profile, permission) {
|
|
43
|
+
const p = PROFILES[profile];
|
|
44
|
+
if (!p) return false;
|
|
45
|
+
return p.permissions.includes(permission);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cria uma sessão break-glass com expiração automática.
|
|
50
|
+
* @param {string} policyPath — caminho do arquivo de políticas
|
|
51
|
+
* @param {object} params
|
|
52
|
+
* @param {string} params.requestedBy — quem solicitou
|
|
53
|
+
* @param {string} params.reason — motivo do break-glass
|
|
54
|
+
* @param {number} [params.durationMinutes=15] — duração em minutos
|
|
55
|
+
* @returns {{ id: string, expiresAt: string }}
|
|
56
|
+
*/
|
|
57
|
+
function createBreakGlass(policyPath, { requestedBy, reason, durationMinutes = 15 }) {
|
|
58
|
+
const policy = readJsonSafe(policyPath) || { breakGlass: [] };
|
|
59
|
+
|
|
60
|
+
const expiresAt = new Date(Date.now() + durationMinutes * 60 * 1000).toISOString();
|
|
61
|
+
const id = `bg-${Date.now()}`;
|
|
62
|
+
|
|
63
|
+
const entry = {
|
|
64
|
+
id,
|
|
65
|
+
requestedBy,
|
|
66
|
+
reason,
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
expiresAt,
|
|
69
|
+
active: true,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (!policy.breakGlass) policy.breakGlass = [];
|
|
73
|
+
policy.breakGlass.push(entry);
|
|
74
|
+
writeJsonSafe(policyPath, policy);
|
|
75
|
+
|
|
76
|
+
return { id, expiresAt };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Verifica se existe um break-glass ativo e válido.
|
|
81
|
+
* @param {string} policyPath — caminho do arquivo de políticas
|
|
82
|
+
* @returns {{ active: boolean, entry?: object }}
|
|
83
|
+
*/
|
|
84
|
+
function checkBreakGlass(policyPath) {
|
|
85
|
+
const policy = readJsonSafe(policyPath);
|
|
86
|
+
if (!policy || !policy.breakGlass) return { active: false };
|
|
87
|
+
|
|
88
|
+
const now = new Date();
|
|
89
|
+
const activeEntry = policy.breakGlass.find(
|
|
90
|
+
(bg) => bg.active && new Date(bg.expiresAt) > now
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (activeEntry) {
|
|
94
|
+
return { active: true, entry: activeEntry };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { active: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Expira todos os break-glass vencidos.
|
|
102
|
+
* @param {string} policyPath — caminho do arquivo de políticas
|
|
103
|
+
* @returns {number} quantidade de break-glass expirados
|
|
104
|
+
*/
|
|
105
|
+
function expireBreakGlass(policyPath) {
|
|
106
|
+
const policy = readJsonSafe(policyPath);
|
|
107
|
+
if (!policy || !policy.breakGlass) return 0;
|
|
108
|
+
|
|
109
|
+
const now = new Date();
|
|
110
|
+
let count = 0;
|
|
111
|
+
|
|
112
|
+
for (const bg of policy.breakGlass) {
|
|
113
|
+
if (bg.active && new Date(bg.expiresAt) <= now) {
|
|
114
|
+
bg.active = false;
|
|
115
|
+
count++;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (count > 0) writeJsonSafe(policyPath, policy);
|
|
120
|
+
return count;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Retorna a allowlist de comandos permitidos para um perfil.
|
|
125
|
+
* @param {string} profile — nome do perfil
|
|
126
|
+
* @returns {string[]} lista de padrões de comando permitidos
|
|
127
|
+
*/
|
|
128
|
+
function getAllowedCommands(profile) {
|
|
129
|
+
const allowlists = {
|
|
130
|
+
viewer: [],
|
|
131
|
+
operator: [
|
|
132
|
+
"systemctl status *",
|
|
133
|
+
"docker ps",
|
|
134
|
+
"docker compose ps",
|
|
135
|
+
"wg show *",
|
|
136
|
+
"ping *",
|
|
137
|
+
"cat /var/log/*",
|
|
138
|
+
"tail -f /var/log/*",
|
|
139
|
+
],
|
|
140
|
+
admin: ["*"], // Admin pode tudo (com confirmação)
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return allowlists[profile] || [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
PROFILES,
|
|
148
|
+
hasPermission,
|
|
149
|
+
createBreakGlass,
|
|
150
|
+
checkBreakGlass,
|
|
151
|
+
expireBreakGlass,
|
|
152
|
+
getAllowedCommands,
|
|
153
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: File Transfer Safe
|
|
5
|
+
*
|
|
6
|
+
* Transferência de arquivos via VPN com allowlist de diretórios,
|
|
7
|
+
* hashing, limites de tamanho e auditoria.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/05-openclaw-file-transfer-safe/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const crypto = require("crypto");
|
|
15
|
+
|
|
16
|
+
// Tamanho máximo padrão: 50MB
|
|
17
|
+
const DEFAULT_MAX_SIZE = 50 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Calcula o SHA-256 de um arquivo.
|
|
21
|
+
* @param {string} filePath — caminho do arquivo
|
|
22
|
+
* @returns {string} hash hex
|
|
23
|
+
*/
|
|
24
|
+
function hashFile(filePath) {
|
|
25
|
+
const content = fs.readFileSync(filePath);
|
|
26
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verifica se um caminho está dentro da allowlist.
|
|
31
|
+
* @param {string} filePath — caminho do arquivo
|
|
32
|
+
* @param {string[]} allowedDirs — diretórios permitidos
|
|
33
|
+
* @returns {{ allowed: boolean, reason?: string }}
|
|
34
|
+
*/
|
|
35
|
+
function checkAllowlist(filePath, allowedDirs) {
|
|
36
|
+
const resolved = path.resolve(filePath);
|
|
37
|
+
|
|
38
|
+
for (const dir of allowedDirs) {
|
|
39
|
+
const resolvedDir = path.resolve(dir);
|
|
40
|
+
if (resolved.startsWith(resolvedDir + path.sep) || resolved === resolvedDir) {
|
|
41
|
+
return { allowed: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
allowed: false,
|
|
47
|
+
reason: `Caminho '${resolved}' fora da allowlist de diretórios`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Verifica se o arquivo não excede o tamanho máximo.
|
|
53
|
+
* @param {string} filePath — caminho do arquivo
|
|
54
|
+
* @param {number} [maxSize] — tamanho máximo em bytes
|
|
55
|
+
* @returns {{ allowed: boolean, size: number, reason?: string }}
|
|
56
|
+
*/
|
|
57
|
+
function checkFileSize(filePath, maxSize = DEFAULT_MAX_SIZE) {
|
|
58
|
+
const stats = fs.statSync(filePath);
|
|
59
|
+
if (stats.size > maxSize) {
|
|
60
|
+
const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
|
|
61
|
+
const maxMB = (maxSize / 1024 / 1024).toFixed(2);
|
|
62
|
+
return {
|
|
63
|
+
allowed: false,
|
|
64
|
+
size: stats.size,
|
|
65
|
+
reason: `Arquivo (${sizeMB}MB) excede o limite de ${maxMB}MB`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { allowed: true, size: stats.size };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Executa transferência segura de arquivo com validações.
|
|
73
|
+
* @param {object} params
|
|
74
|
+
* @param {string} params.source — caminho do arquivo fonte
|
|
75
|
+
* @param {string} params.destination — caminho do destino
|
|
76
|
+
* @param {string[]} params.allowedDirs — diretórios permitidos
|
|
77
|
+
* @param {number} [params.maxSize] — tamanho máximo em bytes
|
|
78
|
+
* @param {string} params.operator — quem está fazendo a transferência
|
|
79
|
+
* @returns {{ success: boolean, auditEntry: object, error?: string }}
|
|
80
|
+
*/
|
|
81
|
+
function transferFile({ source, destination, allowedDirs, maxSize, operator }) {
|
|
82
|
+
const auditEntry = {
|
|
83
|
+
action: "file.transfer",
|
|
84
|
+
operator,
|
|
85
|
+
source: path.resolve(source),
|
|
86
|
+
destination: path.resolve(destination),
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
hashBefore: null,
|
|
89
|
+
hashAfter: null,
|
|
90
|
+
size: null,
|
|
91
|
+
success: false,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// 1. Verificar se o arquivo fonte existe
|
|
95
|
+
if (!fs.existsSync(source)) {
|
|
96
|
+
auditEntry.error = "Arquivo fonte não encontrado";
|
|
97
|
+
return { success: false, auditEntry, error: auditEntry.error };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. Verificar allowlist para fonte e destino
|
|
101
|
+
const srcCheck = checkAllowlist(source, allowedDirs);
|
|
102
|
+
if (!srcCheck.allowed) {
|
|
103
|
+
auditEntry.error = `Fonte: ${srcCheck.reason}`;
|
|
104
|
+
return { success: false, auditEntry, error: auditEntry.error };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const destCheck = checkAllowlist(destination, allowedDirs);
|
|
108
|
+
if (!destCheck.allowed) {
|
|
109
|
+
auditEntry.error = `Destino: ${destCheck.reason}`;
|
|
110
|
+
return { success: false, auditEntry, error: auditEntry.error };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. Verificar tamanho
|
|
114
|
+
const sizeCheck = checkFileSize(source, maxSize);
|
|
115
|
+
if (!sizeCheck.allowed) {
|
|
116
|
+
auditEntry.error = sizeCheck.reason;
|
|
117
|
+
auditEntry.size = sizeCheck.size;
|
|
118
|
+
return { success: false, auditEntry, error: auditEntry.error };
|
|
119
|
+
}
|
|
120
|
+
auditEntry.size = sizeCheck.size;
|
|
121
|
+
|
|
122
|
+
// 4. Hash antes da cópia
|
|
123
|
+
auditEntry.hashBefore = hashFile(source);
|
|
124
|
+
|
|
125
|
+
// 5. Copiar o arquivo
|
|
126
|
+
const destDir = path.dirname(destination);
|
|
127
|
+
if (!fs.existsSync(destDir)) {
|
|
128
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
fs.copyFileSync(source, destination);
|
|
131
|
+
|
|
132
|
+
// 6. Hash depois da cópia (verificar integridade)
|
|
133
|
+
auditEntry.hashAfter = hashFile(destination);
|
|
134
|
+
|
|
135
|
+
if (auditEntry.hashBefore !== auditEntry.hashAfter) {
|
|
136
|
+
auditEntry.error = "Integridade comprometida: hash pré/pós não confere";
|
|
137
|
+
// Remove arquivo corrompido
|
|
138
|
+
fs.unlinkSync(destination);
|
|
139
|
+
return { success: false, auditEntry, error: auditEntry.error };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
auditEntry.success = true;
|
|
143
|
+
return { success: true, auditEntry };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
hashFile,
|
|
148
|
+
checkAllowlist,
|
|
149
|
+
checkFileSize,
|
|
150
|
+
transferFile,
|
|
151
|
+
DEFAULT_MAX_SIZE,
|
|
152
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: Safe Update
|
|
5
|
+
*
|
|
6
|
+
* Atualização segura com verificação (hash/assinatura),
|
|
7
|
+
* canary e rollback automático.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/07-openclaw-safe-update/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const { execSync } = require("child_process");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cria um snapshot de backup antes do update.
|
|
19
|
+
* @param {string} targetDir — diretório a fazer backup
|
|
20
|
+
* @param {string} backupDir — diretório de backups
|
|
21
|
+
* @returns {{ backupPath: string, timestamp: string }}
|
|
22
|
+
*/
|
|
23
|
+
function createSnapshot(targetDir, backupDir) {
|
|
24
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
25
|
+
const backupPath = path.join(backupDir, `snapshot-${timestamp}`);
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(backupDir)) {
|
|
28
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Cópia recursiva do diretório
|
|
32
|
+
copyDirRecursive(targetDir, backupPath);
|
|
33
|
+
|
|
34
|
+
return { backupPath, timestamp };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Copia diretório recursivamente.
|
|
39
|
+
* @param {string} src — fonte
|
|
40
|
+
* @param {string} dest — destino
|
|
41
|
+
*/
|
|
42
|
+
function copyDirRecursive(src, dest) {
|
|
43
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
44
|
+
|
|
45
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
const srcPath = path.join(src, entry.name);
|
|
48
|
+
const destPath = path.join(dest, entry.name);
|
|
49
|
+
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
copyDirRecursive(srcPath, destPath);
|
|
52
|
+
} else {
|
|
53
|
+
fs.copyFileSync(srcPath, destPath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Restaura um snapshot (rollback).
|
|
60
|
+
* @param {string} backupPath — caminho do backup
|
|
61
|
+
* @param {string} targetDir — diretório alvo para restaurar
|
|
62
|
+
* @returns {{ success: boolean, error?: string }}
|
|
63
|
+
*/
|
|
64
|
+
function rollback(backupPath, targetDir) {
|
|
65
|
+
try {
|
|
66
|
+
if (!fs.existsSync(backupPath)) {
|
|
67
|
+
return { success: false, error: `Backup não encontrado: ${backupPath}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Remove o diretório atual
|
|
71
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
72
|
+
|
|
73
|
+
// Restaura do backup
|
|
74
|
+
copyDirRecursive(backupPath, targetDir);
|
|
75
|
+
|
|
76
|
+
return { success: true };
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { success: false, error: err.message };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verifica hash de integridade de um arquivo/diretório.
|
|
84
|
+
* @param {string} filePath — caminho do arquivo
|
|
85
|
+
* @param {string} expectedHash — hash SHA-256 esperado
|
|
86
|
+
* @returns {{ valid: boolean, actualHash: string }}
|
|
87
|
+
*/
|
|
88
|
+
function verifyHash(filePath, expectedHash) {
|
|
89
|
+
const content = fs.readFileSync(filePath);
|
|
90
|
+
const actualHash = crypto.createHash("sha256").update(content).digest("hex");
|
|
91
|
+
return { valid: actualHash === expectedHash, actualHash };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Executa um healthcheck pós-update.
|
|
96
|
+
* @param {object} [checks] — funções de verificação customizáveis
|
|
97
|
+
* @param {function} [checks.configValid] — verifica se config é válida
|
|
98
|
+
* @param {function} [checks.serviceRunning] — verifica se serviço está rodando
|
|
99
|
+
* @returns {{ healthy: boolean, results: object[] }}
|
|
100
|
+
*/
|
|
101
|
+
function postUpdateHealthcheck(checks = {}) {
|
|
102
|
+
const results = [];
|
|
103
|
+
|
|
104
|
+
// Check 1: Configuração válida
|
|
105
|
+
if (checks.configValid) {
|
|
106
|
+
try {
|
|
107
|
+
const ok = checks.configValid();
|
|
108
|
+
results.push({ name: "config", status: ok ? "ok" : "fail" });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
results.push({ name: "config", status: "fail", error: err.message });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check 2: Serviço rodando
|
|
115
|
+
if (checks.serviceRunning) {
|
|
116
|
+
try {
|
|
117
|
+
const ok = checks.serviceRunning();
|
|
118
|
+
results.push({ name: "service", status: ok ? "ok" : "fail" });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
results.push({ name: "service", status: "fail", error: err.message });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const healthy = results.every((r) => r.status === "ok");
|
|
125
|
+
return { healthy, results };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fluxo completo de safe update com canary e rollback.
|
|
130
|
+
* @param {object} params
|
|
131
|
+
* @param {string} params.targetDir — diretório a atualizar
|
|
132
|
+
* @param {string} params.backupDir — diretório de backups
|
|
133
|
+
* @param {function} params.applyUpdate — função que aplica o update
|
|
134
|
+
* @param {object} [params.healthchecks] — funções de verificação
|
|
135
|
+
* @returns {{ success: boolean, backup: string, error?: string }}
|
|
136
|
+
*/
|
|
137
|
+
async function safeUpdate({ targetDir, backupDir, applyUpdate, healthchecks = {} }) {
|
|
138
|
+
// 1. Snapshot/backup
|
|
139
|
+
const { backupPath } = createSnapshot(targetDir, backupDir);
|
|
140
|
+
|
|
141
|
+
// 2. Aplicar update
|
|
142
|
+
try {
|
|
143
|
+
await applyUpdate();
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Rollback imediato
|
|
146
|
+
rollback(backupPath, targetDir);
|
|
147
|
+
return { success: false, backup: backupPath, error: `Update falhou: ${err.message}. Rollback aplicado.` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3. Healthcheck pós-update
|
|
151
|
+
const health = postUpdateHealthcheck(healthchecks);
|
|
152
|
+
|
|
153
|
+
if (!health.healthy) {
|
|
154
|
+
// Rollback automático
|
|
155
|
+
rollback(backupPath, targetDir);
|
|
156
|
+
const failedChecks = health.results.filter((r) => r.status === "fail").map((r) => r.name);
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
backup: backupPath,
|
|
160
|
+
error: `Healthcheck falhou (${failedChecks.join(", ")}). Rollback aplicado.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { success: true, backup: backupPath };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
createSnapshot,
|
|
169
|
+
rollback,
|
|
170
|
+
verifyHash,
|
|
171
|
+
postUpdateHealthcheck,
|
|
172
|
+
safeUpdate,
|
|
173
|
+
};
|
package/lib/ops/vpn.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Script operacional: VPN WireGuard
|
|
5
|
+
*
|
|
6
|
+
* Provisiona e verifica WireGuard entre VPS e hosts.
|
|
7
|
+
* Princípio: sem VPN, sem acesso remoto.
|
|
8
|
+
*
|
|
9
|
+
* Referência: skills/openclaw-ops/01-openclaw-vpn-wireguard/SKILL.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { execSync } = require("child_process");
|
|
13
|
+
const crypto = require("crypto");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Verifica se o WireGuard está instalado.
|
|
17
|
+
* @returns {{ installed: boolean, version?: string }}
|
|
18
|
+
*/
|
|
19
|
+
function checkInstalled() {
|
|
20
|
+
try {
|
|
21
|
+
const output = execSync("wg --version", { encoding: "utf8", timeout: 5000 }).trim();
|
|
22
|
+
return { installed: true, version: output };
|
|
23
|
+
} catch {
|
|
24
|
+
return { installed: false };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verifica o status da interface WireGuard (wg0).
|
|
30
|
+
* @returns {{ active: boolean, peers: number, latestHandshake?: string }}
|
|
31
|
+
*/
|
|
32
|
+
function getInterfaceStatus() {
|
|
33
|
+
try {
|
|
34
|
+
const output = execSync("wg show wg0", { encoding: "utf8", timeout: 5000 });
|
|
35
|
+
const peers = (output.match(/peer:/g) || []).length;
|
|
36
|
+
const handshakeMatch = output.match(/latest handshake:\s*(.+)/);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
active: true,
|
|
40
|
+
peers,
|
|
41
|
+
latestHandshake: handshakeMatch ? handshakeMatch[1].trim() : undefined,
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
return { active: false, peers: 0 };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gera par de chaves WireGuard.
|
|
50
|
+
* @returns {{ privateKey: string, publicKey: string }}
|
|
51
|
+
*/
|
|
52
|
+
function generateKeyPair() {
|
|
53
|
+
try {
|
|
54
|
+
const privateKey = execSync("wg genkey", { encoding: "utf8", timeout: 5000 }).trim();
|
|
55
|
+
const publicKey = execSync(`echo "${privateKey}" | wg pubkey`, {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
timeout: 5000,
|
|
58
|
+
shell: true,
|
|
59
|
+
}).trim();
|
|
60
|
+
return { privateKey, publicKey };
|
|
61
|
+
} catch {
|
|
62
|
+
// Fallback: gera chaves com crypto (para ambientes sem wg)
|
|
63
|
+
const key = crypto.randomBytes(32);
|
|
64
|
+
return {
|
|
65
|
+
privateKey: key.toString("base64"),
|
|
66
|
+
publicKey: crypto.createHash("sha256").update(key).digest("base64"),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Gera configuração WireGuard para a VPS (servidor).
|
|
73
|
+
* @param {object} params
|
|
74
|
+
* @param {string} params.privateKey — chave privada do servidor
|
|
75
|
+
* @param {string} params.listenPort — porta UDP (padrão: 51820)
|
|
76
|
+
* @param {string} params.address — endereço IP da VPN (padrão: 10.60.0.1/24)
|
|
77
|
+
* @returns {string} conteúdo do arquivo wg0.conf
|
|
78
|
+
*/
|
|
79
|
+
function generateServerConfig({ privateKey, listenPort = "51820", address = "10.60.0.1/24" }) {
|
|
80
|
+
return `[Interface]
|
|
81
|
+
Address = ${address}
|
|
82
|
+
ListenPort = ${listenPort}
|
|
83
|
+
PrivateKey = ${privateKey}
|
|
84
|
+
|
|
85
|
+
# PostUp/PostDown para firewall (ajuste conforme sua interface de rede)
|
|
86
|
+
# PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
|
|
87
|
+
# PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Gera configuração de peer para adicionar à VPS.
|
|
93
|
+
* @param {object} params
|
|
94
|
+
* @param {string} params.publicKey — chave pública do peer
|
|
95
|
+
* @param {string} params.allowedIPs — IPs permitidos (ex: 10.60.0.2/32)
|
|
96
|
+
* @returns {string} bloco [Peer] para wg0.conf
|
|
97
|
+
*/
|
|
98
|
+
function generatePeerBlock({ publicKey, allowedIPs }) {
|
|
99
|
+
return `
|
|
100
|
+
[Peer]
|
|
101
|
+
PublicKey = ${publicKey}
|
|
102
|
+
AllowedIPs = ${allowedIPs}
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Valida conectividade dentro da VPN via ping.
|
|
108
|
+
* @param {string} ip — IP para pingar (ex: 10.60.0.1)
|
|
109
|
+
* @returns {{ reachable: boolean, latency?: string }}
|
|
110
|
+
*/
|
|
111
|
+
function validateConnectivity(ip) {
|
|
112
|
+
try {
|
|
113
|
+
const output = execSync(`ping -c 2 -W 3 ${ip}`, { encoding: "utf8", timeout: 10000 });
|
|
114
|
+
const latencyMatch = output.match(/time=(\d+\.?\d*)/);
|
|
115
|
+
return {
|
|
116
|
+
reachable: true,
|
|
117
|
+
latency: latencyMatch ? `${latencyMatch[1]}ms` : undefined,
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return { reachable: false };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
checkInstalled,
|
|
126
|
+
getInterfaceStatus,
|
|
127
|
+
generateKeyPair,
|
|
128
|
+
generateServerConfig,
|
|
129
|
+
generatePeerBlock,
|
|
130
|
+
validateConnectivity,
|
|
131
|
+
};
|
package/lib/security.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Módulo de segurança para o OpenClaw Setup Wizard.
|
|
3
|
+
* Contém verificação de portas, mascaramento de segredos e geração de tokens.
|
|
4
|
+
*
|
|
5
|
+
* @module lib/security
|
|
6
|
+
*/
|
|
7
|
+
const crypto = require("crypto");
|
|
8
|
+
const net = require("net");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Verifica se uma porta está em uso em um host específico.
|
|
12
|
+
* Usa timeout curto (600ms) para não travar o wizard.
|
|
13
|
+
* @param {string} host - Endereço do host (ex: "127.0.0.1")
|
|
14
|
+
* @param {number} port - Número da porta
|
|
15
|
+
* @returns {Promise<boolean>} true se a porta respondeu (em uso)
|
|
16
|
+
*/
|
|
17
|
+
function portInUse(host, port) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const socket = new net.Socket();
|
|
20
|
+
socket.setTimeout(600);
|
|
21
|
+
socket.once("error", () => resolve(false));
|
|
22
|
+
socket.once("timeout", () => { socket.destroy(); resolve(false); });
|
|
23
|
+
socket.connect(port, host, () => { socket.end(); resolve(true); });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Mascara um segredo para exibição segura (logs/console).
|
|
29
|
+
* Mostra apenas os 3 primeiros e 3 últimos caracteres.
|
|
30
|
+
* @param {string} s - String secreta a ser mascarada
|
|
31
|
+
* @returns {string} String mascarada (ex: "abc…xyz") ou "***" se curta
|
|
32
|
+
*/
|
|
33
|
+
function mask(s) {
|
|
34
|
+
if (!s) return "";
|
|
35
|
+
if (s.length <= 6) return "***";
|
|
36
|
+
return s.slice(0, 3) + "…" + s.slice(-3);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Gera um token de autenticação seguro usando crypto.randomBytes.
|
|
41
|
+
* Produz 48 caracteres hexadecimais (24 bytes de entropia).
|
|
42
|
+
* @returns {string} Token hexadecimal de 48 caracteres
|
|
43
|
+
*/
|
|
44
|
+
function generateToken() {
|
|
45
|
+
return crypto.randomBytes(24).toString("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { portInUse, mask, generateToken };
|