# SiteWatch

Monitor and maintain your own websites from one dashboard.

- **Uptime**: response time, TTFB, SSL expiry, "up but broken" detection
- **Server**: CPU, RAM, swap, disk, network throughput, connections
- **Maintenance**: clear temp / sessions / cache, trim big logs, reset OPcache
- **Database**: table sizes, fragmentation, slow queries, optimize
- **Logs & backups**: error log viewer, backup freshness check
- **Alerts** (email + dashboard) and a full **audit log** of every action

```
 dashboard/   -> install ONCE (its own subdomain, e.g. monitor.yourdomain.com)
 agent/       -> install on EACH site you want server metrics/maintenance for
```

Requires PHP 8.0+ with `curl`, `pdo_sqlite`, `openssl`. The dashboard uses SQLite,
so it keeps working even if your MySQL goes down.

---

## 1. Install the dashboard

1. Upload `dashboard/` to e.g. `/var/www/sitewatch/`, serve it on its own **HTTPS** subdomain.
2. Make `data/` writable by the web user:
   ```
   chown -R www-data:www-data /var/www/sitewatch/data
   chmod 700 /var/www/sitewatch/data
   ```
3. Create the config:
   ```
   cd /var/www/sitewatch
   cp config.sample.php config.php
   php -r "echo password_hash('YOUR-STRONG-PASSWORD', PASSWORD_DEFAULT);"
   ```
   Paste the hash into `config.php` (`admin_pass_hash`), set `admin_user`, and `alert_email`.
   Then lock it down — note it must stay **readable by the web user**, so group-read,
   not `600`:
   ```
   chgrp www-data config.php && chmod 640 config.php
   ```
4. **Nginx users**: `.htaccess` is ignored. Add this so private folders are never served:
   ```
   location ~ ^/(data|lib|cron|views)/ { deny all; }
   location = /config.php { deny all; }
   ```
   (The code also refuses direct access itself, but block them anyway.)
5. Add the collector to cron (runs every minute). In `/etc/cron.d/sitewatch`:
   ```
   * * * * * www-data /usr/bin/php /var/www/sitewatch/cron/collect.php >> /var/log/sitewatch.log 2>&1
   ```
   No root? Use your own `crontab -e` instead — but set `umask 0002` first, so the
   SQLite WAL files it creates stay group-writable for the web user:
   ```
   * * * * * umask 0002; /usr/bin/php /var/www/sitewatch/dashboard/cron/collect.php >> ~/logs/sitewatch.log 2>&1
   ```
   `data/` must then be setgid (`chgrp www-data data && chmod 2770 data`) so both
   users share the files. Without the collector cron, every site page just says
   "No agent data yet".
6. Open `https://monitor.yourdomain.com/`, log in.

## 2. Install the agent on each site

1. Generate a secret (one per site):
   ```
   php -r "echo bin2hex(random_bytes(32));"
   ```
2. Upload `agent/agent.php` as `sw-agent.php` into the site's web root.
3. Copy `sw-agent.config.sample.php` next to it as `sw-agent.config.php` and edit:
   - `secret` (same value you enter in the dashboard)
   - `allowed_ips` -> the dashboard server's public IP (strongly recommended)
   - the directory lists (see "Choosing directories" below)
4. Lock the config down. It is read by PHP running as the **web user**, so it needs
   group-read — `chmod 600` gives a blank HTTP 500 and the dashboard reports
   "agent unreachable: bad response (HTTP 500)":
   ```
   chgrp www-data sw-agent.config.php && chmod 640 sw-agent.config.php
   ```
5. In the dashboard: **Manage sites -> Add site**, fill the site URL, agent URL
   (`https://yoursite.com/sw-agent.php`) and secret, then click **Test**.

### Choosing directories (IMPORTANT)
The agent deletes files only inside directories you list. Be exact.
- `temp_dirs`: a folder that only ever holds throwaway files
- `cache_dirs`: your app's cache folder
- `session_dirs`: PHP's session save path (`php -i | grep session.save_path`)
- **Never** list a folder containing uploads, invoices, or anything you can't regenerate.
- Directories you point at must be writable by the PHP user, or deletion silently frees nothing.

### Database access (least privilege)
Do NOT reuse your app's DB user. Create a dedicated one:
```sql
CREATE USER 'sw_monitor'@'127.0.0.1' IDENTIFIED BY 'long-random-password';
GRANT SELECT ON your_database.* TO 'sw_monitor'@'127.0.0.1';
GRANT SELECT ON performance_schema.* TO 'sw_monitor'@'127.0.0.1';
GRANT PROCESS ON *.* TO 'sw_monitor'@'127.0.0.1';
```
`OPTIMIZE TABLE` needs `SELECT` + `INSERT` on the tables; if you want that button,
add `GRANT INSERT ON your_database.* ...`. Skip it if you'd rather run OPTIMIZE by hand.
For the slow-query panel: `SET GLOBAL performance_schema` must be ON in `my.cnf`
(`performance_schema=ON`, needs restart).

---

## Security model (read this)

The agent can delete files, so it is treated as a high-value target:
- Every request is **HMAC-SHA256 signed** with the shared secret and must be < 60 s old.
- Optional **IP allowlist** so only the dashboard can call it.
- **Fixed action whitelist.** No action accepts a path from the network; all paths come
  from the local config file.
- Deletion **never follows symlinks**, re-checks every path stays inside its root, refuses
  system roots (`/`, `/etc`, `/var`...), never deletes the root folder itself, and only
  deletes files older than the configured age.
- Dashboard: bcrypt password, CSRF tokens on every state-changing request, login lockout
  (5 failures / 15 min), HttpOnly + SameSite=Strict cookies, 2 h idle timeout, audit log.

What it does NOT protect against:
- If someone steals the shared secret **and** is on your allowlisted IP, they can run the
  whitelisted maintenance actions (not arbitrary code).
- If the dashboard server is compromised, the attacker holds every site's secret
  (stored in plaintext in SQLite). Keep it patched, HTTPS-only, and behind a strong password.
  Consider also restricting the dashboard by IP at the web-server level.
- Always use **HTTPS** for both dashboard and agent URLs. Signatures stop tampering, but
  responses (error-log lines, table names) travel in the clear over HTTP.

## Limits you should know about
- Metrics are read from `/proc` -> **Linux only**.
- "Web connections" counts established TCP connections to ports 80/443, not bandwidth per site.
  Network throughput is **server-wide** (all interfaces), not per website.
- The agent runs as your web-server user, so it only sees what that user can read.
  Apache logs in `/var/log/apache2` are usually root-only; add the web user to the `adm`
  group or point `error_log` at a PHP log the user owns.
- It cannot restart services or edit server config (deliberately, that needs root).
- CPU is sampled over 250 ms per check, so it is a snapshot, not an average.
- History is kept `retention_days` (default 30) at 1-minute resolution.

---

## Cleanup (disk reclaim from the dashboard)

The **Cleanup** tab frees disk space — package caches, stray junk files — from the
browser, with a confirmation step and an audit-log entry.

### Why there is a cron runner

Apache runs as `www-data`. The caches worth reclaiming (`~/.npm`, `~/.cache`) and the
files under `htdocs/` are owned by the login user, and deleting a file needs write
permission on its *containing directory*. `www-data` has none there, so the dashboard
**cannot** delete them itself — and giving it those rights would hand the web server
delete access over the home directory.

Instead the work is split:

| Component | Runs as | Does |
|---|---|---|
| `dashboard/cleanup.php` | `www-data` | Shows sizes, queues a **signed job**. Deletes nothing. |
| `cleaner/run-cleaner.php` | you, via cron | Picks up the job, deletes, writes the result. |
| `dashboard/data/queue/` | shared (`0770`, group `www-data`) | The handoff point. |

The dashboard never sends a path — only a target **id**. Paths exist solely in
`cleaner/cleaner.config.php`, which the runner reads locally.

### Safety rails

- **Signed jobs.** Every job carries an HMAC-SHA256 signature over its canonical JSON.
  Unsigned or forged jobs are refused, so the shared queue dir cannot be abused by
  anything that can write to it.
- **Replay window.** Jobs older than `job_max_age_sec` (default 600s) are refused.
- **Id whitelist.** A target id absent from the config is refused by both sides.
- **`safe_roots` backstop.** Every resolved path must sit inside a declared root.
  `/`, `/etc`, `/home`, `~/.ssh`, and `../` traversal are all refused even if the
  config is edited to name them.
- **No symlink following**, and a `dir_contents` target empties a directory without
  removing the directory itself.
- **`pattern_sweep`** deletes only filenames matching explicit patterns, with an
  optional `max_size` — that is how "empty .sql dumps" targets 0-byte files and
  leaves real backups alone.
- Every run is written to the audit log with the user who requested it.

### Setup

```bash
cp cleaner/cleaner.config.sample.php cleaner/cleaner.config.php
php -r "echo bin2hex(random_bytes(32));"     # paste into 'secret'
chmod 640 cleaner/cleaner.config.php
chgrp www-data cleaner/cleaner.config.php    # dashboard reads the signing key

mkdir -p dashboard/data/queue
chgrp www-data dashboard/data/queue
chmod 2770 dashboard/data/queue              # setgid: new files inherit the group

crontab -e
* * * * * /usr/bin/php /full/path/sitewatch/cleaner/run-cleaner.php >/dev/null 2>&1
```

Edit the `targets` list to match the machine. Then open **Cleanup** in the dashboard.

### Useful commands

```bash
php cleaner/run-cleaner.php --status   # current measurements as JSON
php cleaner/run-cleaner.php --scan     # force a re-measure now
php cleaner/run-cleaner.php            # process the queue (what cron runs)
```

A "runner looks stopped" banner on the page means cron has not run in the last hour —
measurements are stale and queued jobs will wait.
