Added user counter and analytics to the kst4contest page

This commit is contained in:
Marc Froehlich
2026-09-11 21:03:45 +02:00
parent 4ad1cf71dd
commit b23884bde1
23 changed files with 2863 additions and 7 deletions
+457
View File
@@ -0,0 +1,457 @@
# Server-side website statistics
This directory contains installation examples for the confirmed Ubuntu 24.04
server baseline and privacy-conscious traffic statistics. Nothing here
installs or activates the production service automatically.
The design has two separate outputs:
- private static GoAccess HTML and JSON reports for each registered project
subdomain and for all registered project subdomains combined;
- a small public `visitor-count.json` file for sites which explicitly enable
the counter.
The public number is the sum of daily approximate unique visits since the
configured activation date. GoAccess treats requests with the same IP address,
date and user agent as one visit. That is useful for a rough trend. It is not a
count of people.
## Data flow
```text
eligible page request
|
v
dedicated Nginx analytics log (14 days)
|
v
GoAccess with IP anonymisation
|
+--> private per-site and combined HTML/JSON reports (395 days)
|
v
durable daily counter state --> public visitor-count.json
```
Nginx writes a dedicated, reduced log. For each site, the generator gives the
existing, uncompressed `.1` rotation and then the current log directly to
GoAccess. GoAccess uses its persistent state to skip entries already
processed. The generator does not use an incremental shell pipeline or
decompress older rotations. Reports, database updates and public counter data
are first prepared in a staging directory. GoAccess output is validated
before any published report changes. The last valid report therefore survives
a failed GoAccess run.
The counter state is deliberately separate from the 395-day report database.
Each day is replaced with the latest value reported by GoAccess instead of
being added again. This makes repeated runs idempotent. Values older than 395
days remain in the counter state and continue to contribute to the public
total.
## Files
- `generate-reports.js` validates configuration and state, runs GoAccess and
publishes outputs atomically.
- `sites.example.json` is the registry template.
- `goaccess.conf.template` is rendered per report with a private database path
and the configured GeoIP2 Country database.
- `nginx/` contains the reduced log format, request filters, public endpoint
and protected report-vhost examples.
- `systemd/` contains a hardened oneshot service and hourly timer.
- `logrotate/` retains 14 daily analytics-log rotations.
## Prerequisites
- Node.js 18.19.1 or newer;
- GoAccess with GeoIP2/MMDB support;
- one GeoIP2 **Country** database, not a City database;
- Nginx;
- an unprivileged service account, shown as `hamradio-analytics` in the
examples.
No npm package is required by the generator. GoAccess is the only external
program it starts.
The production compatibility baseline is GoAccess 1.8.1 built with
`--enable-geoip=mmdb` and `--with-openssl`, but without `--with-zlib`. Zlib is
not required for the regular operating mode because it reads only
uncompressed files. The configuration check reports the detected build
features and explicitly accepts this combination.
The confirmed production baseline is Node.js 18.19.1. The generator and tests
must remain compatible with it; upgrading Node.js is not part of this setup.
The service account needs read access to the dedicated analytics logs and
`/var/lib/GeoIP/GeoLite2-Country.mmdb`. It needs write access only to its state,
report and public-output directories. Access is group-based. The setup does
not depend on ACLs or `setfacl`. Nginx receives read access to reports and the
public counter through the `www-data` group, but no write access.
## Installation and permissions
Do not trust Unix modes stored in a ZIP created on Windows. Install every
script, configuration and unit with an explicit owner, group and mode. The
following commands assume that the package has been unpacked into the current
directory. Create the dedicated, unprivileged service account once:
```sh
if ! getent passwd hamradio-analytics >/dev/null; then
sudo useradd --system --user-group --home-dir /nonexistent --no-create-home \
--shell /usr/sbin/nologin hamradio-analytics
fi
```
Then install the generator, configuration and units:
```sh
sudo install -d -o root -g hamradio-analytics -m 0750 \
/opt/hamradioonline-analytics /etc/hamradioonline-analytics
sudo install -o root -g hamradio-analytics -m 0750 \
website/ops/analytics/generate-reports.js \
/opt/hamradioonline-analytics/generate-reports.js
sudo install -o root -g hamradio-analytics -m 0640 \
website/ops/analytics/goaccess.conf.template \
/etc/hamradioonline-analytics/goaccess.conf.template
sudo install -o root -g hamradio-analytics -m 0640 \
website/ops/analytics/sites.example.json \
/etc/hamradioonline-analytics/sites.json
sudo install -o root -g root -m 0644 \
website/ops/analytics/systemd/hamradioonline-analytics.service.example \
/etc/systemd/system/hamradioonline-analytics.service
sudo install -o root -g root -m 0644 \
website/ops/analytics/systemd/hamradioonline-analytics.timer.example \
/etc/systemd/system/hamradioonline-analytics.timer
sudo install -o root -g root -m 0644 \
website/ops/analytics/logrotate/hamradioonline-analytics.example \
/etc/logrotate.d/hamradioonline-analytics
```
Create the writable tree deliberately. The state root is traversable but not
readable by Nginx. Only the report and public branches use the `www-data`
group and the set-group-ID bit:
```sh
sudo install -d -o hamradio-analytics -g hamradio-analytics -m 0711 \
/var/lib/hamradioonline-analytics
sudo install -d -o hamradio-analytics -g hamradio-analytics -m 0750 \
/var/lib/hamradioonline-analytics/db
sudo install -d -o hamradio-analytics -g www-data -m 2750 \
/var/lib/hamradioonline-analytics/reports \
/var/lib/hamradioonline-analytics/reports/combined \
/var/lib/hamradioonline-analytics/reports/kst4contest \
/var/lib/hamradioonline-analytics/public \
/var/lib/hamradioonline-analytics/public/kst4contest
```
Generated HTML and JSON reports use mode `0640`. The public
`visitor-count.json` uses `0644`. Private GoAccess databases and
`public-counter-state.json` remain owned by `hamradio-analytics` and unreadable
by Nginx. The service unit uses `StateDirectoryMode=0711` to retain this
boundary after systemd has prepared the state directory.
Create the analytics log only when it does not already exist. Running
`install /dev/null` unconditionally would empty an existing log:
```sh
if [ ! -e /var/log/nginx/kst4contest-analytics.log ]; then
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
/var/log/nginx/kst4contest-analytics.log
fi
sudo stat -c '%U:%G %a %n' /var/log/nginx/kst4contest-analytics.log
```
The resulting log owner and mode must be
`www-data:hamradio-analytics 640`. Logrotate preserves that ownership. The
generator's configuration check fails clearly if required output directories
are missing or if the executing user cannot read an analytics log or the
Country database.
## Registry
Copy `sites.example.json` outside the checkout and adjust it to the private
server layout. Each site entry contains:
- a stable `id` used for state and the GoAccess database;
- its exact `hostname`;
- the current, uncompressed `analyticsLog` path;
- the `activatedOn` date used by the public counter;
- a `publicCounter` switch;
- the private `reportOutputDirectory`;
- a `publicJsonPath` when the public counter is enabled.
The top-level `combined.reportOutputDirectory` receives the combined report.
Only registered sites are included. The generator rejects
`stats.hamradioonline.de`, so the report host cannot accidentally become part
of the project statistics.
To add another project subdomain later, add one registry entry and one matching
dedicated `access_log` line to its Nginx server block. Do not enable a public
counter unless that site should publish one.
Treat `activatedOn` as persistent data. Once counting has started, changing it
would change the meaning of the total. The generator refuses to combine a new
activation date with existing counter state.
The generator derives the optional `.1` path from `analyticsLog`. It is valid
for `.1` not to exist before the first rotation. Do not enter a rotation or a
compressed `.gz` file in the registry.
## Nginx logging
Install the log-format and filter maps from `nginx/` in the `http` context.
Then add a dedicated analytics `access_log` to every registered project server
block. Keep the existing operational access log unless its replacement has
been reviewed separately. If the operational log is inherited from the
`http` context, repeat its directive in the server block before adding the
analytics log; an `access_log` at a lower level changes inheritance.
Install Nginx snippets explicitly as `root:root` with mode `0644`; do not copy
the modes from the ZIP. Nginx `map` exact-string keys use the path itself, for
example `/visitor-count.json`, without the location-modifier prefix `=`.
```sh
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/analytics-filters.conf.example \
/etc/nginx/snippets/hamradioonline-analytics-filters.conf
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/analytics-log.conf.example \
/etc/nginx/conf.d/hamradioonline-analytics-log.conf
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/public-counter.conf.example \
/etc/nginx/snippets/kst4contest-public-counter.conf
```
Prepare the public-counter include without enabling it in the active site yet.
Likewise, keep the statistics vhost disabled until the certificate bootstrap
step below.
The analytics format contains only:
- server name;
- client IP address;
- timestamp;
- method;
- normalized path without query string;
- protocol;
- status;
- transferred body size;
- user agent.
It does not contain a referrer, query string or authenticated user name. The
filter accepts only eligible page `GET` requests. It excludes the update feed,
public counter, sitemap, robots file, favicons, CSS, JavaScript, images, fonts,
source maps, manual assets and the listed monitoring paths. Known crawler user
agents are rejected before logging. GoAccess applies its own crawler list as a
second layer and treats unknown browsers or operating systems as crawlers.
Review the monitoring-path list against the real server before activation.
When a new health endpoint or asset family is added, update the filter first.
Test the complete Nginx configuration before reloading it:
```sh
sudo nginx -t
```
## GoAccess reports
The template enables IP anonymisation before persistent aggregation, ignores
crawlers, keeps 395 days, and uses a separate persistent database for every
site and the combined report. It leaves only the panels needed here: visits by
day, requested pages, countries, HTTP status codes and virtual hosts. Host,
remote-user, referrer, keyphrase, operating-system, browser and other detailed
panels are disabled.
The Country database is provided through the registry at
`/var/lib/GeoIP/GeoLite2-Country.mmdb`. A file whose name contains `City` is
rejected. Do not replace it with a City database merely because one happens to
be available.
The generator supplies `--persist`, conditionally supplies `--restore`, and
uses an isolated `--db-path` through the rendered template. It passes the
uncompressed `.1` rotation, when present, and then the current log as direct
GoAccess arguments. This chronological order also covers entries appended
shortly before rotation. GoAccess tracks the processed files in its persistent
state and processes only new entries on later runs. The first successful run
creates each database. Later runs copy the last valid database into staging,
restore it and persist the updated result only after all reports have
succeeded.
Logrotate must use `delaycompress`, as shown in the example. This leaves `.1`
uncompressed for one rotation cycle. Older `.gz` files are not part of the
regular hourly run, and importing them is a separate maintenance task outside
this repository workflow. Do not add an unstable decompression pipeline to
the timer service.
If the generator is unavailable for longer than the uncompressed rotation
window, the regular run cannot recover entries found only in older `.gz`
files. Preserve those files under the raw-log retention policy and plan any
necessary historical import separately before resuming normal processing.
## Checking and running
Validate paths, registry values, the template contract and GoAccess
availability without producing reports:
```sh
sudo -u hamradio-analytics /usr/bin/node \
/opt/hamradioonline-analytics/generate-reports.js \
--registry /etc/hamradioonline-analytics/sites.json \
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
--check
```
The check prints the detected GoAccess version and whether GeoIP2/MMDB,
OpenSSL and Zlib build options are present. Missing GeoIP2/MMDB support is a
configuration error with exit code 2. OpenSSL remains informational. Missing
Zlib is supported for this operating mode and does not make the check fail.
The same message explains that only the current log and optional uncompressed
`.1` are processed and that older `.gz` files are not imported.
Exercise the complete GoAccess and output-validation path without changing
published reports, databases or counter state:
```sh
sudo -u hamradio-analytics /usr/bin/node \
/opt/hamradioonline-analytics/generate-reports.js \
--registry /etc/hamradioonline-analytics/sites.json \
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
--dry-run
```
Run without either flag to publish. A lock prevents concurrent production
runs. A dry-run uses a temporary working directory and deliberately neither
needs nor creates the production lock below `/run`. Configuration errors use
exit code 2, an active production lock uses exit code 3, and generation or
publication errors use exit code 1.
Before the first production run, seed any earlier daily values which must be
preserved into `public-counter-state.json`. There is no honest way to recreate
history which is no longer present in the raw logs. Back up this state file: it
is the durable source for public totals older than the detailed retention
window.
## Scheduling and report access
Install the systemd files as local units after adapting paths and permissions.
The timer runs hourly, catches up after downtime and adds a small random delay.
The service has no network access and only the documented read/write paths.
If the installed GoAccess build unexpectedly requires network access, find the
reason before weakening that restriction; local log processing and a local
Country database do not require it.
The statistics vhost serves static files over HTTPS and protects the complete
host with HTTP Basic Authentication. This includes `/`, its redirect to
`/combined/`, and every individual report. Store the password file outside
this repository. Prepare it so only root and the Nginx group can access it:
```sh
sudo install -d -o root -g www-data -m 0750 /etc/nginx/htpasswd
if [ -e /etc/nginx/htpasswd/hamradioonline-analytics ]; then
sudo htpasswd /etc/nginx/htpasswd/hamradioonline-analytics stats-reader
else
sudo htpasswd -c /etc/nginx/htpasswd/hamradioonline-analytics stats-reader
fi
sudo chown root:www-data /etc/nginx/htpasswd/hamradioonline-analytics
sudo chmod 0640 /etc/nginx/htpasswd/hamradioonline-analytics
```
Choose the account name locally and enter the password interactively. Never
store the resulting password hash in this repository or the installation ZIP.
The example opens no GoAccess WebSocket and no additional GoAccess port. Its
own access log is disabled and responses use a private, no-store cache policy.
Do not activate the final HTTPS vhost before its certificate files exist.
First install the temporary HTTP bootstrap without changing the parallel apt
Certbot installation or either renewal timer:
```sh
sudo install -d -o root -g root -m 0755 /var/lib/letsencrypt
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example \
/etc/nginx/sites-available/stats.hamradioonline.de
if [ ! -e /etc/nginx/sites-enabled/stats.hamradioonline.de ] && \
[ ! -L /etc/nginx/sites-enabled/stats.hamradioonline.de ]; then
sudo ln -s /etc/nginx/sites-available/stats.hamradioonline.de \
/etc/nginx/sites-enabled/stats.hamradioonline.de
fi
sudo nginx -t
sudo systemctl reload nginx
sudo /snap/bin/certbot certonly --webroot \
--webroot-path /var/lib/letsencrypt \
-d stats.hamradioonline.de
```
Only after Certbot has created the certificate, replace the bootstrap with the
final vhost. This does not remove HTTP completely. The final file keeps an
IPv4 port 80 block for `/.well-known/acme-challenge/` so the certificate issued
with `--webroot` can be renewed automatically. Every other HTTP request is
redirected permanently to the same URI on HTTPS. The HTTPS block uses the
existing Ubuntu/Certbot TLS files
`/etc/letsencrypt/options-ssl-nginx.conf` and
`/etc/letsencrypt/ssl-dhparams.pem`:
```sh
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/stats-vhost.conf.example \
/etc/nginx/sites-available/stats.hamradioonline.de
sudo nginx -t
sudo systemctl reload nginx
```
After activation, verify both authentication and renewal from the server:
```sh
curl -I https://stats.hamradioonline.de/
curl -I -u stats-reader https://stats.hamradioonline.de/
sudo /snap/bin/certbot renew --dry-run \
--cert-name stats.hamradioonline.de
```
The first HTTPS request must return `401`. The authenticated request must
return the redirect to `/combined/`. Enter the Basic Auth password
interactively; do not put it on the command line. The Certbot dry-run must
complete while the final vhost is active.
The templates listen on IPv4 only. Add an IPv6 listener later, after the AAAA
record for `stats.hamradioonline.de` has been confirmed and tested.
The public counter location serves only `visitor-count.json` through an exact
Nginx `alias`; it does not modify the deployed Eleventy release directory. Its
own access log is disabled. Responses use
`Cache-Control: public, max-age=3600` and
`X-Content-Type-Options: nosniff`. The file contains the schema version, total,
activation date and update time. It contains no IP address, user agent,
hostname or per-day detail.
Use this rollout order:
1. Install the server files with explicit modes. Prepare directories, log
ownership, filters and still-inactive Nginx and systemd configuration.
2. Push the website changes, including the Privacy Policy, and let the existing
deployment cron job publish them. Until the JSON endpoint exists, the
visitor count remains hidden automatically.
3. Verify the published Privacy Policy. Only then activate analytics logging,
the report generator and timer, the public counter location and the
protected statistics vhost.
4. Run `nginx -t` before every Nginx reload and perform the real `--check` and
`--dry-run` on the server before the first production generation.
A short period in which the Privacy Policy is already visible but logging is
not yet active is acceptable. Starting analytics logging before publishing the
updated policy is not.
## Retention and recovery
- Dedicated analytics raw logs: 14 days through the Logrotate example.
- Anonymised detailed GoAccess aggregates: rolling 395 days.
- Public daily counter values: retained from activation onward.
Back up the counter state and, if fast report recovery matters, the GoAccess
database directories. Reports themselves are derived output. To recover, stop
the timer, restore the state and database directories with their ownership,
run `--check`, then run `--dry-run` before publishing again.
The repository contains no password, password hash, MaxMind download key,
server IP address, TLS private key or private backup destination. Keep it that
way.
+874
View File
@@ -0,0 +1,874 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const STATE_SCHEMA_VERSION = 1;
const PUBLIC_SCHEMA_VERSION = 1;
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
const RESERVED_STATS_HOST = "stats.hamradioonline.de";
class ConfigurationError extends Error {
constructor(message) {
super(message);
this.name = "ConfigurationError";
this.exitCode = 2;
}
}
class LockError extends Error {
constructor(message) {
super(message);
this.name = "LockError";
this.exitCode = 3;
}
}
function readJson(filePath, label) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
throw new ConfigurationError(`${label} is not valid JSON: ${error.message}`);
}
return parsed;
}
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function requireAbsolutePath(value, label) {
if (typeof value !== "string" || /[\r\n\0]/.test(value) || !path.isAbsolute(value)) {
throw new ConfigurationError(`${label} must be an absolute path`);
}
return path.normalize(value);
}
function isWithin(parent, candidate) {
const relative = path.relative(parent, candidate);
return relative !== ""
&& relative !== ".."
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative);
}
function validateRegistry(registry) {
if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
throw new ConfigurationError("registry must be an object");
}
if (registry.schemaVersion !== 1) {
throw new ConfigurationError("registry schemaVersion must be 1");
}
if (!Array.isArray(registry.sites) || registry.sites.length === 0) {
throw new ConfigurationError("registry.sites must contain at least one site");
}
const stateDirectory = requireAbsolutePath(
registry.stateDirectory,
"registry.stateDirectory"
);
const counterStatePath = requireAbsolutePath(
registry.counterStatePath,
"registry.counterStatePath"
);
if (!isWithin(stateDirectory, counterStatePath)) {
throw new ConfigurationError("counterStatePath must be below stateDirectory");
}
const ids = new Set();
const hostnames = new Set();
const analyticsLogPaths = new Set();
const outputDirectories = new Set();
const publicJsonPaths = new Set();
const sites = registry.sites.map((site, index) => {
const label = `registry.sites[${index}]`;
if (!site || typeof site !== "object" || Array.isArray(site)) {
throw new ConfigurationError(`${label} must be an object`);
}
if (typeof site.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(site.id)) {
throw new ConfigurationError(`${label}.id is invalid`);
}
if (site.id === "combined") {
throw new ConfigurationError(`${label}.id is reserved`);
}
if (ids.has(site.id)) {
throw new ConfigurationError(`duplicate site id: ${site.id}`);
}
ids.add(site.id);
if (typeof site.hostname !== "string"
|| !/^[a-z0-9.-]+$/.test(site.hostname)
|| site.hostname.includes("..")) {
throw new ConfigurationError(`${label}.hostname is invalid`);
}
const hostname = site.hostname.toLowerCase();
if (hostname === RESERVED_STATS_HOST) {
throw new ConfigurationError(`${RESERVED_STATS_HOST} must not be registered`);
}
if (hostnames.has(hostname)) {
throw new ConfigurationError(`duplicate hostname: ${hostname}`);
}
hostnames.add(hostname);
const activatedOn = parseIsoDate(site.activatedOn);
if (!activatedOn) {
throw new ConfigurationError(`${label}.activatedOn must be a valid ISO date`);
}
if (typeof site.publicCounter !== "boolean") {
throw new ConfigurationError(`${label}.publicCounter must be boolean`);
}
const analyticsLog = requireAbsolutePath(
site.analyticsLog,
`${label}.analyticsLog`
);
if (/\.(?:\d+|gz)$/i.test(path.basename(analyticsLog))) {
throw new ConfigurationError(
`${label}.analyticsLog must identify the current uncompressed log`
);
}
if (analyticsLogPaths.has(analyticsLog)) {
throw new ConfigurationError(`analytics log is registered more than once: ${analyticsLog}`);
}
analyticsLogPaths.add(analyticsLog);
const reportOutputDirectory = requireAbsolutePath(
site.reportOutputDirectory,
`${label}.reportOutputDirectory`
);
if (!isWithin(stateDirectory, reportOutputDirectory)) {
throw new ConfigurationError(`${label}.reportOutputDirectory must be below stateDirectory`);
}
if (outputDirectories.has(reportOutputDirectory)) {
throw new ConfigurationError(`duplicate report output directory: ${reportOutputDirectory}`);
}
outputDirectories.add(reportOutputDirectory);
const publicJsonPath = site.publicCounter
? requireAbsolutePath(site.publicJsonPath, `${label}.publicJsonPath`)
: null;
if (publicJsonPath && !isWithin(stateDirectory, publicJsonPath)) {
throw new ConfigurationError(`${label}.publicJsonPath must be below stateDirectory`);
}
if (publicJsonPath === counterStatePath) {
throw new ConfigurationError(`${label}.publicJsonPath conflicts with counterStatePath`);
}
if (publicJsonPath && publicJsonPaths.has(publicJsonPath)) {
throw new ConfigurationError(`duplicate public JSON path: ${publicJsonPath}`);
}
if (publicJsonPath) {
publicJsonPaths.add(publicJsonPath);
}
return {
id: site.id,
hostname,
activatedOn,
publicCounter: site.publicCounter,
analyticsLog,
reportOutputDirectory,
publicJsonPath
};
});
if (!registry.combined || typeof registry.combined !== "object") {
throw new ConfigurationError("registry.combined must be an object");
}
const combinedReportOutputDirectory = requireAbsolutePath(
registry.combined.reportOutputDirectory,
"registry.combined.reportOutputDirectory"
);
if (!isWithin(stateDirectory, combinedReportOutputDirectory)) {
throw new ConfigurationError(
"registry.combined.reportOutputDirectory must be below stateDirectory"
);
}
if (outputDirectories.has(combinedReportOutputDirectory)) {
throw new ConfigurationError(
`duplicate report output directory: ${combinedReportOutputDirectory}`
);
}
return {
schemaVersion: 1,
stateDirectory,
counterStatePath,
lockFile: requireAbsolutePath(registry.lockFile, "registry.lockFile"),
geoIpCountryDatabase: requireAbsolutePath(
registry.geoIpCountryDatabase,
"registry.geoIpCountryDatabase"
),
combined: {
reportOutputDirectory: combinedReportOutputDirectory
},
sites
};
}
function resolveAnalyticsLogs(site) {
const logs = [];
const candidates = [
{ path: `${site.analyticsLog}.1`, required: false },
{ path: site.analyticsLog, required: true }
];
for (const candidate of candidates) {
let stats;
try {
stats = fs.statSync(candidate.path);
} catch (error) {
if (!candidate.required && error.code === "ENOENT") {
continue;
}
throw new ConfigurationError(`analytics log is not readable: ${candidate.path}`);
}
if (!stats.isFile()) {
throw new ConfigurationError(`analytics log is not a file: ${candidate.path}`);
}
try {
fs.accessSync(candidate.path, fs.constants.R_OK);
} catch (error) {
throw new ConfigurationError(
`analytics log is not readable by the current user: ${candidate.path}`
);
}
logs.push(candidate.path);
}
return logs;
}
function requireWritableDirectory(directory, label) {
let stats;
try {
stats = fs.statSync(directory);
} catch (error) {
throw new ConfigurationError(`${label} does not exist: ${directory}`);
}
if (!stats.isDirectory()) {
throw new ConfigurationError(`${label} is not a directory: ${directory}`);
}
try {
fs.accessSync(directory, fs.constants.W_OK | fs.constants.X_OK);
} catch (error) {
throw new ConfigurationError(
`${label} is not writable by the current user: ${directory}`
);
}
}
function validateInputs(registry, configTemplate) {
const requiredConfigParts = [
"{{DB_PATH}}",
"{{RESTORE_DIRECTIVE}}",
"{{GEOIP_COUNTRY_DATABASE}}",
"persist true",
"anonymize-ip true",
"ignore-crawlers true",
"unknowns-as-crawlers true",
"keep-last 395"
];
for (const required of requiredConfigParts) {
if (!configTemplate.includes(required)) {
throw new ConfigurationError(`GoAccess template is missing: ${required}`);
}
}
const analyticsLogsBySite = new Map(registry.sites.map(site => [
site.id,
resolveAnalyticsLogs(site)
]));
let geoStats;
try {
geoStats = fs.statSync(registry.geoIpCountryDatabase);
} catch (error) {
throw new ConfigurationError(
`GeoIP Country database is not readable: ${registry.geoIpCountryDatabase}`
);
}
if (!geoStats.isFile() || /city/i.test(path.basename(registry.geoIpCountryDatabase))) {
throw new ConfigurationError("geoIpCountryDatabase must be a Country database file");
}
try {
fs.accessSync(registry.geoIpCountryDatabase, fs.constants.R_OK);
} catch (error) {
throw new ConfigurationError(
`GeoIP Country database is not readable by the current user: `
+ registry.geoIpCountryDatabase
);
}
requireWritableDirectory(registry.stateDirectory, "stateDirectory");
requireWritableDirectory(
registry.combined.reportOutputDirectory,
"combined report output directory"
);
for (const site of registry.sites) {
requireWritableDirectory(
site.reportOutputDirectory,
`report output directory for ${site.id}`
);
if (site.publicCounter) {
requireWritableDirectory(
path.dirname(site.publicJsonPath),
`public output directory for ${site.id}`
);
}
}
return analyticsLogsBySite;
}
function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) {
const rendered = template
.replaceAll("{{DB_PATH}}", dbPath)
.replaceAll(
"{{RESTORE_DIRECTIVE}}",
restore ? "restore true" : "# restore is disabled until a database exists"
)
.replaceAll("{{GEOIP_COUNTRY_DATABASE}}", geoIpCountryDatabase);
if (/{{[A-Z_]+}}/.test(rendered)) {
throw new ConfigurationError("GoAccess template contains an unknown placeholder");
}
return rendered;
}
function defaultRunGoAccess({ binary, args }) {
const result = spawnSync(binary, args, {
encoding: "utf8",
maxBuffer: 1024 * 1024,
timeout: 30 * 60 * 1000,
windowsHide: true
});
if (result.error) {
throw new Error(`could not start GoAccess: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || "no diagnostic output").trim();
throw new Error(`GoAccess failed with exit code ${result.status}: ${detail}`);
}
}
function defaultCheckGoAccess(binary) {
const result = spawnSync(binary, ["--version"], {
encoding: "utf8",
timeout: 10000,
windowsHide: true
});
if (result.error || result.status !== 0) {
const detail = result.error
? result.error.message
: (result.stderr || result.stdout || "no diagnostic output").trim();
throw new ConfigurationError(`GoAccess is not available: ${detail}`);
}
return parseGoAccessVersion(`${result.stdout || ""}\n${result.stderr || ""}`);
}
function parseGoAccessVersion(output) {
const versionMatch = /GoAccess\s+-\s+([0-9]+(?:\.[0-9]+)+)/i.exec(output);
return {
version: versionMatch ? versionMatch[1] : "unknown",
geoIpMmdb: /--enable-geoip=mmdb\b/i.test(output),
openSsl: /--with-openssl\b/i.test(output),
zlib: /--with-zlib\b/i.test(output)
};
}
function formatGoAccessCheck(capabilities) {
const detected = [
`GeoIP2/MMDB ${capabilities.geoIpMmdb ? "enabled" : "not detected"}`,
`OpenSSL ${capabilities.openSsl ? "enabled" : "not detected"}`,
`Zlib ${capabilities.zlib ? "enabled" : "not detected"}`
].join(", ");
const summary = `GoAccess ${capabilities.version}: ${detected}.`;
if (capabilities.zlib) {
return summary;
}
return `${summary} This build has no Zlib support. That is valid for regular mode: `
+ "the current analytics log and its optional uncompressed .1 rotation are read "
+ "directly; older .gz logs are not imported.";
}
function parseGoAccessDate(value) {
if (typeof value !== "string") {
return null;
}
if (parseIsoDate(value)) {
return value;
}
if (/^\d{8}$/.test(value)) {
return parseIsoDate(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`);
}
const match = /^(\d{2})\/([A-Za-z]{3})\/(\d{4})$/.exec(value);
const months = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12"
};
return match && months[match[2]]
? parseIsoDate(`${match[3]}-${months[match[2]]}-${match[1]}`)
: null;
}
function validateReport(report, combined) {
if (!report || typeof report !== "object" || Array.isArray(report)) {
throw new Error("GoAccess JSON report must be an object");
}
const requiredPanels = ["visitors", "requests", "status_codes", "geo_location"];
if (combined) {
requiredPanels.push("virtual_hosts");
}
if (!report.general || typeof report.general !== "object") {
throw new Error("GoAccess JSON report has no general summary");
}
for (const panel of requiredPanels) {
if (!report[panel] || !Array.isArray(report[panel].data)) {
throw new Error(`GoAccess JSON report has no ${panel} panel`);
}
}
extractDailyVisits(report);
return report;
}
function readAndValidateReport(jsonPath, htmlPath, combined) {
let report;
try {
report = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
} catch (error) {
throw new Error(`GoAccess JSON output is invalid: ${error.message}`);
}
validateReport(report, combined);
const html = fs.readFileSync(htmlPath, "utf8");
if (html.length < 100 || !/<html(?:\s|>)/i.test(html)) {
throw new Error("GoAccess HTML output is missing or implausibly small");
}
return report;
}
function extractDailyVisits(report) {
const daily = {};
for (const row of report.visitors.data) {
const date = parseGoAccessDate(row && row.data);
const visits = row && row.visitors && row.visitors.count;
if (!date || !Number.isSafeInteger(visits) || visits < 0) {
throw new Error("GoAccess visitors panel contains invalid daily data");
}
if (Object.hasOwn(daily, date)) {
throw new Error(`GoAccess visitors panel contains duplicate date ${date}`);
}
daily[date] = visits;
}
return daily;
}
function loadCounterState(statePath) {
if (!fs.existsSync(statePath)) {
return { schemaVersion: STATE_SCHEMA_VERSION, sites: {} };
}
const state = readJson(statePath, "counter state");
if (state.schemaVersion !== STATE_SCHEMA_VERSION
|| !state.sites || typeof state.sites !== "object" || Array.isArray(state.sites)) {
throw new ConfigurationError("counter state has an unsupported structure");
}
for (const [siteId, site] of Object.entries(state.sites)) {
if (!site || typeof site !== "object" || !parseIsoDate(site.since)
|| !site.dailyVisits || typeof site.dailyVisits !== "object"
|| Array.isArray(site.dailyVisits)) {
throw new ConfigurationError(`counter state for ${siteId} is invalid`);
}
for (const [date, visits] of Object.entries(site.dailyVisits)) {
if (!parseIsoDate(date) || !Number.isSafeInteger(visits) || visits < 0) {
throw new ConfigurationError(`counter state value for ${siteId}/${date} is invalid`);
}
}
}
return state;
}
function updateCounterState(state, site, report) {
const current = state.sites[site.id];
if (current && current.since !== site.activatedOn) {
throw new ConfigurationError(
`activation date for ${site.id} differs from the existing counter state`
);
}
if (current && current.hostname !== site.hostname) {
throw new ConfigurationError(
`hostname for ${site.id} differs from the existing counter state`
);
}
const dailyVisits = current ? { ...current.dailyVisits } : {};
for (const [date, visits] of Object.entries(extractDailyVisits(report))) {
if (date >= site.activatedOn) {
dailyVisits[date] = visits;
}
}
state.sites[site.id] = {
hostname: site.hostname,
since: site.activatedOn,
dailyVisits
};
}
function publicPayload(state, site, now) {
const siteState = state.sites[site.id];
let visits = 0;
for (const [date, value] of Object.entries(siteState.dailyVisits)) {
if (date >= site.activatedOn) {
visits += value;
if (!Number.isSafeInteger(visits) || visits > MAX_SAFE_INTEGER) {
throw new Error(`public visit total for ${site.id} exceeds the safe integer range`);
}
}
}
return {
schemaVersion: PUBLIC_SCHEMA_VERSION,
visits,
since: site.activatedOn,
updatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
};
}
function atomicWriteFile(destination, content, mode = 0o640) {
const directory = path.dirname(destination);
const temporary = path.join(
directory,
`.${path.basename(destination)}.${process.pid}.${Date.now()}.tmp`
);
const handle = fs.openSync(temporary, "wx", mode);
try {
fs.writeFileSync(handle, content);
fs.fchmodSync(handle, mode);
fs.fsyncSync(handle);
} finally {
fs.closeSync(handle);
}
try {
fs.renameSync(temporary, destination);
} catch (error) {
fs.rmSync(temporary, { force: true });
throw error;
}
}
function atomicCopyFile(source, destination) {
atomicWriteFile(destination, fs.readFileSync(source));
}
function replaceDirectory(source, destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const backup = `${destination}.previous-${process.pid}`;
const hadDestination = fs.existsSync(destination);
if (fs.existsSync(backup)) {
throw new Error(`stale database backup blocks replacement: ${backup}`);
}
try {
if (hadDestination) {
fs.renameSync(destination, backup);
}
fs.renameSync(source, destination);
if (hadDestination) {
fs.rmSync(backup, { recursive: true, force: true });
}
} catch (error) {
if (!fs.existsSync(destination) && fs.existsSync(backup)) {
fs.renameSync(backup, destination);
}
throw error;
}
}
function acquireLock(lockPath) {
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
let descriptor;
try {
descriptor = fs.openSync(lockPath, "wx", 0o640);
fs.writeFileSync(descriptor, `${process.pid}\n`);
} catch (error) {
throw new LockError(`another analytics run is active (${lockPath})`);
}
return () => {
fs.closeSync(descriptor);
fs.rmSync(lockPath, { force: true });
};
}
function createReportJob(id, logs, outputDirectory, combined) {
return { id, logs, outputDirectory, combined };
}
function prepareReport(job, context) {
const jobDirectory = path.join(context.runDirectory, job.id);
const stagedDb = path.join(jobDirectory, "db");
const currentDb = path.join(context.registry.stateDirectory, "db", job.id);
const outputJson = path.join(jobDirectory, "report.json");
const outputHtml = path.join(jobDirectory, "report.html");
const runConfig = path.join(jobDirectory, "goaccess.conf");
let hasDatabase = false;
if (fs.existsSync(currentDb)) {
if (!fs.statSync(currentDb).isDirectory()) {
throw new Error(`GoAccess database path is not a directory: ${currentDb}`);
}
hasDatabase = fs.readdirSync(currentDb).length > 0;
}
fs.mkdirSync(jobDirectory, { recursive: true });
if (hasDatabase) {
fs.cpSync(currentDb, stagedDb, { recursive: true, errorOnExist: true });
} else {
fs.mkdirSync(stagedDb);
}
fs.writeFileSync(runConfig, renderGoAccessConfig(
context.configTemplate,
stagedDb,
hasDatabase,
context.registry.geoIpCountryDatabase
));
const args = [
...job.logs,
"--no-global-config",
"--config-file", runConfig,
"--output", outputJson,
"--output", outputHtml
];
context.runGoAccess({
binary: context.goaccessBinary,
args,
id: job.id,
combined: job.combined,
outputJson,
outputHtml,
dbPath: stagedDb
});
return {
...job,
report: readAndValidateReport(outputJson, outputHtml, job.combined),
outputJson,
outputHtml,
stagedDb,
currentDb
};
}
function generateReports(options, dependencies = {}) {
const registryPath = path.resolve(options.registryPath);
const configTemplatePath = path.resolve(options.configTemplatePath);
const registry = validateRegistry(readJson(registryPath, "site registry"));
const configTemplate = fs.readFileSync(configTemplatePath, "utf8");
const runGoAccess = dependencies.runGoAccess || defaultRunGoAccess;
const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess;
const now = dependencies.now ? dependencies.now() : new Date();
const analyticsLogsBySite = validateInputs(registry, configTemplate);
const goAccess = checkGoAccess(options.goaccessBinary || "goaccess");
if (!goAccess || !goAccess.geoIpMmdb) {
throw new ConfigurationError(
"GoAccess must be built with GeoIP2/MMDB support (--enable-geoip=mmdb)"
);
}
if (options.check) {
return { checked: true, sites: registry.sites.length, goAccess };
}
const releaseLock = options.dryRun || dependencies.skipLock
? () => {}
: acquireLock(registry.lockFile);
let runDirectory;
try {
runDirectory = fs.mkdtempSync(path.join(
options.dryRun ? os.tmpdir() : registry.stateDirectory,
".analytics-run-"
));
const jobs = registry.sites.map(site => createReportJob(
site.id,
analyticsLogsBySite.get(site.id),
site.reportOutputDirectory,
false
));
jobs.push(createReportJob(
"combined",
registry.sites.flatMap(site => analyticsLogsBySite.get(site.id)),
registry.combined.reportOutputDirectory,
true
));
const context = {
registry,
configTemplate,
goaccessBinary: options.goaccessBinary || "goaccess",
runGoAccess,
runDirectory
};
const prepared = jobs.map(job => prepareReport(job, context));
const state = loadCounterState(registry.counterStatePath);
for (const site of registry.sites.filter(entry => entry.publicCounter)) {
const generated = prepared.find(entry => entry.id === site.id);
updateCounterState(state, site, generated.report);
}
const publicFiles = registry.sites
.filter(site => site.publicCounter)
.map(site => ({
destination: site.publicJsonPath,
content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n`
}));
if (!options.dryRun) {
for (const report of prepared) {
atomicCopyFile(
report.outputJson,
path.join(report.outputDirectory, "report.json")
);
atomicCopyFile(
report.outputHtml,
path.join(report.outputDirectory, "report.html")
);
}
for (const report of prepared) {
replaceDirectory(report.stagedDb, report.currentDb);
}
atomicWriteFile(
registry.counterStatePath,
`${JSON.stringify(state, null, 2)}\n`
);
for (const publicFile of publicFiles) {
atomicWriteFile(publicFile.destination, publicFile.content, 0o644);
}
}
return {
checked: false,
dryRun: Boolean(options.dryRun),
sites: registry.sites.length,
reports: prepared.length,
publicCounters: publicFiles.length
};
} finally {
if (runDirectory && fs.existsSync(runDirectory)) {
fs.rmSync(runDirectory, { recursive: true, force: true });
}
releaseLock();
}
}
function parseArguments(argv) {
const options = { check: false, dryRun: false, goaccessBinary: "goaccess" };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--check") {
options.check = true;
} else if (argument === "--dry-run") {
options.dryRun = true;
} else if (["--registry", "--config-template", "--goaccess"].includes(argument)) {
const value = argv[index + 1];
if (!value) {
throw new ConfigurationError(`${argument} requires a value`);
}
index += 1;
if (argument === "--registry") options.registryPath = value;
if (argument === "--config-template") options.configTemplatePath = value;
if (argument === "--goaccess") options.goaccessBinary = value;
} else {
throw new ConfigurationError(`unknown argument: ${argument}`);
}
}
if (!options.registryPath || !options.configTemplatePath) {
throw new ConfigurationError(
"usage: generate-reports.js --registry FILE --config-template FILE "
+ "[--goaccess FILE] [--check|--dry-run]"
);
}
if (options.check && options.dryRun) {
throw new ConfigurationError("--check and --dry-run are mutually exclusive");
}
return options;
}
if (require.main === module) {
try {
const result = generateReports(parseArguments(process.argv.slice(2)));
const action = result.checked ? "Configuration check" : "Analytics generation";
if (result.checked) {
process.stdout.write(`${formatGoAccessCheck(result.goAccess)}\n`);
}
process.stdout.write(`${action} completed: ${JSON.stringify(result)}\n`);
} catch (error) {
process.stderr.write(`analytics: ${error.message}\n`);
process.exitCode = error.exitCode || 1;
}
}
module.exports = {
ConfigurationError,
extractDailyVisits,
formatGoAccessCheck,
generateReports,
parseGoAccessVersion,
parseArguments,
publicPayload,
updateCounterState,
validateRegistry,
validateReport
};
@@ -0,0 +1,43 @@
# Rendered by generate-reports.js. Do not use this file without replacing
# all {{...}} placeholders.
datetime-format %Y-%m-%dT%H:%M:%S%z
log-format %v\t%h\t%x\t%m\t%U\t%H\t%s\t%b\t"%u"
anonymize-ip true
anonymize-level 2
ignore-crawlers true
unknowns-as-crawlers true
keep-last 395
persist true
{{RESTORE_DIRECTIVE}}
db-path {{DB_PATH}}
geoip-database {{GEOIP_COUNTRY_DATABASE}}
json-pretty-print true
no-progress true
no-parsing-spinner true
no-color true
agent-list false
http-method true
http-protocol true
max-items 500
# The Nginx analytics log contains $uri rather than $request_uri, so query
# strings never reach GoAccess. These panels are deliberately unavailable.
ignore-panel HOSTS
ignore-panel OS
ignore-panel BROWSERS
ignore-panel REFERRERS
ignore-panel REFERRING_SITES
ignore-panel KEYPHRASES
ignore-panel REMOTE_USER
ignore-panel REQUESTS_STATIC
ignore-panel VISIT_TIMES
ignore-panel NOT_FOUND
ignore-panel ASN
ignore-panel MIME_TYPE
ignore-panel TLS_TYPE
ignore-panel CACHE_STATUS
@@ -0,0 +1,14 @@
/var/log/nginx/*-analytics.log {
daily
rotate 14
missingok
notifempty
compress
# Keep .1 uncompressed because the regular generator reads it directly.
delaycompress
create 0640 www-data hamradio-analytics
sharedscripts
postrotate
invoke-rc.d nginx rotate >/dev/null 2>&1
endscript
}
@@ -0,0 +1,36 @@
# Only page GET requests which are candidates for the reach statistics enter
# the dedicated analytics log. GoAccess performs the second bot-classification
# layer, including unknown browsers and operating systems.
map $request_method $hamradioonline_analytics_method {
default 0;
GET 1;
}
map $uri $hamradioonline_analytics_path {
default 1;
/visitor-count.json 0;
/kst4ContestVersionInfo.xml 0;
/sitemap.xml 0;
/robots.txt 0;
/favicon.ico 0;
/assets/favicon.svg 0;
/health 0;
/healthz 0;
/ping 0;
/status 0;
~*^/(?:assets|manual/assets)/ 0;
~*\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$ 0;
}
map $http_user_agent $hamradioonline_analytics_known_bot {
default 0;
~*(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl) 1;
}
map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioonline_analytics_known_bot"
$hamradioonline_analytics_loggable {
default 0;
"1:1:0" 1;
}
@@ -0,0 +1,15 @@
# Include in the Nginx http context. $uri is the normalized path and excludes
# the query string. The format intentionally omits referrer and remote user.
log_format hamradioonline_analytics
'$server_name\t$remote_addr\t$time_iso8601\t$request_method\t$uri\t'
'$server_protocol\t$status\t$body_bytes_sent\t"$http_user_agent"';
# Include the maps below in the Nginx http context as well.
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
# Add this extra log to each registered project server block. Keep the
# existing operational access_log directive; do not replace it implicitly.
#
# access_log /var/log/nginx/kst4contest-analytics.log
# hamradioonline_analytics if=$hamradioonline_analytics_loggable;
@@ -0,0 +1,9 @@
# Include inside the kst4contest.hamradioonline.de HTTPS server block. Grant
# the Nginx worker read access to the file and directory, but no write access.
location = /visitor-count.json {
alias /var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json;
default_type application/json;
access_log off;
add_header Cache-Control "public, max-age=3600" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -0,0 +1,17 @@
# Temporary HTTP-only virtual host for initial certificate provisioning.
# Replace it with stats-vhost.conf.example after Certbot has succeeded.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 404;
}
}
@@ -0,0 +1,57 @@
# Keep this HTTP server active so Certbot can renew the webroot certificate.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 301 https://$host$request_uri;
}
}
# This server exposes static reports only. Provision the certificate and the
# htpasswd file outside the repository. Do not add this host to sites.json.
server {
listen 443 ssl http2;
server_name stats.hamradioonline.de;
ssl_certificate /etc/letsencrypt/live/stats.hamradioonline.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.hamradioonline.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/lib/hamradioonline-analytics/reports;
index report.html;
auth_basic "Private project statistics";
auth_basic_user_file /etc/nginx/htpasswd/hamradioonline-analytics;
access_log off;
add_header Cache-Control "private, no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location = / {
try_files /__no_report_at_root__ @combined_reports;
}
location @combined_reports {
return 302 /combined/;
}
location / {
try_files $uri $uri/ =404;
}
location ~ /\. {
deny all;
}
}
# IPv6 is deliberately omitted until the DNS AAAA record has been confirmed.
+21
View File
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"stateDirectory": "/var/lib/hamradioonline-analytics",
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
"lockFile": "/run/hamradioonline-analytics/generator.lock",
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb",
"combined": {
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
},
"sites": [
{
"id": "kst4contest",
"hostname": "kst4contest.hamradioonline.de",
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
"activatedOn": "2026-09-11",
"publicCounter": true,
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
}
]
}
@@ -0,0 +1,34 @@
[Unit]
Description=Generate private GoAccess reports and public project counters
After=nginx.service
[Service]
Type=oneshot
User=hamradio-analytics
Group=hamradio-analytics
UMask=0027
Environment=LC_TIME=C
ExecStart=/usr/bin/node /opt/hamradioonline-analytics/generate-reports.js --registry /etc/hamradioonline-analytics/sites.json --config-template /etc/hamradioonline-analytics/goaccess.conf.template
NoNewPrivileges=true
PrivateDevices=true
PrivateNetwork=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
PrivateTmp=true
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_UNIX
ReadOnlyPaths=/etc/hamradioonline-analytics /opt/hamradioonline-analytics /var/log/nginx /var/lib/GeoIP/GeoLite2-Country.mmdb
ReadWritePaths=/var/lib/hamradioonline-analytics
StateDirectory=hamradioonline-analytics
StateDirectoryMode=0711
RuntimeDirectory=hamradioonline-analytics
RuntimeDirectoryMode=0750
RestrictSUIDSGID=true
LockPersonality=true
@@ -0,0 +1,11 @@
[Unit]
Description=Run hamradioonline analytics once per hour
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=4m
AccuracySec=1m
[Install]
WantedBy=timers.target