Compare commits
6 Commits
c6684ea61b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5f1fede8 | |||
| 12c9ad73bb | |||
| 971ab74d44 | |||
| 6140dd7b9c | |||
| 2e3fde2448 | |||
| c42bd1902c |
@@ -12,9 +12,14 @@ services:
|
||||
restart: always
|
||||
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:
|
||||
restart: always
|
||||
ports:
|
||||
ports: !override
|
||||
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
||||
|
||||
discord-bot:
|
||||
@@ -22,5 +27,5 @@ services:
|
||||
|
||||
frontend:
|
||||
restart: always
|
||||
ports:
|
||||
ports: !override
|
||||
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import paho.mqtt.client as mqtt
|
||||
from app.config import settings
|
||||
@@ -94,6 +94,11 @@ class MQTTHandler:
|
||||
"last_seen": now.isoformat(),
|
||||
"assigned_system_id": None,
|
||||
"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)
|
||||
logger.info(f"New node registered: {node_id} — pending admin approval.")
|
||||
@@ -111,6 +116,37 @@ class MQTTHandler:
|
||||
elif existing.get("approval_status") == "approved":
|
||||
# Approved but not yet configured — restore reachable status after reboot
|
||||
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)
|
||||
|
||||
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||
|
||||
@@ -55,3 +55,35 @@ async def _sweep():
|
||||
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
|
||||
from app.routers.tokens import release_token
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
|
||||
configured: bool = False
|
||||
last_seen: Optional[datetime] = 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):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from app.models import CommandPayload
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
@@ -126,10 +127,13 @@ async def assign_system(
|
||||
if not system:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
|
||||
# Include hardware preset in the push so the edge node applies it when
|
||||
# generating the OP25 config. Strip it from the system doc first so it
|
||||
# doesn't collide with SystemConfig field validation on the node side.
|
||||
push_payload = {**system, "hardware_preset": hardware_preset}
|
||||
# Include hardware preset, node type, and enforce timeout in the push
|
||||
push_payload = {
|
||||
**system,
|
||||
"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:
|
||||
push_payload["ppm_override"] = ppm_override
|
||||
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)
|
||||
|
||||
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}
|
||||
|
||||
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
|
||||
<StatusBadge status={node.status} />
|
||||
</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 */}
|
||||
<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}`],
|
||||
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
|
||||
["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]) => (
|
||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
|
||||
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
|
||||
⚠ Needs configuration
|
||||
</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>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,11 @@ const PRESETS = [
|
||||
];
|
||||
|
||||
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
const [systemId, setSystemId] = useState("");
|
||||
const [preset, setPreset] = useState("rtl-sdr-v3");
|
||||
const [ppm, setPpm] = useState("0");
|
||||
const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
|
||||
const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await c2api.updateNode(node.node_id, {
|
||||
node_type: nodeType,
|
||||
enforce_override_timeout: enforceTimeout,
|
||||
});
|
||||
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
/>
|
||||
</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>}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{saving ? "Saving…" : "Assign & Configure"}
|
||||
|
||||
@@ -30,6 +30,12 @@ export const c2api = {
|
||||
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
||||
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
|
||||
getSystems: () => request<unknown[]>("/systems"),
|
||||
|
||||
@@ -52,6 +52,11 @@ export interface NodeRecord {
|
||||
approval_status: ApprovalStatus | null;
|
||||
hardware_preset?: string;
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
@@ -2,12 +2,19 @@
|
||||
# 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.
|
||||
|
||||
- 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:
|
||||
repo: "{{ repo_url }}"
|
||||
dest: "{{ app_dir }}"
|
||||
version: main
|
||||
update: false
|
||||
update: true
|
||||
force: true
|
||||
become: false
|
||||
|
||||
- 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 }} {
|
||||
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 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
# Top-level docker-compose environment — MQTT credentials and registry prefix.
|
||||
# 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_PASS={{ vault_mqtt_c2_pass }}
|
||||
MQTT_C2_PASS={{ vault_mqtt_c2_pass | replace('$', '$$') }}
|
||||
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
|
||||
REGISTRY={{ vault_registry }}
|
||||
|
||||
+19
-2
@@ -36,16 +36,33 @@
|
||||
path: /swapfile
|
||||
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
|
||||
command: mkswap /swapfile
|
||||
when: "'/swapfile' not in _active_swaps.stdout"
|
||||
register: _mkswap
|
||||
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
|
||||
command: swapon /swapfile
|
||||
when: "'/swapfile' not in _active_swaps.stdout"
|
||||
register: _swapon
|
||||
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
|
||||
changed_when: _swapon.rc == 0
|
||||
failed_when: >
|
||||
_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
|
||||
lineinfile:
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
# Edit later with:
|
||||
# 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 ─────────────────────────────────────────────────────────────────────
|
||||
vault_mqtt_c2_user: drb-c2-core
|
||||
vault_mqtt_c2_pass: "CHANGE_ME"
|
||||
@@ -22,7 +29,14 @@ vault_firestore_database: "c2-server"
|
||||
# ── Gitea Container Registry ──────────────────────────────────────────────────
|
||||
vault_registry_host: "git.vpn.cusano.net"
|
||||
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
|
||||
|
||||
# ── Discord Bot ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user