Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
143 changes: 143 additions & 0 deletions .github/scripts/test_ssh_host_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Offline tests of the actual deployment host-trust shell step."""

from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import textwrap
import unittest

ROOT = Path(__file__).resolve().parents[2]
WORKFLOW = ROOT / ".github/workflows/deploy-vps.yml"
DEPLOYMENT = "web"


def shell_path(path):
value = Path(path).as_posix()
if os.name == "nt" and len(value) > 1 and value[1] == ":":
return "/" + value[0].lower() + value[2:]
return value


def executable(name):
if os.name == "nt":
candidate = Path("C:/Program Files/Git/usr/bin") / (name + ".exe")
if candidate.is_file():
return str(candidate)
result = shutil.which(name)
if not result:
raise RuntimeError(name + " is required for host-trust regressions")
return result


class ReviewedSshHostTrustTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="fireguard ssh trust ")
cls.directory = Path(cls.temporary.name)
cls.shell = executable("bash")
cls.keygen = executable("ssh-keygen")
cls.key = cls.directory / "fixture-key"
subprocess.run(
[cls.keygen, "-q", "-t", "ed25519", "-N", "", "-f", shell_path(cls.key)],
check=True, capture_output=True,
)
cls.public_key = (cls.directory / "fixture-key.pub").read_text().strip()
workflow = WORKFLOW.read_text()
step = workflow.split(" - name: Configure reviewed SSH host trust\n", 1)[1]
step = step.split(" - name: ", 1)[0]
cls.script = textwrap.dedent(step.split(" run: |\n", 1)[1])

@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()

def run_step(self, material, host="vps.example.invalid", port="22"):
with tempfile.TemporaryDirectory(dir=self.directory) as directory:
temporary = Path(directory)
environment = os.environ.copy()
environment.update({
"VPS_HOST": host,
"VPS_PORT": port,
"VPS_SSH_KNOWN_HOSTS": material,
"RUNNER_TEMP": shell_path(temporary),
"GITHUB_ENV": shell_path(temporary / "github-env"),
})
environment["PATH"] = str(Path(self.keygen).parent) + os.pathsep + environment["PATH"]
result = subprocess.run(
[self.shell, "-c", self.script], env=environment,
capture_output=True, text=True, timeout=10,
)
installed = temporary / ("fireguard-" + DEPLOYMENT + "-known_hosts")
saved = installed.read_text() if installed.exists() else None
canonical = (temporary / "github-env").read_text() if (temporary / "github-env").exists() else None
return result, saved, canonical

def test_approved_hosts_and_ports(self):
cases = [
("vps.example.invalid", "22", "vps.example.invalid"),
("192.0.2.10", "22", "192.0.2.10"),
("2001:db8::1", "22", "2001:db8::1"),
("vps.example.invalid", "2222", "[vps.example.invalid]:2222"),
("2001:db8::1", "2222", "[2001:db8::1]:2222"),
("vps.example.invalid", "00022", "vps.example.invalid"),
]
for host, port, lookup in cases:
with self.subTest(host=host, port=port):
material = lookup + " " + self.public_key
result, installed, canonical = self.run_step(material, host, port)
self.assertEqual(0, result.returncode, result.stderr + result.stdout)
self.assertEqual(material + "\n", installed)
self.assertEqual("VPS_PORT=" + str(int(port)) + "\n", canonical)

def test_missing_blank_unrelated_and_wrong_port_are_rejected(self):
for material in ["", " \n\t", "other.example.invalid " + self.public_key,
"[vps.example.invalid]:2222 " + self.public_key]:
with self.subTest(material=material[:24]):
result, _, canonical = self.run_step(material)
self.assertNotEqual(0, result.returncode)
self.assertIsNone(canonical)

def test_malformed_matching_key_is_rejected(self):
result, _, canonical = self.run_step("vps.example.invalid ssh-ed25519 not-a-key")
self.assertNotEqual(0, result.returncode)
self.assertIsNone(canonical)

def test_invalid_ports_are_rejected(self):
for port in ["", "0", "65536", "100000", "-1", "22x"]:
with self.subTest(port=port):
result, _, canonical = self.run_step("vps.example.invalid " + self.public_key, port=port)
self.assertNotEqual(0, result.returncode)
self.assertIsNone(canonical)

def test_hashed_host_and_approved_aliases_are_preserved(self):
entry = self.directory / "hashed-hosts"
entry.write_text("vps.example.invalid " + self.public_key + "\n")
subprocess.run([self.keygen, "-H", "-f", shell_path(entry)], check=True, capture_output=True)
material = entry.read_text().strip()
result, installed, _ = self.run_step(material)
self.assertEqual(0, result.returncode, result.stderr + result.stdout)
self.assertEqual(material + "\n", installed)
aliases = "vps.example.invalid,192.0.2.10 " + self.public_key
result, installed, _ = self.run_step(aliases)
self.assertEqual(0, result.returncode, result.stderr + result.stdout)
self.assertEqual(aliases + "\n", installed)

def test_workflow_requires_independent_trust_and_strict_handshake(self):
workflow = WORKFLOW.read_text()
self.assertNotIn("ssh-keyscan", workflow)
self.assertIn("secrets.VPS_SSH_KNOWN_HOSTS", workflow)
self.assertIn("StrictHostKeyChecking=yes", workflow)
self.assertIn("GlobalKnownHostsFile=/dev/null", workflow)
self.assertRegex(workflow, r"ANSIBLE_HOST_KEY_CHECKING[^\n]*(?:true|True)")
self.assertLess(workflow.index("Configure reviewed SSH host trust"), workflow.index("ansible-playbook"))
if DEPLOYMENT == "api":
self.assertIn('UserKnownHostsFile=\\\"$RUNNER_TEMP/fireguard-api-known_hosts\\\"', workflow)
else:
self.assertIn("/fireguard-web-known_hosts", workflow)


if __name__ == "__main__":
unittest.main()
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
- name: Test deployment validation
run: node --test .github/scripts/*.test.mjs
- name: Test reviewed SSH host trust
run: python3 -B .github/scripts/test_ssh_host_trust.py
- name: Require immutable action pins
run: python3 -B .github/scripts/check-action-pins.py
- name: Test immutable API reference validation
Expand Down
35 changes: 32 additions & 3 deletions .github/workflows/deploy-vps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ jobs:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KNOWN_HOSTS: ${{ secrets.VPS_SSH_KNOWN_HOSTS }}
ANSIBLE_HOST_KEY_CHECKING: 'true'
VPS_PORT: ${{ vars.VPS_PORT || '22' }}

steps:
Expand All @@ -124,7 +126,7 @@ jobs:
*) echo "Unsupported deployment branch: $GITHUB_REF_NAME"; exit 1 ;;
esac

for value in APP_DIR APP_HOST APP_API_URL APP_MERCURE_HUB_URL COMPOSE_PROJECT_NAME CONTAINER_NAME TRAEFIK_ROUTER_NAME VPS_HOST VPS_USER VPS_SSH_KEY; do
for value in APP_DIR APP_HOST APP_API_URL APP_MERCURE_HUB_URL COMPOSE_PROJECT_NAME CONTAINER_NAME TRAEFIK_ROUTER_NAME VPS_HOST VPS_USER VPS_SSH_KEY VPS_SSH_KNOWN_HOSTS; do
test -n "${!value}" || { echo "$value is required."; exit 1; }
done

Expand Down Expand Up @@ -155,6 +157,34 @@ jobs:
python -m pip install -r deployment/ansible/requirements.txt
ansible-galaxy collection install -r deployment/ansible/requirements.yml

- name: Configure reviewed SSH host trust
shell: bash
run: |
set -eu
known_hosts="$RUNNER_TEMP/fireguard-web-known_hosts"
matching_keys="$known_hosts.match"
trap 'rm -f "$matching_keys"' EXIT
case "$VPS_PORT" in
''|*[!0-9]*) echo "VPS_PORT must be a valid port."; exit 1 ;;
esac
if [ "${#VPS_PORT}" -gt 5 ]; then
echo "VPS_PORT must be a valid port."; exit 1
fi
port=$((10#$VPS_PORT))
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo "VPS_PORT must be a valid port."; exit 1
fi
test -n "$VPS_SSH_KNOWN_HOSTS" || { echo "VPS_SSH_KNOWN_HOSTS is required."; exit 1; }
printf '%s\n' "$VPS_SSH_KNOWN_HOSTS" > "$known_hosts"
chmod 600 "$known_hosts"
host_lookup="$VPS_HOST"
if [ "$port" -ne 22 ]; then host_lookup="[$VPS_HOST]:$port"; fi
ssh-keygen -F "$host_lookup" -f "$known_hosts" > "$matching_keys" ||
{ echo "No approved SSH host key matches the deployment target."; exit 1; }
ssh-keygen -l -f "$matching_keys" > /dev/null 2>&1 ||
{ echo "The approved SSH host key material is invalid."; exit 1; }
printf 'VPS_PORT=%s\n' "$port" >> "$GITHUB_ENV"

- name: Prepare SSH and Ansible inventory
shell: bash
run: |
Expand Down Expand Up @@ -185,11 +215,10 @@ jobs:
"ansible_port": int(port),
"ansible_python_interpreter": "/usr/bin/python3",
"ansible_ssh_private_key_file": str(directory / "private-key"),
"ansible_ssh_common_args": f"-o UserKnownHostsFile={directory / 'known_hosts'} -o StrictHostKeyChecking=yes",
"ansible_ssh_common_args": f"-o UserKnownHostsFile={os.environ['RUNNER_TEMP']}/fireguard-web-known_hosts -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes",
}}}}}}
(directory / "inventory.json").write_text(json.dumps(inventory))
PY
ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" > "$directory/known_hosts"

- name: Deploy the verified image with Ansible
env:
Expand Down
9 changes: 7 additions & 2 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@
"!@features/organization/models",
"!@features/organization/data-access",
"!@features/organization/utils",
"!@features/organization/services/organization-landing",
"!@features/organization/features/equipments"
],
"message": "ARCHITECTURE.md 4 / 14.2 - onboarding may consume the documented organization surfaces, and they are the ones its FEATURE.md records: setup, models, data-access, utils, and the equipments public API. Anything deeper is a private path. This rule mirrors the documented contract - widen the FEATURE.md first, then this list."
Expand Down Expand Up @@ -261,7 +262,10 @@
"!@features/organization/features/equipments/data-access/**",
"!@features/organization/features/equipments/models",
"!@features/organization/features/equipments/models/**",
"!@features/organization/features/equipments/ui/components/equipment-status-tag"
"!@features/organization/features/equipments/ui/components",
"!@features/organization/features/equipments/state",
"!@features/organization/features/equipments/options",
"!@features/organization/features/equipments/utils"
],
"message": "ARCHITECTURE.md 4 / 14.2 - the assets explorer may consume the equipments subfeature only through its data-access and models barrels (EquipmentService, EquipmentOutput) and the equipment-status-tag component, the surfaces organization/FEATURE.md records for this dependency. Anything deeper is a private path. This rule mirrors the documented contract - widen the FEATURE.md first, then this list."
},
Expand All @@ -272,7 +276,8 @@
"!@features/organization/features/inspections/data-access/**",
"!@features/organization/features/inspections/models",
"!@features/organization/features/inspections/models/**",
"!@features/organization/features/inspections/ui/components/inspection-status-tag"
"!@features/organization/features/inspections/ui/components/inspection-status-tag",
"!@features/organization/features/inspections/ui/dataviews/non-conformity-list"
],
"message": "ARCHITECTURE.md 4 / 14.2 - the assets explorer may consume the inspections subfeature only through its data-access and models barrels (InspectionService, InspectionOutput) and the inspection-status-tag component, the surfaces organization/FEATURE.md records for this dependency. Anything deeper is a private path. This rule mirrors the documented contract - widen the FEATURE.md first, then this list."
}
Expand Down
10 changes: 10 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ Required secrets:
- `VPS_HOST`
- `VPS_USER`
- `VPS_SSH_KEY`
- `VPS_SSH_KNOWN_HOSTS`: complete approved OpenSSH known_hosts entries, verified
through an authenticated operator/provider channel for this environment
- `GHCR_TOKEN` if the workflow token is insufficient

SSH deployment fails before Ansible if the reviewed host-key secret is missing,
invalid or does not match the host and port. Port 22 uses the bare host; custom
ports use `[host]:port`, including IPv6. Hashed entries and multiple approved keys
are supported. Strict checking uses only that file, without a network-scan trust
fallback. Independently verify host-key rotations and update the environment
secret before redeploying or rolling back an image.

- `BASIC_AUTH_USERS` in `development`, in the htpasswd format accepted by Traefik
- `BASIC_AUTH_CREDENTIALS` in `development`, as `username:password`, only for the public health check

Expand Down
64 changes: 51 additions & 13 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,30 @@ workspace. Two primary contexts:
offline (IndexedDB persistence + outbox replay). They need fast,
thumb-reachable actions and a clear "next recommended action".

The job to be done: take a fire-safety intervention from draft → planned →
executed in the field → reviewed → published, without losing data offline and
without ambiguity about what to do next.
The job to be done: know the fire-equipment park, organize its controls and
maintenance, and preserve the record of every intervention. Service providers
select their internal customer and sites; operators manage their own sites
without a mandatory customer record. Both use the same authorized workflows.

## Product Purpose

Fireguard is an organization-scoped platform for planning, executing and
publishing field interventions (facilities, equipment, inspections). Success is
an intervention that moves through its full workflow with zero data loss
offline, clear blocker resolution, and an atomic publication that either fully
succeeds or leaves records untouched.
FireGuard is a specialized maintenance management application for fire-equipment
parks. Its promise is: "Know your fire-equipment park, organize its controls and
maintenance, and preserve the record of every intervention." Fire safety leads
the initial views and workflows; emergency lighting, gas detection, access
control and cameras are adjacent equipment families in the same park.

## Positioning

**The headline claim is atomic offline publication.** The agent works fully
offline; publication then applies every inspection, equipment change and record
as one transaction that either fully succeeds or leaves records untouched.
There is no half-published intervention. This leads because it is the hardest
mechanism for a neighboring product to copy truthfully.
**The headline is fire-equipment park management.** Park is the primary entry
point. The equipment dossier distinguishes declared operational status, control
deadlines and unresolved anomalies. A performed unfavorable control remains a
performed control; its anomalies remain open. A failed repair remains necessary.

Atomic publication and durable offline execution support this promise: publication
applies inspections, equipment changes and validated work results in one transaction.
A validated dossier preserves its customer/site identity, evidence and execution
facts at publication, while later corrections remain separately traceable.

Two further mechanisms are equally true and support it, but do not lead:

Expand All @@ -52,6 +57,22 @@ Two further mechanisms are equally true and support it, but do not lead:

## Operating Context

- **Park context** starts with fire equipment and offers an explicit all-equipment
view. Customer, site and action-queue filters are shared by exact server counts
and their destination lists. Operational unavailability, upcoming controls and
unresolved anomalies form the three principal action queues.
- **Equipment identity** includes an organization-unique asset reference,
descriptive name, declared criticality and simple value/unit characteristics.
Organization-owned catalogue types complement preserved historical codes.
Replacement retires the predecessor and links one successor idempotently;
historical controls, documents and QR identification remain attached to the old item.
- **Independent preventive operations** have original due dates and explicit
validated results. Control and servicing calendars are independent; fixed
calendars retain their anchor across short months. Historical calendars remain
compatible until the organization explicitly switches its single active engine.
- **Organization profile** adapts starting views and onboarding. Effective API
permissions determine rights; the profile never grants permissions or shares
customers between organizations.
- **The intervention lifecycle** is the spine of the product:
`draft → planned → in_progress → submitted (review) → published`, plus
`changes_requested` (reviewer sends work back with a required note) and
Expand Down Expand Up @@ -108,6 +129,23 @@ Two further mechanisms are equally true and support it, but do not lead:

**Constraints future work must preserve**

- FireGuard retains its name. Brand replacement, customer portals, collaboration
between organizations, meters, sensors and a dedicated ERP connector are later
projects. General building maintenance and industry are outside this trajectory.
- Organization-defined intervals and checklists provide operational tracking;
they do not constitute automatic regulatory certification.
- Repair requests, interventions, quantitative parts, warehouses, suppliers,
orders, partial receipts, returns, time and internal costs form one maintenance
chain. Unresolved physical declarations stay traceable and block publication.
Individual equipment in reserve remains separate from quantitative stock.
- Financial reports allocate each contribution once to equipment, site or customer,
with explicit unallocated and unknown costs. Current realized costs remain distinct
from the snapshot frozen at publication and from planned amounts.
- Versioned JSON/CSV maintenance exports preserve their original bytes and stable
references. A later correction creates a linked adjustment. Export generation and
confirmed ERP import are separate states, and financial inclusion requires its
own permission. Commercial quotes, sales prices, invoices, taxes and payments
remain in the external ERP. `Billing` owns the FireGuard SaaS subscription.
- **Market frame: EU multi-market.** No single national fire-safety regime may
be presumed in the domain model, and no copy may claim conformity with a
specific standard (ERP registre de sécurité, APSAD, NF, or any other).
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Fireguard Web

Angular frontend for Fireguard: organization-scoped fire-safety work, field
interventions, offline workflows and collaboration. It uses the
Angular frontend for FireGuard fire-equipment park management: controls,
maintenance, field interventions, offline workflows, parts and purchasing,
private economic reports and retained ERP exports. It uses the
[Fireguard API](https://github.com/DevSkyLex/fireguard-api).

## Tech stack
Expand Down
Loading
Loading