Compare commits

6 Commits

Author SHA1 Message Date
Logan Cusano 1f5f1fede8 Serve the frontend on the bare domain instead of app.<domain>
Build & Deploy / Build & push images (push) Successful in 4m4s
Build & Deploy / Deploy to VM (push) Failing after 2m11s
Only drb.cusano.net and api.drb.cusano.net have public A records, so the
app.<domain> vhost had no cert to present and the bare domain — the record
that actually exists — matched no site at all, producing
ERR_SSL_PROTOCOL_ERROR in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:35:47 -04:00
Logan Cusano 12c9ad73bb Document the no-$-in-vault-values rule that caused the MQTT auth failure
Build & Deploy / Build & push images (push) Successful in 4m4s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
A password containing "$fP" was interpolated away by compose, giving
mosquitto and c2-core two different passwords and producing
"MQTT connect refused: Not authorized" with nothing in the logs pointing at
the cause. Recorded next to the values so the next person generating
credentials sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:28:38 -04:00
Logan Cusano 971ab74d44 Escape $ in the compose-interpolated .env so MQTT passwords survive
Build & Deploy / Build & push images (push) Successful in 4m2s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
Compose interpolates the top-level .env, so a password containing "$fP" was
read as the variable $fP and replaced with an empty string — hence the
repeated "The \"fP\" variable is not set" warnings on every compose command.

The env_file templates are not interpolated, so c2-core kept the literal
password while mosquitto's entrypoint received the mangled one. The two sides
disagreed and c2-core could not authenticate to the broker. Escaping $ as $$
here (and only here) makes compose collapse it back to the real value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:28:38 -04:00
Logan Cusano 6140dd7b9c Fix prod compose port collision and make ansible deploy re-runnable
Build & Deploy / Build & push images (push) Successful in 4m24s
Build & Deploy / Deploy to VM (push) Failing after 2m11s
docker-compose.prod.yml: compose merges `ports` by appending, so the prod
override left the base file's 8888:8000 and 3000:3000 in place next to the
127.0.0.1-scoped ones. Each container tried to bind its port twice and the
second bind failed with "address already in use", so c2-core and frontend
could never start. It also meant the localhost-only binding never applied —
both ports were published on every interface. Marked both `!override`, the
same way mosquitto already used `!reset`.

infra/ansible:
- add the missing "Reload Caddy" handler; the Deploy Caddyfile task notified
  a handler that did not exist, which aborts the play
- guard mkswap/swapon on whether /swapfile is already active, so a second run
  does not fail on "mounted" / "Device or resource busy"
- git task now updates instead of clone-once, otherwise a re-run redeploys
  whatever code was on the VM at first clone
- vault.yml.example: correct the registry token comment to read-only scope

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:19:03 -04:00
Logan Cusano 2e3fde2448 refactor: Clean checkin override parsing and require node type in frontend configuration modal
Build & Deploy / Build & push images (push) Successful in 4m0s
Build & Deploy / Deploy to VM (push) Failing after 2m12s
2026-07-12 23:20:39 -04:00
Logan Cusano c42bd1902c feat: Add local system override with 24h timeout support 2026-07-12 23:05:53 -04:00
16 changed files with 361 additions and 20 deletions
+7 -2
View File
@@ -12,9 +12,14 @@ services:
restart: always restart: always
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
# !override, not a plain list: compose MERGES `ports` by appending, so a plain
# list leaves the base file's "8888:8000" in place alongside this one. The
# container then tries to bind 8888 twice — 0.0.0.0 and 127.0.0.1 — and the
# second bind fails with "address already in use". It also silently defeated
# the whole point of this override, publishing the port on every interface.
c2-core: c2-core:
restart: always restart: always
ports: ports: !override
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly - "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
discord-bot: discord-bot:
@@ -22,5 +27,5 @@ services:
frontend: frontend:
restart: always restart: always
ports: ports: !override
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly - "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
+37 -1
View File
@@ -1,6 +1,6 @@
import asyncio import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
from app.config import settings from app.config import settings
@@ -94,6 +94,11 @@ class MQTTHandler:
"last_seen": now.isoformat(), "last_seen": now.isoformat(),
"assigned_system_id": None, "assigned_system_id": None,
"approval_status": "pending", "approval_status": "pending",
"node_type": payload.get("node_type", "fixed"),
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
"override_system_id": None,
"override_timeout_at": None,
} }
await fstore.doc_set("nodes", node_id, doc, merge=False) await fstore.doc_set("nodes", node_id, doc, merge=False)
logger.info(f"New node registered: {node_id} — pending admin approval.") logger.info(f"New node registered: {node_id} — pending admin approval.")
@@ -111,6 +116,37 @@ class MQTTHandler:
elif existing.get("approval_status") == "approved": elif existing.get("approval_status") == "approved":
# Approved but not yet configured — restore reachable status after reboot # Approved but not yet configured — restore reachable status after reboot
updates["status"] = "unconfigured" updates["status"] = "unconfigured"
node_type = payload.get("node_type") or existing.get("node_type") or "fixed"
enforce_timeout = payload.get("enforce_override_timeout")
if enforce_timeout is None:
enforce_timeout = existing.get("enforce_override_timeout", True)
updates["node_type"] = node_type
updates["enforce_override_timeout"] = enforce_timeout
is_overridden = payload.get("is_overridden", False)
override_system_id = payload.get("override_system_id")
if node_type == "portable":
updates["is_overridden"] = False
updates["override_system_id"] = None
updates["override_timeout_at"] = None
else:
updates["is_overridden"] = is_overridden
updates["override_system_id"] = override_system_id
if is_overridden:
existing_timeout = existing.get("override_timeout_at")
existing_override_id = existing.get("override_system_id")
if enforce_timeout:
if not existing_timeout or existing_override_id != override_system_id:
updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat()
else:
updates["override_timeout_at"] = None
else:
updates["override_timeout_at"] = None
await fstore.doc_update("nodes", node_id, updates) await fstore.doc_update("nodes", node_id, updates)
# NOTE: discord_connected in checkins is informational only — do NOT release the # NOTE: discord_connected in checkins is informational only — do NOT release the
+32
View File
@@ -55,3 +55,35 @@ async def _sweep():
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})") logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
from app.routers.tokens import release_token from app.routers.tokens import release_token
await release_token(node_id) await release_token(node_id)
continue
# Check for expired system overrides (only for fixed nodes with timeout enforced)
override_timeout_raw = node.get("override_timeout_at")
enforce_timeout = node.get("enforce_override_timeout", True)
node_type = node.get("node_type", "fixed")
if override_timeout_raw and enforce_timeout and node_type != "portable":
if isinstance(override_timeout_raw, str):
override_timeout = datetime.fromisoformat(override_timeout_raw)
else:
override_timeout = override_timeout_raw
if override_timeout.tzinfo is None:
override_timeout = override_timeout.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > override_timeout:
node_id = node.get("node_id")
assigned_system_id = node.get("assigned_system_id")
logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.")
# Push the original assigned config if it exists
if assigned_system_id:
system_doc = await fstore.doc_get("systems", assigned_system_id)
if system_doc:
from app.internal.mqtt_handler import mqtt_handler
mqtt_handler.push_config(node_id, system_doc)
await fstore.doc_update("nodes", node_id, {
"is_overridden": False,
"override_system_id": None,
"override_timeout_at": None,
})
+5
View File
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
configured: bool = False configured: bool = False
last_seen: Optional[datetime] = None last_seen: Optional[datetime] = None
assigned_system_id: Optional[str] = None assigned_system_id: Optional[str] = None
node_type: str = "fixed" # fixed or portable
enforce_override_timeout: bool = True
is_overridden: bool = False
override_system_id: Optional[str] = None
override_timeout_at: Optional[datetime] = None
class CommandPayload(BaseModel): class CommandPayload(BaseModel):
+94 -4
View File
@@ -1,6 +1,7 @@
import secrets import secrets
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from app.models import CommandPayload from app.models import CommandPayload
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal.mqtt_handler import mqtt_handler from app.internal.mqtt_handler import mqtt_handler
@@ -126,10 +127,13 @@ async def assign_system(
if not system: if not system:
raise HTTPException(404, f"System '{system_id}' not found.") raise HTTPException(404, f"System '{system_id}' not found.")
# Include hardware preset in the push so the edge node applies it when # Include hardware preset, node type, and enforce timeout in the push
# generating the OP25 config. Strip it from the system doc first so it push_payload = {
# doesn't collide with SystemConfig field validation on the node side. **system,
push_payload = {**system, "hardware_preset": hardware_preset} "hardware_preset": hardware_preset,
"node_type": node.get("node_type", "fixed"),
"enforce_override_timeout": node.get("enforce_override_timeout", True),
}
if ppm_override is not None: if ppm_override is not None:
push_payload["ppm_override"] = ppm_override push_payload["ppm_override"] = ppm_override
mqtt_handler.push_config(node_id, push_payload) mqtt_handler.push_config(node_id, push_payload)
@@ -145,3 +149,89 @@ async def assign_system(
await fstore.doc_update("nodes", node_id, node_updates) await fstore.doc_update("nodes", node_id, node_updates)
return {"ok": True} return {"ok": True}
class NodeUpdateBody(BaseModel):
node_type: Optional[str] = None
enforce_override_timeout: Optional[bool] = None
@router.patch("/{node_id}")
async def update_node(
node_id: str,
body: NodeUpdateBody,
_: dict = Depends(require_admin_token),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
updates = body.model_dump(exclude_unset=True)
if not updates:
return {"ok": True}
await fstore.doc_update("nodes", node_id, updates)
# Re-push config to apply new node settings locally
updated_node = await fstore.doc_get("nodes", node_id)
assigned_system_id = updated_node.get("assigned_system_id")
if assigned_system_id:
system = await fstore.doc_get("systems", assigned_system_id)
if system:
push_payload = {
**system,
"hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"),
"node_type": updated_node.get("node_type", "fixed"),
"enforce_override_timeout": updated_node.get("enforce_override_timeout", True),
}
if updated_node.get("ppm_override") is not None:
push_payload["ppm_override"] = updated_node["ppm_override"]
mqtt_handler.push_config(node_id, push_payload)
return {"ok": True}
class AckOverrideBody(BaseModel):
timeout_minutes: int = 1440
@router.post("/{node_id}/override/ack")
async def ack_override(
node_id: str,
body: AckOverrideBody,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
from datetime import datetime, timezone, timedelta
new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes)
await fstore.doc_update("nodes", node_id, {
"override_timeout_at": new_timeout.isoformat()
})
return {"ok": True, "override_timeout_at": new_timeout.isoformat()}
@router.post("/{node_id}/override/reset")
async def reset_override(
node_id: str,
_: dict = Depends(require_service_key_or_admin),
):
node = await fstore.doc_get("nodes", node_id)
if not node:
raise HTTPException(404, f"Node '{node_id}' not found.")
assigned_system_id = node.get("assigned_system_id")
if assigned_system_id:
system = await fstore.doc_get("systems", assigned_system_id)
if system:
mqtt_handler.push_config(node_id, system)
await fstore.doc_update("nodes", node_id, {
"is_overridden": False,
"override_system_id": None,
"override_timeout_at": None,
})
return {"ok": True}
+50
View File
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
<StatusBadge status={node.status} /> <StatusBadge status={node.status} />
</div> </div>
{/* Override Warning */}
{node.is_overridden && (
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
<span className="text-base"></span>
<span>Local System Override Active</span>
</div>
<p className="text-gray-400 text-xs leading-relaxed">
This node is operating on a local system override.
{node.override_timeout_at ? (
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
) : (
<> No timeout is currently enforced (permanent override).</>
)}
</p>
<div className="flex gap-2">
{node.override_timeout_at && (
<button
onClick={async () => {
try {
await c2api.ackOverride(id, 1440);
} catch (e) {
alert("Failed to extend timer.");
}
}}
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
>
Ack (Reset 24h Timer)
</button>
)}
<button
onClick={async () => {
if (confirm("Force this node to revert back to its assigned system config?")) {
try {
await c2api.resetOverride(id);
} catch (e) {
alert("Failed to reset override.");
}
}
}}
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
>
Force Revert Config
</button>
</div>
</div>
)}
{/* Info */} {/* Info */}
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm"> <div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
{[ {[
@@ -199,6 +247,8 @@ export default function NodeDetailPage() {
["Location", `${node.lat}, ${node.lon}`], ["Location", `${node.lat}, ${node.lon}`],
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"], ["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
["Configured", node.configured ? "Yes" : "No"], ["Configured", node.configured ? "Yes" : "No"],
["Node Type", node.node_type ?? "fixed"],
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
].map(([label, value]) => ( ].map(([label, value]) => (
<div key={label} className="flex justify-between px-4 py-2.5"> <div key={label} className="flex justify-between px-4 py-2.5">
<span className="text-gray-500">{label}</span> <span className="text-gray-500">{label}</span>
+12
View File
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
Needs configuration Needs configuration
</div> </div>
)} )}
{node.is_overridden && (
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
<span> Local Override</span>
{node.override_timeout_at ? (
<span className="text-gray-500">
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
</span>
) : (
<span className="text-gray-500">Permanent</span>
)}
</div>
)}
</div> </div>
</Link> </Link>
); );
+39 -4
View File
@@ -17,9 +17,11 @@ const PRESETS = [
]; ];
export function NodeConfigModal({ node, systems, onClose }: Props) { export function NodeConfigModal({ node, systems, onClose }: Props) {
const [systemId, setSystemId] = useState(""); const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
const [preset, setPreset] = useState("rtl-sdr-v3"); const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
const [ppm, setPpm] = useState("0"); const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0");
const [nodeType, setNodeType] = useState(node.node_type ?? "");
const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await c2api.updateNode(node.node_id, {
node_type: nodeType,
enforce_override_timeout: enforceTimeout,
});
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride); await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
/> />
</div> </div>
<div>
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
<select
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
required
>
<option value="">Select node type...</option>
<option value="fixed">Fixed Node (Standard)</option>
<option value="portable">Portable Node (Handheld)</option>
</select>
</div>
{nodeType === "fixed" && (
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
id="enforceTimeout"
checked={enforceTimeout}
onChange={(e) => setEnforceTimeout(e.target.checked)}
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
/>
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
Enforce Local Override Timeout (24 hours)
</label>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>} {error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1"> <div className="flex gap-3 pt-1">
<button <button
type="submit" type="submit"
disabled={saving || !systemId} disabled={saving || !systemId || !nodeType}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors" className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
> >
{saving ? "Saving…" : "Assign & Configure"} {saving ? "Saving…" : "Assign & Configure"}
+6
View File
@@ -30,6 +30,12 @@ export const c2api = {
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride)); if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" }); return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
}, },
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
resetOverride: (nodeId: string) =>
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
// Systems // Systems
getSystems: () => request<unknown[]>("/systems"), getSystems: () => request<unknown[]>("/systems"),
+5
View File
@@ -52,6 +52,11 @@ export interface NodeRecord {
approval_status: ApprovalStatus | null; approval_status: ApprovalStatus | null;
hardware_preset?: string; hardware_preset?: string;
ppm_override?: number | null; ppm_override?: number | null;
node_type?: string;
enforce_override_timeout?: boolean;
is_overridden?: boolean;
override_system_id?: string | null;
override_timeout_at?: string | null;
} }
export interface VocabularyPendingTerm { export interface VocabularyPendingTerm {
@@ -0,0 +1,13 @@
---
# The "Deploy Caddyfile" task notifies this. Without this file the play aborts
# with "The requested handler 'Reload Caddy' was not found" — notify does not
# tolerate a missing handler.
#
# reloaded, not restarted: caddy reload swaps config with zero downtime and
# keeps existing TLS certs/connections; a restart drops every in-flight request.
- name: Reload Caddy
ansible.builtin.systemd_service:
name: caddy
state: reloaded
enabled: true
+9 -2
View File
@@ -2,12 +2,19 @@
# First-time setup: clone repo, write secrets, pull pre-built images and start stack. # First-time setup: clone repo, write secrets, pull pre-built images and start stack.
# Images are built and pushed by Gitea CI — this role never builds on the VM. # Images are built and pushed by Gitea CI — this role never builds on the VM.
- name: Clone repo (skipped if already present) # update: true (was false) — with update disabled, every re-run of this playbook
# redeployed the code that happened to be on the VM at first clone, so any fix
# pushed to main was invisible here and the only way to ship one was CI or a
# manual pull. force: true discards local edits made on the VM; the templated
# .env files and Caddyfile live outside git tracking, so nothing generated by
# this role is at risk.
- name: Clone or update repo
git: git:
repo: "{{ repo_url }}" repo: "{{ repo_url }}"
dest: "{{ app_dir }}" dest: "{{ app_dir }}"
version: main version: main
update: false update: true
force: true
become: false become: false
- name: Set ownership of app directory - name: Set ownership of app directory
@@ -1,4 +1,4 @@
# Managed by Ansible — do not edit manually on the server. # Managed by Ansible — do not edit manually.
api.{{ domain }} { api.{{ domain }} {
reverse_proxy localhost:8888 { reverse_proxy localhost:8888 {
@@ -6,7 +6,12 @@ api.{{ domain }} {
} }
} }
app.{{ domain }} { # Frontend is served on the bare domain, not app.{{ domain }}: only drb and api
# have public DNS records. A vhost for a name with no A record still starts,
# but Caddy retries ACME against it forever and logs a failure each time.
# To move it to app.{{ domain }}, create the A record first, then change this
# line — the reverse_proxy target stays the same either way.
{{ domain }} {
reverse_proxy localhost:3000 { reverse_proxy localhost:3000 {
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
} }
@@ -1,10 +1,19 @@
# Top-level docker-compose environment — MQTT credentials and registry prefix. # Top-level docker-compose environment — MQTT credentials and registry prefix.
# Managed by Ansible. Do not edit manually. # Managed by Ansible. Do not edit manually.
#
# The passwords are $-escaped ($ -> $$). Compose INTERPOLATES this file, so a
# raw "$fP" in a password is read as the variable $fP, warned about, and
# replaced with an empty string. The env_file templates (c2-core.env.j2 etc.)
# are NOT interpolated, so they keep the literal value — which means an
# unescaped $ here silently gives mosquitto and c2-core two different
# passwords and MQTT auth fails. Compose collapses $$ back to a single $, so
# both sides end up with the real password.
# Do not add the same escaping to the env_file templates; it would be literal.
MQTT_C2_USER={{ vault_mqtt_c2_user }} MQTT_C2_USER={{ vault_mqtt_c2_user }}
MQTT_C2_PASS={{ vault_mqtt_c2_pass }} MQTT_C2_PASS={{ vault_mqtt_c2_pass | replace('$', '$$') }}
MQTT_NODE_USER={{ vault_mqtt_node_user }} MQTT_NODE_USER={{ vault_mqtt_node_user }}
MQTT_NODE_PASS={{ vault_mqtt_node_pass }} MQTT_NODE_PASS={{ vault_mqtt_node_pass | replace('$', '$$') }}
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest # Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
REGISTRY={{ vault_registry }} REGISTRY={{ vault_registry }}
+19 -2
View File
@@ -36,16 +36,33 @@
path: /swapfile path: /swapfile
mode: "0600" mode: "0600"
# mkswap refuses to touch a file that is already active as swap, so a
# re-run would fail here without this guard. The swap file survives
# reboots via the fstab entry below, so on any second run it IS active.
- name: Check whether the swap file is already active
command: swapon --show=NAME --noheadings
register: _active_swaps
changed_when: false
failed_when: false
- name: Format swap file - name: Format swap file
command: mkswap /swapfile command: mkswap /swapfile
when: "'/swapfile' not in _active_swaps.stdout"
register: _mkswap register: _mkswap
changed_when: _mkswap.rc == 0 changed_when: _mkswap.rc == 0
# Guarded by the same check as mkswap above. The stderr test alone was not
# enough: an already-active swap file reports "Device or resource busy",
# not "already", so the original failed_when never matched it.
- name: Enable swap - name: Enable swap
command: swapon /swapfile command: swapon /swapfile
when: "'/swapfile' not in _active_swaps.stdout"
register: _swapon register: _swapon
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr failed_when: >
changed_when: _swapon.rc == 0 _swapon.rc is defined and _swapon.rc != 0
and 'already' not in _swapon.stderr
and 'busy' not in _swapon.stderr
changed_when: _swapon.rc is defined and _swapon.rc == 0
- name: Persist swap in fstab - name: Persist swap in fstab
lineinfile: lineinfile:
+15 -1
View File
@@ -4,6 +4,13 @@
# Edit later with: # Edit later with:
# ansible-vault edit vault.yml # ansible-vault edit vault.yml
# DO NOT put a literal "$" in any value here. Docker compose interpolates the
# top-level .env, and depending on version it also interpolates env_file, so a
# password like "aB$fPx" is read as the variable $fPx and silently replaced
# with an empty string — on one side of the connection but not the other.
# That produced "MQTT connect refused: Not authorized" with no obvious cause.
# Generate with: openssl rand -hex 32 (hex output has no shell metacharacters)
# ── MQTT ───────────────────────────────────────────────────────────────────── # ── MQTT ─────────────────────────────────────────────────────────────────────
vault_mqtt_c2_user: drb-c2-core vault_mqtt_c2_user: drb-c2-core
vault_mqtt_c2_pass: "CHANGE_ME" vault_mqtt_c2_pass: "CHANGE_ME"
@@ -22,7 +29,14 @@ vault_firestore_database: "c2-server"
# ── Gitea Container Registry ────────────────────────────────────────────────── # ── Gitea Container Registry ──────────────────────────────────────────────────
vault_registry_host: "git.vpn.cusano.net" vault_registry_host: "git.vpn.cusano.net"
vault_registry_user: "logan" vault_registry_user: "logan"
vault_registry_token: "" # Gitea access token with package:write scope vault_registry_token: "" # Gitea access token, READ-ONLY package scope.
# The VM only pulls (roles/deploy/tasks/main.yml:62-72);
# nothing here pushes. Pushing is CI's job and uses a
# separate write-scoped token (BUILD_TOKEN in Gitea
# repo secrets). Keep them separate: this token sits on
# an internet-facing VM, and a write-scoped one there
# would let an attacker publish a poisoned image that
# every future deploy and edge node would install.
vault_registry: "git.vpn.cusano.net/logan" # full image prefix vault_registry: "git.vpn.cusano.net/logan" # full image prefix
# ── Discord Bot ─────────────────────────────────────────────────────────────── # ── Discord Bot ───────────────────────────────────────────────────────────────