Projects Finance Real Estate Media Where I've Been Status

Refactor Roadmap

Full site audit — June 2026
9
Critical
17
Warnings
15
Info
7
Already Good
Security & Network
9 findings
Critical
No HTTPS — all traffic is plaintext HTTP
The site serves everything over port 80 (HTTP) with no TLS. HTTP Basic Auth credentials are sent in base64-encoded headers that are trivially sniffable. All page content, form data, and terminal I/O (ttyd WebSocket) are fully visible to any network observer. A self-signed cert exists at /etc/nginx/nginx.conf.bak.https but is not in use. Cloudflare Tunnel requires a domain you own (to add to Cloudflare DNS for cert provisioning) β€” DuckDNS/quick tunnel don't work with Access/named tunnels. Quick tunnel gives ephemeral URLs only. No viable HTTPS path without buying a domain.
Proposed Fix
Not feasible without a domain. Cloudflare Tunnel named tunnels require a Cloudflare-managed domain (can't use DuckDNS/linkpc.net/quick tunnel). Let's Encrypt needs a domain for cert issuance. Self-signed certs trigger browser warnings. Effect: Remains HTTP-only. Mitigation: keep services on 127.0.0.1, use fail2ban, limit open ports. If HTTPS ever needed: buy a cheap domain (~$1/yr .xyz), add to Cloudflare, run named tunnel + Access.
❌ N/A
SecurityAll Pages
Critical
No security headers — XSS, clickjacking, MIME sniffing unprotected
nginx sends no X-Frame-Options, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy, or Permissions-Policy headers. Server token is exposed (server_tokens on by default). Any page can be iframed (clickjacking), scripts can be injected from any origin (XSS), and browsers may MIME-sniff responses.
Proposed Fix
Add security headers in nginx main server blocks (port 80 and 443):
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Effect: One-time nginx config change; all pages inherit automatically. server_tokens off; already set in http block.
βœ… Resolved
SecurityAll Pages
Critical
Stocky v3 & DealFinder bound to 0.0.0.0 — externally exposed
Stocky v3 (app.py line 548/551) and DealFinder (app.py line 2299/2302) use host='127.0.0.1', so they are NOT externally exposed. They bind to localhost only. The original audit (June 2026) found them on 0.0.0.0 but this was fixed before the July 2026 scan. Both are correctly behind nginx proxy with auth.
Proposed Fix
Already fixed. Both apps now bind to 127.0.0.1. No action needed. Effect: Apps only reachable via nginx (which provides auth + rate limiting).
βœ… Resolved
SecurityStocky v3, DealFinder
Warning
ttyd web terminal sends keystrokes in plaintext
ttyd on port 7681 communicates via unencrypted WebSocket frames over HTTP. All terminal I/O (commands, passwords typed, output) is visible to DPI. This is distinct from SSH encryption — ttyd runs tmux locally, no SSH involved. Combined with no HTTPS, this means the full terminal session is readable on the wire.
Proposed Fix
HTTPS would fix this (WebSocket upgrade inherits TLS), but HTTPS requires a domain you own for Let's Encrypt or Cloudflare named tunnel. Cloudflare quick tunnel gives only ephemeral *.trycloudflare.com URLs β€” no cert for a stable domain. Buying a domain (~$1/yr) is the only viable path. Mitigations: terminal only accessible via HTTP Basic Auth; fail2ban blocks brute force; ttyd bound to 127.0.0.1 (nginx proxies). Effect: Remains plaintext HTTP unless domain purchased.
❌ N/A
SecurityTerminal Page
Warning
Orphaned Docker containers running 10+ days
Two kasmweb/firefox:1.16.1 containers (vigorous_jones, peaceful_cartwright) have been running for 10+ days with no associated session. Each uses ~750MB–1GB RAM. The Kasm idle reaper has a known bug: HTTP 200 from the container resets the idle timer even with no active WebSocket connections, so abandoned containers stay alive indefinitely. Additionally, the kasmweb/firefox:1.16.1 image is 18+ months old with likely unpatched CVEs.
Proposed Fix
1. Kill orphaned containers now: docker kill vigorous_jones peaceful_cartwright && docker rm vigorous_jones peaceful_cartwright
2. Fix the reaper: check for active WebSocket connections instead of HTTP 200.
3. Pull updated image: docker pull kasmweb/firefox:1.16.1 (or newer).
Effect: Frees ~1.5–2GB RAM immediately. Reaper fix prevents future orphans.
InfrastructureSession Manager
Critical
Zero CSRF protection across all 8 Flask apps
None of the 8 Flask apps (Steve, Stocky v1, Stocky v3, Where I've Been, Watching, YouTube Playlist, Cooper's Silver, DealFinder) implement CSRF protection. No hits for csrf, CSRFProtect, or flask_wtf in any Python source. Any POST form submission can be forged by a malicious site. This affects all state-changing operations: adding items, updating settings, executing trades, syncing data, OAuth callbacks.
Proposed Fix
Add Flask-WTF CSRFProtect to all apps:
1. pip install flask-wtf in each venv
2. csrf = CSRFProtect(app) in each app.py
3. Add {{ csrf_token() }} hidden field to all forms
4. For AJAX endpoints, set CSRF cookie and validate via header.
Effect: Prevents cross-site request forgery on all POST endpoints. Requires form template updates in each app. Non-breaking if CSRF_ENABLED flag is used for gradual rollout.
SecurityAll Flask Apps
Critical
DealFinder SQL injection risk — f-string SQL construction
dealfinder/app.py line 332 builds SQL via f"SELECT * FROM properties {where_sql}" and line 407 uses f"UPDATE properties SET {update_sql}". While where_sql is currently built from a safe list of column names, the f-string pattern is inherently risky — any future code change that passes unsanitized input into these f-strings creates an instant SQL injection vulnerability. Parameterized queries should be used unconditionally.
Proposed Fix
1. Replace f-string SQL with parameterized queries using ? placeholders and tuple parameters.
2. For dynamic column names, validate against a whitelist constant: ALLOWED_COLUMNS = {'price', 'bedrooms', ...}.
3. For the UPDATE, build SET clause from known column names only. Effect: Eliminates SQL injection risk. DealFinder code only. No downstream impact.
SecurityDealFinder
Warning
No nginx rate limiting — all endpoints unprotected from abuse
nginx config has zero limit_req or limit_conn directives. Any endpoint can be hammered at unlimited rate. Combined with no HTTPS, this makes brute-force attacks on HTTP Basic Auth trivial. The Flask apps also have no built-in rate limiting. fail2ban catches repeat auth failures but only after the fact.
Proposed Fix
Add rate limiting in nginx:
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=2r/s;
Apply limit_req zone=auth burst=5 to auth-protected locations and limit_req zone=general burst=20 to API endpoints. Effect: Protects against brute-force and API abuse. One-time nginx config change. May need tuning for legitimate high-traffic patterns.
SecurityInfrastructure
Info
Dawarich accessible via separate port (8082) without auth
Dawarich runs on its own nginx server block listening on port 8082, accessible without HTTP Basic Auth. It has its own user registration and login system (Devise). Port 8082–8091 is explicitly allowed in iptables.
Proposed Fix
Not needed β€” by design. Dawarich has its own authentication (Devise user accounts, registration, password reset). Adding nginx HTTP Basic Auth on top would create a double-login UX problem. The separate port is intentional for a public-facing GPS tracker with self-registration. DuckDNS subdomain dawarich.duckdns.org also points to this port.
❌ N/A
InfrastructureDawarich
Down / Broken
4 findings
Critical
Cooper's Silver app is down — 502 on /silver/
Port 5010 is not listening. No process is running for Cooper's Silver. There is no systemd service for it (unlike all other Flask apps). The app was likely started manually via tmux and the session was killed without restarting. All other apps have Restart=always systemd services.
Proposed Fix
1. Create /etc/systemd/system/coopers-silver.service (copy from another Flask service, adjust paths/port).
2. sudo systemctl enable --now coopers-silver
3. Remove the manual tmux-based startup pattern. Effect: Auto-restarts on crash/reboot like all other apps. No downstream impact.
Cooper's SilverInfrastructure
Warning
/home/ubuntu/watching/watching.db is 0 bytes — orphaned empty file
The Watching app uses data/watching.db (1.8MB, working). But a 0-byte watching.db exists in the app root, likely a leftover from an old path before the data/ subdirectory was introduced. Not harmful but confusing.
Proposed Fix
Delete the orphaned 0-byte file: rm /home/ubuntu/watching/watching.db. Verify no code references it (already confirmed: DB_PATH in app.py points to data/watching.db). Effect: No functional impact.
Watching
Info
/home/ubuntu/stocky_v3/stocky_v3.db is 0 bytes — orphaned empty file
Stocky v3 uses data/stocky_v3.db (102MB, working). A 0-byte stocky_v3.db exists in the app root, likely a leftover from before the data/ subdirectory was introduced. Same pattern as the Watching app's orphaned DB file.
Proposed Fix
Delete the orphaned 0-byte file: rm /home/ubuntu/stocky_v3/stocky_v3.db. Verify no code references it (confirmed: DB_PATH in app.py points to data/stocky_v3.db). Effect: No functional impact.
Stocky v3
Info
847MB stocky_v2_full.db likely orphaned — no app references it
/home/ubuntu/stocky_web_app/data/stocky_v2_full.db is 847MB. Stocky v1 (the current app) uses data/stocky_v3.db (45MB). The v2 DB appears to be a legacy file from a prior version. It's the single largest file in the project directory and consumes ~0.6% of total disk.
Proposed Fix
1. Confirm no code references stocky_v2_full.db (grep for the filename).
2. If orphaned, move to /tmp/ for a grace period before deletion: mv stocky_v2_full.db /tmp/stocky_v2_full.db.backup
3. After verifying no issues for a week, delete. The GitHub repo already has a split backup of this file. Effect: Frees 847MB. Low risk since repo backup exists.
Stocky v1Disk
Flask App Issues
8 findings
Critical
4 Flask apps running on development server, not waitress
Four apps use Flask's built-in development server (app.run()) instead of waitress in production:
• Where I've Been (port 5006) — serve() exists but the fallback app.run() path is active
• Stocky v1 (port 5001) — has serve() but also app.run() fallback
• Stocky v3 (port 5005) — uses app.run() only
• Steve (port 5003) — uses app.run() only

Flask dev server is single-threaded, has no production-grade error handling, and prints a warning: "Do not use the development server in a production deployment." All other apps (Watching, DealFinder, Owner Finder, HUD FMR) correctly use waitress.
Proposed Fix
Add from waitress import serve and replace app.run() with serve(app, host='127.0.0.1', port=PORT) in all four apps. Remove the app.run() fallback blocks. Add waitress to each app's requirements.txt if missing. Effect: Multi-threaded handling, proper error recovery, no dev server warnings. Restart each service after change.
Steve, Stocky v1, Stocky v3, Where I've BeenInfrastructure
Critical
Stocky v3 paper trading engine: pending orders never execute
_execute_pending_orders() runs at the start of run_daily(), but new orders are queued later in the same run. On the next day, load_state() restores cash from paper_portfolio_snapshots (still showing initial $10K because no orders executed), so 115 pending BUY orders remain stuck forever. The paper_trades table has 0 rows.
Proposed Fix
Restructure flow so pending orders execute on next-day open prices (cron runs after market close). Orders queued today execute tomorrow. Must update portfolio snapshots atomically with trade execution. Effect: Paper trading becomes functional. Requires Stocky v3 paper_engine.py rewrite of the daily execution flow.
Stocky v3
Warning
Stocky v1 uses venv/ instead of .venv/ — inconsistent naming
All Flask apps use .venv/ for their virtual environment except Stocky v1, which uses venv/. This inconsistency means backup scripts, systemd services, and tooling must special-case Stocky v1.
Proposed Fix
Rename venv/ to .venv/ and update the systemd service Environment="PATH=" line. Also update backup exclusion patterns. Effect: Uniform naming; simpler scripts. Requires service restart.
Stocky v1Infrastructure
Warning
Cooper's Silver scraper bugs produce garbage prices
Multiple scrapers are fundamentally broken:
• JM Bullion: cloudscraper extraction lands on _extract_price() which grabs per-oz fragments (e.g., $2.90 for a 5oz bar)
• BGASC/BullionMax: table extraction picks wrong cells — prices 2–5x too high
• Summit Metals: Shopify scraper returns per-oz price instead of total
• No sanity validation exists — garbage prices go directly to latest_prices.json
Proposed Fix
1. Add price sanity validation in main.py (reject prices outside expected per-oz ranges per category).
2. Fix JM Bullion fallback logic to extract total product price.
3. Fix BGASC/BullionMax table cell selection.
4. Fix Summit Metals Shopify total-vs-per-oz. Effect: Prices become accurate; no more wildly wrong values.
Cooper's Silver
Warning
DealFinder Zillow search broken — parameter mismatches
• Frontend sends home_type but backend reads homeTypes — filtering silently fails
• type=recentlySold is invalid; HasData API expects sold
• Address searches return different JSON shape ({"property": {...}}) vs area searches ({"properties": [...]}) — code only handles the plural form
Proposed Fix
1. Align form field name with backend: rename HTML name="home_type" to name="homeTypes".
2. Change value="recentlySold" to value="sold".
3. Add {"property": ...} handling in the Zillow response parser (wrap in array). Effect: Search filtering and recently-sold queries work. No other apps affected.
DealFinder
Warning
Zero custom error handlers across all 8 Flask apps
No Flask app defines @app.errorhandler for any status code. Unhandled exceptions return Flask's default HTML traceback (with debug=False, this is a generic 500 page). 404s return Flask's default "Not Found" page, which has no site nav or styling. Users hitting a bad URL see an unstyled, brandless error page with no way to navigate back.
Proposed Fix
Add error handlers to each app.py:
@app.errorhandler(404) → render a styled 404 page with site nav
@app.errorhandler(500) → render a styled 500 page, log the error
Create a shared error.html template per app that extends base.html. Effect: Users always see branded, navigable pages even on errors. Also prevents leaking server info in default error responses.
All Flask AppsNav
Warning
Watching app secret_key regenerates every restart — invalidates sessions
watching/app.py line 15 uses app.secret_key = os.urandom(24), which generates a new key on every process restart. This invalidates all existing Flask sessions (flash messages, session cookies). Every time the service restarts (crash, deploy, reboot), logged-in users lose their session state. All other Flask apps either use a fixed secret or don't use sessions.
Proposed Fix
Replace os.urandom(24) with a fixed secret from environment variable or config: app.secret_key = os.environ.get('SECRET_KEY', 'fallback-for-dev-only'). Set the actual secret in the systemd service Environment= line. Effect: Sessions survive restarts. Single file change in Watching app only.
Watching
Info
DealFinder Gmail OAuth: function name mismatch + missing credentials
app.py imports start_oauth_flow / complete_oauth_flow but gmail_sync.py defines get_auth_url / complete_auth. The credentials file at credentials/credentials.json is empty. OAuth callback only accepts POST (should also accept GET for browser redirects).
Proposed Fix
1. Align function names between app.py and gmail_sync.py.
2. Add GET support to the OAuth callback route.
3. Add Google OAuth credentials file. Effect: Gmail integration works end-to-end.
DealFinder
Nginx & Routing
4 findings
Warning
Inconsistent proxy_pass trailing-slash patterns
Some locations use trailing-slash proxy_pass (strips prefix, Flask sees /): Watching, Stocky v3, Where I've Been, Stocky v1, Cooper's Silver, GeoPulse.
Others use no trailing slash (preserves prefix, Flask sees /steve/): Steve, YouTube Playlist, Session Manager API, Cheapr.
This inconsistency causes different static file path handling (url_for('static') works in one mode but not the other), requiring different template patterns per app.
Proposed Fix
Standardize on no trailing slash (prefix-preserving) for all Flask apps. Update Flask route definitions to include the prefix. Remove all hardcoded static path prefixes from templates. This is a larger refactor but eliminates an entire class of bugs. Effect: All Flask apps use url_for() normally. Requires updating route definitions and templates in ~6 apps.
Infrastructure6 Flask Apps
Warning
Multiple stale nginx config backups
Backup files cluttering the nginx config directory:
• nginx.conf.bak
• nginx.conf.bak.1779932781
• nginx.conf.bak.dawarich
• nginx.conf.bak.https (self-signed SSL attempt)
These are unnecessary since the full config is backed up to GitHub and B2.
Proposed Fix
Delete all .bak files: sudo rm /etc/nginx/nginx.conf.bak*. The active config is version-controlled in the GitHub backup repo. Effect: Cleaner config directory; no functional impact.
Infrastructure
Info
Nginx gzip already enabled but compression level is low
Gzip is enabled with gzip_comp_level 4 (of 9). Static asset caching is configured for /watching/static/, /dealfinder/static/, /owner-finder/static/ (7–30 day expires). This is good but could be optimized further.
Proposed Fix
Consider bumping to gzip_comp_level 6 for better compression ratios with minimal CPU cost on a 4-core machine. Add application/manifest+json to gzip_types for ProjectionLab. Effect: Slightly smaller transfers. No risk.
Infrastructure
Info
Stale index.nginx-debian.html default page exists
The default nginx Debian landing page at /var/www/html/index.nginx-debian.html still exists. It's not served (because index.html takes priority), but it's a leftover from the initial install.
Proposed Fix
sudo rm /var/www/html/index.nginx-debian.html. Already excluded from backups. Effect: Minor cleanup only.
Infrastructure
Navigation & Design System
5 findings
Warning
Streaming page (/streaming/) breaks design system — uses Tailwind CDN instead of shared nav
/var/www/html/streaming/index.html loads Tailwind CSS via CDN (cdn.tailwindcss.com) and has no shared nav, no mobile menu, no search overlay, no footer. It's a completely different visual style from the rest of the site. This is the Ads Are Dumb / Neko landing page.
Proposed Fix
Rewrite the streaming page using the standard site template (shared nav.css, nav.js, search overlay, footer). Remove Tailwind CDN dependency. Keep the Neko launch functionality but wrap it in the site design system. Effect: Visual consistency across the entire site. Removes an external CDN dependency.
NavStreaming / Ads Are Dumb
Warning
Games index page missing search overlay and footer
/var/www/html/games/index.html links nav.css but is missing the search overlay HTML, search footer, and standard About footer. The three game sub-pages (tower-defense, tower-takeover, tower-takeover-2) have no nav at all — this may be intentional for embedded games, but the games index should have full nav.
Proposed Fix
Add the search overlay HTML and standard footer to games/index.html. Leave game sub-pages as-is (intentionally minimal for gameplay). Effect: Search (Ctrl+K) works from the games index page. No impact on game pages.
NavGames
Already Good
Nav version drift: nav.js comment was 3 versions behind β€” now fixed to ?v=16
The inline comment in nav.js previously said ?v=12 while all pages used ?v=15. This has been resolved: the comment now says ?v=16 and all pages (static HTML + Flask templates) were bumped from ?v=15 to ?v=16 in a site-wide cache bust. Remaining risk: No single source of truth β€” the version number is manually duplicated across ~30 files. A VERSION constant in nav.js or a build step would prevent future drift.
Proposed Fix
Add a NAV_VERSION constant at the top of nav.js and have a script that bumps the ?v=N query string across all pages automatically. Effect: Prevents future version drift. No immediate action needed β€” current state is correct.
Nav
Info
TabScout page exists but is not in main nav
/var/www/html/tabscout/index.html is a static landing page (200 OK) with full site nav. It's in the nav.js search index with parent: 'Projects'. But it doesn't appear on the Projects page card grid or in the main nav links. It appears to be an early-stage project page.
Proposed Fix
Add TabScout to the Projects page card grid (under "In Development" with reduced opacity, or "Wishlist"). Effect: Discoverability from the Projects page. No code changes to TabScout itself.
NavTabScout
Info
Daily Specials has a .bak file in its directory
/var/www/html/daily-specials/index.html.bak exists alongside the live index.html. Should be cleaned up.
Proposed Fix
rm /var/www/html/daily-specials/index.html.bak. Effect: Minor cleanup.
Daily Specials
Database & Performance
5 findings
Critical
Owner Finder parcels.db is 3.4GB — FTS5 index present but tokenized LIKE queries bypass it
The parcels database is 3.4GB with ~6M rows. It has 10 indexes including an FTS5 virtual table, but the tokenized search queries (LIKE '%TOKEN%' per field per token) in app.py bypass FTS5 and cause full table scans on every search. The FTS index exists but isn't being used by the application code.
Proposed Fix
1. Refactor search queries to use the existing FTS5 virtual table: SELECT * FROM parcels_fts WHERE parcels_fts MATCH ?
2. Add indexes on siteadd, mailadd, ownname, mcity as fallback for non-FTS queries.
3. If FTS5 performance is still insufficient, consider PostgreSQL with PostGIS (already available via Docker). Effect: Search queries go from seconds to milliseconds by leveraging the existing FTS5 index. Application code change only; no DB migration needed.
Owner Finder
Warning
Stocky v3 database is 102MB — growing without cleanup
stocky_v3.db at 102MB includes years of signal data, backtest results, and paper trading history. No archival or cleanup strategy exists. The WFO engine generates large volumes of parameter grid results per run.
Proposed Fix
1. Add a data retention policy (e.g., keep 90 days of raw signals, archive older data).
2. Run VACUUM periodically to reclaim space.
3. Consider splitting into hot/warm SQLite files (recent data vs archive). Effect: Keeps DB size manageable. No data loss if archived properly.
Stocky v3
Info
No SQLite VACUUM or integrity checks scheduled
None of the SQLite databases have scheduled VACUUM, PRAGMA integrity_check, or backup jobs. Over time, deleted rows leave fragmented free space. Corruption risks go undetected.
Proposed Fix
Add a weekly cron job that runs PRAGMA integrity_check on each DB and alerts on failure. Run VACUUM monthly (during low-traffic hours). Effect: Early corruption detection; reclaimed disk space.
InfrastructureAll SQLite Apps
Info
Watching app loads Chart.js on all pages (only needed on ratings page)
Chart.js (~200KB) is loaded in base.html, meaning every page (watching, completed, plan to watch, ratings) loads it. Only the ratings page actually uses it.
Proposed Fix
Move Chart.js <script> tag from base.html to ratings.html only, with defer attribute. Effect: ~200KB less JS on 3 of 4 Watching pages. Faster page loads.
Watching
Info
Watching API uses SELECT * including large synopsis text fields
The Watching API's item listing endpoint returns all columns including large text fields (synopses). For the card grid view, only title, poster, status, and rating are needed.
Proposed Fix
Define column subset constants in app.py for list vs detail queries. Add an /api/items/detail/<id> endpoint that returns full data. Effect: Smaller API responses, faster card grid rendering.
Watching
Infrastructure & Operations
7 findings
Warning
Docker images never updated — 18-month-old Firefox image with likely CVEs
kasmweb/firefox:1.16.1 image is pinned and never refreshed. Docker images don't self-update. The same applies to other images (neko, alpine, redis). Stale images miss security patches. The update alias only covers apt packages, not Docker images.
Proposed Fix
1. Add docker pull for all images to the update-all.sh script.
2. Recreate containers with fresh images periodically (quarterly at minimum).
3. Pin to minor versions (e.g., kasmweb/firefox:1.16) to get patch updates. Effect: Security patches applied. Requires brief downtime per container during recreate.
InfrastructureSecurity
Warning
pip user packages and venv dependencies never updated
Security-sensitive Python packages (cryptography, pyOpenSSL, certifi, urllib3, requests, flask, werkzeug) across 5+ venvs are never updated. The update alias only covers apt, not pip. These packages have frequent CVE fixes.
Proposed Fix
Add venv dependency updates to update-all.sh:
for venv in /home/ubuntu/*/.venv; do $venv/bin/pip install --upgrade cryptography pyOpenSSL certifi urllib3 requests flask werkzeug; done
Test each app after update. Effect: Security patches applied to TLS/HTTP libraries. Low risk of breakage for patch updates.
InfrastructureSecurity
Warning
geopulse-keygen container exited — stale container
A geopulse-keygen container exists in exited state. This was likely a one-shot container for generating GeoPulse API keys that should have been removed after use.
Proposed Fix
docker rm geopulse-keygen. If the key generation needs to be repeated, use docker run --rm for one-shot containers. Effect: Cleanup only.
Infrastructure
Info
Stocky v1 stocky_v2_full.db at 846MB not in backup scope
The large Stocky v1 database exceeds GitHub's 100MB limit and is excluded from B2 backup. A split-chunk approach exists for GitHub but hasn't been run recently. This DB contains years of stock data and would be costly to rebuild.
Proposed Fix
1. Add the DB to B2 backup (no size limit on B2).
2. Run the GitHub split-chunk backup more frequently.
3. Consider migrating to PostgreSQL (already have Docker Postgres for Cheapr). Effect: Disaster recovery for Stocky v1 data.
Stocky v1Infrastructure
Info
Neko container removed — no shared browser running
The Neko (site-neko) container is no longer running (previously used ~674MB RAM). The /streaming/ page still references it but there's no container to connect to. This may be intentional (cost savings) but the landing page doesn't explain the service is offline.
Proposed Fix
Either: (a) add an "offline" message to the streaming page, or (b) add a "Start Group Watch" button that calls the Session Manager API to start the Neko container on demand. Effect: Users understand why the service is unavailable or can start it themselves.
Streaming / Ads Are Dumb
Info
~968MB across 7 Python venvs — all recreatable, excluded from backups
7 virtual environments across Flask apps total ~968MB:
• stocky_web_app/venv/ — 191MB (note: uses venv/ not .venv/)
• stocky_v3/.venv/ — 149MB
• watching/.venv/ — 147MB
• where-ive-been/.venv/ — 133MB
• coopers-silver/.venv/ — 126MB
• youtube-playlist/.venv/ — 120MB
• dealfinder/.venv/ — 102MB

All are properly excluded from GitHub and B2 backups. They're fully recreatable from requirements.txt. Not a problem, but worth knowing the disk footprint.
Proposed Fix
No action needed — venvs are properly managed. Consider adding a rebuild-venvs.sh script to update-all.sh that runs pip install -r requirements.txt in each venv for security updates. Effect: Keeps dependencies fresh without manual per-app intervention.
Infrastructure
Info
10.22GB Docker reclaimable space (88% of total Docker disk usage)
Docker uses 11.64GB total, of which 10.22GB is reclaimable via docker system prune. This includes dangling images, stopped containers, and unused networks. The running containers themselves use ~1.4GB. On a 145GB disk, 10GB of reclaimable Docker waste is significant (~7% of available space).
Proposed Fix
Run docker system prune -f to reclaim ~10GB. Add to update-all.sh as a quarterly cleanup step. Effect: Frees ~10GB disk space. No impact on running containers (prune only removes stopped containers and dangling images).
InfrastructureDisk
Performance & Resource Waste
4 findings
Critical
WFO daemons burning 247% CPU + ~3GB RAM
4 Python WFO daemon processes running at 61% CPU each since Jul 17 (48+ hours continuous). Each uses ~770MB RSS. Combined: 247% CPU, ~3GB RAM. This is the single biggest resource consumer on the server. The AGENTS.md documents that setting WFO intensity to "off" on the status page frees ~1GB + 2+ CPU cores, but the current usage is even worse than documented.
Proposed Fix
Set WFO intensity to off via the status page or run: bash /home/ubuntu/stocky_v3/wfo_intensity.sh off. For permanent fix, disable the stocky-wfo-daemon.service systemd unit. Effect: Frees ~247% CPU and ~3GB RAM instantly. No other app impact. Can be re-enabled later.
Stocky v3Infrastructure
Critical
Swap thrashing — 12.3GB of 26GB swap used, load avg 6.6
Server has 24GB RAM (11GB used, 11GB buff/cache) and 26GB swap (12.3GB used). Load average is 6.6 on a 4-core VM. The high swap usage means the system is memory-starved and actively paging, which degrades all service performance. Main contributors: WFO daemons (~3GB), 8 opencode processes (~5GB), Docker containers (~2GB), sidekiq workers (~800MB).
Proposed Fix
1. Kill WFO daemons (reclaims ~3GB RAM).
2. Kill stale opencode sessions (reclaims ~3GB RAM).
3. Stop unused Docker containers (nostalgic_nightingale, geopulse-keygen).
4. Consider adding more swap or buying more RAM if OCI allows. Effect: Drastically reduces swap thrashing. Services become more responsive.
InfrastructureAll Services
Warning
43-day-old opencode session — 8 processes using ~5GB RAM
PID 116251 has been running since June 5 (43 days). In total there are 8 opencode processes from 6 tmux sessions using ~5GB RSS collectively. Sessions from Jul 6, Jul 9, Jul 16, and Jul 19 are all still alive. Only the current session needs to be running.
Proposed Fix
Kill stale sessions: kill 116251 3190197 2614129 2854616 3810451 (keep only current). Review tmux sessions periodically: tmux list-sessions. Effect: Frees ~3-5GB RAM immediately. No data loss (session history is in SQLite).
Infrastructure
Warning
Kasm orphan nostalgic_nightingale running 33 days
A kasmweb/firefox:1.19.0 container named nostalgic_nightingale has been running since June 16 (33 days) with ports 4901/5901/6901 exposed. This is the same orphan pattern documented in the original audit finding — a Kasm session container left running with no active WebSocket connection. The image version 1.19.0 is newer than the 1.16.1 from the original audit, but the container still wastes ~750MB-1GB RAM.
Proposed Fix
docker kill nostalgic_nightingale && docker rm nostalgic_nightingale. This is the same fix as the original audit finding — the Kasm reaper still doesn't clean these up. Effect: Frees ~750MB-1GB RAM.
InfrastructureSession Manager
Unused Scripts & Duplicates
4 findings
Info
pre-migration-backup.sh — 24KB one-time script, not referenced anywhere
/usr/local/bin/pre-migration-backup.sh (24KB) appears to be a one-time backup script for a database migration. It is not referenced by any systemd service, cron job, or AGENTS.md. It's been sitting unused since at least June 2.
Proposed Fix
Review contents, then delete: sudo rm /usr/local/bin/pre-migration-backup.sh. If the backup logic is still useful, merge into b2-backup.sh or github-backup.sh. Effect: Cleaner /usr/local/bin.
Infrastructure
Info
opencode-queue-daemon-v3.sh — identical copy of live queue daemon
/home/ubuntu/opencode-queue-daemon-v3.sh is an identical copy of /usr/local/bin/opencode-queue-daemon.sh (14.6KB). The v3 version is in the home directory and not referenced by anything — the live version is in /usr/local/bin. Likely a development leftover.
Proposed Fix
Delete: rm /home/ubuntu/opencode-queue-daemon-v3.sh. Effect: Minor cleanup.
Infrastructure
Info
qd.sh, qd-freeze.sh, qd-status.sh — queue daemon helpers, not in PATH
Three queue daemon helper scripts in /home/ubuntu/: qd.sh (2.8KB), qd-freeze.sh, qd-status.sh. They're not in any systemd service, cron, or AGENTS.md. They may be used manually but have no discoverability. Also: launch-daemon.sh and start-daemon.sh are small wrappers for the same queue daemon.
Proposed Fix
Either: (a) document them in AGENTS.md, (b) move to /usr/local/bin/, or (c) delete if no longer needed. Consolidate launch-daemon.sh and start-daemon.sh into the main daemon script. Effect: Less clutter.
Infrastructure
Info
run_check.sh + backfill-is-animated.py — one-off Watching app scripts
/home/ubuntu/run_check.sh runs check_mushoku.py (a one-off anime check). /usr/local/bin/backfill-is-animated.py backfills the is_animated column in the Watching app. Both appear to be one-time use scripts that should have been cleaned up.
Proposed Fix
Verify the backfill is complete. If so, delete both: rm /home/ubuntu/run_check.sh /usr/local/bin/backfill-is-animated.py. Effect: Cleanup only.
WatchingInfrastructure
Docker & Cache Waste
4 findings
Warning
16.67GB Docker reclaimable — 67% of images are dangling
Docker uses 24.58GB total for images, of which 16.67GB (67%) is reclaimable via docker system prune. This is up from the 10.22GB reported in the June audit (+6.5GB in 6 weeks). The growing waste comes from: multiple opencode plugin versions (4.5.12 through 4.8.0 cached), old geopulse images, and dangling intermediate layers.
Proposed Fix
Run docker system prune -f. Add to update-all.sh as a monthly cleanup step. Effect: Frees 10-17GB disk. No impact on running containers.
InfrastructureDisk
Info
.npm 4.5GB + uv 3.1GB + Playwright 2GB — build tool caches, safe to clean
Three large caches on the server: ~/.npm/_cacache (4.5GB, npm package cache), ~/.cache/uv (3.1GB, uv Python tool cache), ~/.cache/ms-playwright (2GB, Playwright browser binaries). These are build-time dependencies that aren't needed for serving the site. Total: ~9.6GB of safe-to-clean cache.
Proposed Fix
rm -rf ~/.npm/_cacache ~/.cache/uv ~/.cache/ms-playwright. Only the Playwright deletion might require re-install if tests are run. Effect: Frees ~9.6GB disk. No impact on running services.
InfrastructureDisk
Info
geopulse-keygen container still exited — from June audit
The geopulse-keygen container has been in exited state since the June audit. It was noted as a finding to clean up but was never removed. It's an alpine:latest container that was used for one-shot GeoPulse key generation.
Proposed Fix
docker rm geopulse-keygen — same fix as the original audit. Effect: Cleanup only.
Infrastructure
Info
immich_machine_learning unhealthy for 6+ days
The Immich ML container has been reporting unhealthy status since Jul 13 (6+ days). It still starts and serves on port 3003, but Docker health checks are failing. This likely means face detection, CLIP search, and OCR aren't working properly in Immich.
Proposed Fix
1. Check health: docker inspect immich_machine_learning --format '{{.State.Health.Status}}'
2. Restart: docker restart immich_machine_learning
3. If persists, check logs: docker logs immich_machine_learning --tail 20
4. Consider pulling a fresh image. Effect: Restores ML features in Immich.
Immich
Already Good
7 findings
Good
All Flask apps have systemd services with Restart=always
Every Flask app (except Cooper's Silver, which has no service) is managed by systemd with Restart=always and RestartSec=5. This ensures automatic recovery from crashes and reboots. Services: steve-app, session-manager, status-backend, stocky_v3, stocky_web, watching, where-ive-been, youtube-playlist, dealfinder, owner-finder, hud-fmr.
Good
fail2ban active with SSH + nginx auth jails
Two jails running: sshd (905 total bans, 2 currently banned) and nginx-http-auth. This provides brute-force protection for SSH and HTTP Basic Auth.
Good
Nginx gzip and static asset caching configured
Gzip is enabled with reasonable settings. Flask static assets have expires 7d–30d with Cache-Control: public headers. Shared nav files under /shared/ are served with auth bypass for cross-page loading.
Good
Dual backup strategy: GitHub (manual) + Backblaze B2 (daily cron)
GitHub repo provides version-controlled backups with git tags. B2 provides daily automated cloud backups via rclone. Both exclude secrets, venvs, and generated files appropriately.
Good
Shared nav system with 72-page search index
The nav.js search index covers 72 pages with parent attribution for subpages. The shared nav.css and nav.js are loaded by all pages (except the streaming page, noted above). Sub-nav centering is JS-driven and responsive.
Good
Cheapr and ProjectionLab running and accessible
Cheapr (Next.js on port 3001) returns 200 for API ping and all routes. ProjectionLab (static SPA) is deployed and serving correctly. Both are stable.