Migrate declarative configs to config/ dir and remove hardcoded paths
Replace all hardcoded /home/wall/vacuum-wall paths in lib/ with Path(__file__).resolve()
auto-discovery. Move config files from data/ to config/<subsystem>/config.json.
ACME now uses ACME_HOME env var and data/acme/ for cert storage. Systemd units
and sudoers use {{ USER_NAME }}, {{ PROJECT_DIR }}, {{ ACME_HOME }} Jinja2
template variables for install-time substitution. Remove sys.path.insert boot
strap from test files.
This commit is contained in:
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"dhcp": {
|
|
||||||
"ranges": [],
|
|
||||||
"static_leases": []
|
|
||||||
},
|
|
||||||
"dns": {
|
|
||||||
"upstreams": [
|
|
||||||
"8.8.8.8",
|
|
||||||
"1.1.1.1"
|
|
||||||
],
|
|
||||||
"domain": null,
|
|
||||||
"custom_records": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
-13
@@ -2,7 +2,7 @@
|
|||||||
ACME certificate manager for Vacuum Wall.
|
ACME certificate manager for Vacuum Wall.
|
||||||
|
|
||||||
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
|
Wraps acme.sh to issue, renew, and manage SSL/TLS certificates
|
||||||
from Let's Encrypt (or other ACME providers). acme.sh runs as the
|
from ACME providers such as ZeroSSL or Let's Encrypt. acme.sh runs as the
|
||||||
vacuum-wall system user; nginx is reloaded via a deploy hook script.
|
vacuum-wall system user; nginx is reloaded via a deploy hook script.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -16,16 +16,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
_ACME_HOME = PROJECT_DIR / "data" / "acme"
|
||||||
|
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
|
||||||
|
|
||||||
_ACME_ENVIRON = {
|
_ACME_ENVIRON = {
|
||||||
"HOME": str(Path.home()),
|
"HOME": str(PROJECT_DIR),
|
||||||
"PATH": os.environ.get(
|
"PATH": os.environ.get(
|
||||||
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
"PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
|
||||||
_DEPLOY_HOOK = str(PROJECT_DIR / "system" / "acme-deploy.sh")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_acme() -> str:
|
def _find_acme() -> str:
|
||||||
"""Locate the acme.sh binary on the system.
|
"""Locate the acme.sh binary on the system.
|
||||||
@@ -41,7 +42,7 @@ def _find_acme() -> str:
|
|||||||
FileNotFoundError: If acme.sh cannot be found.
|
FileNotFoundError: If acme.sh cannot be found.
|
||||||
"""
|
"""
|
||||||
candidates = [
|
candidates = [
|
||||||
Path.home() / ".acme.sh" / "acme.sh",
|
_ACME_HOME / "acme.sh",
|
||||||
Path("/usr/local/bin/acme.sh"),
|
Path("/usr/local/bin/acme.sh"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -81,12 +82,15 @@ def _run_acme(args: list[str]) -> str:
|
|||||||
"""
|
"""
|
||||||
acme_bin = _find_acme()
|
acme_bin = _find_acme()
|
||||||
|
|
||||||
|
# Check for ACME_HOME env var (set by systemd in production)
|
||||||
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
|
|
||||||
cmd: list[str] = [
|
cmd: list[str] = [
|
||||||
acme_bin,
|
acme_bin,
|
||||||
"--home",
|
"--home",
|
||||||
str(Path.home() / ".acme.sh"),
|
acme_home_env,
|
||||||
"--config-home",
|
"--config-home",
|
||||||
str(Path.home() / ".acme.sh"),
|
acme_home_env,
|
||||||
*args,
|
*args,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -131,7 +135,8 @@ def set_email(email: str) -> None:
|
|||||||
def get_email() -> str:
|
def get_email() -> str:
|
||||||
"""Return the ACME contact email, or '' if none is configured."""
|
"""Return the ACME contact email, or '' if none is configured."""
|
||||||
try:
|
try:
|
||||||
account_conf = Path.home() / ".acme.sh" / "account.conf"
|
acme_home = Path(os.environ.get("ACME_HOME", str(_ACME_HOME)))
|
||||||
|
account_conf = acme_home / "account.conf"
|
||||||
if account_conf.is_file():
|
if account_conf.is_file():
|
||||||
text = account_conf.read_text()
|
text = account_conf.read_text()
|
||||||
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
match = re.search(r"^ACME_LEEMAIL=(.+)$", text, re.MULTILINE)
|
||||||
@@ -246,7 +251,8 @@ def list_certs() -> list[dict]:
|
|||||||
certs: list[dict] = []
|
certs: list[dict] = []
|
||||||
|
|
||||||
entries = _parse_list_output(raw)
|
entries = _parse_list_output(raw)
|
||||||
acme_home = Path.home() / ".acme.sh"
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
|
acme_home = Path(acme_home_env)
|
||||||
|
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
main = entry["main_domain"]
|
main = entry["main_domain"]
|
||||||
@@ -393,7 +399,8 @@ def get_cert_paths(domain: str) -> dict:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
Dict with keys 'cert', 'key', 'ca', 'fullchain' mapped to paths.
|
||||||
"""
|
"""
|
||||||
acme_home = str(Path.home() / ".acme.sh" / domain)
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
|
acme_home = str(Path(acme_home_env) / domain)
|
||||||
return {
|
return {
|
||||||
"cert": f"{acme_home}/{domain}.cert",
|
"cert": f"{acme_home}/{domain}.cert",
|
||||||
"key": f"{acme_home}/{domain}.key",
|
"key": f"{acme_home}/{domain}.key",
|
||||||
@@ -484,6 +491,6 @@ def _has_auto_renew(domain: str) -> bool:
|
|||||||
under ``~/.acme.sh/``; existence of this file means the systemd
|
under ``~/.acme.sh/``; existence of this file means the systemd
|
||||||
timer's ``--cron`` run will pick it up.
|
timer's ``--cron`` run will pick it up.
|
||||||
"""
|
"""
|
||||||
acme_home = Path.home() / ".acme.sh"
|
acme_home_env = os.environ.get("ACME_HOME", str(_ACME_HOME))
|
||||||
domain_conf = acme_home / f"{domain}.conf"
|
domain_conf = Path(acme_home_env) / f"{domain}.conf"
|
||||||
return bool(domain_conf.is_file())
|
return bool(domain_conf.is_file())
|
||||||
|
|||||||
+4
-2
@@ -15,9 +15,10 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
|
||||||
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
|
||||||
CONFIG_PATH = DATA_DIR / "config.json"
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
||||||
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
FRAGMENTS_DIR = DATA_DIR / "fragments"
|
||||||
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
DNSMASQ_CONF = "/etc/dnsmasq.d/vacuum-wall.conf"
|
||||||
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
LEASE_FILE = "/var/lib/dnsmasq/dnsmasq.leases"
|
||||||
@@ -46,6 +47,7 @@ DEFAULT_CFG: dict[str, Any] = {
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_dirs() -> None:
|
def _ensure_dirs() -> None:
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -12,10 +12,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
|
||||||
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
DATA_DIR = PROJECT_DIR / "data" / "nginx"
|
||||||
SITES_DIR = DATA_DIR / "sites-enabled"
|
SITES_DIR = DATA_DIR / "sites-enabled"
|
||||||
CONFIG_FILE = DATA_DIR / "config.json"
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||||
INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf")
|
INCLUDE_FILE = Path("/etc/nginx/conf.d/vacuum-wall.conf")
|
||||||
SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf")
|
SSL_SNIPPET = Path("/etc/nginx/snippets/vacuum-wall-ssl.conf")
|
||||||
HTPASSWD_FILE = DATA_DIR / ".htpasswd"
|
HTPASSWD_FILE = DATA_DIR / ".htpasswd"
|
||||||
@@ -53,6 +54,7 @@ DEFAULT_CONFIG = {
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_dirs():
|
def _ensure_dirs():
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
SITES_DIR.mkdir(parents=True, exist_ok=True)
|
SITES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,13 +180,15 @@ def generate_server_conf(domain_cfg: dict) -> str:
|
|||||||
cert=domain_cfg.get("cert"),
|
cert=domain_cfg.get("cert"),
|
||||||
auth=domain_cfg.get("auth"),
|
auth=domain_cfg.get("auth"),
|
||||||
is_management=False,
|
is_management=False,
|
||||||
|
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
||||||
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_management_conf(management: dict) -> str:
|
def _generate_management_conf(management: dict) -> str:
|
||||||
tmpl = ENV.get_template("nginx/server_block.conf")
|
tmpl = ENV.get_template("nginx/server_block.conf")
|
||||||
return tmpl.render(
|
return tmpl.render(
|
||||||
domain=management.get("domain", "wall.lan"),
|
domain=management.get("domain"),
|
||||||
backend=dict(
|
backend=dict(
|
||||||
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
management.get("backend", {}), host="127.0.0.1", port=9090, proto="http"
|
||||||
),
|
),
|
||||||
@@ -193,6 +197,8 @@ def _generate_management_conf(management: dict) -> str:
|
|||||||
cert=None,
|
cert=None,
|
||||||
auth=management.get("auth"),
|
auth=management.get("auth"),
|
||||||
is_management=True,
|
is_management=True,
|
||||||
|
acme_home=str(PROJECT_DIR / "data" / "acme"),
|
||||||
|
certs_dir=str(PROJECT_DIR / "data" / "certs"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -13,8 +13,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
PROJECT_DIR = Path("/home/wall/vacuum-wall")
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
CONFIG_PATH = str(PROJECT_DIR / "data" / "wireguard" / "config.json")
|
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json")
|
||||||
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
|
||||||
WG_QUICK_BIN = "wg-quick"
|
WG_QUICK_BIN = "wg-quick"
|
||||||
WG_BIN = "wg"
|
WG_BIN = "wg"
|
||||||
@@ -141,7 +141,7 @@ def apply() -> None:
|
|||||||
conf_text = generate_conf(cfg)
|
conf_text = generate_conf(cfg)
|
||||||
save_config(cfg) # ensure latest state persisted
|
save_config(cfg) # ensure latest state persisted
|
||||||
|
|
||||||
local_dir = Path("/home/wall/vacuum-wall/data/wireguard")
|
local_dir = PROJECT_DIR / "data" / "wireguard"
|
||||||
local_dir.mkdir(parents=True, exist_ok=True)
|
local_dir.mkdir(parents=True, exist_ok=True)
|
||||||
local_tmp = local_dir / "wg0.conf.tmp"
|
local_tmp = local_dir / "wg0.conf.tmp"
|
||||||
with open(local_tmp, "w") as f:
|
with open(local_tmp, "w") as f:
|
||||||
|
|||||||
@@ -21,21 +21,21 @@ server {
|
|||||||
{% if cert.type == "acme" %}
|
{% if cert.type == "acme" %}
|
||||||
# Certificate managed by acme.sh
|
# Certificate managed by acme.sh
|
||||||
{% if cert.email %} # ACME contact: {{ cert.email }}
|
{% if cert.email %} # ACME contact: {{ cert.email }}
|
||||||
{% endif %} ssl_certificate /home/vacuum-wall/.acme.sh/{{ domain }}/fullchain.cer;
|
{% endif %} ssl_certificate {{ acme_home }}/{{ domain }}/fullchain.cer;
|
||||||
ssl_certificate_key /home/vacuum-wall/.acme.sh/{{ domain }}/{{ domain }}.key;
|
ssl_certificate_key {{ acme_home }}/{{ domain }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% elif cert.type == "file" %}
|
{% elif cert.type == "file" %}
|
||||||
ssl_certificate {{ cert.path }};
|
ssl_certificate {{ cert.path }};
|
||||||
ssl_certificate_key {{ cert.key_path }};
|
ssl_certificate_key {{ cert.key_path }};
|
||||||
|
|
||||||
{% elif cert.type == "selfsigned" %}
|
{% elif cert.type == "selfsigned" %}
|
||||||
ssl_certificate /home/wall/vacuum-wall/data/certs/{{ domain }}.crt;
|
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
|
||||||
ssl_certificate_key /home/wall/vacuum-wall/data/certs/{{ domain }}.key;
|
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif is_management %}
|
{% elif is_management %}
|
||||||
ssl_certificate /home/wall/vacuum-wall/data/certs/{{ domain }}.crt;
|
ssl_certificate {{ certs_dir }}/{{ domain }}.crt;
|
||||||
ssl_certificate_key /home/wall/vacuum-wall/data/certs/{{ domain }}.key;
|
ssl_certificate_key {{ certs_dir }}/{{ domain }}.key;
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
# Shared SSL settings
|
# Shared SSL settings
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
# Defaults directives
|
# Defaults directives
|
||||||
Defaults:vacuum-wall !requiretty
|
Defaults:{{ USER_NAME }} !requiretty
|
||||||
Defaults:vacuum-wall secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
Defaults:{{ USER_NAME }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
|
||||||
# Firewall management
|
# Firewall management
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/firewall-cmd *
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/firewall-cmd *
|
||||||
|
|
||||||
# Nginx management
|
# Nginx management
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -s reload
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/sbin/nginx -t
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/conf.d/
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/nginx/snippets/
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/conf.d/vacuum-wall.conf
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/rm /etc/nginx/snippets/vacuum-wall-ssl.conf
|
||||||
|
|
||||||
# Dnsmasq management
|
# Dnsmasq management
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl reload dnsmasq
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/systemctl is-active dnsmasq
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/dnsmasq.d/
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/lib/dnsmasq/dnsmasq.leases
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/tee /etc/dnsmasq.d/vacuum-wall.conf
|
||||||
|
|
||||||
# WireGuard management
|
# WireGuard management
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg-quick *
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg-quick *
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/wg *
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/wg *
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cp -- * /etc/wireguard/
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/journalctl --unit=* -n *
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/cat /var/log/nginx/*
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/dnsmasq.d
|
||||||
vacuum-wall ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/wireguard
|
{{ USER_NAME }} ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/wireguard
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ Description=Vacuum Wall ACME Certificate Renewal
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=vacuum-wall
|
User={{ USER_NAME }}
|
||||||
WorkingDirectory=/home/wall/vacuum-wall
|
WorkingDirectory={{ PROJECT_DIR }}
|
||||||
ExecStart=/usr/local/bin/acme.sh --cron --home /home/vacuum-wall/.acme.sh
|
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||||
|
Environment=HOME={{ PROJECT_DIR }}
|
||||||
|
ExecStart=/usr/local/bin/acme.sh --cron --home {{ ACME_HOME }}
|
||||||
|
|||||||
@@ -6,20 +6,21 @@ Wants=firewalld.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=vacuum-wall
|
User={{ USER_NAME }}
|
||||||
Group=vacuum-wall
|
Group={{ USER_NAME }}
|
||||||
WorkingDirectory=/home/wall/vacuum-wall
|
WorkingDirectory={{ PROJECT_DIR }}
|
||||||
ExecStart=/home/wall/vacuum-wall/.venv/bin/python webui/server.py
|
ExecStart={{ PROJECT_DIR }}/.venv/bin/python webui/server.py
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin
|
Environment=PATH=/usr/local/bin:/usr/bin
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||||
|
Environment=HOME={{ PROJECT_DIR }}
|
||||||
|
|
||||||
# Security hardening
|
# Security hardening
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=read-only
|
ReadWritePaths={{ PROJECT_DIR }}/config {{ PROJECT_DIR }}/data /tmp
|
||||||
ReadWritePaths=/home/wall/vacuum-wall/data /tmp
|
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
@@ -33,7 +34,8 @@ LockPersonality=yes
|
|||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
PrivateDevices=yes
|
PrivateDevices=yes
|
||||||
|
|
||||||
# Network - only loopback (nginx proxies to us)
|
ProtectHome=read-only
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
IPAddressDeny=all
|
IPAddressDeny=all
|
||||||
IPAddressAllow=localhost
|
IPAddressAllow=localhost
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|||||||
+40
-44
@@ -1,4 +1,3 @@
|
|||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -6,31 +5,28 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|
||||||
from lib import acme
|
from lib import acme
|
||||||
|
|
||||||
|
|
||||||
class TestFindAcme:
|
class TestFindAcme:
|
||||||
@patch("lib.acme.shutil.which")
|
@patch("lib.acme.shutil.which")
|
||||||
@patch("lib.acme.Path.home")
|
@patch("lib.acme._ACME_HOME")
|
||||||
def test_finds_in_home(self, mock_home, mock_which):
|
def test_finds_in_acme_home(self, mock_acme_home, mock_which):
|
||||||
mock_home.return_value = Path("/tmp/fakehome")
|
mock_acme_home = Path("/tmp/fake-acme-home")
|
||||||
acme_path = mock_home.return_value / ".acme.sh" / "acme.sh"
|
mock_acme_home.mkdir(parents=True, exist_ok=True)
|
||||||
acme_path.parent.mkdir(parents=True, exist_ok=True)
|
acme_bin = mock_acme_home / "acme.sh"
|
||||||
acme_path.write_text("#!/bin/sh\n")
|
acme_bin.write_text("#!/bin/sh\n")
|
||||||
acme_path.chmod(0o755)
|
acme_bin.chmod(0o755)
|
||||||
try:
|
|
||||||
|
with patch.object(acme, "_ACME_HOME", mock_acme_home):
|
||||||
result = acme._find_acme()
|
result = acme._find_acme()
|
||||||
assert "acme.sh" in result
|
assert "acme.sh" in result
|
||||||
finally:
|
|
||||||
acme_path.unlink()
|
|
||||||
|
|
||||||
@patch("lib.acme.shutil.which")
|
acme_bin.unlink()
|
||||||
@patch("lib.acme.Path.home")
|
|
||||||
def test_raises_when_not_found(self, mock_home, mock_which):
|
@patch("lib.acme._find_acme")
|
||||||
mock_home.return_value = Path("/tmp/nonexistent-acme-dir")
|
def test_raises_when_not_found(self, mock_find):
|
||||||
mock_which.return_value = None
|
mock_find.side_effect = FileNotFoundError()
|
||||||
with pytest.raises(FileNotFoundError):
|
with pytest.raises(FileNotFoundError):
|
||||||
acme._find_acme()
|
acme._find_acme()
|
||||||
|
|
||||||
@@ -112,32 +108,30 @@ class TestDaysUntil:
|
|||||||
|
|
||||||
class TestGetEmail:
|
class TestGetEmail:
|
||||||
def test_returns_empty_when_no_account_conf(self):
|
def test_returns_empty_when_no_account_conf(self):
|
||||||
with patch("lib.acme.Path.home") as mock_home:
|
with patch.object(acme, "_ACME_HOME", Path("/tmp/no-acme-email")):
|
||||||
mock_home.return_value = Path("/tmp/no-acme-email")
|
|
||||||
result = acme.get_email()
|
result = acme.get_email()
|
||||||
assert result == ""
|
assert result == ""
|
||||||
|
|
||||||
def test_parses_email_from_account_conf(self):
|
def test_parses_email_from_account_conf(self):
|
||||||
tmpdir = tempfile.mkdtemp()
|
tmpdir = tempfile.mkdtemp()
|
||||||
acme_dir = Path(tmpdir) / ".acme.sh"
|
acme_dir = Path(tmpdir) / "data" / "acme"
|
||||||
acme_dir.mkdir(exist_ok=True)
|
acme_dir.mkdir(parents=True, exist_ok=True)
|
||||||
conf = acme_dir / "account.conf"
|
conf = acme_dir / "account.conf"
|
||||||
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
|
conf.write_text("ACME_LEEMAIL='test@example.com'\n")
|
||||||
|
|
||||||
with patch("lib.acme.Path.home", return_value=Path(tmpdir)):
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
||||||
result = acme.get_email()
|
result = acme.get_email()
|
||||||
assert result == "test@example.com"
|
assert result == "test@example.com"
|
||||||
|
|
||||||
|
|
||||||
class TestGetCertPaths:
|
class TestGetCertPaths:
|
||||||
@patch("lib.acme.Path.home")
|
def test_returns_paths(self, tmp_path):
|
||||||
def test_returns_paths(self, mock_home):
|
with patch.object(acme, "_ACME_HOME", tmp_path / "data" / "acme"):
|
||||||
mock_home.return_value = Path("/home/user")
|
paths = acme.get_cert_paths("example.com")
|
||||||
paths = acme.get_cert_paths("example.com")
|
assert paths["cert"].endswith("example.com/example.com.cert")
|
||||||
assert paths["cert"].endswith("example.com/example.com.cert")
|
assert paths["key"].endswith("example.com/example.com.key")
|
||||||
assert paths["key"].endswith("example.com/example.com.key")
|
assert paths["ca"].endswith("example.com/ca.cer")
|
||||||
assert paths["ca"].endswith("example.com/ca.cer")
|
assert paths["fullchain"].endswith("example.com/fullchain.cer")
|
||||||
assert paths["fullchain"].endswith("example.com/fullchain.cer")
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeployHook:
|
class TestDeployHook:
|
||||||
@@ -153,20 +147,22 @@ class TestDeployHook:
|
|||||||
|
|
||||||
|
|
||||||
class TestHasAutoRenew:
|
class TestHasAutoRenew:
|
||||||
@patch("lib.acme.Path.home")
|
def test_true_when_conf_exists(self, tmp_path):
|
||||||
def test_true_when_conf_exists(self, mock_home):
|
acme_dir = tmp_path / "data" / "acme"
|
||||||
tmpdir = tempfile.mkdtemp()
|
acme_dir.mkdir(parents=True)
|
||||||
acme_dir = Path(tmpdir) / ".acme.sh"
|
|
||||||
acme_dir.mkdir()
|
|
||||||
conf = acme_dir / "example.com.conf"
|
conf = acme_dir / "example.com.conf"
|
||||||
conf.touch()
|
conf.touch()
|
||||||
mock_home.return_value = Path(tmpdir)
|
|
||||||
result = acme._has_auto_renew("example.com")
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
||||||
assert result is True
|
result = acme._has_auto_renew("example.com")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
conf.unlink()
|
conf.unlink()
|
||||||
|
|
||||||
@patch("lib.acme.Path.home")
|
def test_false_when_conf_missing(self, tmp_path):
|
||||||
def test_false_when_conf_missing(self, mock_home):
|
acme_dir = tmp_path / "data" / "acme"
|
||||||
mock_home.return_value = Path(tempfile.mkdtemp())
|
acme_dir.mkdir(parents=True)
|
||||||
result = acme._has_auto_renew("nonexistent.com")
|
|
||||||
assert result is False
|
with patch.object(acme, "_ACME_HOME", acme_dir):
|
||||||
|
result = acme._has_auto_renew("nonexistent.com")
|
||||||
|
assert result is False
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import sys
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|
||||||
from webui.api.certs import bp as certs_bp
|
from webui.api.certs import bp as certs_bp
|
||||||
from webui.api.dhcp import bp as dhcp_bp
|
from webui.api.dhcp import bp as dhcp_bp
|
||||||
from webui.api.firewall import bp
|
from webui.api.firewall import bp
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import sys
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|
||||||
from lib import dnsmasq
|
from lib import dnsmasq
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def temp_data_dir(tmp_path):
|
def temp_data_dir(tmp_path):
|
||||||
|
original_config_dir = dnsmasq.CONFIG_DIR
|
||||||
original = dnsmasq.DATA_DIR
|
original = dnsmasq.DATA_DIR
|
||||||
original_config = dnsmasq.CONFIG_PATH
|
original_config = dnsmasq.CONFIG_PATH
|
||||||
|
original_fragments = dnsmasq.FRAGMENTS_DIR
|
||||||
|
dnsmasq.CONFIG_DIR = tmp_path / "dnsmasq"
|
||||||
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
|
dnsmasq.DATA_DIR = tmp_path / "dnsmasq"
|
||||||
dnsmasq.CONFIG_PATH = dnsmasq.DATA_DIR / "config.json"
|
dnsmasq.CONFIG_PATH = dnsmasq.CONFIG_DIR / "config.json"
|
||||||
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
|
dnsmasq.FRAGMENTS_DIR = dnsmasq.DATA_DIR / "fragments"
|
||||||
|
dnsmasq.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
dnsmasq.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
dnsmasq.FRAGMENTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
yield tmp_path
|
yield tmp_path
|
||||||
|
dnsmasq.CONFIG_DIR = original_config_dir
|
||||||
dnsmasq.DATA_DIR = original
|
dnsmasq.DATA_DIR = original
|
||||||
dnsmasq.CONFIG_PATH = original_config
|
dnsmasq.CONFIG_PATH = original_config
|
||||||
|
dnsmasq.FRAGMENTS_DIR = original_fragments
|
||||||
|
|
||||||
|
|
||||||
class TestDeepMerge:
|
class TestDeepMerge:
|
||||||
|
|||||||
+6
-3
@@ -1,11 +1,8 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|
||||||
from lib import nginx
|
from lib import nginx
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +22,10 @@ def temp_data_dir(tmp_path):
|
|||||||
original_htpasswd = nginx.HTPASSWD_FILE
|
original_htpasswd = nginx.HTPASSWD_FILE
|
||||||
original_ssl_snippet = nginx.SSL_SNIPPET
|
original_ssl_snippet = nginx.SSL_SNIPPET
|
||||||
original_include = nginx.INCLUDE_FILE
|
original_include = nginx.INCLUDE_FILE
|
||||||
|
original_config_dir = nginx.CONFIG_DIR
|
||||||
|
original_data_dir = nginx.DATA_DIR
|
||||||
|
|
||||||
|
nginx.CONFIG_DIR = tmp_path / "nginx"
|
||||||
nginx.DATA_DIR = tmp_path / "nginx"
|
nginx.DATA_DIR = tmp_path / "nginx"
|
||||||
nginx.SITES_DIR = tmp_path / "nginx" / "sites-enabled"
|
nginx.SITES_DIR = tmp_path / "nginx" / "sites-enabled"
|
||||||
nginx.CONFIG_FILE = tmp_path / "nginx" / "config.json"
|
nginx.CONFIG_FILE = tmp_path / "nginx" / "config.json"
|
||||||
@@ -33,6 +33,7 @@ def temp_data_dir(tmp_path):
|
|||||||
nginx.SSL_SNIPPET = tmp_path / "ssl_snippet.conf"
|
nginx.SSL_SNIPPET = tmp_path / "ssl_snippet.conf"
|
||||||
nginx.INCLUDE_FILE = tmp_path / "include.conf"
|
nginx.INCLUDE_FILE = tmp_path / "include.conf"
|
||||||
|
|
||||||
|
nginx.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
nginx.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
nginx.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
nginx.SITES_DIR.mkdir(parents=True, exist_ok=True)
|
nginx.SITES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ def temp_data_dir(tmp_path):
|
|||||||
nginx.HTPASSWD_FILE = original_htpasswd
|
nginx.HTPASSWD_FILE = original_htpasswd
|
||||||
nginx.SSL_SNIPPET = original_ssl_snippet
|
nginx.SSL_SNIPPET = original_ssl_snippet
|
||||||
nginx.INCLUDE_FILE = original_include
|
nginx.INCLUDE_FILE = original_include
|
||||||
|
nginx.CONFIG_DIR = original_config_dir
|
||||||
|
nginx.DATA_DIR = original_data_dir
|
||||||
|
|
||||||
|
|
||||||
class TestGetConfig:
|
class TestGetConfig:
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import sys
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client():
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, "/home/wall/vacuum-wall")
|
|
||||||
|
|
||||||
from lib import wireguard
|
from lib import wireguard
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user