Initial import of HortusFox Nix flake packaging
Packages HortusFox from source via php.buildComposerProject and provides a NixOS module (services.hortusfox-web) to deploy it as a full service (mariadb + phpfpm + nginx/caddy + ACME + cron timers). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services."hortusfox-web";
|
||||
|
||||
# The app's own canonical URL (used for login/session redirects and cron
|
||||
# requests) must track proxy.ssl, or a plain-HTTP deployment (proxy.ssl =
|
||||
# false, e.g. the test VM) redirects users to an https:// URL nothing is
|
||||
# listening on.
|
||||
scheme = if cfg.proxy.ssl then "https" else "http";
|
||||
portSuffix = if cfg.port == null then "" else ":${toString cfg.port}";
|
||||
appUrl = "${scheme}://${cfg.domain}${portSuffix}";
|
||||
|
||||
# PHP with the extensions HortusFox needs (GD for photo resize, pdo_mysql, etc.)
|
||||
phpPackage = cfg.phpPackage.buildEnv {
|
||||
extensions = ({ enabled, all }:
|
||||
enabled ++ (with all; [ pdo_mysql mysqli gd exif fileinfo mbstring curl zip openssl ]));
|
||||
};
|
||||
|
||||
package = cfg.package;
|
||||
appRoot = "${package}/share/php/hortusfox-web";
|
||||
|
||||
runDir = "/run/hortusfox/www"; # ephemeral symlink tree (tmpfs)
|
||||
stateDir = "/var/lib/hortusfox"; # persistent writable data
|
||||
|
||||
# Writable subdirs that must survive reboots.
|
||||
writableDirs = [
|
||||
"cache" "app/logs"
|
||||
"public/img" "public/attachments" "public/backup" "public/themes"
|
||||
];
|
||||
|
||||
# Intermediate parent dirs of the above (e.g. "app", "public") that need
|
||||
# their own explicit tmpfiles rule too: systemd-tmpfiles auto-vivifies a
|
||||
# missing parent when processing a nested `d` line, but does so with
|
||||
# default (root:root 0755) ownership and does NOT reliably go on to
|
||||
# create the requested leaf itself -- so a nested writableDirs entry
|
||||
# whose parent has no rule of its own silently never gets created.
|
||||
writableDirParents = lib.unique (builtins.filter (p: p != null) (map
|
||||
(d:
|
||||
let parts = lib.splitString "/" d;
|
||||
in if builtins.length parts > 1 then lib.concatStringsSep "/" (lib.init parts) else null)
|
||||
writableDirs));
|
||||
|
||||
# Render one KEY=VALUE .env line. Booleans/ints/floats are left bare so
|
||||
# Asatru's datatype auto-detection picks them up as such rather than string.
|
||||
mkEnvLine = name: value:
|
||||
let
|
||||
rendered =
|
||||
if builtins.isBool value then (if value then "true" else "false")
|
||||
else if builtins.isInt value then toString value
|
||||
else if builtins.isFloat value then toString value
|
||||
else ''"${toString value}"'';
|
||||
in
|
||||
"${name}=${rendered}";
|
||||
|
||||
# Base .env content; `settings` can override or extend any of this. Beyond
|
||||
# our own curated options (domain/timezone/db/smtp), this also seeds every
|
||||
# other key from upstream's .env.example that app code reads directly from
|
||||
# $_ENV without an isset() guard -- e.g. APP_BASEDIR, omitted here before,
|
||||
# produced a "PHP Warning: Undefined array key" on every single request.
|
||||
envVars = {
|
||||
APP_NAME = "HortusFox";
|
||||
APP_VERSION = package.version or "unknown";
|
||||
APP_AUTHOR = "Daniel Brendel";
|
||||
APP_CONTACT = "dbrendel1988@gmail.com";
|
||||
APP_DEBUG = false;
|
||||
APP_BASEDIR = "";
|
||||
APP_LANG = "en";
|
||||
APP_WORKSPACE = "My workspace";
|
||||
APP_OVERDUETASK_HOURS = 10;
|
||||
APP_CRONJOB_MAILLIMIT = 5;
|
||||
APP_GITHUB_URL = "https://github.com/danielbrendel/hortusfox-web";
|
||||
APP_SERVICE_URL = "https://www.hortusfox.com";
|
||||
APP_GITHUB_SPONSOR = "https://github.com/sponsors/danielbrendel";
|
||||
APP_DONATION_KOFI = "https://ko-fi.com/danielbrendel";
|
||||
APP_URL = appUrl;
|
||||
APP_TIMEZONE = cfg.timezone;
|
||||
|
||||
SESSION_ENABLE = true;
|
||||
SESSION_DURATION = 32532000;
|
||||
# SESSION_NAME deliberately omitted: the app only uses it behind an
|
||||
# isset() guard, so "unset" (fall back to PHP's own default) is safe.
|
||||
|
||||
PHOTO_RESIZE_FACTOR_DEFAULT = 1.0;
|
||||
PHOTO_RESIZE_FACTOR_1 = 0.5;
|
||||
PHOTO_RESIZE_FACTOR_2 = 0.4;
|
||||
PHOTO_RESIZE_FACTOR_3 = 0.4;
|
||||
PHOTO_RESIZE_FACTOR_4 = 0.3;
|
||||
PHOTO_RESIZE_FACTOR_5 = 0.2;
|
||||
|
||||
DB_ENABLE = true;
|
||||
DB_DRIVER = "mysql";
|
||||
DB_HOST = "localhost";
|
||||
DB_PORT = 3306;
|
||||
DB_DATABASE = cfg.database.name;
|
||||
DB_USER = cfg.user;
|
||||
DB_PASSWORD = "";
|
||||
DB_CHARSET = "utf8mb4";
|
||||
|
||||
SMTP_AUTH = true;
|
||||
SMTP_FROMNAME = cfg.smtp.fromName;
|
||||
SMTP_FROMADDRESS = cfg.smtp.fromAddress;
|
||||
SMTP_HOST = cfg.smtp.host;
|
||||
SMTP_PORT = cfg.smtp.port;
|
||||
SMTP_USERNAME = cfg.smtp.username;
|
||||
SMTP_ENCRYPTION = cfg.smtp.encryption;
|
||||
|
||||
LOG_ENABLE = true;
|
||||
} // cfg.settings;
|
||||
|
||||
envFile = pkgs.writeText "hortusfox.env" (
|
||||
lib.concatStringsSep "\n" (lib.mapAttrsToList mkEnvLine envVars) + "\n"
|
||||
);
|
||||
|
||||
# curl'd cron endpoints -> (name, path, calendar spec)
|
||||
cronjobs = {
|
||||
"tasks-overdue" = { path = "/cronjob/tasks/overdue"; when = "*-*-* 06,18:00:00"; };
|
||||
"tasks-tomorrow" = { path = "/cronjob/tasks/tomorrow"; when = "*-*-* 06,18:00:00"; };
|
||||
"tasks-recurring" = { path = "/cronjob/tasks/recurring"; when = "hourly"; };
|
||||
"calendar-reminder"= { path = "/cronjob/calendar/reminder";when = "*-*-* 07:00:00"; };
|
||||
"backup-auto" = { path = "/cronjob/backup/auto"; when = "*-*-* 03:00:00"; };
|
||||
};
|
||||
in {
|
||||
options.services."hortusfox-web" = {
|
||||
enable = lib.mkEnableOption "HortusFox plant management system";
|
||||
|
||||
domain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "localhost";
|
||||
example = "plants.example.com";
|
||||
description = "FQDN served over HTTPS (used for the nginx vhost + ACME).";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.port;
|
||||
default = null;
|
||||
example = 8080;
|
||||
description = ''
|
||||
Port to append to the app's own canonical URL (APP_URL, used for
|
||||
login/session redirects and cron requests). Leave unset for a normal
|
||||
deployment reachable on the standard 80/443. Set this when the proxy
|
||||
is only reachable through a non-standard or forwarded port -- e.g.
|
||||
the QEMU test VM's hostfwd (see justfile) -- otherwise redirects
|
||||
drop the port and land somewhere unreachable. This does not change
|
||||
what port nginx/caddy actually listens on.
|
||||
'';
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.callPackage ../package.nix { };
|
||||
description = "HortusFox package to deploy.";
|
||||
};
|
||||
|
||||
proxy.server = lib.mkOption {
|
||||
type = lib.types.enum [ "nginx" "caddy" ];
|
||||
default = "nginx";
|
||||
description = "Reverse proxy/webserver to front phpfpm.";
|
||||
};
|
||||
|
||||
proxy.ssl = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Whether to force HTTPS and request an ACME certificate for the
|
||||
vhost. Disable for local/test deployments that have no real domain
|
||||
or internet access to reach Let's Encrypt.
|
||||
'';
|
||||
};
|
||||
|
||||
phpPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.php83;
|
||||
description = "Base PHP used to build the phpfpm environment.";
|
||||
};
|
||||
|
||||
user = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
|
||||
group = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
|
||||
|
||||
timezone = lib.mkOption { type = lib.types.str; default = "UTC"; };
|
||||
|
||||
database.name = lib.mkOption { type = lib.types.str; default = "hortusfox"; };
|
||||
|
||||
secretFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
example = "/run/agenix/hortusfox.env";
|
||||
description = ''
|
||||
File with secret KEY=VALUE lines (APP_KEY, SMTP_PASSWORD, API keys,
|
||||
...) appended verbatim to the generated .env at runtime. Keep it out
|
||||
of the Nix store in production (e.g. agenix/sops-nix); a store path
|
||||
is fine for throwaway test configs.
|
||||
'';
|
||||
};
|
||||
|
||||
adminAccount.passwordFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = ''
|
||||
File containing the initial admin account's password (plaintext,
|
||||
no trailing newline needed). HortusFox has no CLI/seed hook of its
|
||||
own for this -- its only "create user" flow lives in the admin
|
||||
panel, which itself requires already being logged in as an admin,
|
||||
and `migrate:fresh` creates no user rows at all. Without this set,
|
||||
a fresh deployment has no way to log in at all. Only used on first
|
||||
boot (guarded by the same stateDir/.installed marker as
|
||||
migrate:fresh); changing it afterwards has no effect on an
|
||||
existing account.
|
||||
|
||||
Seeds a user named "admin", email "admin@''${domain}", bcrypt-hashed
|
||||
via `password_hash(..., PASSWORD_BCRYPT)` (matching UserModel's own
|
||||
hashing) and inserted directly into the UserModel table with the
|
||||
admin flag set, bypassing the app's normal two-step
|
||||
create-then-promote admin-panel flow.
|
||||
'';
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.oneOf [ lib.types.str lib.types.int lib.types.bool ]);
|
||||
default = { };
|
||||
example = { APP_DEBUG = true; APP_OVERDUETASK_HOURS = 6; };
|
||||
description = ''
|
||||
Extra/overriding .env entries, merged over the generated defaults.
|
||||
Note that the `php asatru` CLI (used here for migrations) only runs
|
||||
when APP_DEBUG is true, so migrations silently no-op unless that's
|
||||
set here or in secretFile.
|
||||
'';
|
||||
};
|
||||
|
||||
smtp = {
|
||||
host = lib.mkOption { type = lib.types.str; default = ""; };
|
||||
port = lib.mkOption { type = lib.types.port; default = 587; };
|
||||
username = lib.mkOption { type = lib.types.str; default = ""; };
|
||||
fromName = lib.mkOption { type = lib.types.str; default = "HortusFox"; };
|
||||
fromAddress = lib.mkOption { type = lib.types.str; default = "hortusfox@localhost"; };
|
||||
encryption = lib.mkOption { type = lib.types.enum [ "tls" "ssl" "" ]; default = "tls"; };
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = stateDir;
|
||||
};
|
||||
users.groups.${cfg.group} = { };
|
||||
|
||||
# --- Database: unix-socket auth, no password needed ------------------
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb_114;
|
||||
ensureDatabases = [ cfg.database.name ];
|
||||
ensureUsers = [{
|
||||
name = cfg.user;
|
||||
ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
|
||||
}];
|
||||
};
|
||||
|
||||
# --- Persistent writable dirs ---------------------------------------
|
||||
# World-readable (0755), same reasoning as RuntimeDirectoryMode above:
|
||||
# some of these (public/*) are nginx's webroot, and nginx (a different
|
||||
# user) needs to traverse them directly. Nothing secret lives at the
|
||||
# directory level -- .env (the one real secret under stateDir) is a
|
||||
# separate file installed at mode 600, unaffected by this.
|
||||
systemd.tmpfiles.rules =
|
||||
[ "d ${stateDir} 0755 ${cfg.user} ${cfg.group} - -" ]
|
||||
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirParents
|
||||
++ map (d: "d ${stateDir}/${d} 0755 ${cfg.user} ${cfg.group} - -") writableDirs;
|
||||
|
||||
# Kept as one attrset: hortusfox-setup/phpfpm-hortusfox plus the
|
||||
# cron-generated services all live under systemd.services, and a
|
||||
# separate full reassignment (as the cron block used to be) would
|
||||
# conflict with these partial-path definitions.
|
||||
systemd.services = {
|
||||
hortusfox-setup = {
|
||||
description = "Prepare HortusFox runtime tree and run migrations";
|
||||
after = [ "mysql.service" "network.target" ];
|
||||
requires = [ "mysql.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
StateDirectory = "hortusfox";
|
||||
# World-readable: nginx (a different user) needs to stat/read
|
||||
# everything under here to serve `public/` directly. Nothing
|
||||
# secret lives in this tree -- .env is a symlink to stateDir/.env,
|
||||
# which stays mode 600 regardless of this.
|
||||
RuntimeDirectory = "hortusfox";
|
||||
RuntimeDirectoryMode = "0755";
|
||||
};
|
||||
|
||||
path = [ phpPackage config.services.mysql.package pkgs.coreutils pkgs.gnused ];
|
||||
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Mirror the read-only store package into a writable-structured
|
||||
# tree with a real (non-symlinked) copy of every file. This
|
||||
# duplicates the app into tmpfs on every boot -- NOT `cp -rs`
|
||||
# (per-file symlinks back into the store): PHP resolves
|
||||
# __DIR__/__FILE__ through symlinks to their real target, so
|
||||
# any symlinked file here would see itself as living in the
|
||||
# read-only store rather than under runDir, breaking the
|
||||
# app's own self-relative path lookups (its .env discovery
|
||||
# included). `cp` carries the store's read-only directory
|
||||
# mode (555) onto the new directories, so reopen them for
|
||||
# writing -- only directories, since we never need to touch
|
||||
# the copied files' own permissions.
|
||||
rm -rf "${runDir}"
|
||||
mkdir -p "${runDir}"
|
||||
cp -r "${appRoot}/." "${runDir}/"
|
||||
find "${runDir}" -type d -exec chmod u+w {} +
|
||||
|
||||
# 2. Swap the writable subdirs for persistent state dirs. Some of
|
||||
# these (public/img, public/themes) ship real default assets
|
||||
# (backgrounds, the default theme) in the package, not just
|
||||
# user-generated content -- seed the persistent copy from the
|
||||
# package on every boot, without ever clobbering what's
|
||||
# already there, before replacing the dir with a symlink to
|
||||
# it. Harmless no-op for dirs that ship nothing but the app's
|
||||
# own .gitignore.
|
||||
${lib.concatMapStringsSep "\n" (d: ''
|
||||
mkdir -p "${stateDir}/${d}"
|
||||
cp -rn "${appRoot}/${d}/." "${stateDir}/${d}/" || true
|
||||
find "${stateDir}/${d}" -type d -exec chmod u+w {} +
|
||||
rm -rf "${runDir}/${d}"
|
||||
ln -sfn "${stateDir}/${d}" "${runDir}/${d}"
|
||||
'') writableDirs}
|
||||
|
||||
# 3. Render .env: static body + secrets from secretFile (if any),
|
||||
# appended verbatim.
|
||||
install -m600 /dev/null "${stateDir}/.env"
|
||||
cat ${envFile} > "${stateDir}/.env"
|
||||
${lib.optionalString (cfg.secretFile != null) ''
|
||||
{
|
||||
echo ""
|
||||
cat "${cfg.secretFile}"
|
||||
} >> "${stateDir}/.env"
|
||||
''}
|
||||
ln -sfn "${stateDir}/.env" "${runDir}/.env"
|
||||
|
||||
# 4. Migrate. First boot builds the schema (destructive fresh); later
|
||||
# boots only upgrade. The marker guards you from nuking your data.
|
||||
cd "${runDir}"
|
||||
if [ ! -e "${stateDir}/.installed" ]; then
|
||||
php asatru migrate:fresh
|
||||
|
||||
# 5. Seed the initial admin account. migrate:fresh creates no
|
||||
# user rows at all, and the app's only "create user" flow
|
||||
# lives behind the admin panel -- without this, a fresh
|
||||
# deployment has no way to log in, ever. Insert directly
|
||||
# (name/email hardcoded, bypassing the app's normal
|
||||
# create-then-promote-to-admin two-step flow) rather than
|
||||
# going through any app-level API, since none exists yet at
|
||||
# this point.
|
||||
${lib.optionalString (cfg.adminAccount.passwordFile != null) ''
|
||||
admin_hash="$(php -r 'echo password_hash($argv[1], PASSWORD_BCRYPT);' "$(cat "${cfg.adminAccount.passwordFile}")")"
|
||||
mariadb "${cfg.database.name}" -e "INSERT INTO UserModel (name, email, password, admin) VALUES ('admin', 'admin@${cfg.domain}', '$admin_hash', 1);"
|
||||
''}
|
||||
|
||||
touch "${stateDir}/.installed"
|
||||
else
|
||||
php asatru migrate:upgrade || true
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
phpfpm-hortusfox = {
|
||||
after = [ "hortusfox-setup.service" ];
|
||||
requires = [ "hortusfox-setup.service" ];
|
||||
};
|
||||
} // (lib.mapAttrs' (name: job:
|
||||
lib.nameValuePair "hortusfox-cron-${name}" {
|
||||
description = "HortusFox cronjob: ${name}";
|
||||
after = [ "nginx.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = cfg.user;
|
||||
ExecStart = "${pkgs.curl}/bin/curl -fsS --max-time 300 "
|
||||
+ "${appUrl}${job.path}";
|
||||
};
|
||||
}) cronjobs);
|
||||
|
||||
systemd.timers = lib.mapAttrs' (name: job:
|
||||
lib.nameValuePair "hortusfox-cron-${name}" {
|
||||
description = "Timer for HortusFox cronjob: ${name}";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = job.when;
|
||||
Persistent = true;
|
||||
};
|
||||
}) cronjobs;
|
||||
|
||||
# --- phpfpm pool ------------------------------------------------------
|
||||
services.phpfpm.pools.hortusfox = {
|
||||
user = cfg.user;
|
||||
group = cfg.group;
|
||||
phpPackage = phpPackage;
|
||||
settings = {
|
||||
"listen.owner" = if cfg.proxy.server == "caddy" then config.services.caddy.user else config.services.nginx.user;
|
||||
"listen.group" = if cfg.proxy.server == "caddy" then config.services.caddy.group else config.services.nginx.group;
|
||||
"pm" = "dynamic";
|
||||
"pm.max_children" = 16;
|
||||
"pm.start_servers" = 2;
|
||||
"pm.min_spare_servers" = 1;
|
||||
"pm.max_spare_servers" = 4;
|
||||
"catch_workers_output" = true;
|
||||
};
|
||||
};
|
||||
|
||||
# --- nginx + ACME ------------------------------------------------------
|
||||
services.nginx = lib.mkIf (cfg.proxy.server == "nginx") {
|
||||
enable = true;
|
||||
recommendedTlsSettings = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
|
||||
virtualHosts.${cfg.domain} = {
|
||||
forceSSL = cfg.proxy.ssl;
|
||||
enableACME = cfg.proxy.ssl;
|
||||
|
||||
root = "${runDir}/public";
|
||||
|
||||
# Asatru front controller: everything that isn't a real file
|
||||
# rewrites to index.php (mirrors the upstream .htaccess).
|
||||
locations."/" = {
|
||||
index = "index.php";
|
||||
tryFiles = "$uri $uri/ /index.php?$query_string";
|
||||
};
|
||||
|
||||
locations."~ \\.php$".extraConfig = ''
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass unix:${config.services.phpfpm.pools.hortusfox.socket};
|
||||
fastcgi_index index.php;
|
||||
include ${config.services.nginx.package}/conf/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
'';
|
||||
|
||||
# Don't let anyone fetch the rendered .env or app internals.
|
||||
locations."~ /\\.".extraConfig = "deny all;";
|
||||
|
||||
extraConfig = ''
|
||||
client_max_body_size 64M;
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# --- caddy ---------------------------------------------------------
|
||||
# `proxy.ssl = false` prefixes the site address with "http://", which
|
||||
# disables Caddy's automatic HTTPS/ACME for this vhost specifically
|
||||
# (rather than needing a separate flag like nginx's forceSSL/enableACME).
|
||||
services.caddy = lib.mkIf (cfg.proxy.server == "caddy") {
|
||||
enable = true;
|
||||
|
||||
virtualHosts.${if cfg.proxy.ssl then cfg.domain else "http://${cfg.domain}"}.extraConfig = ''
|
||||
root * ${runDir}/public
|
||||
encode gzip
|
||||
|
||||
# Don't let anyone fetch the rendered .env or app internals.
|
||||
@dotfiles path /.*
|
||||
respond @dotfiles 403
|
||||
|
||||
request_body {
|
||||
max_size 64MB
|
||||
}
|
||||
|
||||
# php_fastcgi already covers the Asatru front-controller rewrite
|
||||
# (try real file, then index.php) and static file serving.
|
||||
php_fastcgi unix/${config.services.phpfpm.pools.hortusfox.socket}
|
||||
file_server
|
||||
'';
|
||||
};
|
||||
|
||||
# ACME needs an email; set this once globally in your host config:
|
||||
# nginx: security.acme = { acceptTerms = true; defaults.email = "you@example.com"; };
|
||||
# caddy: services.caddy.email = "you@example.com";
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user