Initialize clean CSR inventory repository

This commit is contained in:
DenisToxic
2026-09-29 08:43:05 +02:00
commit 059f386c60
107 changed files with 9639 additions and 0 deletions

20
scripts/backup.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
umask 077
BACKUP_DIR=${BACKUP_DIR:-./backups}
mkdir -p "$BACKUP_DIR"
WORK=$(mktemp -d "$BACKUP_DIR/.backup.XXXXXX")
trap 'rm -rf "$WORK"' EXIT
trap 'exit 1' HUP INT TERM
STAMP=$(date +%Y-%m-%d_%H%M%S)
FILE="$BACKUP_DIR/backup_${STAMP}_$$.sql.gz"
# Separate commands: POSIX sh has no portable pipefail. Credentials stay inside
# the container; a .env file must never be sourced as shell code.
docker compose exec -T db sh -c 'exec pg_dump --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$WORK/dump.sql"
test -s "$WORK/dump.sql"
gzip -c "$WORK/dump.sql" > "$WORK/dump.sql.gz"
gzip -t "$WORK/dump.sql.gz"
mv "$WORK/dump.sql.gz" "$FILE"
echo "Backup erstellt: $FILE"

7
scripts/entrypoint.sh Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
python manage.py migrate --noinput
python manage.py seed_initial_data
python manage.py collectstatic --noinput
exec "$@"

57
scripts/export_sqlite.py Normal file
View File

@@ -0,0 +1,57 @@
"""Export a consistent SQLite snapshot without modifying the source database."""
import argparse
import os
from pathlib import Path
import sqlite3
import sys
import tempfile
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
source = args.source.resolve(strict=True)
output = args.output.resolve()
if output.exists():
parser.error("Output already exists; choose a new filename.")
output.parent.mkdir(parents=True, exist_ok=True)
# Refuse to overwrite either the original database or an earlier export.
with tempfile.TemporaryDirectory(prefix="csr-sqlite-export-") as temporary:
snapshot = Path(temporary) / "snapshot.sqlite3"
origin = sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)
destination = sqlite3.connect(snapshot)
try:
origin.backup(destination)
finally:
destination.close()
origin.close()
os.environ["SQLITE_MIGRATION_SNAPSHOT"] = str(snapshot)
os.environ["DJANGO_SETTINGS_MODULE"] = "csr_inventory.sqlite_migration_settings"
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import django
django.setup()
from django.core.management import call_command
from django.db import connections
try:
call_command("migrate", interactive=False, verbosity=0)
with output.open("x", encoding="utf-8") as stream:
os.chmod(output, 0o600)
try:
call_command(
"dumpdata", natural_foreign=True,
exclude=["contenttypes", "auth.permission", "sessions.session"],
stdout=stream, verbosity=0,
)
except BaseException:
stream.close()
output.unlink()
raise
finally:
connections.close_all()
print(f"Export created: {output}. Original SQLite database unchanged.")
if __name__ == "__main__":
main()

117
scripts/renew_local_tls.py Normal file
View File

@@ -0,0 +1,117 @@
"""Create/renew LAN TLS certificates for Traefik. Run on Linux with OpenSSL.
An existing CA can be installed as tls/ca/root.crt and tls/ca/root.key before
the first run. The CA key is never mounted into the proxy or copied into images.
"""
import argparse
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import secrets
import subprocess
import tempfile
def openssl(*args, check=True):
return subprocess.run(["openssl", *map(str, args)], check=check, capture_output=True)
def identity(value):
try:
host = str(ipaddress.ip_address(value))
return host, "IP", "-verify_ip"
except ValueError:
host = value.encode("idna").decode("ascii").lower()
if len(host) > 253 or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in host.split(".")):
raise ValueError("Invalid hostname. Supply only a DNS name or IP address, without a port.")
return host, "DNS", "-verify_hostname"
def publish(path, content):
# Publish complete config/state files atomically. Traefik watches the directory.
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, prefix=".tls-", suffix=".tmp", delete=False) as stream:
temporary = Path(stream.name)
stream.write(content)
try:
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def renew(project, host, *, force=False):
import fcntl
host, san_type, verify_flag = identity(host)
os.umask(0o077)
ca = project / "tls" / "ca"
certificates = project / "tls" / "certs"
dynamic = project / "traefik" / "dynamic"
for directory in (ca, certificates, dynamic):
directory.mkdir(parents=True, exist_ok=True)
with (project / "tls" / "renew.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
root_cert, root_key = ca / "root.crt", ca / "root.key"
if root_cert.exists() != root_key.exists():
raise RuntimeError("Incomplete CA: both root.crt and root.key are required. Existing files were retained.")
if not root_cert.exists():
openssl("genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", root_key)
openssl("req", "-new", "-x509", "-sha256", "-key", root_key, "-out", root_cert,
"-days", "3650", "-subj", "/CN=CS-R Local Authority",
"-addext", "basicConstraints=critical,CA:TRUE",
"-addext", "keyUsage=critical,keyCertSign,cRLSign")
root_key.chmod(0o600)
# Refuse to issue a certificate beyond the lifetime of its trust anchor.
openssl("x509", "-in", root_cert, "-checkend", str(91 * 86400), "-noout")
fingerprint = hashlib.sha256(root_cert.read_bytes()).hexdigest()
state_path = certificates / "current.json"
state = json.loads(state_path.read_text()) if state_path.exists() else {}
serial = state.get("serial", "")
config = dynamic / "tls.yml"
if not force and state.get("host") == host and state.get("ca") == fingerprint and re.fullmatch(r"[0-9a-f]{32}", serial):
cert, key = certificates / f"{serial}.crt", certificates / f"{serial}.key"
if cert.exists() and key.exists() and config.exists():
valid = openssl("verify", "-CAfile", root_cert, "-purpose", "sslserver", verify_flag, host, cert, check=False)
lifetime = openssl("x509", "-in", cert, "-checkend", str(30 * 86400), "-noout", check=False)
if valid.returncode == lifetime.returncode == 0:
print("TLS certificate is valid for more than 30 days; no renewal needed.")
return
serial = secrets.token_hex(16)
cert, key = certificates / f"{serial}.crt", certificates / f"{serial}.key"
with tempfile.TemporaryDirectory(dir=certificates, prefix=".issue-") as temporary:
temp = Path(temporary)
extensions = temp / "extensions.cnf"
extensions.write_text(
"basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n"
f"extendedKeyUsage=serverAuth\nsubjectAltName={san_type}:{host}\n"
)
openssl("genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256", "-out", key)
openssl("req", "-new", "-key", key, "-out", temp / "request.csr", "-subj", "/CN=CS-R Inventory")
openssl("x509", "-req", "-in", temp / "request.csr", "-CA", root_cert, "-CAkey", root_key,
"-set_serial", "0x" + serial, "-days", "90", "-sha256", "-extfile", extensions, "-out", cert)
openssl("verify", "-CAfile", root_cert, "-purpose", "sslserver", verify_flag, host, cert)
publish(config, f"""tls:
certificates:
- certFile: /certs/{serial}.crt
keyFile: /certs/{serial}.key
stores:
default:
defaultCertificate:
certFile: /certs/{serial}.crt
keyFile: /certs/{serial}.key
options:
default:
minVersion: VersionTLS12
""")
publish(state_path, json.dumps({"host": host, "ca": fingerprint, "serial": serial}))
print(f"TLS certificate issued for {host}; Traefik will reload it automatically.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--hostname", required=True)
parser.add_argument("--project", type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument("--force", action="store_true")
arguments = parser.parse_args()
renew(arguments.project.resolve(), arguments.hostname, force=arguments.force)

17
scripts/restore.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
umask 077
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "Aufruf: scripts/restore.sh backups/backup_YYYY-MM-DD_HHMM.sql.gz" >&2
exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
trap 'exit 1' HUP INT TERM
# Decompress completely before touching the target database. SQL errors roll
# back the entire restore rather than leaving a partially restored database.
gzip -dc "$1" > "$WORK/dump.sql"
test -s "$WORK/dump.sql"
docker compose exec -T db sh -c 'exec psql --single-transaction -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$WORK/dump.sql"
echo "Wiederherstellung abgeschlossen."

View File

@@ -0,0 +1,67 @@
"""Failure-path checks for backup/restore; no database or Docker required."""
import gzip
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@unittest.skipUnless(shutil.which("sh") and shutil.which("gzip"), "POSIX shell and gzip required")
class BackupScriptTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.scripts = Path(__file__).resolve().parent
docker = self.root / "docker"
docker.write_text('''#!/bin/sh
if [ "${FAIL_DOCKER:-0}" = 1 ]; then exit 9; fi
case "$*" in
*pg_dump*) printf 'SELECT 1;\\n' ;;
*psql*) cat > "$RESTORE_CAPTURE" ;;
esac
''', encoding="utf-8", newline="\n")
docker.chmod(0o700)
self.env = {**os.environ, "PATH": str(self.root) + os.pathsep + os.environ["PATH"],
"RESTORE_CAPTURE": str(self.root / "restored.sql")}
def run_script(self, name, *args, **env):
return subprocess.run(["sh", str(self.scripts / name), *map(str, args)],
cwd=self.root, env={**self.env, **env}, capture_output=True, text=True)
def test_dump_failure_does_not_publish_backup_or_report_success(self):
result = self.run_script("backup.sh", FAIL_DOCKER="1")
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("Backup erstellt", result.stdout)
self.assertEqual(list((self.root / "backups").iterdir()), [])
def test_success_round_trip_and_env_is_not_executed(self):
(self.root / ".env").write_text("touch env_was_executed\n", encoding="utf-8")
result = self.run_script("backup.sh")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.root / "env_was_executed").exists())
backup, = (self.root / "backups").glob("*.sql.gz")
self.assertEqual(gzip.decompress(backup.read_bytes()), b"SELECT 1;\n")
result = self.run_script("restore.sh", backup)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.root / "restored.sql").read_text(), "SELECT 1;\n")
def test_corrupt_backup_never_reaches_database(self):
corrupt = self.root / "corrupt.sql.gz"
corrupt.write_bytes(gzip.compress(b"SELECT 1;\n")[:-6])
result = self.run_script("restore.sh", corrupt)
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.root / "restored.sql").exists())
def test_restore_error_is_not_reported_as_success(self):
backup = self.root / "valid.sql.gz"
backup.write_bytes(gzip.compress(b"SELECT 1;\n"))
result = self.run_script("restore.sh", backup, FAIL_DOCKER="1")
self.assertNotEqual(result.returncode, 0)
self.assertNotIn("abgeschlossen", result.stdout)
if __name__ == "__main__":
unittest.main()

58
scripts/test_frontend.cjs Normal file
View File

@@ -0,0 +1,58 @@
// npm install --prefix <temporary directory> jsdom@26
// node scripts/test_frontend.cjs <temporary directory>/node_modules/jsdom
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {JSDOM} = require(process.argv[2] || "jsdom");
const source = fs.readFileSync(path.join(__dirname, "../inventory/static/inventory/js/app.js"), "utf8");
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function run() {
const dom = new JSDOM(`<!doctype html><body data-csrf="test">
<div id="toast-root"></div><div id="drawer-root"></div>
<form data-confirm="Remove employee?"><button>Remove</button></form>
<div id="device-table-container"><table><tbody><tr data-device-id="1" data-revision="7">
<td data-field="employee" data-input="select" data-value="23">Current employee</td>
</tr></tbody></table></div>
</body>`, {url: "https://inventory.test/", runScripts: "outside-only"});
try {
const {window} = dom, requests = [];
window.fetch = async (url, options) => {
requests.push({url, options});
return {ok: true, json: async () => options?.method === "POST"
? {value: 1, display: "Employee 1", revision: 8, updated_at: "2026-09-23T10:00:00Z"}
: {results: [{id: 1, label: "Employee 1"}]}};
};
window.eval(source);
const form = window.document.querySelector("form");
form.dataset.confirm = '<img src=x onerror="window.injected=true">';
form.dataset.confirmAction = "<b>Unsafe label</b>";
form.dispatchEvent(new window.Event("submit", {bubbles: true, cancelable: true}));
const box = window.document.querySelector(".confirm-box");
assert.equal(box.querySelector("img"), null);
assert.equal(box.querySelector("b"), null);
assert.equal(box.querySelector("p").textContent, form.dataset.confirm);
box.querySelector("button").click();
const cell = window.document.querySelector("td");
cell.dispatchEvent(new window.MouseEvent("dblclick", {bubbles: true}));
await pause(20);
assert.equal(cell.querySelector("select").value, "23", "Missing/inactive current value must be preserved");
cell.querySelector("select").dispatchEvent(new window.Event("blur"));
await pause(250);
assert.equal(requests.filter(item => item.options?.method === "POST").length, 0, "Unchanged field must not save on blur");
cell.dispatchEvent(new window.MouseEvent("dblclick", {bubbles: true}));
await pause(20);
const select = cell.querySelector("select");
select.value = "1";
select.dispatchEvent(new window.KeyboardEvent("keydown", {key: "Enter", bubbles: true}));
await pause(20);
const save = requests.find(item => item.options?.method === "POST");
assert.equal(JSON.parse(save.options.body).expected_revision, "7");
assert.equal(cell.closest("tr").dataset.revision, "8");
assert.equal(cell.textContent, "Employee 1");
console.log("Frontend regression checks passed: safe confirmation text, preserved selection, no-op blur, revision save.");
} finally { dom.window.close(); }
}
run().catch(error => { console.error(error); process.exitCode = 1; });

54
scripts/test_local_tls.py Normal file
View File

@@ -0,0 +1,54 @@
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from renew_local_tls import identity, openssl, renew
class IdentityTests(unittest.TestCase):
def test_ip_and_dns_names(self):
self.assertEqual(identity("192.168.188.45"), ("192.168.188.45", "IP", "-verify_ip"))
self.assertEqual(identity("Inventory.Example.org")[0], "inventory.example.org")
def test_invalid_names_are_rejected(self):
for name in ("https://example.org", "example.org:443", "", "a\nsubjectAltName=DNS:evil", "*.example.org"):
with self.subTest(name=name), self.assertRaises(ValueError):
identity(name)
@unittest.skipUnless(sys.platform != "win32" and shutil.which("openssl"), "Linux/OpenSSL required")
class CertificateTests(unittest.TestCase):
def test_issue_reuse_and_renew_preserve_ca(self):
with tempfile.TemporaryDirectory() as directory:
project = Path(directory)
renew(project, "192.168.188.45")
ca = (project / "tls/ca/root.crt").read_bytes()
state = project / "tls/certs/current.json"
first = json.loads(state.read_text())
renew(project, "192.168.188.45")
self.assertEqual(first, json.loads(state.read_text()))
renew(project, "192.168.188.45", force=True)
second = json.loads(state.read_text())
self.assertNotEqual(first["serial"], second["serial"])
self.assertEqual(ca, (project / "tls/ca/root.crt").read_bytes())
self.assertIn(second["serial"], (project / "traefik/dynamic/tls.yml").read_text())
self.assertEqual((project / "tls/certs" / (second["serial"] + ".key")).stat().st_mode & 0o777, 0o600)
openssl("verify", "-CAfile", project / "tls/ca/root.crt", "-verify_ip", "192.168.188.45",
project / "tls/certs" / (second["serial"] + ".crt"))
def test_incomplete_ca_is_not_overwritten(self):
with tempfile.TemporaryDirectory() as directory:
project = Path(directory)
ca = project / "tls/ca"
ca.mkdir(parents=True)
(ca / "root.key").write_text("existing key")
with self.assertRaises(RuntimeError):
renew(project, "192.168.188.45")
self.assertEqual((ca / "root.key").read_text(), "existing key")
if __name__ == "__main__":
unittest.main()