@cybermem/cli 0.13.17 → 0.14.4
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/dist/templates/ansible/playbooks/deploy-cybermem.yml +111 -17
- package/dist/templates/auth-sidecar/Dockerfile +2 -1
- package/dist/templates/auth-sidecar/package.json +3 -1
- package/dist/templates/auth-sidecar/server.js +320 -22
- package/package.json +1 -1
- package/templates/ansible/playbooks/deploy-cybermem.yml +111 -17
- package/templates/auth-sidecar/Dockerfile +2 -1
- package/templates/auth-sidecar/package.json +3 -1
- package/templates/auth-sidecar/server.js +320 -22
|
@@ -61,15 +61,106 @@
|
|
|
61
61
|
group: "{{ ansible_user }}"
|
|
62
62
|
mode: "0755"
|
|
63
63
|
|
|
64
|
-
- name:
|
|
64
|
+
- name: Check if API key secret file exists
|
|
65
|
+
stat:
|
|
66
|
+
path: "{{ project_dir }}/secrets/om_api_key"
|
|
67
|
+
register: api_key_file
|
|
68
|
+
|
|
69
|
+
- name: Read existing API key if it exists
|
|
70
|
+
command: cat "{{ project_dir }}/secrets/om_api_key"
|
|
71
|
+
register: existing_key_content
|
|
72
|
+
when: api_key_file.stat.exists
|
|
73
|
+
changed_when: false
|
|
74
|
+
no_log: true
|
|
75
|
+
ignore_errors: true
|
|
76
|
+
|
|
77
|
+
- name: Determine if token is missing or placeholder
|
|
78
|
+
set_fact:
|
|
79
|
+
token_needs_generation: "{{ not api_key_file.stat.exists or (existing_key_content.stdout | default('') == 'sk-not-generated-yet') }}"
|
|
80
|
+
|
|
81
|
+
- name: Generate new API token if not provided and not exists (or is placeholder)
|
|
82
|
+
set_fact:
|
|
83
|
+
auto_generated_token: "sk-{{ lookup('ansible.builtin.password', '/dev/null length=32 chars=hexdigits') }}"
|
|
84
|
+
auto_generated_id: "{{ lookup('ansible.builtin.password', '/dev/null length=16 chars=hexdigits') }}"
|
|
85
|
+
auto_generated_name: "ansible-auto-generated"
|
|
86
|
+
no_log: true
|
|
87
|
+
when: (auth_token_value is not defined or auth_token_value == '' or auth_token_value == 'sk-not-generated-yet') and token_needs_generation
|
|
88
|
+
|
|
89
|
+
- name: Generate token hash for auto-generated token
|
|
90
|
+
shell: |
|
|
91
|
+
python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
|
|
92
|
+
args:
|
|
93
|
+
stdin: "{{ auto_generated_token }}"
|
|
94
|
+
register: auto_generated_hash_result
|
|
95
|
+
when: auto_generated_token is defined
|
|
96
|
+
changed_when: false
|
|
97
|
+
no_log: true
|
|
98
|
+
|
|
99
|
+
- name: Set auto-generated token hash
|
|
100
|
+
set_fact:
|
|
101
|
+
auto_generated_hash: "{{ auto_generated_hash_result.stdout }}"
|
|
102
|
+
when: auto_generated_hash_result is not skipped
|
|
103
|
+
|
|
104
|
+
- name: Use auto-generated token if available
|
|
105
|
+
set_fact:
|
|
106
|
+
final_token_value: "{{ (auth_token_value | default(auto_generated_token, true)) if (auth_token_value | default('', true) != 'sk-not-generated-yet') else auto_generated_token }}"
|
|
107
|
+
final_token_hash: "{{ auth_token_hash | default(auto_generated_hash, true) }}"
|
|
108
|
+
final_token_id: "{{ auth_token_id | default(auto_generated_id, true) }}"
|
|
109
|
+
final_token_name: "{{ auth_token_name | default(auto_generated_name, true) }}"
|
|
110
|
+
no_log: true
|
|
111
|
+
when: (auth_token_value is defined and auth_token_value != '') or auto_generated_token is defined
|
|
112
|
+
|
|
113
|
+
- name: Generate token hash when only token value is provided
|
|
114
|
+
shell: |
|
|
115
|
+
python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
|
|
116
|
+
args:
|
|
117
|
+
stdin: "{{ final_token_value }}"
|
|
118
|
+
register: computed_token_hash_result
|
|
119
|
+
when:
|
|
120
|
+
- final_token_value is defined and final_token_value != ""
|
|
121
|
+
- final_token_hash is not defined or final_token_hash == ""
|
|
122
|
+
changed_when: false
|
|
123
|
+
no_log: true
|
|
124
|
+
|
|
125
|
+
- name: Set computed token hash
|
|
126
|
+
set_fact:
|
|
127
|
+
final_token_hash: "{{ computed_token_hash_result.stdout }}"
|
|
128
|
+
when: computed_token_hash_result is not skipped
|
|
129
|
+
no_log: true
|
|
130
|
+
|
|
131
|
+
- name: Write API Key Secret File (only if no token exists or explicitly provided/rotated)
|
|
65
132
|
copy:
|
|
66
|
-
content: "{{
|
|
133
|
+
content: "{{ final_token_value }}"
|
|
67
134
|
dest: "{{ project_dir }}/secrets/om_api_key"
|
|
68
135
|
owner: "{{ ansible_user }}"
|
|
69
136
|
group: "{{ ansible_user }}"
|
|
70
137
|
mode: "0600"
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
no_log: true
|
|
139
|
+
when: final_token_value is defined and (token_needs_generation or (auth_token_value is defined and auth_token_value != '') or (force_rotate_token | default(false)))
|
|
140
|
+
|
|
141
|
+
- name: Display auto-generated token info
|
|
142
|
+
debug:
|
|
143
|
+
msg: |
|
|
144
|
+
🔐 AUTO-GENERATED SECURITY TOKEN:
|
|
145
|
+
Token: {{ auto_generated_token[:7] }}...{{ auto_generated_token[-4:] }}
|
|
146
|
+
|
|
147
|
+
Full token saved to: {{ project_dir }}/secrets/om_api_key
|
|
148
|
+
Retrieve with: cat {{ project_dir }}/secrets/om_api_key
|
|
149
|
+
Also visible in Dashboard Settings after deployment.
|
|
150
|
+
when:
|
|
151
|
+
- auto_generated_token is defined
|
|
152
|
+
- show_token_output | default(false)
|
|
153
|
+
|
|
154
|
+
- name: Display token retrieval instructions
|
|
155
|
+
debug:
|
|
156
|
+
msg: |
|
|
157
|
+
🔐 SECURITY TOKEN generated.
|
|
158
|
+
Full token saved to: {{ project_dir }}/secrets/om_api_key
|
|
159
|
+
Retrieve with: cat {{ project_dir }}/secrets/om_api_key
|
|
160
|
+
Masked token visible in Dashboard Settings after deployment.
|
|
161
|
+
when:
|
|
162
|
+
- auto_generated_token is defined
|
|
163
|
+
- not (show_token_output | default(false))
|
|
73
164
|
|
|
74
165
|
- name: Ensure data directory exists
|
|
75
166
|
file:
|
|
@@ -178,19 +269,22 @@
|
|
|
178
269
|
state: present
|
|
179
270
|
|
|
180
271
|
- name: Inject Authentication Token
|
|
181
|
-
shell:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
272
|
+
shell: "sqlite3 {{ project_dir }}/data/openmemory.sqlite"
|
|
273
|
+
args:
|
|
274
|
+
stdin: |
|
|
275
|
+
CREATE TABLE IF NOT EXISTS access_keys (
|
|
276
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
277
|
+
key_hash TEXT NOT NULL,
|
|
278
|
+
name TEXT DEFAULT 'default',
|
|
279
|
+
user_id TEXT DEFAULT 'default',
|
|
280
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
281
|
+
last_used_at TEXT,
|
|
282
|
+
is_active INTEGER DEFAULT 1
|
|
283
|
+
);
|
|
284
|
+
DELETE FROM access_keys WHERE name='{{ final_token_name }}';
|
|
285
|
+
INSERT INTO access_keys (id, key_hash, name, user_id) VALUES ('{{ final_token_id }}', '{{ final_token_hash }}', '{{ final_token_name }}', 'admin');
|
|
286
|
+
when: final_token_hash is defined
|
|
287
|
+
no_log: true
|
|
194
288
|
ignore_errors: true
|
|
195
289
|
|
|
196
290
|
- name: Wait for Traefik (Proxy) to be ready
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. Bearer tokens (sk-xxx) against Docker Secret file (SSoT)
|
|
6
6
|
* 2. Local requests bypass (localhost, *.local domains)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Dependencies: sqlite3 (for auto-token persistence)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const http = require("http");
|
|
@@ -13,12 +13,175 @@ const crypto = require("crypto");
|
|
|
13
13
|
const path = require("path");
|
|
14
14
|
const fs = require("fs");
|
|
15
15
|
const SECRET_PATH = process.env.API_KEY_FILE || "/run/secrets/om_api_key";
|
|
16
|
+
const DB_PATH = process.env.OM_DB_PATH || "/data/openmemory.sqlite";
|
|
17
|
+
const FALLBACK_TOKEN_PATH = "/data/.cybermem_token";
|
|
16
18
|
let cachedToken = null;
|
|
19
|
+
let isAutoGenerated = false;
|
|
17
20
|
|
|
18
21
|
const PORT = process.env.PORT || 3001;
|
|
19
22
|
|
|
23
|
+
// Generate a new secure token
|
|
24
|
+
function generateToken() {
|
|
25
|
+
return `sk-${crypto.randomBytes(16).toString("hex")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Hash token using PBKDF2 (same algorithm as CLI)
|
|
29
|
+
function hashToken(token) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const salt = crypto
|
|
32
|
+
.createHash("sha256")
|
|
33
|
+
.update("cybermem-salt-v1")
|
|
34
|
+
.digest("hex")
|
|
35
|
+
.slice(0, 16);
|
|
36
|
+
crypto.pbkdf2(token, salt, 100000, 64, "sha512", (err, key) => {
|
|
37
|
+
if (err) reject(err);
|
|
38
|
+
else resolve(key.toString("hex"));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Store token in database
|
|
44
|
+
async function storeTokenInDatabase(token) {
|
|
45
|
+
// Check if sqlite3 module is available
|
|
46
|
+
let sqlite3;
|
|
47
|
+
try {
|
|
48
|
+
sqlite3 = require("sqlite3");
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.warn("sqlite3 module not available, skipping database storage");
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const db = new sqlite3.Database(DB_PATH);
|
|
55
|
+
|
|
56
|
+
// Configure busy timeout to reduce SQLITE_BUSY errors on concurrent access
|
|
57
|
+
if (typeof db.configure === "function") {
|
|
58
|
+
db.configure("busyTimeout", 5000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tokenHash = await hashToken(token);
|
|
62
|
+
const tokenId = crypto.randomBytes(8).toString("hex");
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
db.serialize(() => {
|
|
66
|
+
const expectedColumns = [
|
|
67
|
+
"id",
|
|
68
|
+
"key_hash",
|
|
69
|
+
"name",
|
|
70
|
+
"user_id",
|
|
71
|
+
"created_at",
|
|
72
|
+
"last_used_at",
|
|
73
|
+
"is_active",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const proceedWithSchema = () => {
|
|
77
|
+
// Create table if not exists (after optional drop)
|
|
78
|
+
db.run(
|
|
79
|
+
`CREATE TABLE IF NOT EXISTS access_keys (
|
|
80
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
81
|
+
key_hash TEXT NOT NULL,
|
|
82
|
+
name TEXT DEFAULT 'default',
|
|
83
|
+
user_id TEXT DEFAULT 'default',
|
|
84
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
85
|
+
last_used_at TEXT,
|
|
86
|
+
is_active INTEGER DEFAULT 1
|
|
87
|
+
)`,
|
|
88
|
+
(createErr) => {
|
|
89
|
+
if (createErr) {
|
|
90
|
+
console.error(
|
|
91
|
+
"Failed to ensure access_keys table exists:",
|
|
92
|
+
createErr.message,
|
|
93
|
+
);
|
|
94
|
+
db.close();
|
|
95
|
+
// Resolve false instead of rejecting to distinguish from fatal file persistence error
|
|
96
|
+
resolve(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Delete any existing auto-generated tokens
|
|
101
|
+
db.run(
|
|
102
|
+
"DELETE FROM access_keys WHERE name='auto-generated'",
|
|
103
|
+
(deleteErr) => {
|
|
104
|
+
if (deleteErr) {
|
|
105
|
+
console.warn(
|
|
106
|
+
"Warning cleaning old tokens:",
|
|
107
|
+
deleteErr.message,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Insert new token
|
|
112
|
+
db.run(
|
|
113
|
+
"INSERT INTO access_keys (id, key_hash, name, user_id) VALUES (?, ?, 'auto-generated', 'admin')",
|
|
114
|
+
[tokenId, tokenHash],
|
|
115
|
+
(insertErr) => {
|
|
116
|
+
if (insertErr) {
|
|
117
|
+
console.error(
|
|
118
|
+
"Failed to store token in database:",
|
|
119
|
+
insertErr.message,
|
|
120
|
+
);
|
|
121
|
+
db.close();
|
|
122
|
+
// Resolve false instead of rejecting to distinguish from fatal file persistence error
|
|
123
|
+
resolve(false);
|
|
124
|
+
} else {
|
|
125
|
+
console.log("✅ Auto-generated token stored in database");
|
|
126
|
+
db.close();
|
|
127
|
+
resolve(true);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Validate existing access_keys schema and drop if malformed
|
|
138
|
+
db.all("PRAGMA table_info(access_keys)", (pragmaErr, columns) => {
|
|
139
|
+
if (pragmaErr) {
|
|
140
|
+
console.warn(
|
|
141
|
+
"Unable to inspect access_keys schema, proceeding with creation:",
|
|
142
|
+
pragmaErr.message,
|
|
143
|
+
);
|
|
144
|
+
proceedWithSchema();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let needsDrop = false;
|
|
149
|
+
if (Array.isArray(columns) && columns.length > 0) {
|
|
150
|
+
const existingColumns = columns.map((c) => c.name);
|
|
151
|
+
const hasAllExpected = expectedColumns.every((col) =>
|
|
152
|
+
existingColumns.includes(col),
|
|
153
|
+
);
|
|
154
|
+
const sameLength = existingColumns.length === expectedColumns.length;
|
|
155
|
+
if (!hasAllExpected || !sameLength) {
|
|
156
|
+
needsDrop = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!needsDrop) {
|
|
161
|
+
proceedWithSchema();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("⚠️ Malformed access_keys schema detected, recreating...");
|
|
166
|
+
db.run("DROP TABLE IF EXISTS access_keys", (dropErr) => {
|
|
167
|
+
if (dropErr) {
|
|
168
|
+
console.error(
|
|
169
|
+
"Failed to drop malformed access_keys table:",
|
|
170
|
+
dropErr.message,
|
|
171
|
+
);
|
|
172
|
+
db.close();
|
|
173
|
+
resolve(false);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
proceedWithSchema();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
20
183
|
// Load token from secret file once at startup (SSoT)
|
|
21
|
-
function loadSecret() {
|
|
184
|
+
async function loadSecret() {
|
|
22
185
|
try {
|
|
23
186
|
// 1. Check environment variable first (Direct)
|
|
24
187
|
if (process.env.OM_API_KEY) {
|
|
@@ -34,30 +197,103 @@ function loadSecret() {
|
|
|
34
197
|
// Check if it's a shell-formatted env file (OM_API_KEY=sk-...)
|
|
35
198
|
const envMatch = content.match(/OM_API_KEY=["']?(sk-[a-zA-Z0-9]+)["']?/);
|
|
36
199
|
if (envMatch) {
|
|
37
|
-
|
|
38
|
-
|
|
200
|
+
const extractedToken = envMatch[1];
|
|
201
|
+
// Reject known insecure placeholder
|
|
202
|
+
if (extractedToken === "sk-not-generated-yet") {
|
|
203
|
+
console.warn(
|
|
204
|
+
`⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
cachedToken = extractedToken;
|
|
208
|
+
console.log(`SSoT Token extracted from env file: ${SECRET_PATH}`);
|
|
209
|
+
}
|
|
210
|
+
} else if (content && content.startsWith("sk-")) {
|
|
211
|
+
// Reject known insecure placeholder
|
|
212
|
+
if (content === "sk-not-generated-yet") {
|
|
213
|
+
console.warn(
|
|
214
|
+
`⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
|
|
215
|
+
);
|
|
216
|
+
} else {
|
|
217
|
+
// Assume raw token file
|
|
218
|
+
cachedToken = content;
|
|
219
|
+
console.log(`SSoT Token loaded from raw file: ${SECRET_PATH}`);
|
|
220
|
+
}
|
|
221
|
+
} else if (content) {
|
|
222
|
+
console.warn(
|
|
223
|
+
`SECRET WARNING: ${SECRET_PATH} exists but doesn't contain a valid token format`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 3. Check fallback token file (auto-generated location)
|
|
229
|
+
if (!cachedToken && fs.existsSync(FALLBACK_TOKEN_PATH)) {
|
|
230
|
+
const fallbackContent = fs
|
|
231
|
+
.readFileSync(FALLBACK_TOKEN_PATH, "utf8")
|
|
232
|
+
.trim();
|
|
233
|
+
if (fallbackContent.startsWith("sk-")) {
|
|
234
|
+
cachedToken = fallbackContent;
|
|
235
|
+
console.log(`SSoT Token loaded from fallback: ${FALLBACK_TOKEN_PATH}`);
|
|
236
|
+
isAutoGenerated = true;
|
|
237
|
+
return;
|
|
39
238
|
} else {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
239
|
+
console.warn(
|
|
240
|
+
`SECRET WARNING: ${FALLBACK_TOKEN_PATH} exists but doesn't contain a valid token format (sk-*)`,
|
|
241
|
+
);
|
|
43
242
|
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. Auto-generate if no token found anywhere
|
|
246
|
+
if (!cachedToken) {
|
|
247
|
+
console.warn("⚠️ NO TOKEN FOUND - Auto-generating security token...");
|
|
248
|
+
cachedToken = generateToken();
|
|
249
|
+
isAutoGenerated = true;
|
|
250
|
+
|
|
251
|
+
// Try to persist to fallback location
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(FALLBACK_TOKEN_PATH, cachedToken, {
|
|
254
|
+
mode: 0o600,
|
|
255
|
+
});
|
|
256
|
+
console.log(`✅ Auto-generated token saved to ${FALLBACK_TOKEN_PATH}`);
|
|
257
|
+
|
|
258
|
+
// Also try to store in database
|
|
259
|
+
const dbSuccess = await storeTokenInDatabase(cachedToken);
|
|
260
|
+
if (!dbSuccess) {
|
|
261
|
+
console.warn("⚠️ Token saved to file but database storage failed");
|
|
262
|
+
console.warn(
|
|
263
|
+
" You may need to manually add the token to the database",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error("Failed to persist auto-generated token:", err.message);
|
|
268
|
+
console.error(
|
|
269
|
+
"⚠️ Token is in-memory only and will be lost on restart!",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const masked =
|
|
273
|
+
cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4);
|
|
274
|
+
|
|
275
|
+
console.log("\n" + "=".repeat(70));
|
|
276
|
+
console.log("🔐 AUTO-GENERATED SECURITY TOKEN:");
|
|
277
|
+
console.log(` ${masked}`);
|
|
278
|
+
console.log("");
|
|
279
|
+
console.log("Token saved to: " + FALLBACK_TOKEN_PATH);
|
|
280
|
+
console.log("Retrieve the full token from:");
|
|
281
|
+
console.log(` cat ${FALLBACK_TOKEN_PATH}`);
|
|
282
|
+
console.log("=".repeat(70) + "\n");
|
|
48
283
|
}
|
|
49
284
|
} catch (err) {
|
|
50
285
|
console.error("SECRET LOAD ERROR:", err.message);
|
|
51
286
|
}
|
|
52
287
|
}
|
|
53
288
|
|
|
54
|
-
|
|
289
|
+
// Initialization promise to avoid race conditions
|
|
290
|
+
const initPromise = loadSecret();
|
|
55
291
|
|
|
56
292
|
// Verify token against SSoT (file-based)
|
|
57
293
|
async function verifyToken(token) {
|
|
58
294
|
if (!cachedToken) {
|
|
59
295
|
// Try one more time if not loaded (e.g. race condition/debug)
|
|
60
|
-
loadSecret();
|
|
296
|
+
await loadSecret();
|
|
61
297
|
}
|
|
62
298
|
|
|
63
299
|
if (cachedToken && token === cachedToken) {
|
|
@@ -76,11 +312,6 @@ function isLocalRequest(req) {
|
|
|
76
312
|
forwarded?.split(",")[0]?.trim() || realIp || req.socket.remoteAddress;
|
|
77
313
|
|
|
78
314
|
// 1. First reject Tailscale/Remote requests (.ts.net, 100.x.x.x)
|
|
79
|
-
// CRITICAL: Tailscale requests (via Funnel) must NEVER be treated as local.
|
|
80
|
-
// If host contains .ts.net, it's external.
|
|
81
|
-
// NOTE: Legacy CYBERMEM_TAILSCALE env-flag logic was removed on purpose.
|
|
82
|
-
// We now auto-detect Tailscale by host/IP (".ts.net" / "100.x.x.x"),
|
|
83
|
-
// so .local bypass works regardless of CYBERMEM_TAILSCALE being set.
|
|
84
315
|
if (
|
|
85
316
|
host.includes(".ts.net") ||
|
|
86
317
|
(typeof ip === "string" && ip.startsWith("100."))
|
|
@@ -94,9 +325,6 @@ function isLocalRequest(req) {
|
|
|
94
325
|
return true;
|
|
95
326
|
}
|
|
96
327
|
|
|
97
|
-
// Host-based check REMOVED for security (CVE-2026-001)
|
|
98
|
-
// We only trust loopback IP if not on Tailscale.
|
|
99
|
-
|
|
100
328
|
// Allow localhost bypass ONLY for local Dev environment (Docker Desktop)
|
|
101
329
|
const isDev = process.env.CYBERMEM_INSTANCE === "local";
|
|
102
330
|
if (isDev && (host.startsWith("localhost") || host.startsWith("127.0.0.1"))) {
|
|
@@ -112,12 +340,82 @@ function isLocalRequest(req) {
|
|
|
112
340
|
return isLocalIp;
|
|
113
341
|
}
|
|
114
342
|
|
|
343
|
+
// Strictly localhost for sensitive metadata endpoints
|
|
344
|
+
function isStrictlyLocalRequest(req) {
|
|
345
|
+
const ip = req.socket.remoteAddress;
|
|
346
|
+
|
|
347
|
+
// 1. Direct loopback check
|
|
348
|
+
const isLoopback =
|
|
349
|
+
ip === "127.0.0.1" ||
|
|
350
|
+
ip === "::1" ||
|
|
351
|
+
ip === "::ffff:127.0.0.1" ||
|
|
352
|
+
ip === "localhost";
|
|
353
|
+
|
|
354
|
+
if (isLoopback) return true;
|
|
355
|
+
|
|
356
|
+
// 2. Docker bridge check (e.g. 172.17.0.1 or 172.18.x.x)
|
|
357
|
+
// This allows host-to-container calls for diagnostics
|
|
358
|
+
if (typeof ip === "string" && ip.startsWith("172.")) {
|
|
359
|
+
const parts = ip.split(".");
|
|
360
|
+
const second = parseInt(parts[1], 10);
|
|
361
|
+
if (second >= 16 && second <= 31) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
115
369
|
// ForwardAuth handler
|
|
116
370
|
const server = http.createServer(async (req, res) => {
|
|
371
|
+
// Wait for secret to be loaded before processing any requests
|
|
372
|
+
await initPromise;
|
|
117
373
|
// Health check
|
|
118
374
|
if (req.url === "/health") {
|
|
119
375
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
120
|
-
res.end(
|
|
376
|
+
res.end(
|
|
377
|
+
JSON.stringify({
|
|
378
|
+
status: "ok",
|
|
379
|
+
mode: "token-auth",
|
|
380
|
+
tokenSource: isAutoGenerated ? "auto-generated" : "configured",
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Token info endpoint (metadata only — never expose raw token over HTTP)
|
|
387
|
+
if (req.url === "/token-info") {
|
|
388
|
+
if (!isStrictlyLocalRequest(req)) {
|
|
389
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
390
|
+
res.end(JSON.stringify({ error: "Forbidden - localhost only" }));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!isAutoGenerated) {
|
|
395
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
396
|
+
res.end(
|
|
397
|
+
JSON.stringify({
|
|
398
|
+
error: "Not Found",
|
|
399
|
+
message: "Token info only available for auto-generated tokens",
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const masked = cachedToken
|
|
406
|
+
? cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4)
|
|
407
|
+
: null;
|
|
408
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
409
|
+
res.end(
|
|
410
|
+
JSON.stringify({
|
|
411
|
+
tokenSource: isAutoGenerated ? "auto-generated" : "configured",
|
|
412
|
+
tokenPrefix: masked,
|
|
413
|
+
fallbackPath: FALLBACK_TOKEN_PATH,
|
|
414
|
+
message: isAutoGenerated
|
|
415
|
+
? `Token was auto-generated. Read it from: cat ${FALLBACK_TOKEN_PATH}`
|
|
416
|
+
: "Token is configured from deployment.",
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
121
419
|
return;
|
|
122
420
|
}
|
|
123
421
|
|
package/package.json
CHANGED
|
@@ -61,15 +61,106 @@
|
|
|
61
61
|
group: "{{ ansible_user }}"
|
|
62
62
|
mode: "0755"
|
|
63
63
|
|
|
64
|
-
- name:
|
|
64
|
+
- name: Check if API key secret file exists
|
|
65
|
+
stat:
|
|
66
|
+
path: "{{ project_dir }}/secrets/om_api_key"
|
|
67
|
+
register: api_key_file
|
|
68
|
+
|
|
69
|
+
- name: Read existing API key if it exists
|
|
70
|
+
command: cat "{{ project_dir }}/secrets/om_api_key"
|
|
71
|
+
register: existing_key_content
|
|
72
|
+
when: api_key_file.stat.exists
|
|
73
|
+
changed_when: false
|
|
74
|
+
no_log: true
|
|
75
|
+
ignore_errors: true
|
|
76
|
+
|
|
77
|
+
- name: Determine if token is missing or placeholder
|
|
78
|
+
set_fact:
|
|
79
|
+
token_needs_generation: "{{ not api_key_file.stat.exists or (existing_key_content.stdout | default('') == 'sk-not-generated-yet') }}"
|
|
80
|
+
|
|
81
|
+
- name: Generate new API token if not provided and not exists (or is placeholder)
|
|
82
|
+
set_fact:
|
|
83
|
+
auto_generated_token: "sk-{{ lookup('ansible.builtin.password', '/dev/null length=32 chars=hexdigits') }}"
|
|
84
|
+
auto_generated_id: "{{ lookup('ansible.builtin.password', '/dev/null length=16 chars=hexdigits') }}"
|
|
85
|
+
auto_generated_name: "ansible-auto-generated"
|
|
86
|
+
no_log: true
|
|
87
|
+
when: (auth_token_value is not defined or auth_token_value == '' or auth_token_value == 'sk-not-generated-yet') and token_needs_generation
|
|
88
|
+
|
|
89
|
+
- name: Generate token hash for auto-generated token
|
|
90
|
+
shell: |
|
|
91
|
+
python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
|
|
92
|
+
args:
|
|
93
|
+
stdin: "{{ auto_generated_token }}"
|
|
94
|
+
register: auto_generated_hash_result
|
|
95
|
+
when: auto_generated_token is defined
|
|
96
|
+
changed_when: false
|
|
97
|
+
no_log: true
|
|
98
|
+
|
|
99
|
+
- name: Set auto-generated token hash
|
|
100
|
+
set_fact:
|
|
101
|
+
auto_generated_hash: "{{ auto_generated_hash_result.stdout }}"
|
|
102
|
+
when: auto_generated_hash_result is not skipped
|
|
103
|
+
|
|
104
|
+
- name: Use auto-generated token if available
|
|
105
|
+
set_fact:
|
|
106
|
+
final_token_value: "{{ (auth_token_value | default(auto_generated_token, true)) if (auth_token_value | default('', true) != 'sk-not-generated-yet') else auto_generated_token }}"
|
|
107
|
+
final_token_hash: "{{ auth_token_hash | default(auto_generated_hash, true) }}"
|
|
108
|
+
final_token_id: "{{ auth_token_id | default(auto_generated_id, true) }}"
|
|
109
|
+
final_token_name: "{{ auth_token_name | default(auto_generated_name, true) }}"
|
|
110
|
+
no_log: true
|
|
111
|
+
when: (auth_token_value is defined and auth_token_value != '') or auto_generated_token is defined
|
|
112
|
+
|
|
113
|
+
- name: Generate token hash when only token value is provided
|
|
114
|
+
shell: |
|
|
115
|
+
python3 -c "import sys, hashlib, binascii; token = sys.stdin.read().strip(); salt = hashlib.sha256(b'cybermem-salt-v1').hexdigest()[:16]; h = hashlib.pbkdf2_hmac('sha512', token.encode(), salt.encode(), 100000, 64); print(binascii.hexlify(h).decode())"
|
|
116
|
+
args:
|
|
117
|
+
stdin: "{{ final_token_value }}"
|
|
118
|
+
register: computed_token_hash_result
|
|
119
|
+
when:
|
|
120
|
+
- final_token_value is defined and final_token_value != ""
|
|
121
|
+
- final_token_hash is not defined or final_token_hash == ""
|
|
122
|
+
changed_when: false
|
|
123
|
+
no_log: true
|
|
124
|
+
|
|
125
|
+
- name: Set computed token hash
|
|
126
|
+
set_fact:
|
|
127
|
+
final_token_hash: "{{ computed_token_hash_result.stdout }}"
|
|
128
|
+
when: computed_token_hash_result is not skipped
|
|
129
|
+
no_log: true
|
|
130
|
+
|
|
131
|
+
- name: Write API Key Secret File (only if no token exists or explicitly provided/rotated)
|
|
65
132
|
copy:
|
|
66
|
-
content: "{{
|
|
133
|
+
content: "{{ final_token_value }}"
|
|
67
134
|
dest: "{{ project_dir }}/secrets/om_api_key"
|
|
68
135
|
owner: "{{ ansible_user }}"
|
|
69
136
|
group: "{{ ansible_user }}"
|
|
70
137
|
mode: "0600"
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
no_log: true
|
|
139
|
+
when: final_token_value is defined and (token_needs_generation or (auth_token_value is defined and auth_token_value != '') or (force_rotate_token | default(false)))
|
|
140
|
+
|
|
141
|
+
- name: Display auto-generated token info
|
|
142
|
+
debug:
|
|
143
|
+
msg: |
|
|
144
|
+
🔐 AUTO-GENERATED SECURITY TOKEN:
|
|
145
|
+
Token: {{ auto_generated_token[:7] }}...{{ auto_generated_token[-4:] }}
|
|
146
|
+
|
|
147
|
+
Full token saved to: {{ project_dir }}/secrets/om_api_key
|
|
148
|
+
Retrieve with: cat {{ project_dir }}/secrets/om_api_key
|
|
149
|
+
Also visible in Dashboard Settings after deployment.
|
|
150
|
+
when:
|
|
151
|
+
- auto_generated_token is defined
|
|
152
|
+
- show_token_output | default(false)
|
|
153
|
+
|
|
154
|
+
- name: Display token retrieval instructions
|
|
155
|
+
debug:
|
|
156
|
+
msg: |
|
|
157
|
+
🔐 SECURITY TOKEN generated.
|
|
158
|
+
Full token saved to: {{ project_dir }}/secrets/om_api_key
|
|
159
|
+
Retrieve with: cat {{ project_dir }}/secrets/om_api_key
|
|
160
|
+
Masked token visible in Dashboard Settings after deployment.
|
|
161
|
+
when:
|
|
162
|
+
- auto_generated_token is defined
|
|
163
|
+
- not (show_token_output | default(false))
|
|
73
164
|
|
|
74
165
|
- name: Ensure data directory exists
|
|
75
166
|
file:
|
|
@@ -178,19 +269,22 @@
|
|
|
178
269
|
state: present
|
|
179
270
|
|
|
180
271
|
- name: Inject Authentication Token
|
|
181
|
-
shell:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
272
|
+
shell: "sqlite3 {{ project_dir }}/data/openmemory.sqlite"
|
|
273
|
+
args:
|
|
274
|
+
stdin: |
|
|
275
|
+
CREATE TABLE IF NOT EXISTS access_keys (
|
|
276
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
277
|
+
key_hash TEXT NOT NULL,
|
|
278
|
+
name TEXT DEFAULT 'default',
|
|
279
|
+
user_id TEXT DEFAULT 'default',
|
|
280
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
281
|
+
last_used_at TEXT,
|
|
282
|
+
is_active INTEGER DEFAULT 1
|
|
283
|
+
);
|
|
284
|
+
DELETE FROM access_keys WHERE name='{{ final_token_name }}';
|
|
285
|
+
INSERT INTO access_keys (id, key_hash, name, user_id) VALUES ('{{ final_token_id }}', '{{ final_token_hash }}', '{{ final_token_name }}', 'admin');
|
|
286
|
+
when: final_token_hash is defined
|
|
287
|
+
no_log: true
|
|
194
288
|
ignore_errors: true
|
|
195
289
|
|
|
196
290
|
- name: Wait for Traefik (Proxy) to be ready
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 1. Bearer tokens (sk-xxx) against Docker Secret file (SSoT)
|
|
6
6
|
* 2. Local requests bypass (localhost, *.local domains)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Dependencies: sqlite3 (for auto-token persistence)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const http = require("http");
|
|
@@ -13,12 +13,175 @@ const crypto = require("crypto");
|
|
|
13
13
|
const path = require("path");
|
|
14
14
|
const fs = require("fs");
|
|
15
15
|
const SECRET_PATH = process.env.API_KEY_FILE || "/run/secrets/om_api_key";
|
|
16
|
+
const DB_PATH = process.env.OM_DB_PATH || "/data/openmemory.sqlite";
|
|
17
|
+
const FALLBACK_TOKEN_PATH = "/data/.cybermem_token";
|
|
16
18
|
let cachedToken = null;
|
|
19
|
+
let isAutoGenerated = false;
|
|
17
20
|
|
|
18
21
|
const PORT = process.env.PORT || 3001;
|
|
19
22
|
|
|
23
|
+
// Generate a new secure token
|
|
24
|
+
function generateToken() {
|
|
25
|
+
return `sk-${crypto.randomBytes(16).toString("hex")}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Hash token using PBKDF2 (same algorithm as CLI)
|
|
29
|
+
function hashToken(token) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const salt = crypto
|
|
32
|
+
.createHash("sha256")
|
|
33
|
+
.update("cybermem-salt-v1")
|
|
34
|
+
.digest("hex")
|
|
35
|
+
.slice(0, 16);
|
|
36
|
+
crypto.pbkdf2(token, salt, 100000, 64, "sha512", (err, key) => {
|
|
37
|
+
if (err) reject(err);
|
|
38
|
+
else resolve(key.toString("hex"));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Store token in database
|
|
44
|
+
async function storeTokenInDatabase(token) {
|
|
45
|
+
// Check if sqlite3 module is available
|
|
46
|
+
let sqlite3;
|
|
47
|
+
try {
|
|
48
|
+
sqlite3 = require("sqlite3");
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.warn("sqlite3 module not available, skipping database storage");
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const db = new sqlite3.Database(DB_PATH);
|
|
55
|
+
|
|
56
|
+
// Configure busy timeout to reduce SQLITE_BUSY errors on concurrent access
|
|
57
|
+
if (typeof db.configure === "function") {
|
|
58
|
+
db.configure("busyTimeout", 5000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tokenHash = await hashToken(token);
|
|
62
|
+
const tokenId = crypto.randomBytes(8).toString("hex");
|
|
63
|
+
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
db.serialize(() => {
|
|
66
|
+
const expectedColumns = [
|
|
67
|
+
"id",
|
|
68
|
+
"key_hash",
|
|
69
|
+
"name",
|
|
70
|
+
"user_id",
|
|
71
|
+
"created_at",
|
|
72
|
+
"last_used_at",
|
|
73
|
+
"is_active",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const proceedWithSchema = () => {
|
|
77
|
+
// Create table if not exists (after optional drop)
|
|
78
|
+
db.run(
|
|
79
|
+
`CREATE TABLE IF NOT EXISTS access_keys (
|
|
80
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
81
|
+
key_hash TEXT NOT NULL,
|
|
82
|
+
name TEXT DEFAULT 'default',
|
|
83
|
+
user_id TEXT DEFAULT 'default',
|
|
84
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
85
|
+
last_used_at TEXT,
|
|
86
|
+
is_active INTEGER DEFAULT 1
|
|
87
|
+
)`,
|
|
88
|
+
(createErr) => {
|
|
89
|
+
if (createErr) {
|
|
90
|
+
console.error(
|
|
91
|
+
"Failed to ensure access_keys table exists:",
|
|
92
|
+
createErr.message,
|
|
93
|
+
);
|
|
94
|
+
db.close();
|
|
95
|
+
// Resolve false instead of rejecting to distinguish from fatal file persistence error
|
|
96
|
+
resolve(false);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Delete any existing auto-generated tokens
|
|
101
|
+
db.run(
|
|
102
|
+
"DELETE FROM access_keys WHERE name='auto-generated'",
|
|
103
|
+
(deleteErr) => {
|
|
104
|
+
if (deleteErr) {
|
|
105
|
+
console.warn(
|
|
106
|
+
"Warning cleaning old tokens:",
|
|
107
|
+
deleteErr.message,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Insert new token
|
|
112
|
+
db.run(
|
|
113
|
+
"INSERT INTO access_keys (id, key_hash, name, user_id) VALUES (?, ?, 'auto-generated', 'admin')",
|
|
114
|
+
[tokenId, tokenHash],
|
|
115
|
+
(insertErr) => {
|
|
116
|
+
if (insertErr) {
|
|
117
|
+
console.error(
|
|
118
|
+
"Failed to store token in database:",
|
|
119
|
+
insertErr.message,
|
|
120
|
+
);
|
|
121
|
+
db.close();
|
|
122
|
+
// Resolve false instead of rejecting to distinguish from fatal file persistence error
|
|
123
|
+
resolve(false);
|
|
124
|
+
} else {
|
|
125
|
+
console.log("✅ Auto-generated token stored in database");
|
|
126
|
+
db.close();
|
|
127
|
+
resolve(true);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Validate existing access_keys schema and drop if malformed
|
|
138
|
+
db.all("PRAGMA table_info(access_keys)", (pragmaErr, columns) => {
|
|
139
|
+
if (pragmaErr) {
|
|
140
|
+
console.warn(
|
|
141
|
+
"Unable to inspect access_keys schema, proceeding with creation:",
|
|
142
|
+
pragmaErr.message,
|
|
143
|
+
);
|
|
144
|
+
proceedWithSchema();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let needsDrop = false;
|
|
149
|
+
if (Array.isArray(columns) && columns.length > 0) {
|
|
150
|
+
const existingColumns = columns.map((c) => c.name);
|
|
151
|
+
const hasAllExpected = expectedColumns.every((col) =>
|
|
152
|
+
existingColumns.includes(col),
|
|
153
|
+
);
|
|
154
|
+
const sameLength = existingColumns.length === expectedColumns.length;
|
|
155
|
+
if (!hasAllExpected || !sameLength) {
|
|
156
|
+
needsDrop = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!needsDrop) {
|
|
161
|
+
proceedWithSchema();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("⚠️ Malformed access_keys schema detected, recreating...");
|
|
166
|
+
db.run("DROP TABLE IF EXISTS access_keys", (dropErr) => {
|
|
167
|
+
if (dropErr) {
|
|
168
|
+
console.error(
|
|
169
|
+
"Failed to drop malformed access_keys table:",
|
|
170
|
+
dropErr.message,
|
|
171
|
+
);
|
|
172
|
+
db.close();
|
|
173
|
+
resolve(false);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
proceedWithSchema();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
20
183
|
// Load token from secret file once at startup (SSoT)
|
|
21
|
-
function loadSecret() {
|
|
184
|
+
async function loadSecret() {
|
|
22
185
|
try {
|
|
23
186
|
// 1. Check environment variable first (Direct)
|
|
24
187
|
if (process.env.OM_API_KEY) {
|
|
@@ -34,30 +197,103 @@ function loadSecret() {
|
|
|
34
197
|
// Check if it's a shell-formatted env file (OM_API_KEY=sk-...)
|
|
35
198
|
const envMatch = content.match(/OM_API_KEY=["']?(sk-[a-zA-Z0-9]+)["']?/);
|
|
36
199
|
if (envMatch) {
|
|
37
|
-
|
|
38
|
-
|
|
200
|
+
const extractedToken = envMatch[1];
|
|
201
|
+
// Reject known insecure placeholder
|
|
202
|
+
if (extractedToken === "sk-not-generated-yet") {
|
|
203
|
+
console.warn(
|
|
204
|
+
`⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
|
|
205
|
+
);
|
|
206
|
+
} else {
|
|
207
|
+
cachedToken = extractedToken;
|
|
208
|
+
console.log(`SSoT Token extracted from env file: ${SECRET_PATH}`);
|
|
209
|
+
}
|
|
210
|
+
} else if (content && content.startsWith("sk-")) {
|
|
211
|
+
// Reject known insecure placeholder
|
|
212
|
+
if (content === "sk-not-generated-yet") {
|
|
213
|
+
console.warn(
|
|
214
|
+
`⚠️ INSECURE PLACEHOLDER DETECTED in ${SECRET_PATH}. Treating as missing.`,
|
|
215
|
+
);
|
|
216
|
+
} else {
|
|
217
|
+
// Assume raw token file
|
|
218
|
+
cachedToken = content;
|
|
219
|
+
console.log(`SSoT Token loaded from raw file: ${SECRET_PATH}`);
|
|
220
|
+
}
|
|
221
|
+
} else if (content) {
|
|
222
|
+
console.warn(
|
|
223
|
+
`SECRET WARNING: ${SECRET_PATH} exists but doesn't contain a valid token format`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 3. Check fallback token file (auto-generated location)
|
|
229
|
+
if (!cachedToken && fs.existsSync(FALLBACK_TOKEN_PATH)) {
|
|
230
|
+
const fallbackContent = fs
|
|
231
|
+
.readFileSync(FALLBACK_TOKEN_PATH, "utf8")
|
|
232
|
+
.trim();
|
|
233
|
+
if (fallbackContent.startsWith("sk-")) {
|
|
234
|
+
cachedToken = fallbackContent;
|
|
235
|
+
console.log(`SSoT Token loaded from fallback: ${FALLBACK_TOKEN_PATH}`);
|
|
236
|
+
isAutoGenerated = true;
|
|
237
|
+
return;
|
|
39
238
|
} else {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
239
|
+
console.warn(
|
|
240
|
+
`SECRET WARNING: ${FALLBACK_TOKEN_PATH} exists but doesn't contain a valid token format (sk-*)`,
|
|
241
|
+
);
|
|
43
242
|
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. Auto-generate if no token found anywhere
|
|
246
|
+
if (!cachedToken) {
|
|
247
|
+
console.warn("⚠️ NO TOKEN FOUND - Auto-generating security token...");
|
|
248
|
+
cachedToken = generateToken();
|
|
249
|
+
isAutoGenerated = true;
|
|
250
|
+
|
|
251
|
+
// Try to persist to fallback location
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(FALLBACK_TOKEN_PATH, cachedToken, {
|
|
254
|
+
mode: 0o600,
|
|
255
|
+
});
|
|
256
|
+
console.log(`✅ Auto-generated token saved to ${FALLBACK_TOKEN_PATH}`);
|
|
257
|
+
|
|
258
|
+
// Also try to store in database
|
|
259
|
+
const dbSuccess = await storeTokenInDatabase(cachedToken);
|
|
260
|
+
if (!dbSuccess) {
|
|
261
|
+
console.warn("⚠️ Token saved to file but database storage failed");
|
|
262
|
+
console.warn(
|
|
263
|
+
" You may need to manually add the token to the database",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error("Failed to persist auto-generated token:", err.message);
|
|
268
|
+
console.error(
|
|
269
|
+
"⚠️ Token is in-memory only and will be lost on restart!",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const masked =
|
|
273
|
+
cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4);
|
|
274
|
+
|
|
275
|
+
console.log("\n" + "=".repeat(70));
|
|
276
|
+
console.log("🔐 AUTO-GENERATED SECURITY TOKEN:");
|
|
277
|
+
console.log(` ${masked}`);
|
|
278
|
+
console.log("");
|
|
279
|
+
console.log("Token saved to: " + FALLBACK_TOKEN_PATH);
|
|
280
|
+
console.log("Retrieve the full token from:");
|
|
281
|
+
console.log(` cat ${FALLBACK_TOKEN_PATH}`);
|
|
282
|
+
console.log("=".repeat(70) + "\n");
|
|
48
283
|
}
|
|
49
284
|
} catch (err) {
|
|
50
285
|
console.error("SECRET LOAD ERROR:", err.message);
|
|
51
286
|
}
|
|
52
287
|
}
|
|
53
288
|
|
|
54
|
-
|
|
289
|
+
// Initialization promise to avoid race conditions
|
|
290
|
+
const initPromise = loadSecret();
|
|
55
291
|
|
|
56
292
|
// Verify token against SSoT (file-based)
|
|
57
293
|
async function verifyToken(token) {
|
|
58
294
|
if (!cachedToken) {
|
|
59
295
|
// Try one more time if not loaded (e.g. race condition/debug)
|
|
60
|
-
loadSecret();
|
|
296
|
+
await loadSecret();
|
|
61
297
|
}
|
|
62
298
|
|
|
63
299
|
if (cachedToken && token === cachedToken) {
|
|
@@ -76,11 +312,6 @@ function isLocalRequest(req) {
|
|
|
76
312
|
forwarded?.split(",")[0]?.trim() || realIp || req.socket.remoteAddress;
|
|
77
313
|
|
|
78
314
|
// 1. First reject Tailscale/Remote requests (.ts.net, 100.x.x.x)
|
|
79
|
-
// CRITICAL: Tailscale requests (via Funnel) must NEVER be treated as local.
|
|
80
|
-
// If host contains .ts.net, it's external.
|
|
81
|
-
// NOTE: Legacy CYBERMEM_TAILSCALE env-flag logic was removed on purpose.
|
|
82
|
-
// We now auto-detect Tailscale by host/IP (".ts.net" / "100.x.x.x"),
|
|
83
|
-
// so .local bypass works regardless of CYBERMEM_TAILSCALE being set.
|
|
84
315
|
if (
|
|
85
316
|
host.includes(".ts.net") ||
|
|
86
317
|
(typeof ip === "string" && ip.startsWith("100."))
|
|
@@ -94,9 +325,6 @@ function isLocalRequest(req) {
|
|
|
94
325
|
return true;
|
|
95
326
|
}
|
|
96
327
|
|
|
97
|
-
// Host-based check REMOVED for security (CVE-2026-001)
|
|
98
|
-
// We only trust loopback IP if not on Tailscale.
|
|
99
|
-
|
|
100
328
|
// Allow localhost bypass ONLY for local Dev environment (Docker Desktop)
|
|
101
329
|
const isDev = process.env.CYBERMEM_INSTANCE === "local";
|
|
102
330
|
if (isDev && (host.startsWith("localhost") || host.startsWith("127.0.0.1"))) {
|
|
@@ -112,12 +340,82 @@ function isLocalRequest(req) {
|
|
|
112
340
|
return isLocalIp;
|
|
113
341
|
}
|
|
114
342
|
|
|
343
|
+
// Strictly localhost for sensitive metadata endpoints
|
|
344
|
+
function isStrictlyLocalRequest(req) {
|
|
345
|
+
const ip = req.socket.remoteAddress;
|
|
346
|
+
|
|
347
|
+
// 1. Direct loopback check
|
|
348
|
+
const isLoopback =
|
|
349
|
+
ip === "127.0.0.1" ||
|
|
350
|
+
ip === "::1" ||
|
|
351
|
+
ip === "::ffff:127.0.0.1" ||
|
|
352
|
+
ip === "localhost";
|
|
353
|
+
|
|
354
|
+
if (isLoopback) return true;
|
|
355
|
+
|
|
356
|
+
// 2. Docker bridge check (e.g. 172.17.0.1 or 172.18.x.x)
|
|
357
|
+
// This allows host-to-container calls for diagnostics
|
|
358
|
+
if (typeof ip === "string" && ip.startsWith("172.")) {
|
|
359
|
+
const parts = ip.split(".");
|
|
360
|
+
const second = parseInt(parts[1], 10);
|
|
361
|
+
if (second >= 16 && second <= 31) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
115
369
|
// ForwardAuth handler
|
|
116
370
|
const server = http.createServer(async (req, res) => {
|
|
371
|
+
// Wait for secret to be loaded before processing any requests
|
|
372
|
+
await initPromise;
|
|
117
373
|
// Health check
|
|
118
374
|
if (req.url === "/health") {
|
|
119
375
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
120
|
-
res.end(
|
|
376
|
+
res.end(
|
|
377
|
+
JSON.stringify({
|
|
378
|
+
status: "ok",
|
|
379
|
+
mode: "token-auth",
|
|
380
|
+
tokenSource: isAutoGenerated ? "auto-generated" : "configured",
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Token info endpoint (metadata only — never expose raw token over HTTP)
|
|
387
|
+
if (req.url === "/token-info") {
|
|
388
|
+
if (!isStrictlyLocalRequest(req)) {
|
|
389
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
390
|
+
res.end(JSON.stringify({ error: "Forbidden - localhost only" }));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!isAutoGenerated) {
|
|
395
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
396
|
+
res.end(
|
|
397
|
+
JSON.stringify({
|
|
398
|
+
error: "Not Found",
|
|
399
|
+
message: "Token info only available for auto-generated tokens",
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const masked = cachedToken
|
|
406
|
+
? cachedToken.substring(0, 7) + "..." + cachedToken.slice(-4)
|
|
407
|
+
: null;
|
|
408
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
409
|
+
res.end(
|
|
410
|
+
JSON.stringify({
|
|
411
|
+
tokenSource: isAutoGenerated ? "auto-generated" : "configured",
|
|
412
|
+
tokenPrefix: masked,
|
|
413
|
+
fallbackPath: FALLBACK_TOKEN_PATH,
|
|
414
|
+
message: isAutoGenerated
|
|
415
|
+
? `Token was auto-generated. Read it from: cat ${FALLBACK_TOKEN_PATH}`
|
|
416
|
+
: "Token is configured from deployment.",
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
121
419
|
return;
|
|
122
420
|
}
|
|
123
421
|
|