max / makenotwork
6 files changed,
+276 insertions,
-0 deletions
| @@ -0,0 +1,79 @@ | |||
| 1 | + | # SSH access — production server (Hetzner) | |
| 2 | + | ||
| 3 | + | Two SSH paths into the production server, with different audiences and | |
| 4 | + | different break-glass behavior. Read this before disabling either one. | |
| 5 | + | ||
| 6 | + | ## The two paths | |
| 7 | + | ||
| 8 | + | | Port | Interface | Audience | Allowed from | | |
| 9 | + | |------|-----------|----------|--------------| | |
| 10 | + | | `22` | public (eth0) | mnw-cli git operations | anywhere (firewall + sshd config) | | |
| 11 | + | | `2200` | Tailscale (tailscale0) | admin access | tailnet only (firewall blocks public) | | |
| 12 | + | ||
| 13 | + | - **Public :22** is intentionally open so creators can `git clone`/`push` | |
| 14 | + | over SSH against `ssh.makenot.work`. The sshd config on this port is | |
| 15 | + | locked to git-shell only — see `setup-git-ssh.sh` and `sshd-git.conf`. | |
| 16 | + | No interactive shell, no port forwarding, no admin access. | |
| 17 | + | - **Tailnet :2200** is the admin path. Full interactive shell, used for | |
| 18 | + | every `deploy.sh` invocation and any manual maintenance. Reachable only | |
| 19 | + | from devices on the tailnet (firewall rule `ufw allow in on tailscale0` | |
| 20 | + | in `setup-firewall.sh`). | |
| 21 | + | ||
| 22 | + | ## Why this split exists | |
| 23 | + | ||
| 24 | + | The audit-flagged risk was disabling Tailscale SSH (the admin path) | |
| 25 | + | without first verifying the public sshd was still functional. Tailscale | |
| 26 | + | runs its own SSH server when configured; if that goes down — Tailscale | |
| 27 | + | service crashes, ACL misconfiguration, accidental `tailscale down` — | |
| 28 | + | you can lose admin access entirely if you've also locked down public | |
| 29 | + | sshd. | |
| 30 | + | ||
| 31 | + | The split solves this: public :22 is always alive (firewalled to allow | |
| 32 | + | SSH from anywhere) but restricted to git-shell, so an attacker who | |
| 33 | + | probes :22 finds nothing but git commands. Admin :2200 lives on the | |
| 34 | + | tailnet, where the firewall blocks public access and the surface area | |
| 35 | + | is small. | |
| 36 | + | ||
| 37 | + | ## Break-glass procedure | |
| 38 | + | ||
| 39 | + | If the tailnet path stops working (Tailscale down, key revoked, ACL | |
| 40 | + | broken): | |
| 41 | + | ||
| 42 | + | 1. **Verify public sshd is up** from any machine: | |
| 43 | + | `ssh -p 22 root@5.78.144.244 -o BatchMode=yes -o ConnectTimeout=5 true` | |
| 44 | + | Expect a key-based prompt or a refused git-shell — both prove sshd | |
| 45 | + | is listening. A timeout or "Connection refused" means public sshd is | |
| 46 | + | ALSO down and you need Hetzner Cloud Console. | |
| 47 | + | 2. **Edit `/etc/ssh/sshd_config.d/git-shell.conf` from Hetzner Console** | |
| 48 | + | to temporarily restore an interactive shell for the `root` user on | |
| 49 | + | port `22`. Match `User root` block, set `ForceCommand` to nothing. | |
| 50 | + | 3. **Restart sshd**: `systemctl restart ssh`. Test from your laptop. | |
| 51 | + | 4. **Fix the tailnet path** (re-auth `tailscale up`, restore key, etc). | |
| 52 | + | 5. **Revert the sshd edit** and restart `ssh` again. Don't leave the | |
| 53 | + | interactive root shell on public :22 — it defeats the whole split. | |
| 54 | + | ||
| 55 | + | ## What NOT to do | |
| 56 | + | ||
| 57 | + | - **Do not** disable Tailscale SSH (`tailscale set --ssh=false`) without | |
| 58 | + | first proving public :22 is reachable and you have a working root key | |
| 59 | + | for it. Memory rule: `feedback_tailscale_ssh` — getting locked out | |
| 60 | + | requires Hetzner Console access, which costs time we don't always | |
| 61 | + | have. | |
| 62 | + | - **Do not** restrict public :22 to specific IPs without coordinating — | |
| 63 | + | the mnw-cli git endpoint serves users worldwide. | |
| 64 | + | - **Do not** open port 2200 to the public — it's the admin shell. | |
| 65 | + | ||
| 66 | + | ## Hetzner Console (last resort) | |
| 67 | + | ||
| 68 | + | If both paths are dead, the Hetzner Cloud Console provides KVM-style | |
| 69 | + | access to the server's serial console. Login at | |
| 70 | + | <https://console.hetzner.cloud>, select the project, select the server, | |
| 71 | + | "Console" tab. Slow but always available. Same root credentials. | |
| 72 | + | ||
| 73 | + | ## Key paths | |
| 74 | + | ||
| 75 | + | - Firewall: `deploy/setup-firewall.sh` | |
| 76 | + | - Public sshd (git-only): `deploy/sshd-git.conf`, `deploy/setup-git-ssh.sh` | |
| 77 | + | - Admin sshd (tailnet): `/etc/ssh/sshd_config` on the server | |
| 78 | + | - Deploy entry point: `deploy/deploy.sh` (uses `-p 2200`) | |
| 79 | + | - CI runner setup: `deploy/setup-ci.sh` (uses `-p 2200`) |
| @@ -23,9 +23,17 @@ document.addEventListener('DOMContentLoaded', function() { | |||
| 23 | 23 | TOAST NOTIFICATIONS | |
| 24 | 24 | =========================================== */ | |
| 25 | 25 | ||
| 26 | + | // Maximum simultaneously-visible toasts. Anything beyond this drops the | |
| 27 | + | // oldest first so a burst of HTMX errors can't bury the viewport. | |
| 28 | + | var TOAST_MAX_VISIBLE = 5; | |
| 29 | + | ||
| 26 | 30 | document.body.addEventListener('showToast', function(evt) { | |
| 27 | 31 | var container = document.getElementById('notifications'); | |
| 28 | 32 | if (!container) return; | |
| 33 | + | // Cap the stack: drop the oldest toast immediately when at capacity. | |
| 34 | + | while (container.childElementCount >= TOAST_MAX_VISIBLE) { | |
| 35 | + | container.firstElementChild.remove(); | |
| 36 | + | } | |
| 29 | 37 | var toast = document.createElement('div'); | |
| 30 | 38 | toast.className = 'toast toast-' + (evt.detail.type || 'info'); | |
| 31 | 39 | toast.textContent = evt.detail.message || 'Action completed'; |
| @@ -4708,6 +4708,29 @@ footer { | |||
| 4708 | 4708 | } | |
| 4709 | 4709 | ||
| 4710 | 4710 | /* =========================================== | |
| 4711 | + | "NEW" BADGE (data-new-until) | |
| 4712 | + | =========================================== | |
| 4713 | + | Applied by whats_new.js while today's date is <= the element's | |
| 4714 | + | data-new-until. Renders a small violet dot to the right of the | |
| 4715 | + | element's content. Pair with relative-positioned parents. */ | |
| 4716 | + | ||
| 4717 | + | .is-new { | |
| 4718 | + | position: relative; | |
| 4719 | + | } | |
| 4720 | + | ||
| 4721 | + | .is-new::after { | |
| 4722 | + | content: ""; | |
| 4723 | + | position: absolute; | |
| 4724 | + | top: -2px; | |
| 4725 | + | right: -8px; | |
| 4726 | + | width: 6px; | |
| 4727 | + | height: 6px; | |
| 4728 | + | border-radius: 50%; | |
| 4729 | + | background: var(--violet, #6c5ce7); | |
| 4730 | + | box-shadow: 0 0 0 2px var(--background, #fff); | |
| 4731 | + | } | |
| 4732 | + | ||
| 4733 | + | /* =========================================== | |
| 4711 | 4734 | RESTART WARNING BANNER | |
| 4712 | 4735 | =========================================== */ | |
| 4713 | 4736 |
| @@ -0,0 +1,158 @@ | |||
| 1 | + | /* What's New modal + "New" badge mechanism. | |
| 2 | + | * | |
| 3 | + | * Two related UI surfaces that share localStorage as their source of truth: | |
| 4 | + | * | |
| 5 | + | * 1. Auto-show modal on a "feature version" bump. FEATURE_VERSION is an | |
| 6 | + | * opaque string controlled here (not CARGO_PKG_VERSION). Edit it when | |
| 7 | + | * you want to fire the modal — typically at the end of a sprint when | |
| 8 | + | * the changelog has something worth surfacing. Skipping a bump is | |
| 9 | + | * fine: nothing happens if FEATURE_VERSION matches the last-seen | |
| 10 | + | * version in localStorage. | |
| 11 | + | * | |
| 12 | + | * 2. "New" badge on individual links/buttons. Mark any element with | |
| 13 | + | * data-new-until="YYYY-MM-DD" — JS adds a `is-new` class while today | |
| 14 | + | * is before the date. CSS renders the dot. Once the date passes the | |
| 15 | + | * class is removed at page load and the dot disappears. | |
| 16 | + | * | |
| 17 | + | * Both deliberately avoid server cooperation: no API to call, no template | |
| 18 | + | * plumbing, no version-detection brittleness. The whole feature lives in | |
| 19 | + | * this file plus the CSS for `.is-new`. | |
| 20 | + | */ | |
| 21 | + | ||
| 22 | + | (function () { | |
| 23 | + | 'use strict'; | |
| 24 | + | ||
| 25 | + | // ── What's New modal ────────────────────────────────────────────── | |
| 26 | + | ||
| 27 | + | /// Bump this when /changelog has shipped something users should see. | |
| 28 | + | /// Setting it to a NEW value triggers the modal once per user. | |
| 29 | + | var FEATURE_VERSION = 'v0.8-synckit-per-key'; | |
| 30 | + | ||
| 31 | + | /// Summary shown in the modal body. Keep to 1–3 sentences; the link | |
| 32 | + | /// to /changelog is the canonical full list. | |
| 33 | + | var FEATURE_HEADLINE = 'SyncKit per-key storage'; | |
| 34 | + | var FEATURE_BODY = | |
| 35 | + | "SyncKit apps now bill per developer-defined key rather than per app, " + | |
| 36 | + | "with mini-gauges in the dashboard and per-key warning emails. " + | |
| 37 | + | "Existing SyncKit clients keep working — the SDK signature is updated " + | |
| 38 | + | "for new integrations."; | |
| 39 | + | ||
| 40 | + | var STORAGE_KEY = 'mnw_seen_feature_version'; | |
| 41 | + | ||
| 42 | + | function safeGet(key) { | |
| 43 | + | try { return localStorage.getItem(key); } catch (e) { return null; } | |
| 44 | + | } | |
| 45 | + | function safeSet(key, value) { | |
| 46 | + | try { localStorage.setItem(key, value); } catch (e) { /* ignore */ } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Show the modal regardless of seen state. Used by the "What's new" | |
| 50 | + | /// link in the footer. | |
| 51 | + | function showWhatsNewModal() { | |
| 52 | + | var existing = document.getElementById('whats-new-modal'); | |
| 53 | + | if (existing) { existing.remove(); return; } | |
| 54 | + | ||
| 55 | + | var overlay = document.createElement('div'); | |
| 56 | + | overlay.id = 'whats-new-modal'; | |
| 57 | + | overlay.className = 'modal-overlay'; | |
| 58 | + | overlay.style.display = 'flex'; | |
| 59 | + | overlay.onclick = function (e) { | |
| 60 | + | if (e.target === overlay) { | |
| 61 | + | overlay.remove(); | |
| 62 | + | safeSet(STORAGE_KEY, FEATURE_VERSION); | |
| 63 | + | } | |
| 64 | + | }; | |
| 65 | + | ||
| 66 | + | var content = document.createElement('div'); | |
| 67 | + | content.className = 'modal-content'; | |
| 68 | + | content.style.maxWidth = '480px'; | |
| 69 | + | content.style.padding = '2rem'; | |
| 70 | + | ||
| 71 | + | var header = document.createElement('div'); | |
| 72 | + | header.className = 'modal-header'; | |
| 73 | + | header.style.marginBottom = '1rem'; | |
| 74 | + | var h2 = document.createElement('h2'); | |
| 75 | + | h2.textContent = "What's new: " + FEATURE_HEADLINE; | |
| 76 | + | header.appendChild(h2); | |
| 77 | + | var closeBtn = document.createElement('button'); | |
| 78 | + | closeBtn.type = 'button'; | |
| 79 | + | closeBtn.className = 'modal-close'; | |
| 80 | + | closeBtn.setAttribute('aria-label', 'Dismiss'); | |
| 81 | + | closeBtn.innerHTML = '×'; | |
| 82 | + | closeBtn.onclick = function () { | |
| 83 | + | overlay.remove(); | |
| 84 | + | safeSet(STORAGE_KEY, FEATURE_VERSION); | |
| 85 | + | }; | |
| 86 | + | header.appendChild(closeBtn); | |
| 87 | + | ||
| 88 | + | var body = document.createElement('p'); | |
| 89 | + | body.textContent = FEATURE_BODY; | |
| 90 | + | ||
| 91 | + | var link = document.createElement('a'); | |
| 92 | + | link.href = '/changelog'; | |
| 93 | + | link.textContent = 'Full changelog →'; | |
| 94 | + | link.className = 'section-link'; | |
| 95 | + | link.style.display = 'inline-block'; | |
| 96 | + | link.style.marginTop = '0.75rem'; | |
| 97 | + | ||
| 98 | + | content.appendChild(header); | |
| 99 | + | content.appendChild(body); | |
| 100 | + | content.appendChild(link); | |
| 101 | + | overlay.appendChild(content); | |
| 102 | + | document.body.appendChild(overlay); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Auto-show on first visit after a feature-version bump. Records the | |
| 106 | + | /// seen version in localStorage so dismissal sticks across reloads. | |
| 107 | + | function maybeAutoShowWhatsNew() { | |
| 108 | + | var seen = safeGet(STORAGE_KEY); | |
| 109 | + | if (seen === FEATURE_VERSION) return; | |
| 110 | + | // First-ever visit also seeds the storage — no modal flash for | |
| 111 | + | // genuinely new users (they're already getting onboarded). | |
| 112 | + | if (seen === null) { | |
| 113 | + | safeSet(STORAGE_KEY, FEATURE_VERSION); | |
| 114 | + | return; | |
| 115 | + | } | |
| 116 | + | showWhatsNewModal(); | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Expose for footer link click + onboarding flows. | |
| 120 | + | window.showWhatsNewModal = showWhatsNewModal; | |
| 121 | + | ||
| 122 | + | // ── "New" badge ──────────────────────────────────────────────────── | |
| 123 | + | ||
| 124 | + | /// Walk every `[data-new-until]` element. If today < that date, mark | |
| 125 | + | /// it `.is-new` so the CSS dot renders. Otherwise strip the attribute | |
| 126 | + | /// so it doesn't get re-evaluated on later page loads. | |
| 127 | + | function applyNewBadges() { | |
| 128 | + | var today = new Date(); | |
| 129 | + | today.setHours(0, 0, 0, 0); | |
| 130 | + | var nodes = document.querySelectorAll('[data-new-until]'); | |
| 131 | + | for (var i = 0; i < nodes.length; i++) { | |
| 132 | + | var el = nodes[i]; | |
| 133 | + | var until = new Date(el.getAttribute('data-new-until')); | |
| 134 | + | if (isNaN(until.getTime())) { | |
| 135 | + | el.removeAttribute('data-new-until'); | |
| 136 | + | continue; | |
| 137 | + | } | |
| 138 | + | if (today <= until) { | |
| 139 | + | el.classList.add('is-new'); | |
| 140 | + | } else { | |
| 141 | + | el.removeAttribute('data-new-until'); | |
| 142 | + | el.classList.remove('is-new'); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // ── Init ────────────────────────────────────────────────────────── | |
| 148 | + | ||
| 149 | + | if (document.readyState === 'loading') { | |
| 150 | + | document.addEventListener('DOMContentLoaded', function () { | |
| 151 | + | applyNewBadges(); | |
| 152 | + | maybeAutoShowWhatsNew(); | |
| 153 | + | }); | |
| 154 | + | } else { | |
| 155 | + | applyNewBadges(); | |
| 156 | + | maybeAutoShowWhatsNew(); | |
| 157 | + | } | |
| 158 | + | })(); |
| @@ -22,6 +22,8 @@ | |||
| 22 | 22 | <a href="/docs">Docs</a> | |
| 23 | 23 | <a href="/policy">Legal</a> | |
| 24 | 24 | <a href="/changelog">Changelog</a> | |
| 25 | + | <a href="#" onclick="event.preventDefault(); showWhatsNewModal();">What's new</a> | |
| 26 | + | <a href="#" onclick="event.preventDefault(); toggleShortcutsHelp();" title="Keyboard shortcuts (?)">Shortcuts</a> | |
| 25 | 27 | </div> | |
| 26 | 28 | <p>© 2026 Makenotwork</p> | |
| 27 | 29 | </footer> | |
| @@ -32,6 +34,7 @@ | |||
| 32 | 34 | <script src="/static/mnw.js?v=0514"></script> | |
| 33 | 35 | <script src="/static/collections.js?v=0514"></script> | |
| 34 | 36 | <script src="/static/synckit-billing.js?v=0620"></script> | |
| 37 | + | <script src="/static/whats_new.js?v=0522"></script> | |
| 35 | 38 | {% block scripts %}{% endblock %} | |
| 36 | 39 | </body> | |
| 37 | 40 | </html> |
| @@ -100,6 +100,11 @@ | |||
| 100 | 100 | </p> | |
| 101 | 101 | {% endif %} | |
| 102 | 102 | ||
| 103 | + | <p class="landing-stats-line"> | |
| 104 | + | <a href="/changelog">What's new →</a> | |
| 105 | + | </p> | |
| 106 | + | ||
| 107 | + | ||
| 103 | 108 | <div class="landing-principles"> | |
| 104 | 109 | <h2 class="section-label">Why this exists</h2> | |
| 105 | 110 | <ul class="how-list"> |