Initialize clean CSR inventory repository
This commit is contained in:
20
scripts/backup.sh
Normal file
20
scripts/backup.sh
Normal 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
7
scripts/entrypoint.sh
Normal 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
57
scripts/export_sqlite.py
Normal 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
117
scripts/renew_local_tls.py
Normal 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
17
scripts/restore.sh
Normal 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."
|
||||
67
scripts/test_backup_scripts.py
Normal file
67
scripts/test_backup_scripts.py
Normal 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
58
scripts/test_frontend.cjs
Normal 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
54
scripts/test_local_tls.py
Normal 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()
|
||||
Reference in New Issue
Block a user