first commit
This commit is contained in:
commit
2eb8aac027
91 files changed
+20139
No files matched your search
Executable
+151
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="$ROOT/packages"
|
||||
IMAGE="${POSTFIXADMIN_RS_DEB_IMAGE:-debian:12-slim}"
|
||||
TOOLCHAIN="$(sed -n 's/^channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/rust-toolchain.toml" | head -n1)"
|
||||
|
||||
command -v docker >/dev/null 2>&1 || { echo "error: docker is required" >&2; exit 1; }
|
||||
[ -n "$TOOLCHAIN" ] || { echo "error: cannot read Rust toolchain" >&2; exit 1; }
|
||||
mkdir -p "$OUT"
|
||||
|
||||
printf 'Building Debian package with %s / Rust %s\n' "$IMAGE" "$TOOLCHAIN"
|
||||
printf 'Output: %s\n' "$OUT"
|
||||
|
||||
docker run --rm \
|
||||
-e DEBIAN_FRONTEND=noninteractive \
|
||||
-e RUSTUP_HOME=/opt/rustup \
|
||||
-e CARGO_HOME=/opt/cargo \
|
||||
-e POSTFIXADMIN_RS_TOOLCHAIN="$TOOLCHAIN" \
|
||||
-v "$ROOT:/src:ro" \
|
||||
-v "$OUT:/packages" \
|
||||
-v postfixadmin-rs-cargo-registry:/opt/cargo/registry \
|
||||
-v postfixadmin-rs-cargo-git:/opt/cargo/git \
|
||||
-v postfixadmin-rs-rustup:/opt/rustup \
|
||||
"$IMAGE" bash -euxo pipefail -c '
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev dpkg-dev python3
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
if [ ! -x /opt/cargo/bin/rustup ]; then
|
||||
curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal
|
||||
fi
|
||||
export PATH="/opt/cargo/bin:$PATH"
|
||||
rustup toolchain install "$POSTFIXADMIN_RS_TOOLCHAIN" --profile minimal
|
||||
rustup default "$POSTFIXADMIN_RS_TOOLCHAIN"
|
||||
rustup component add rustfmt clippy --toolchain "$POSTFIXADMIN_RS_TOOLCHAIN"
|
||||
|
||||
rm -rf /work/postfixadmin-rs /work/pkg
|
||||
mkdir -p /work/postfixadmin-rs
|
||||
cp -a /src/. /work/postfixadmin-rs/
|
||||
cd /work/postfixadmin-rs
|
||||
|
||||
python3 ./scripts/validate-release-static.py
|
||||
cargo generate-lockfile
|
||||
cargo fmt --all
|
||||
cargo fmt --all -- --check
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo build --release
|
||||
|
||||
VERSION="$(sed -n "s/^version[[:space:]]*=[[:space:]]*\"\([^\"]*\)\".*/\1/p" Cargo.toml | head -n1)"
|
||||
DEB_VERSION="${VERSION/-dev/~dev}"
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
PKG=/work/pkg
|
||||
|
||||
install -d "$PKG/DEBIAN" "$PKG/usr/bin" "$PKG/etc/postfixadmin-rs" "$PKG/etc/default" "$PKG/lib/systemd/system" "$PKG/usr/share/doc/postfixadmin-rs"
|
||||
install -m 0755 target/release/postfixadmin-rs "$PKG/usr/bin/postfixadmin-rs"
|
||||
install -m 0640 config.example.toml "$PKG/etc/postfixadmin-rs/config.toml"
|
||||
install -m 0644 packaging/postfixadmin-rs.default "$PKG/etc/default/postfixadmin-rs"
|
||||
install -m 0644 packaging/postfixadmin-rs.service "$PKG/lib/systemd/system/postfixadmin-rs.service"
|
||||
install -m 0644 README.md SECURITY.md COMPATIBILITY.md PORT-PARITY.md "$PKG/usr/share/doc/postfixadmin-rs/"
|
||||
|
||||
cat > "$PKG/DEBIAN/control" <<CONTROL
|
||||
Package: postfixadmin-rs
|
||||
Version: $DEB_VERSION
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Architecture: $ARCH
|
||||
Maintainer: local build <root@localhost>
|
||||
Depends: adduser, libc6 (>= 2.36), libgcc-s1, libssl3, ca-certificates
|
||||
Description: Rust web administration panel for Postfix virtual mail data
|
||||
PostfixAdmin-RS is a Rust administration panel compatible with PostfixAdmin
|
||||
database layouts. This is a development package built locally.
|
||||
CONTROL
|
||||
|
||||
cat > "$PKG/DEBIAN/conffiles" <<CONFFILES
|
||||
/etc/postfixadmin-rs/config.toml
|
||||
/etc/default/postfixadmin-rs
|
||||
CONFFILES
|
||||
|
||||
cat > "$PKG/DEBIAN/postinst" <<'POSTINST'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Dedicated unprivileged runtime identity. Preserve an existing account if present.
|
||||
if ! getent group postfixadmin >/dev/null 2>&1; then
|
||||
addgroup --system postfixadmin >/dev/null
|
||||
fi
|
||||
if ! getent passwd postfixadmin >/dev/null 2>&1; then
|
||||
adduser --system \
|
||||
--ingroup postfixadmin \
|
||||
--home /var/lib/postfixadmin-rs \
|
||||
--no-create-home \
|
||||
--disabled-login \
|
||||
--shell /usr/sbin/nologin \
|
||||
postfixadmin >/dev/null
|
||||
fi
|
||||
|
||||
# Runtime/state directory is writable by the service account.
|
||||
install -d -o postfixadmin -g postfixadmin -m 0750 /var/lib/postfixadmin-rs
|
||||
|
||||
# Configuration stays root-owned: the service can read secrets but cannot edit them.
|
||||
install -d -o root -g postfixadmin -m 0750 /etc/postfixadmin-rs
|
||||
if [ -f /etc/postfixadmin-rs/config.toml ]; then
|
||||
chown root:postfixadmin /etc/postfixadmin-rs/config.toml
|
||||
chmod 0640 /etc/postfixadmin-rs/config.toml
|
||||
fi
|
||||
if [ -f /etc/default/postfixadmin-rs ]; then
|
||||
chown root:postfixadmin /etc/default/postfixadmin-rs
|
||||
chmod 0640 /etc/default/postfixadmin-rs
|
||||
fi
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
printf "%s\\n" "PostfixAdmin-RS installed. Configure /etc/postfixadmin-rs/config.toml, then run: systemctl enable --now postfixadmin-rs"
|
||||
exit 0
|
||||
POSTINST
|
||||
chmod 0755 "$PKG/DEBIAN/postinst"
|
||||
|
||||
cat > "$PKG/DEBIAN/prerm" <<'PRERM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ "${1:-}" = remove ] && command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl stop postfixadmin-rs.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
PRERM
|
||||
chmod 0755 "$PKG/DEBIAN/prerm"
|
||||
|
||||
cat > "$PKG/DEBIAN/postrm" <<'POSTRM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
POSTRM
|
||||
chmod 0755 "$PKG/DEBIAN/postrm"
|
||||
|
||||
grep -qx "User=postfixadmin" "$PKG/lib/systemd/system/postfixadmin-rs.service"
|
||||
grep -qx "Group=postfixadmin" "$PKG/lib/systemd/system/postfixadmin-rs.service"
|
||||
test -f "$PKG/etc/postfixadmin-rs/config.toml"
|
||||
|
||||
dpkg-deb --root-owner-group --build "$PKG" "/packages/postfixadmin-rs_${DEB_VERSION}_${ARCH}.deb"
|
||||
sha256sum "/packages/postfixadmin-rs_${DEB_VERSION}_${ARCH}.deb" > "/packages/postfixadmin-rs_${DEB_VERSION}_${ARCH}.deb.sha256"
|
||||
'
|
||||
|
||||
printf '\nPackages created:\n'
|
||||
find "$OUT" -maxdepth 1 -type f -name 'postfixadmin-rs_*.deb*' -printf ' %f\n' | sort
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
python3 ./scripts/validate-release-static.py
|
||||
command -v cargo >/dev/null 2>&1 || { echo "error: Rust/Cargo is required" >&2; exit 1; }
|
||||
[ -f Cargo.lock ] || cargo generate-lockfile
|
||||
cargo fmt --all
|
||||
cargo fmt --all -- --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
cargo build --release --locked
|
||||
"$ROOT/target/release/postfixadmin-rs" --version
|
||||
printf 'Built: %s\n' "$ROOT/target/release/postfixadmin-rs"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
./scripts/build-release.sh
|
||||
VERSION="$(python3 - <<'PY'
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
with Path('Cargo.toml').open('rb') as fh:
|
||||
print(tomllib.load(fh)['package']['version'])
|
||||
PY
|
||||
)"
|
||||
OUT_DIR="${1:-$ROOT/dist}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
ARCHIVE="$OUT_DIR/PostfixAdmin-RS-$VERSION.zip"
|
||||
CHECKSUM="$ARCHIVE.sha256"
|
||||
python3 make_zip.py "$ARCHIVE"
|
||||
(
|
||||
cd "$(dirname "$ARCHIVE")"
|
||||
sha256sum "$(basename "$ARCHIVE")" > "$(basename "$CHECKSUM")"
|
||||
)
|
||||
printf 'Release archive: %s\n' "$ARCHIVE"
|
||||
printf 'SHA256: '
|
||||
cat "$CHECKSUM"
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
printf '%s\n' '==> cargo fmt'
|
||||
cargo fmt --all
|
||||
printf '%s\n' '==> cargo check'
|
||||
cargo check
|
||||
printf '%s\n' '==> cargo test'
|
||||
cargo test
|
||||
printf '%s\n' '==> cargo clippy --all-targets -- -D warnings'
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
printf '%s\n' 'quality gate: OK'
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a local, public-only Rspamd DKIM cache from a remote SSH host.
|
||||
|
||||
The helper never downloads private key bytes. It reads only Rspamd mapping/config
|
||||
metadata and derives the public key on the remote host with OpenSSL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import posixpath
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry:
|
||||
domain: str
|
||||
selector: str
|
||||
path: str
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--ssh", default="ssh")
|
||||
p.add_argument("--host", required=True)
|
||||
p.add_argument("--user", required=True)
|
||||
p.add_argument("--port", type=int, default=22)
|
||||
p.add_argument("--identity", default="")
|
||||
p.add_argument("--known-hosts", default="")
|
||||
p.add_argument("--signing-config", required=True)
|
||||
p.add_argument("--selector-map", required=True)
|
||||
p.add_argument("--path-map", required=True)
|
||||
p.add_argument("--key-dir", required=True)
|
||||
p.add_argument("--default-selector", default="mail")
|
||||
p.add_argument("--output-dir", required=True)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def ssh_base(a: argparse.Namespace) -> list[str]:
|
||||
out = [a.ssh, "-T", "-p", str(a.port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]
|
||||
if a.identity:
|
||||
out += ["-i", a.identity]
|
||||
if a.known_hosts:
|
||||
out += ["-o", f"UserKnownHostsFile={a.known_hosts}"]
|
||||
out.append(f"{a.user}@{a.host}")
|
||||
return out
|
||||
|
||||
|
||||
def remote(a: argparse.Namespace, command: str, *, check: bool = False) -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.run(ssh_base(a) + [command], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check)
|
||||
|
||||
|
||||
def read_remote_text(a: argparse.Namespace, path: str) -> str:
|
||||
cmd = f"test -r {shlex.quote(path)} && cat -- {shlex.quote(path)} || true"
|
||||
cp = remote(a, cmd)
|
||||
return cp.stdout.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def parse_map(text: str) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
result[parts[0].lower()] = parts[1]
|
||||
return result
|
||||
|
||||
|
||||
def unquote_assignment(line: str, key: str) -> str | None:
|
||||
m = re.match(rf"^\s*{re.escape(key)}\s*=\s*\"((?:\\.|[^\"])*)\"\s*;?\s*,?\s*$", line)
|
||||
if not m:
|
||||
return None
|
||||
return bytes(m.group(1), "utf-8").decode("unicode_escape")
|
||||
|
||||
|
||||
def parse_legacy(text: str, default_selector: str) -> list[Entry]:
|
||||
entries: list[Entry] = []
|
||||
in_domain = False
|
||||
depth = 0
|
||||
domain: str | None = None
|
||||
selector = ""
|
||||
path = ""
|
||||
for raw in text.splitlines():
|
||||
line = raw.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
continue
|
||||
if not in_domain:
|
||||
if line.startswith("domain") and "{" in line:
|
||||
in_domain = True
|
||||
depth = 1
|
||||
continue
|
||||
if depth == 1 and domain is None and "{" in line:
|
||||
name = line.split("{", 1)[0].strip().rstrip(",").strip('"')
|
||||
if name and "=" not in name:
|
||||
domain = name.lower()
|
||||
selector = ""
|
||||
path = ""
|
||||
if domain is not None and depth >= 2:
|
||||
selector = unquote_assignment(line, "selector") or selector
|
||||
path = unquote_assignment(line, "path") or path
|
||||
depth += line.count("{") - line.count("}")
|
||||
if domain is not None and depth == 1 and "}" in line:
|
||||
if path:
|
||||
entries.append(Entry(domain, selector or default_selector, path))
|
||||
domain = None
|
||||
selector = ""
|
||||
path = ""
|
||||
if depth <= 0:
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def under_key_dir(path: str, key_dir: str) -> bool:
|
||||
normalized = posixpath.normpath(path)
|
||||
normalized_root = posixpath.normpath(key_dir)
|
||||
if not normalized.startswith("/") or not normalized_root.startswith("/"):
|
||||
return False
|
||||
p = pathlib.PurePosixPath(normalized)
|
||||
root = pathlib.PurePosixPath(normalized_root)
|
||||
return p == root or root in p.parents
|
||||
|
||||
|
||||
def remote_sidecar(a: argparse.Namespace, private_path: str) -> str:
|
||||
sidecar = str(pathlib.PurePosixPath(private_path).with_suffix(".txt"))
|
||||
text = read_remote_text(a, sidecar).strip()
|
||||
return text if "DKIM1" in text.upper() and "p=" in text else ""
|
||||
|
||||
|
||||
def remote_public_record(a: argparse.Namespace, private_path: str, domain: str, selector: str) -> str:
|
||||
existing = remote_sidecar(a, private_path)
|
||||
if existing:
|
||||
return existing
|
||||
q = shlex.quote(private_path)
|
||||
cmd = (
|
||||
"pub=$("
|
||||
f"(openssl pkey -in {q} -pubout -outform DER 2>/dev/null "
|
||||
f"|| openssl rsa -in {q} -pubout -outform DER 2>/dev/null) "
|
||||
"| openssl base64 -A 2>/dev/null"
|
||||
") && printf '__PA_DKIM__%s\\n' \"$pub\""
|
||||
)
|
||||
cp = remote(a, cmd)
|
||||
if cp.returncode != 0:
|
||||
return ""
|
||||
text = cp.stdout.decode("ascii", "ignore")
|
||||
marker = "__PA_DKIM__"
|
||||
line = next((line for line in text.splitlines() if line.startswith(marker)), "")
|
||||
public = line[len(marker):].strip()
|
||||
if not public or not re.fullmatch(r"[A-Za-z0-9+/=]+", public):
|
||||
return ""
|
||||
return f'{selector}._domainkey.{domain} IN TXT "v=DKIM1; k=rsa; p={public}"'
|
||||
|
||||
|
||||
def main() -> int:
|
||||
a = parse_args()
|
||||
out = pathlib.Path(a.output_dir).resolve()
|
||||
keys = out / "keys"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
keys.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
signing = read_remote_text(a, a.signing_config)
|
||||
selectors = parse_map(read_remote_text(a, a.selector_map))
|
||||
paths = parse_map(read_remote_text(a, a.path_map))
|
||||
|
||||
for entry in parse_legacy(signing, a.default_selector):
|
||||
selectors.setdefault(entry.domain, entry.selector)
|
||||
paths.setdefault(entry.domain, entry.path)
|
||||
|
||||
local_selectors: dict[str, str] = {}
|
||||
local_paths: dict[str, str] = {}
|
||||
synced = 0
|
||||
skipped = 0
|
||||
|
||||
for domain in sorted(set(selectors) | set(paths)):
|
||||
selector = selectors.get(domain, a.default_selector)
|
||||
raw_path = paths.get(domain)
|
||||
if not raw_path:
|
||||
skipped += 1
|
||||
continue
|
||||
remote_path = raw_path.replace("$domain", domain).replace("$selector", selector)
|
||||
if not under_key_dir(remote_path, a.key_dir):
|
||||
print(f"warning: skip {domain}: key path is outside {a.key_dir}", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
record = remote_public_record(a, remote_path, domain, selector)
|
||||
if not record:
|
||||
print(f"warning: skip {domain}: unable to derive public DKIM record", file=sys.stderr)
|
||||
skipped += 1
|
||||
continue
|
||||
domain_dir = keys / domain
|
||||
domain_dir.mkdir(parents=True, exist_ok=True)
|
||||
local_private = domain_dir / f"{selector}.private"
|
||||
local_private.with_suffix(".txt").write_text(record.strip() + "\n", encoding="utf-8")
|
||||
local_selectors[domain] = selector
|
||||
local_paths[domain] = str(local_private)
|
||||
synced += 1
|
||||
|
||||
(out / "selectors.map").write_text(
|
||||
"# Public-only development cache generated over SSH.\n" + "".join(f"{k} {v}\n" for k, v in sorted(local_selectors.items())),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "paths.map").write_text(
|
||||
"# Public-only development cache generated over SSH.\n" + "".join(f"{k} {v}\n" for k, v in sorted(local_paths.items())),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "dkim_signing.conf").write_text("# Public-only development cache; no private keys are stored here.\n", encoding="utf-8")
|
||||
print(f"Rspamd DKIM public cache: {synced} domain(s), {skipped} skipped")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+260
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
errors.append(message)
|
||||
|
||||
|
||||
def load_toml(path: Path):
|
||||
try:
|
||||
with path.open('rb') as handle:
|
||||
return tomllib.load(handle)
|
||||
except Exception as exc:
|
||||
fail(f'{path.relative_to(ROOT)}: invalid TOML: {exc}')
|
||||
return {}
|
||||
|
||||
|
||||
cargo = load_toml(ROOT / 'Cargo.toml')
|
||||
toolchain = load_toml(ROOT / 'rust-toolchain.toml')
|
||||
package = cargo.get('package', {})
|
||||
if package.get('edition') != '2024':
|
||||
fail('Cargo.toml must use edition 2024')
|
||||
if package.get('rust-version') != '1.99.1':
|
||||
fail('Cargo.toml rust-version must be 1.99.1')
|
||||
if package.get('version') != '0.0.1-dev':
|
||||
fail('Cargo.toml version must be 0.0.1-dev during the development phase')
|
||||
if toolchain.get('toolchain', {}).get('channel') != '1.99.1':
|
||||
fail('rust-toolchain.toml must pin 1.99.1')
|
||||
|
||||
for path in ROOT.glob('config*.toml'):
|
||||
load_toml(path)
|
||||
|
||||
for path in (ROOT / 'src/web/i18n').glob('*.json'):
|
||||
try:
|
||||
json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception as exc:
|
||||
fail(f'{path.relative_to(ROOT)}: invalid JSON: {exc}')
|
||||
|
||||
for kind, extension in [('css', '.css'), ('js', '.js')]:
|
||||
directory = ROOT / f'src/web/{kind}'
|
||||
manifest = directory / 'manifest.txt'
|
||||
entries = []
|
||||
for raw in manifest.read_text(encoding='utf-8').splitlines():
|
||||
name = raw.strip()
|
||||
if not name or name.startswith('#'):
|
||||
continue
|
||||
if '/' in name or '\\' in name or '..' in name or not name.endswith(extension):
|
||||
fail(f'{manifest.relative_to(ROOT)}: unsafe entry {name!r}')
|
||||
continue
|
||||
entries.append(name)
|
||||
if not (directory / name).is_file():
|
||||
fail(f'{manifest.relative_to(ROOT)}: missing {name}')
|
||||
actual = sorted(p.name for p in directory.glob(f'*{extension}'))
|
||||
if sorted(entries) != actual:
|
||||
fail(f'{manifest.relative_to(ROOT)} does not list exactly all {extension} files: manifest={sorted(entries)}, files={actual}')
|
||||
|
||||
index = (ROOT / 'src/web/index.html').read_text(encoding='utf-8')
|
||||
if re.search(r'<script(?![^>]*\bsrc=)[^>]*>', index, flags=re.I):
|
||||
fail('index.html contains an inline script')
|
||||
if re.search(r'\sstyle\s*=', index, flags=re.I):
|
||||
fail('index.html contains inline style attributes')
|
||||
|
||||
main = (ROOT / 'src/main.rs').read_text(encoding='utf-8')
|
||||
csp_match = re.search(r'content-security-policy.*?HeaderValue::from_static\("([^"]+)"\)', main, flags=re.S)
|
||||
if not csp_match:
|
||||
fail('main.rs: CSP header not found')
|
||||
else:
|
||||
csp = csp_match.group(1)
|
||||
if "'unsafe-inline'" in csp or "'unsafe-eval'" in csp:
|
||||
fail('CSP must not contain unsafe-inline/unsafe-eval')
|
||||
|
||||
for path in ROOT.rglob('*'):
|
||||
if not path.is_file() or any(part in {'target', 'dist', '.git'} for part in path.parts):
|
||||
continue
|
||||
if path.suffix.lower() == '.php':
|
||||
fail(f'PHP file present: {path.relative_to(ROOT)}')
|
||||
|
||||
rust_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src').rglob('*.rs'))
|
||||
for forbidden in ['Command::new("sh")', 'Command::new("bash")', '.arg("-c")']:
|
||||
if forbidden in rust_text:
|
||||
fail(f'forbidden shell execution marker: {forbidden}')
|
||||
|
||||
required_markers = [
|
||||
'php_crypt:MD5',
|
||||
'hash_internal_secret',
|
||||
'/auth/recovery/request',
|
||||
'/alias-domains',
|
||||
'/dkim/signing',
|
||||
'/security/totp-exceptions',
|
||||
'/security/app-passwords',
|
||||
'/aliases/delete-inactive/{domain}',
|
||||
'/preferences/{key}',
|
||||
'SUPPORTED_SCHEMES',
|
||||
'database.dev_mock',
|
||||
'quota_level_med_pct',
|
||||
'quota_level_high_pct',
|
||||
'generate_password',
|
||||
'show_password',
|
||||
'content-security-policy',
|
||||
]
|
||||
project_text = rust_text + '\n' + (ROOT / 'src/routes/mod.rs').read_text(encoding='utf-8')
|
||||
for marker in required_markers:
|
||||
if marker not in project_text:
|
||||
fail(f'missing expected compatibility marker: {marker}')
|
||||
|
||||
user_config = (ROOT / 'config.from-user.toml').read_text(encoding='utf-8')
|
||||
if re.search(r'^password\s*=\s*"[^"\n]+"', user_config, flags=re.M):
|
||||
fail('config.from-user.toml contains a database password')
|
||||
env_example = (ROOT / '.env.example').read_text(encoding='utf-8')
|
||||
if 'POSTFIXADMIN_RS__DATABASE__PASSWORD=CHANGE_ME' not in env_example:
|
||||
fail('.env.example must contain a non-secret placeholder')
|
||||
|
||||
|
||||
js_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src/web/js').glob('*.js'))
|
||||
if 'style=' in js_text or '.style.' in js_text:
|
||||
fail('frontend JS contains inline style mutations that are blocked by CSP')
|
||||
for forbidden in ['prompt(', 'confirm(', 'alert(']:
|
||||
if forbidden in js_text:
|
||||
fail(f'native browser dialog remains in frontend: {forbidden}')
|
||||
for marker in ['/rspamd-dkim/generate', 'selector_map', 'path_map', 'RspamdDkimConfig']:
|
||||
if marker not in project_text + '\n' + (ROOT / 'src/config.rs').read_text(encoding='utf-8'):
|
||||
fail(f'missing Rspamd DKIM integration marker: {marker}')
|
||||
|
||||
node = shutil.which('node')
|
||||
if node:
|
||||
for path in sorted((ROOT / 'src/web/js').glob('*.js')):
|
||||
result = subprocess.run([node, '--check', str(path)], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
fail(f'{path.relative_to(ROOT)}: node --check failed: {result.stderr.strip()}')
|
||||
|
||||
for script in [ROOT / 'dev.sh', ROOT / 'scripts/build-release.sh', ROOT / 'scripts/package-release.sh', ROOT / 'scripts/build-deb.sh']:
|
||||
result = subprocess.run(['bash', '-n', str(script)], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
fail(f'{script.relative_to(ROOT)}: bash -n failed: {result.stderr.strip()}')
|
||||
|
||||
# UI asset cache busting: the SVG sprite is immutable only under a content-hashed URL.
|
||||
ui_rs = (ROOT / 'src/ui.rs').read_text(encoding='utf-8')
|
||||
build_rs = (ROOT / 'build.rs').read_text(encoding='utf-8')
|
||||
core_js = (ROOT / 'src/web/js/00-core.js').read_text(encoding='utf-8')
|
||||
for marker in ['icons.{ASSET_ID}.svg', 'pa-rs-asset']:
|
||||
if marker not in ui_rs + index + core_js:
|
||||
fail(f'missing versioned icon asset marker: {marker}')
|
||||
if 'src/web/icons.svg' not in build_rs or 'let icons = fs::read' not in build_rs:
|
||||
fail('asset id must include icons.svg content')
|
||||
|
||||
# Local Debian packaging must stay Docker-based and emit only into ./packages.
|
||||
deb_script = (ROOT / 'scripts/build-deb.sh').read_text(encoding='utf-8')
|
||||
for marker in ['docker run --rm', 'debian:12-slim', 'dpkg-deb --root-owner-group', 'OUT="$ROOT/packages"']:
|
||||
if marker not in deb_script:
|
||||
fail(f'missing Debian packaging marker: {marker}')
|
||||
|
||||
# Development safety: mock DB must not be accepted as a production backend.
|
||||
config_rs = (ROOT / 'src/config.rs').read_text(encoding='utf-8')
|
||||
if 'database.dev_mock is allowed only with environment = development' not in config_rs:
|
||||
fail('missing development mock environment guard')
|
||||
if 'database.dev_mock requires database.backend = sqlite' not in config_rs:
|
||||
fail('missing development mock SQLite guard')
|
||||
|
||||
passwords_rs = (ROOT / 'src/passwords.rs').read_text(encoding='utf-8')
|
||||
for marker in ['verifies_postfixadmin_reference_vectors', 'verifies_postfixadmin_b64_crypt_vectors', 'php_crypt:MD5', 'courier:md5raw', 'ARGON2ID.B64']:
|
||||
if marker not in passwords_rs:
|
||||
fail(f'missing password compatibility marker/test: {marker}')
|
||||
|
||||
# sqlx::Any + MySQL compatibility: PostfixAdmin stores flags as TINYINT(1),
|
||||
# which the SQLx Any bridge does not expose directly. Keep the normalization
|
||||
# helper and make sure the login path uses it.
|
||||
db_rs = (ROOT / 'src/db.rs').read_text(encoding='utf-8')
|
||||
for marker in ['pub fn cast_i64(&self, expr: &str)', 'CAST({expr} AS SIGNED)', 'self.flag("active", "active")', 'self.flag("superadmin", "superadmin")']:
|
||||
if marker not in db_rs:
|
||||
fail(f'missing MySQL TINYINT/sqlx::Any compatibility marker: {marker}')
|
||||
|
||||
# UI internationalization / development tunnel safety.
|
||||
config_dev = load_toml(ROOT / 'config.dev.toml')
|
||||
if config_dev.get('default_language') != 'en':
|
||||
fail('config.dev.toml must default to English')
|
||||
for lang in ['en', 'pl']:
|
||||
if not (ROOT / f'src/web/i18n/{lang}.json').is_file():
|
||||
fail(f'missing UI language catalog: {lang}')
|
||||
if 'initLanguage' not in js_text or 'languageSelect' not in js_text:
|
||||
fail('frontend language selector/i18n bootstrap is missing')
|
||||
branding_text = '\n'.join(p.read_text(encoding='utf-8', errors='ignore') for p in (ROOT / 'src/web').rglob('*') if p.is_file())
|
||||
if ('· bez ' + 'PHP') in branding_text or ('Panel poczty bez ' + 'PHP') in branding_text:
|
||||
fail('deprecated PHP-free branding text remains')
|
||||
if 'POSTFIXADMIN_RS_DEV_DB_MODE' not in (ROOT / 'dev.sh').read_text(encoding='utf-8'):
|
||||
fail('development SSH DB mode is missing')
|
||||
if 'POSTFIXADMIN_RS_DEV_DB_WRITES' not in (ROOT / 'dev.sh').read_text(encoding='utf-8'):
|
||||
fail('development production-DB write opt-in is missing')
|
||||
if 'read_only' not in config_rs or 'Database is read-only in this development session' not in project_text:
|
||||
fail('read-only production DB guard is missing')
|
||||
|
||||
|
||||
# Mailbox-user UX and optional ManageSieve integration.
|
||||
sieve_rs = (ROOT / 'src/routes/sieve.rs').read_text(encoding='utf-8')
|
||||
auth_rs = (ROOT / 'src/auth.rs').read_text(encoding='utf-8')
|
||||
routes_mod = (ROOT / 'src/routes/mod.rs').read_text(encoding='utf-8')
|
||||
dev_sh = (ROOT / 'dev.sh').read_text(encoding='utf-8')
|
||||
pages_js = (ROOT / 'src/web/js/10-pages.js').read_text(encoding='utf-8')
|
||||
core_js = (ROOT / 'src/web/js/00-core.js').read_text(encoding='utf-8')
|
||||
for marker in ['/sieve/status', '/sieve/scripts/{name}', '/sieve/check']:
|
||||
if marker not in routes_mod:
|
||||
fail(f'missing ManageSieve route: {marker}')
|
||||
for marker in ['openSieveRuleBuilder', 'buildSieveRule', 'mergeSieveRequirements']:
|
||||
if marker not in pages_js:
|
||||
fail(f'missing ManageSieve visual rule builder marker: {marker}')
|
||||
for marker in ['SieveConfig', 'allow_insecure_auth', 'timeout_secs', 'read_only']:
|
||||
if marker not in config_rs:
|
||||
fail(f'missing ManageSieve config marker: {marker}')
|
||||
for marker in ['mail_password: Option<Arc<str>>', 'pub fn mail_password', 'update_mail_password']:
|
||||
if marker not in auth_rs:
|
||||
fail(f'missing in-memory ManageSieve session credential marker: {marker}')
|
||||
for marker in ['POSTFIXADMIN_RS_DEV_SIEVE_ENABLED', 'POSTFIXADMIN_RS_DEV_SIEVE_LOCAL_PORT', 'POSTFIXADMIN_RS_DEV_SIEVE_WRITES']:
|
||||
if marker not in dev_sh:
|
||||
fail(f'missing ManageSieve development tunnel marker: {marker}')
|
||||
for marker in ['POSTFIXADMIN_RS_DEV_RSPAMD_PUBLIC_SYNC', 'sync-rspamd-dkim-readonly.py', 'rspamd-remote-public']:
|
||||
if marker not in dev_sh:
|
||||
fail(f'missing read-only Rspamd DKIM SSH sync marker: {marker}')
|
||||
if 'common_record' in config_rs or 'common_selector' in config_rs or 'common_record' in user_config or 'common_selector' in user_config:
|
||||
fail('shared/common DKIM configuration must not exist; DKIM is per-domain')
|
||||
for marker in ['/dkim/records', 'user_read_only', 'read_only_dns_records']:
|
||||
if marker not in project_text + '\n' + pages_js + '\n' + config_rs:
|
||||
fail(f'missing mailbox-user read-only DKIM marker: {marker}')
|
||||
sync_script = ROOT / 'scripts/sync-rspamd-dkim-readonly.py'
|
||||
if not sync_script.is_file():
|
||||
fail('missing public-only Rspamd DKIM SSH sync helper')
|
||||
else:
|
||||
result = subprocess.run([sys.executable, '-m', 'py_compile', str(sync_script)], capture_output=True, text=True)
|
||||
if result.returncode:
|
||||
fail(f'{sync_script.relative_to(ROOT)}: py_compile failed: {result.stderr.strip()}')
|
||||
for marker in ["['mailboxes', 'mail', 'Skrzynki', S.me.kind === 'admin']", "S.me.kind === 'user'", 'readonlyDomain(selected)', "api('/mailboxes')", "api('/aliases')", 'vacationUserForm', 'passwordForm']:
|
||||
if marker not in core_js + '\n' + pages_js:
|
||||
fail(f'missing mailbox-user UX marker: {marker}')
|
||||
if 'Sesja: HttpOnly' in branding_text or 'Session: HttpOnly' in branding_text or 'SameSite=Strict, Secure w produkcji' in branding_text:
|
||||
fail('technical session-security blurb must not remain in the frontend')
|
||||
if 'Aktywne moduły' in pages_js:
|
||||
# Allowed only in the administrator branch after the explicit user return.
|
||||
user_branch = pages_js.find("if (S.me.kind === 'user')")
|
||||
module_panel = pages_js.find('Aktywne moduły')
|
||||
if user_branch < 0 or module_panel < user_branch:
|
||||
fail('mailbox-user overview exposes the active-modules panel')
|
||||
if 'ManageSieve credentials are not available; sign in again' not in sieve_rs:
|
||||
fail('ManageSieve routes must require a fresh in-memory mailbox credential')
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f'ERROR: {error}', file=sys.stderr)
|
||||
print(f'static validation: FAILED ({len(errors)} issue(s))', file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print('static validation: OK')
|
||||
Reference in new issue
Block a user