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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,7 @@ jobs:
working-directory: ansible/tests
run: >-
python3 -B -m unittest -v
test_topology test_deployment_maintenance
test_topology test_deployment_maintenance test_ssh_host_trust

- name: Validate production and development Compose files
run: |
Expand Down
38 changes: 34 additions & 4 deletions .github/workflows/deploy-vps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ jobs:
VPS_USER: ${{ secrets.VPS_USER }}
VPS_APP_DIR: ${{ vars.VPS_APP_DIR }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_SSH_KNOWN_HOSTS: ${{ secrets.VPS_SSH_KNOWN_HOSTS }}
ANSIBLE_HOST_KEY_CHECKING: "true"
VPS_HEALTHCHECK_URL: ${{ vars.VPS_HEALTHCHECK_URL }}
VPS_HEALTHCHECK_VALIDATE_CERTS: ${{ vars.VPS_HEALTHCHECK_VALIDATE_CERTS || 'true' }}
GHCR_USERNAME: ${{ vars.GHCR_USERNAME || github.actor }}
Expand Down Expand Up @@ -375,7 +377,7 @@ jobs:
exit 1
fi

for value in VPS_HOST VPS_USER VPS_SSH_KEY IMAGE_REF VPS_APP_DIR COMPOSE_PROJECT_NAME VOLUME_PREFIX API_HOST MERCURE_HOST TRAEFIK_API_ROUTER_NAME TRAEFIK_MERCURE_ROUTER_NAME DEFAULT_URI MERCURE_PUBLIC_URL; do
for value in VPS_HOST VPS_USER VPS_SSH_KEY VPS_SSH_KNOWN_HOSTS IMAGE_REF VPS_APP_DIR COMPOSE_PROJECT_NAME VOLUME_PREFIX API_HOST MERCURE_HOST TRAEFIK_API_ROUTER_NAME TRAEFIK_MERCURE_ROUTER_NAME DEFAULT_URI MERCURE_PUBLIC_URL; do
test -n "${!value}" || { echo "$value is required."; exit 1; }
done

Expand All @@ -389,13 +391,39 @@ jobs:
test -n "$FIXTURE_IMAGE_REF" || { echo "FIXTURE_IMAGE_REF is required to reset development fixtures."; exit 1; }
fi

- name: Configure reviewed SSH host trust
shell: bash
run: |
set -eu
known_hosts="$RUNNER_TEMP/fireguard-api-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: Configure SSH
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$VPS_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts

- name: Install Ansible
run: |
Expand All @@ -411,4 +439,6 @@ jobs:
} > .deploy/inventory.ini

- name: Run Ansible deployment
run: ansible-playbook -i .deploy/inventory.ini ansible/deploy.yml
run: |
export ANSIBLE_SSH_COMMON_ARGS="-o UserKnownHostsFile=\"$RUNNER_TEMP/fireguard-api-known_hosts\" -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes"
ansible-playbook -i .deploy/inventory.ini ansible/deploy.yml
10 changes: 10 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,18 @@ have the same value.
Deployment secrets:

- `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`
- `VPS_SSH_KNOWN_HOSTS`: complete approved OpenSSH known_hosts entries for this
environment, verified through an authenticated operator/provider channel.
- `GHCR_TOKEN` if the workflow token is insufficient

SSH deployment fails before Ansible when the reviewed host-key secret is absent,
invalid or does not match the configured host and port. Port 22 uses the bare host;
other ports use `[host]:port`, including IPv6. Hashed entries and multiple approved
keys are supported. Strict checking uses only this reviewed file; the workflow
never obtains trust from a network scan. For a host-key rotation, verify the new
key independently and update the environment secret before redeploying. Existing
image rollbacks use the same current workflow trust gate.

Application secrets specific to each environment:

- `APP_SECRET`
Expand Down
159 changes: 159 additions & 0 deletions ansible/tests/test_ssh_host_trust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""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 = "api"


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)

def test_missing_router_identity_still_stops_before_deployment(self):
step = WORKFLOW.read_text().split(" - name: Validate required deployment configuration\n", 1)[1]
script = textwrap.dedent(step.split(" run: |\n", 1)[1].split(" - name: ", 1)[0])
required = (
"VPS_HOST VPS_USER VPS_SSH_KEY VPS_SSH_KNOWN_HOSTS IMAGE_REF VPS_APP_DIR "
"COMPOSE_PROJECT_NAME VOLUME_PREFIX API_HOST MERCURE_HOST "
"TRAEFIK_API_ROUTER_NAME TRAEFIK_MERCURE_ROUTER_NAME DEFAULT_URI MERCURE_PUBLIC_URL"
).split()
environment = os.environ.copy()
environment.update({name: "fixture" for name in required})
environment.update({"SOURCE_BRANCH": "main", "DEPLOY_ENVIRONMENT": "production", "RESET_DEVELOPMENT_FIXTURES": "false"})
environment.pop("TRAEFIK_API_ROUTER_NAME", None)
result = subprocess.run([self.shell, "-c", script], env=environment, capture_output=True, text=True, timeout=10)
self.assertNotEqual(0, result.returncode)
self.assertIn("TRAEFIK_API_ROUTER_NAME is required", result.stdout)


if __name__ == "__main__":
unittest.main()
6 changes: 6 additions & 0 deletions config/modules/intervention.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ services:
Intervention\Application\UseCase\Query\Time\ListTimeEntries\ListTimeEntriesHandler:
tags: ['messenger.message_handler']

Intervention\Application\UseCase\Query\Time\ListTimeEntryVersions\ListTimeEntryVersionsHandler:
tags: ['messenger.message_handler']

Intervention\Application\UseCase\Query\Time\GetTimeEntry\GetTimeEntryHandler:
tags: ['messenger.message_handler']

# Bind the rich-text comment sanitizer (Quill / PrimeNG editor output).
Intervention\Presentation\Api\Processor\InterventionActivityProcessor:
arguments:
Expand Down
7 changes: 7 additions & 0 deletions config/modules/inventory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,10 @@ services:
Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionResourcesAdapter: ~
Inventory\Application\Port\Inbound\InventoryInterventionResourcesPort:
alias: Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionResourcesAdapter

Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionHistoryAdapter:
arguments:
$connection: '@doctrine.dbal.main_connection'

Inventory\Application\Port\Inbound\InventoryInterventionHistoryPort:
alias: Inventory\Infrastructure\Adapter\Intervention\InventoryInterventionHistoryAdapter
7 changes: 7 additions & 0 deletions config/modules/maintenance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ services:
Maintenance\Application\Port\Inbound\MaintenanceOperationResultsPort:
alias: Maintenance\Application\Service\MaintenancePlanAuthorityService

Maintenance\Infrastructure\Adapter\Intervention\MaintenanceInterventionHistoryAdapter:
arguments:
$connection: '@doctrine.dbal.main_connection'

Maintenance\Application\Port\Inbound\MaintenanceInterventionHistoryPort:
alias: Maintenance\Infrastructure\Adapter\Intervention\MaintenanceInterventionHistoryAdapter

Maintenance\Application\Port\Inbound\MaintenanceOperationsDuePort:
alias: Maintenance\Infrastructure\Adapter\Equipment\MaintenanceOperationsDueAdapter

Expand Down
7 changes: 7 additions & 0 deletions config/modules/maintenance_cost.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ services:
MaintenanceCost\Application\Port\Inbound\MaintenanceCostReadPort:
alias: MaintenanceCost\Infrastructure\Adapter\Reporting\MaintenanceCostReadAdapter

MaintenanceCost\Infrastructure\Adapter\Intervention\MaintenanceCostInterventionHistoryAdapter:
arguments:
$connection: '@doctrine.dbal.main_connection'

MaintenanceCost\Application\Port\Inbound\MaintenanceCostInterventionHistoryPort:
alias: MaintenanceCost\Infrastructure\Adapter\Intervention\MaintenanceCostInterventionHistoryAdapter

maintenance_cost.main_transaction_manager:
class: Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter
arguments:
Expand Down
2 changes: 2 additions & 0 deletions config/modules/shared.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ services:

Shared\Application\Port\Outbound\EventBusPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\MessengerEventBusAdapter'
Shared\Application\Port\Outbound\FileStoragePort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\FlysystemFileStorageAdapter'
Shared\Application\Port\Outbound\SpreadsheetSafeTextPort: '@Shared\Infrastructure\Csv\SpreadsheetSafeTextAdapter'
Shared\Application\Port\Outbound\ImageInputValidationPort: '@Shared\Infrastructure\Image\ImageInputValidationAdapter'
Shared\Application\Port\Outbound\LoggerPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\LoggerAdapter'
Shared\Application\Port\Outbound\MailerPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\MailerAdapter'
Shared\Application\Port\Outbound\TransactionManagerPort: '@Shared\Infrastructure\Symfony\Adapter\Outbound\DoctrineTransactionManagerAdapter'
Expand Down
1 change: 1 addition & 0 deletions config/packages/api_platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ api_platform:
# The prescribed fix for a handler that wants a status is a DOMAIN exception,
# not an entry here. That work is cleanup, not a defect: nothing is broken.
exception_to_status:
Shared\Application\Contract\Image\InvalidImageInputException: 422
Audit\Domain\Exception\AuditEventNotFoundException: 404
Workload\Domain\Exception\WorkloadNotFoundException: 404
Workload\Domain\Exception\WorkloadAccessDeniedException: 403
Expand Down
67 changes: 67 additions & 0 deletions migrations/main/Version20261008121000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations\Main;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Class Version20261008121000
*
* Aligns retained supplier references with the public eighty-character domain limit.
*
* @category Migration
*/
final class Version20261008121000 extends AbstractMigration
{
// #region Methods
/**
* Method getDescription
*
* Describes the additive supplier-reference expansion.
*
* @access public
*
* @return string the migration purpose
*/
public function getDescription(): string
{
return 'Widen procurement supplier codes to the validated eighty-character limit.';
}

/**
* Method up
*
* Expands the main supplier reference without changing retained values.
*
* @access public
*
* @param Schema $schema the main schema
*
* @return void
*/
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE procurement_suppliers ALTER code TYPE VARCHAR(80)');
}

/**
* Method down
*
* Restores the former bound only when every retained value still fits it.
*
* @access public
*
* @param Schema $schema the main schema
*
* @return void
*/
public function down(Schema $schema): void
{
// PostgreSQL refuses rollback while any retained reference exceeds the former limit.
$this->addSql('ALTER TABLE procurement_suppliers ALTER code TYPE VARCHAR(64)');
}
// #endregion
}
Loading
Loading