Skip to content

Commit 8463388

Browse files
authored
Merge pull request #250 from mieweb/cmyers_wazuh-int
Add Default Container Environment Variables + Wazuh Agent Installation
2 parents c56076b + 6efe068 commit 8463388

9 files changed

Lines changed: 382 additions & 8 deletions

File tree

create-a-container/bin/create-container.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const path = require('path');
2828

2929
// Load models from parent directory
3030
const db = require(path.join(__dirname, '..', 'models'));
31-
const { Container, Node, Site, Service, HTTPService, ExternalDomain } = db;
31+
const { Container, Node, Site, Service, HTTPService, ExternalDomain, Setting } = db;
3232

3333
// Load utilities
3434
const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli'));
@@ -330,7 +330,23 @@ async function main() {
330330

331331
// Merge user-specified env vars (user values override defaults)
332332
const userEnvVars = container.environmentVars ? JSON.parse(container.environmentVars) : {};
333-
mergedEnvVars = { ...mergedEnvVars, ...userEnvVars };
333+
334+
// Load system-wide default env vars from Settings.
335+
// Descriptions are metadata only and are not passed into the container.
336+
let systemDefaultEnvVars = {};
337+
try {
338+
const entries = await Setting.getDefaultContainerEnvVars();
339+
for (const entry of entries) {
340+
if (entry.key && entry.key.trim()) {
341+
systemDefaultEnvVars[entry.key.trim()] = entry.value || '';
342+
}
343+
}
344+
} catch (_) {
345+
console.warn('Could not load default_container_env_vars from settings, skipping');
346+
}
347+
348+
// Merge priority: image defaults < system defaults < per-container user values
349+
mergedEnvVars = { ...mergedEnvVars, ...systemDefaultEnvVars, ...userEnvVars };
334350

335351
// Use user entrypoint if specified, otherwise keep default
336352
const finalEntrypoint = container.entrypoint || defaultEntrypoint;
@@ -359,8 +375,9 @@ async function main() {
359375
if (Object.keys(envConfig).length > 0) {
360376
console.log('Applying environment variables and entrypoint...');
361377
if (defaultEntrypoint) console.log(`Default entrypoint: ${defaultEntrypoint}`);
362-
if (defaultEnvStr) console.log(`Default env vars: ${Object.keys(mergedEnvVars).length - Object.keys(userEnvVars).length} from image`);
363-
if (Object.keys(userEnvVars).length > 0) console.log(`User env vars: ${Object.keys(userEnvVars).length} overrides`);
378+
if (defaultEnvStr) console.log(`Image default env vars: ${Object.keys(mergedEnvVars).length - Object.keys(userEnvVars).length - Object.keys(systemDefaultEnvVars).length}`);
379+
if (Object.keys(systemDefaultEnvVars).length > 0) console.log(`System default env vars: ${Object.keys(systemDefaultEnvVars).length} from settings`);
380+
if (Object.keys(userEnvVars).length > 0) console.log(`Per-container env vars: ${Object.keys(userEnvVars).length}`);
364381
await client.updateLxcConfig(node.name, vmid, envConfig);
365382
console.log('Environment/entrypoint configuration applied');
366383
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.changeColumn('Settings', 'value', {
7+
type: Sequelize.TEXT,
8+
allowNull: false
9+
});
10+
},
11+
12+
async down(queryInterface, Sequelize) {
13+
await queryInterface.changeColumn('Settings', 'value', {
14+
type: Sequelize.STRING,
15+
allowNull: false
16+
});
17+
}
18+
};

create-a-container/models/setting.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ module.exports = (sequelize, DataTypes) => {
5151
return acc;
5252
}, {});
5353
}
54+
55+
/**
56+
* Returns the default_container_env_vars setting as an array of
57+
* {key, value, description} objects. Handles both the current array
58+
* format and the legacy flat-object format {KEY: value}.
59+
* @returns {Promise<Array<{key: string, value: string, description: string}>>}
60+
*/
61+
static async getDefaultContainerEnvVars() {
62+
const json = await Setting.get('default_container_env_vars');
63+
if (!json) return [];
64+
const parsed = JSON.parse(json);
65+
if (Array.isArray(parsed)) return parsed;
66+
if (typeof parsed === 'object' && parsed !== null) {
67+
return Object.entries(parsed).map(([key, value]) => ({ key, value, description: '' }));
68+
}
69+
return [];
70+
}
5471
}
5572

5673
Setting.init({
@@ -61,7 +78,7 @@ module.exports = (sequelize, DataTypes) => {
6178
unique: true
6279
},
6380
value: {
64-
type: DataTypes.STRING,
81+
type: DataTypes.TEXT,
6582
allowNull: false
6683
}
6784
}, {

create-a-container/routers/settings.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,24 @@ router.get('/', async (req, res) => {
1212
'push_notification_enabled',
1313
'push_notification_api_key',
1414
'smtp_url',
15-
'smtp_noreply_address'
15+
'smtp_noreply_address',
16+
'default_container_env_vars'
1617
]);
18+
19+
let defaultContainerEnvVars = [];
20+
try {
21+
defaultContainerEnvVars = await Setting.getDefaultContainerEnvVars();
22+
} catch (_) {
23+
// ignore malformed JSON — treat as empty
24+
}
1725

1826
res.render('settings/index', {
1927
pushNotificationUrl: settings.push_notification_url || '',
2028
pushNotificationEnabled: settings.push_notification_enabled === 'true',
2129
pushNotificationApiKey: settings.push_notification_api_key || '',
2230
smtpUrl: settings.smtp_url || '',
2331
smtpNoreplyAddress: settings.smtp_noreply_address || '',
32+
defaultContainerEnvVars,
2433
req
2534
});
2635
});
@@ -31,7 +40,8 @@ router.post('/', async (req, res) => {
3140
push_notification_enabled,
3241
push_notification_api_key,
3342
smtp_url,
34-
smtp_noreply_address
43+
smtp_noreply_address,
44+
defaultEnvVars
3545
} = req.body;
3646

3747
const enabled = push_notification_enabled === 'on';
@@ -40,12 +50,28 @@ router.post('/', async (req, res) => {
4050
await req.flash('error', 'Push notification URL is required when push notifications are enabled');
4151
return res.redirect('/settings');
4252
}
53+
54+
// Build default container env vars as an array of {key, value, description} objects.
55+
// Descriptions are metadata only — they are never passed to containers.
56+
const envVarsArray = [];
57+
if (Array.isArray(defaultEnvVars)) {
58+
for (const entry of defaultEnvVars) {
59+
if (entry && entry.key && entry.key.trim()) {
60+
envVarsArray.push({
61+
key: entry.key.trim(),
62+
value: entry.value || '',
63+
description: entry.description || ''
64+
});
65+
}
66+
}
67+
}
4368

4469
await Setting.set('push_notification_url', push_notification_url || '');
4570
await Setting.set('push_notification_enabled', enabled ? 'true' : 'false');
4671
await Setting.set('push_notification_api_key', push_notification_api_key || '');
4772
await Setting.set('smtp_url', smtp_url || '');
4873
await Setting.set('smtp_noreply_address', smtp_noreply_address || '');
74+
await Setting.set('default_container_env_vars', JSON.stringify(envVarsArray));
4975

5076
await req.flash('success', 'Settings saved successfully');
5177
return res.redirect('/settings');
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
3+
// Variables seeded into the default_container_env_vars setting.
4+
// Add future cross-container variables here and create a new seeder
5+
// that calls the same merge logic.
6+
const WAZUH_DEFAULTS = [
7+
{
8+
key: 'WAZUH_MANAGER',
9+
value: '',
10+
description: 'Hostname of the Wazuh manager for agent enrollment (e.g. wazuh.example.com)'
11+
},
12+
{
13+
key: 'WAZUH_REGISTRATION_PASSWORD',
14+
value: '',
15+
description: 'Enrollment password for Wazuh agent registration — deleted from /etc/environment inside the container immediately after first-boot enrollment completes'
16+
}
17+
];
18+
19+
/** @type {import('sequelize-cli').Migration} */
20+
module.exports = {
21+
async up(queryInterface) {
22+
const [rows] = await queryInterface.sequelize.query(
23+
`SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'`
24+
);
25+
26+
let existing = [];
27+
if (rows.length > 0) {
28+
try {
29+
const parsed = JSON.parse(rows[0].value);
30+
if (Array.isArray(parsed)) {
31+
existing = parsed;
32+
} else if (typeof parsed === 'object' && parsed !== null) {
33+
// Migrate from old flat-object format {KEY: value} to array format
34+
existing = Object.entries(parsed).map(([key, value]) => ({ key, value, description: '' }));
35+
}
36+
} catch (_) {
37+
existing = [];
38+
}
39+
}
40+
41+
const existingKeys = new Set(existing.map(e => e.key));
42+
const toAdd = WAZUH_DEFAULTS.filter(e => !existingKeys.has(e.key));
43+
if (toAdd.length === 0) return; // all keys already present
44+
45+
const merged = [...existing, ...toAdd];
46+
const now = new Date();
47+
48+
if (rows.length > 0) {
49+
await queryInterface.sequelize.query(
50+
`UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`,
51+
{ replacements: { value: JSON.stringify(merged), now } }
52+
);
53+
} else {
54+
await queryInterface.bulkInsert('Settings', [{
55+
key: 'default_container_env_vars',
56+
value: JSON.stringify(merged),
57+
createdAt: now,
58+
updatedAt: now
59+
}]);
60+
}
61+
},
62+
63+
async down(queryInterface) {
64+
const [rows] = await queryInterface.sequelize.query(
65+
`SELECT value FROM "Settings" WHERE key = 'default_container_env_vars'`
66+
);
67+
if (rows.length === 0) return;
68+
69+
let existing = [];
70+
try {
71+
const parsed = JSON.parse(rows[0].value);
72+
existing = Array.isArray(parsed) ? parsed : [];
73+
} catch (_) {
74+
return;
75+
}
76+
77+
const keysToRemove = new Set(WAZUH_DEFAULTS.map(e => e.key));
78+
const reverted = existing.filter(e => !keysToRemove.has(e.key));
79+
80+
await queryInterface.sequelize.query(
81+
`UPDATE "Settings" SET value = :value, "updatedAt" = :now WHERE key = 'default_container_env_vars'`,
82+
{ replacements: { value: JSON.stringify(reverted), now: new Date() } }
83+
);
84+
}
85+
};

create-a-container/views/settings/index.ejs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@
110110
</div>
111111
</div>
112112

113+
<div class="mb-4">
114+
<h5 class="mb-3">Default Container Environment Variables</h5>
115+
<p class="text-muted mb-3" id="default_env_vars_help">
116+
These variables are automatically injected into every new container at creation time.
117+
Use them for shared configuration such as agent connection details.
118+
Per-container values always take precedence over these defaults.
119+
</p>
120+
<div class="d-flex justify-content-end mb-2">
121+
<button type="button" id="addDefaultEnvVarBtn" class="btn btn-sm btn-primary" aria-label="Add default environment variable">
122+
Add Variable
123+
</button>
124+
</div>
125+
<table class="table table-sm table-bordered mb-0" aria-describedby="default_env_vars_help">
126+
<thead>
127+
<tr>
128+
<th style="width: 22%;">Name</th>
129+
<th style="width: 22%;">Value</th>
130+
<th>Description</th>
131+
<th style="width: 80px;" class="text-center">Action</th>
132+
</tr>
133+
</thead>
134+
<tbody id="defaultEnvVarsTableBody"></tbody>
135+
</table>
136+
</div>
137+
113138
<div class="d-flex gap-2">
114139
<button type="submit" class="btn btn-primary" aria-label="Save settings">
115140
Save Settings
@@ -123,3 +148,72 @@
123148
</div>
124149

125150
<%- include('../layouts/footer') %>
151+
152+
<script>
153+
let envVarCounter = 0;
154+
155+
function addDefaultEnvVarRow(key = '', value = '', description = '') {
156+
const row = document.createElement('tr');
157+
row.id = `default-env-row-${envVarCounter}`;
158+
159+
const keyCell = document.createElement('td');
160+
keyCell.style.cssText = 'border: 1px solid #ddd; padding: 8px;';
161+
const keyInput = document.createElement('input');
162+
keyInput.type = 'text';
163+
keyInput.className = 'form-control form-control-sm font-monospace';
164+
keyInput.name = `defaultEnvVars[${envVarCounter}][key]`;
165+
keyInput.value = key;
166+
keyInput.placeholder = 'VARIABLE_NAME';
167+
keyInput.pattern = '[A-Za-z_][A-Za-z0-9_]*';
168+
keyInput.setAttribute('aria-label', 'Environment variable name');
169+
keyCell.appendChild(keyInput);
170+
171+
const valueCell = document.createElement('td');
172+
valueCell.style.cssText = 'border: 1px solid #ddd; padding: 8px;';
173+
const valueInput = document.createElement('input');
174+
valueInput.type = 'text';
175+
valueInput.className = 'form-control form-control-sm font-monospace';
176+
valueInput.name = `defaultEnvVars[${envVarCounter}][value]`;
177+
valueInput.value = value;
178+
valueInput.placeholder = 'value';
179+
valueInput.setAttribute('aria-label', 'Environment variable value');
180+
valueCell.appendChild(valueInput);
181+
182+
const descCell = document.createElement('td');
183+
descCell.style.cssText = 'border: 1px solid #ddd; padding: 8px;';
184+
const descInput = document.createElement('input');
185+
descInput.type = 'text';
186+
descInput.className = 'form-control form-control-sm text-muted';
187+
descInput.name = `defaultEnvVars[${envVarCounter}][description]`;
188+
descInput.value = description;
189+
descInput.placeholder = 'optional description (not sent to containers)';
190+
descInput.setAttribute('aria-label', 'Environment variable description');
191+
descCell.appendChild(descInput);
192+
193+
const actionCell = document.createElement('td');
194+
actionCell.style.cssText = 'border: 1px solid #ddd; padding: 8px; text-align: center;';
195+
const removeBtn = document.createElement('button');
196+
removeBtn.type = 'button';
197+
removeBtn.className = 'btn btn-sm btn-danger';
198+
removeBtn.textContent = 'Delete';
199+
removeBtn.setAttribute('aria-label', 'Remove environment variable');
200+
const idx = envVarCounter;
201+
removeBtn.onclick = () => document.getElementById(`default-env-row-${idx}`).remove();
202+
actionCell.appendChild(removeBtn);
203+
204+
row.appendChild(keyCell);
205+
row.appendChild(valueCell);
206+
row.appendChild(descCell);
207+
row.appendChild(actionCell);
208+
document.getElementById('defaultEnvVarsTableBody').appendChild(row);
209+
envVarCounter++;
210+
}
211+
212+
document.getElementById('addDefaultEnvVarBtn').addEventListener('click', () => addDefaultEnvVarRow());
213+
214+
// Pre-populate saved values (array of {key, value, description} objects)
215+
const savedEnvVars = <%- JSON.stringify(defaultContainerEnvVars) %>;
216+
for (const entry of savedEnvVars) {
217+
addDefaultEnvVarRow(entry.key, entry.value, entry.description || '');
218+
}
219+
</script>

images/base/Dockerfile

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ COPY --from=builder /rootfs /
1414

1515
# Install and setup sssd autoconfiguration
1616
RUN apt-get update && \
17-
apt-get install -y sssd sudo tmux curl git jq unattended-upgrades && \
17+
apt-get install -y sssd sudo tmux curl gnupg git jq unattended-upgrades && \
1818
pam-auth-update --enable mkhomedir && \
1919
apt-get clean && \
2020
rm -rf /var/lib/apt/lists/*
@@ -27,6 +27,25 @@ COPY --chmod=0440 ldapusers /etc/sudoers.d/ldapusers
2727
COPY environment.sh /usr/local/bin/environment.sh
2828
COPY environment.service /etc/systemd/system/environment.service
2929

30+
# Wazuh agent — pre-installed at build time so containers don't need internet
31+
# access at first boot to install it. The enrollment service only runs the
32+
# lightweight config + enrollment step at runtime.
33+
RUN curl -fsSL https://packages.wazuh.com/key/GPG-KEY-WAZUH \
34+
| gpg --dearmor -o /usr/share/keyrings/wazuh.gpg && \
35+
printf 'Types: deb\nURIs: https://packages.wazuh.com/4.x/apt/\nSuites: stable\nComponents: main\nSigned-By: /usr/share/keyrings/wazuh.gpg\n' \
36+
> /etc/apt/sources.list.d/wazuh.sources && \
37+
apt-get update && \
38+
apt-get install -y --no-install-recommends wazuh-agent && \
39+
apt-get clean && \
40+
rm -rf /var/lib/apt/lists/*
41+
42+
# Wazuh agent first-boot enrollment service.
43+
# The script checks for WAZUH_MANAGER at runtime; containers without this
44+
# variable skip enrollment silently. ConditionFileNotEmpty ensures the service
45+
# only runs when the agent has never successfully enrolled (client.keys is empty).
46+
COPY --chmod=0755 wazuh-enroll.sh /usr/local/bin/wazuh-enroll.sh
47+
COPY wazuh-enroll.service /etc/systemd/system/wazuh-enroll.service
48+
3049
# We have to disable systemd-networkd.service and systemd-networkd.socket to
3150
# prevent a race condition preventing DHCP-assigned IP addresses from being used
3251
# when the network is managed via ifupdown2 (which it should be in a Debian LXC)

0 commit comments

Comments
 (0)