Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32d5621cf4 | |||
| 95bb95cecd | |||
| 0861047952 | |||
| ac572e165a | |||
| dbeaa645c0 |
@@ -27,16 +27,22 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
- **IPv4 and IPv6:** IPv6 inside the tunnel is turned on automatically when the
|
||||
server has a global IPv6 address.
|
||||
- **Traffic history:** kept in `stats.json`, hourly for 48 h and daily for
|
||||
400 days by default (Settings → Data retention).
|
||||
400 days by default (Settings → Logs & history).
|
||||
- **Connection history:** every online session per peer, with start, duration,
|
||||
address and traffic. A new session starts when a device changes networks.
|
||||
Country and network operator come from the free
|
||||
[DB-IP Lite](https://db-ip.com) databases (CC BY 4.0). GHOSTWIRE downloads
|
||||
them monthly (about 20 MB) and looks addresses up locally, so peer addresses
|
||||
never leave the server. You can switch this off under Settings → Data
|
||||
retention.
|
||||
never leave the server. You can switch this off under Settings → Logs &
|
||||
history.
|
||||
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
|
||||
kept by default. Changes are marked as audit entries.
|
||||
kept by default, and shown on the Log page. Changes are marked as audit
|
||||
entries.
|
||||
- **Update notice:** once a day the server asks Gitea or GitHub (your choice
|
||||
under Settings → Updates) for the latest release. A newer one shows in the
|
||||
sidebar, on the Dashboard and in Settings, with its release notes and the
|
||||
commands to update this server. Nothing about the server is sent; the check
|
||||
can be switched off.
|
||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||
certificate files, or plain HTTP behind a reverse proxy.
|
||||
|
||||
@@ -176,7 +182,7 @@ the running service.
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>` (keeping the newest 3 such copies), replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary and config are put back and restarted. It refuses older versions without `-force`. |
|
||||
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
||||
| `GHOSTWIRE passwd [username]` | Sets a user's password (default: the first user) and reloads the running service. The way back in if you are locked out. |
|
||||
| `GHOSTWIRE version` | Prints the version. |
|
||||
@@ -218,6 +224,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
|---|---|
|
||||
| `GHOSTWIRE` | the program |
|
||||
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
||||
| `config.json.bak-*` | copies of `config.json` made by `update`; the newest 3 are kept, and Settings → Upkeep lists and removes them |
|
||||
| `stats.json` | traffic and connection history per peer |
|
||||
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
||||
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
||||
@@ -264,9 +271,10 @@ POST /peers/{id}/enable | /disable | /issue-config
|
||||
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
|
||||
GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
||||
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||
GET /settings PATCH /settings POST /restart
|
||||
GET /settings PATCH /settings POST /restart POST /updates/check
|
||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||
signed in: GET|DELETE /update-backups · DELETE /update-backups/{name} (config copies made by update)
|
||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||
```
|
||||
|
||||
@@ -275,6 +283,13 @@ public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link o
|
||||
setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a
|
||||
link, the peer's current keys keep working until the link is opened.
|
||||
|
||||
`GET /settings` includes `updates`: the running and latest version,
|
||||
`available`, the release notes and the download links for this server's
|
||||
platform. `PATCH /settings` `{"updates": {"source": "gitea"|"github",
|
||||
"check": false}}` picks the source or switches the daily check off;
|
||||
`POST /updates/check` checks now. `GET /auth/me` has `updateAvailable` with
|
||||
the newer version while there is one.
|
||||
|
||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||
downloaded, `up` is what it uploaded.
|
||||
|
||||
@@ -303,6 +318,9 @@ Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and past
|
||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||
pairing.
|
||||
|
||||
The iOS app is currently in beta testing. For an invite, email
|
||||
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
|
||||
|
||||
## Development
|
||||
|
||||
On macOS (or any non-Linux system), `make dev` starts the app on
|
||||
|
||||
@@ -26,6 +26,7 @@ type App struct {
|
||||
logPath string
|
||||
logw *rotatingWriter // nil in tests
|
||||
geo *Geo // nil in tests
|
||||
updates *Updater // nil in tests
|
||||
started time.Time
|
||||
shutdown func() // graceful stop; systemd restarts the service
|
||||
}
|
||||
@@ -173,6 +174,7 @@ func (a *App) routes() http.Handler {
|
||||
// need a signed-in user.
|
||||
g("GET /api/v1/settings", a.getSettings)
|
||||
g("PATCH /api/v1/settings", a.patchSettings)
|
||||
g("POST /api/v1/updates/check", a.checkUpdates)
|
||||
g("POST /api/v1/restart", a.restart)
|
||||
adm("GET /api/v1/tokens", a.listTokens)
|
||||
adm("POST /api/v1/tokens", a.createToken)
|
||||
@@ -181,6 +183,9 @@ func (a *App) routes() http.Handler {
|
||||
g("GET /api/v1/logs/download", a.downloadLog)
|
||||
adm("GET /api/v1/backup", a.backup)
|
||||
adm("POST /api/v1/restore", a.restore)
|
||||
adm("GET /api/v1/update-backups", a.listUpdateBackups)
|
||||
adm("DELETE /api/v1/update-backups", a.removeUpdateBackups)
|
||||
adm("DELETE /api/v1/update-backups/{name}", a.removeUpdateBackup)
|
||||
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
|
||||
@@ -258,6 +263,9 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||
}
|
||||
if v := a.updates.Available(); v != "" {
|
||||
out["updateAvailable"] = v
|
||||
}
|
||||
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||
}
|
||||
@@ -999,6 +1007,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"updates": a.updates.Status(),
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
"logPath": a.logPath,
|
||||
})
|
||||
@@ -1016,12 +1025,19 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var restart bool
|
||||
err = a.store.Update(func(c *Config) error {
|
||||
before, _ := json.Marshal(c.Web)
|
||||
// Session length applies to the next sign-in; everything else in
|
||||
// web needs a restart.
|
||||
listen := func() string {
|
||||
w := c.Web
|
||||
w.SessionHours = 0
|
||||
b, _ := json.Marshal(w)
|
||||
return string(b)
|
||||
}
|
||||
before := listen()
|
||||
if err := field(m, "web", &c.Web); err != nil {
|
||||
return err
|
||||
}
|
||||
after, _ := json.Marshal(c.Web)
|
||||
restart = string(before) != string(after)
|
||||
restart = listen() != before
|
||||
if err := field(m, "stats", &c.Stats); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1031,6 +1047,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "updates", &c.Updates); err != nil {
|
||||
return err
|
||||
}
|
||||
return field(m, "log", &c.Log)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1176,6 +1195,17 @@ func (a *App) applyRuntime(c *Config) {
|
||||
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
||||
}
|
||||
a.geo.SetEnabled(c.Stats.geoEnabled())
|
||||
a.updates.Set(c.Updates)
|
||||
}
|
||||
|
||||
// checkUpdates asks the release source now and returns what it found.
|
||||
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
if a.updates == nil || !a.updates.Status().Enabled {
|
||||
writeErr(w, badRequest("the update check is switched off"))
|
||||
return
|
||||
}
|
||||
a.updates.Check(r.Context())
|
||||
writeJSON(w, http.StatusOK, a.updates.Status())
|
||||
}
|
||||
|
||||
func (a *App) geoStatus() GeoStatus {
|
||||
|
||||
@@ -69,7 +69,10 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
||||
.side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
|
||||
.side .acct span span { color: #a9aaa5; }
|
||||
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
||||
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
||||
.side .footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px 0; }
|
||||
.side .footrow .upd { font-size: 11.5px; font-weight: 500; color: #cfe2f8; background: #1f3550; border: 1px solid #2d4a6e; padding: 2px 8px; border-radius: 999px; text-decoration: none; white-space: nowrap; }
|
||||
.side .footrow .upd:hover { color: #fff; }
|
||||
.side .nav .pip { margin-left: auto; width: 7px; height: 7px; border-radius: 50%; background: #6aa6ea; }
|
||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
||||
/* Beside the page (not stacked above it on a phone), the sidebar stays in
|
||||
place while the page scrolls, so the account link is always visible. */
|
||||
@@ -129,6 +132,9 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
|
||||
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
|
||||
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
|
||||
.notice.err { background: #fbefee; color: var(--bad-ink); }
|
||||
.notice.new { background: #e8f0fa; color: #174d8f; flex-wrap: wrap; align-items: center; }
|
||||
.notice.new .actions { margin-left: auto; }
|
||||
.btn.ghost { background: transparent; border-color: transparent; }
|
||||
.notice .btn { margin-left: auto; }
|
||||
|
||||
/* tables */
|
||||
@@ -173,28 +179,50 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
||||
.kv dt { color: var(--ink-2); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* health: public addresses, then one tile per check */
|
||||
/* health: public addresses on the left, one row per check on the right */
|
||||
.hcbody { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 12px; margin-top: 16px; }
|
||||
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.hchead > span { font-size: 13px; color: var(--ink-2); }
|
||||
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
|
||||
.hcaddrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.hcaddr { background: var(--ground); border-radius: 10px; padding: 14px 16px; min-width: 0; }
|
||||
.hcaddrs { display: flex; flex-direction: column; gap: 12px; }
|
||||
.hcaddr { flex: 1; display: flex; flex-direction: column; justify-content: center; background: var(--ground); border-radius: 10px; padding: 14px 18px; min-width: 0; }
|
||||
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.hcaddr .v.mono { font-size: 20px; }
|
||||
.hcaddr .v.mono { font-size: 22px; }
|
||||
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
|
||||
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
|
||||
.hcaddr.bad .v { color: var(--bad-ink); }
|
||||
.hctiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin-top: 12px; }
|
||||
.hctile { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.hctile .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||
.hctile .l > span:first-child { flex: 1; min-width: 0; }
|
||||
.hctile .s { font-size: 15px; font-weight: 500; }
|
||||
.hctile .r { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.hctile .p { font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||
.hctile.bad { border-color: #e6b3b0; background: #fdf6f5; }
|
||||
.hctile.bad .s { color: var(--bad-ink); }
|
||||
@media (max-width: 640px) { .hctiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.hclist { border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
|
||||
.hcrow { display: grid; grid-template-columns: 8px minmax(0, 190px) minmax(0, 1fr); gap: 2px 14px; align-items: baseline; padding: 11px 16px; border-top: 1px solid var(--line-2); }
|
||||
.hcrow:first-child { border-top: 0; }
|
||||
.hcrow > .dot { align-self: center; }
|
||||
.hcrow .l { font-size: 13px; color: var(--ink-2); }
|
||||
.hcrow .v { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0; }
|
||||
.hcrow .s { font-weight: 500; }
|
||||
.hcrow .r { font-size: 12px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.hcrow .p { grid-column: 2 / -1; font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||
.hcrow.bad { background: #fdf6f5; }
|
||||
.hcrow.bad .s { color: var(--bad-ink); }
|
||||
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
|
||||
|
||||
/* updates */
|
||||
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.upbox > span { font-size: 12px; color: var(--ink-2); }
|
||||
.upbox strong { font-size: 15px; font-weight: 500; }
|
||||
.upbox strong.mono { font-size: 16px; }
|
||||
.upbox.new { background: #e8f0fa; box-shadow: inset 0 0 0 1px #bcd2ee; }
|
||||
.upbox.new strong { color: #174d8f; }
|
||||
.uptodate { display: flex; align-items: center; gap: 10px; margin: 14px 0 0; font-weight: 500; }
|
||||
.upnotes { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-top: 14px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
|
||||
.upnotes p { margin: 0; max-width: 80ch; }
|
||||
.upnotes ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; max-width: 80ch; }
|
||||
.upnotes .hd, .upcmd .hd { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.upnotes .hd a { margin-left: auto; }
|
||||
.upcmd { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
|
||||
.uprow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-2); }
|
||||
#updates > .notice { margin-top: 14px; }
|
||||
|
||||
/* activity */
|
||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
@@ -342,3 +370,11 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
||||
.mfarow .grow { flex: 1; min-width: 0; }
|
||||
.dlg .secret { font-size: 15px; letter-spacing: 0.04em; overflow-wrap: anywhere; }
|
||||
.dlg .codes { columns: 2; font-size: 15px; line-height: 1.8; }
|
||||
|
||||
/* settings groups; the log page */
|
||||
.group { margin-top: 20px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.group h2 { margin: 0; font-size: 19px; font-weight: 600; }
|
||||
.group p { margin: 0; font-size: 13px; color: var(--ink-2); }
|
||||
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.saves { font-size: 12px; color: var(--ink-3); }
|
||||
pre.log.tall { max-height: calc(100vh - 260px); min-height: 420px; }
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||
log: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
|
||||
};
|
||||
|
||||
@@ -528,9 +529,9 @@
|
||||
|
||||
// ---------- shell, router ----------
|
||||
|
||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/log', 'log', 'Log'], ['#/settings', 'settings', 'Settings']];
|
||||
let navLinks = {};
|
||||
let srvBox, peerCount;
|
||||
let srvBox, peerCount, verRow;
|
||||
|
||||
function buildShell() {
|
||||
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
|
||||
@@ -546,13 +547,25 @@
|
||||
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
|
||||
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
|
||||
h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))),
|
||||
h('div', { class: 'footrow' },
|
||||
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
||||
(verRow = h('div', { class: 'footrow' }))));
|
||||
main = h('main', { class: 'main', id: 'main' });
|
||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||
drawUpdateHint();
|
||||
refreshSide();
|
||||
}
|
||||
|
||||
// drawUpdateHint shows a newer release next to the version in the sidebar
|
||||
// and as a dot on Settings.
|
||||
function drawUpdateHint() {
|
||||
if (!verRow) return;
|
||||
const v = me.updateAvailable;
|
||||
fill(verRow, h('span', null, 'v' + me.version.replace(/^v/, '')),
|
||||
v ? h('a', { class: 'upd', href: '#/settings#updates' }, v + ' available') : null);
|
||||
const set = navLinks['#/settings'];
|
||||
set.querySelectorAll('.pip, .sr').forEach((e) => e.remove());
|
||||
if (v) set.append(h('span', { class: 'pip', title: 'Update available' }), h('span', { class: 'sr' }, ', update available'));
|
||||
}
|
||||
|
||||
async function refreshSide() {
|
||||
try {
|
||||
const s = await api('GET', '/status');
|
||||
@@ -582,7 +595,8 @@
|
||||
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||
[/^#\/server$/, '#/server', viewServer],
|
||||
[/^#\/settings$/, '#/settings', viewSettings],
|
||||
[/^#\/log$/, '#/log', viewLog],
|
||||
[/^#\/settings(#\w+)?$/, '#/settings', viewSettings],
|
||||
[/^#\/account$/, '#/account', viewAccount],
|
||||
];
|
||||
|
||||
@@ -1005,6 +1019,8 @@
|
||||
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
||||
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
|
||||
|
||||
updateBanner(),
|
||||
|
||||
h('div', { class: 'tiles' },
|
||||
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
|
||||
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
|
||||
@@ -1035,7 +1051,7 @@
|
||||
h('td', { class: 'num' }, fmtBytes(p.stats.up24h)))))))
|
||||
: h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))),
|
||||
logs ? h('section', { class: 'card' },
|
||||
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/settings' }, 'Log')),
|
||||
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, 'Log')),
|
||||
logs.lines.length
|
||||
? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l)))))
|
||||
: h('p', { class: 'empty' }, 'No changes yet.')) : null));
|
||||
@@ -1511,9 +1527,9 @@
|
||||
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
|
||||
|
||||
// healthParts turns the server's checks into the Health card: the public
|
||||
// address per IP family (from its uplink and public address checks), then
|
||||
// one tile per other check with a plain-word status, the raw setting and,
|
||||
// when it fails, what is wrong.
|
||||
// address per IP family (from its uplink and public address checks) and,
|
||||
// beside them, one row per other check with a plain-word status, the raw
|
||||
// setting and, when it fails, what is wrong.
|
||||
function healthParts(checks) {
|
||||
const by = Object.fromEntries(checks.map((c) => [c.name, c]));
|
||||
const addrs = [];
|
||||
@@ -1563,14 +1579,14 @@
|
||||
return h('section', { class: 'card', 'aria-labelledby': 'hc' },
|
||||
h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'),
|
||||
h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')),
|
||||
h('div', { class: 'hcbody' },
|
||||
hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' },
|
||||
h('div', { class: 'l' }, dot(a.ok), a.label),
|
||||
h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null,
|
||||
h('div', { class: 'hctiles' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hctile' : 'hctile bad', title: t.title || null },
|
||||
h('div', { class: 'l' }, h('span', null, t.label), dot(t.ok)),
|
||||
h('div', { class: 's' }, t.status),
|
||||
t.raw ? h('div', { class: 'r mono' }, t.raw) : null,
|
||||
t.problem ? h('div', { class: 'p' }, t.problem) : null))));
|
||||
h('div', { class: 'hclist' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hcrow' : 'hcrow bad', title: t.title || null },
|
||||
dot(t.ok), h('span', { class: 'l' }, t.label),
|
||||
h('span', { class: 'v' }, h('span', { class: 's' }, t.status), t.raw ? h('span', { class: 'r mono' }, t.raw) : null),
|
||||
t.problem ? h('div', { class: 'p' }, t.problem) : null)))));
|
||||
}
|
||||
|
||||
async function viewServer(wrap) {
|
||||
@@ -1693,6 +1709,155 @@
|
||||
bar);
|
||||
}
|
||||
|
||||
// ---------- log ----------
|
||||
|
||||
async function viewLog(wrap) {
|
||||
const s = await api('GET', '/settings');
|
||||
let level = 'all';
|
||||
const box = h('pre', { class: 'log tall', tabindex: '0', 'aria-label': 'Log lines, newest first' });
|
||||
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' });
|
||||
let audit = false;
|
||||
const draw = async () => {
|
||||
pills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) =>
|
||||
h('button', { type: 'button', class: level === k && !audit ? 'pill on' : 'pill', 'aria-pressed': String(level === k && !audit), onClick: () => { level = k; audit = false; draw(); } }, t)),
|
||||
h('button', { type: 'button', class: audit ? 'pill on' : 'pill', 'aria-pressed': String(audit), onClick: () => { audit = true; draw(); } }, 'Changes only'));
|
||||
try {
|
||||
const r = await api('GET', '/logs?limit=500&level=' + level + (audit ? '&audit=1' : ''));
|
||||
box.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
|
||||
} catch (e) { box.textContent = e.message; }
|
||||
};
|
||||
fill(wrap,
|
||||
h('div', { class: 'head' },
|
||||
h('div', null, h('h1', null, 'Log'), h('p', { class: 'sub' }, h('span', { class: 'mono' }, s.logPath), ' · level ' + s.log.level + ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
|
||||
h('div', { class: 'actions' },
|
||||
h('a', { class: 'btn', href: '#/settings#logs' }, 'Log settings'),
|
||||
h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log'))),
|
||||
h('section', { class: 'card', 'aria-label': 'Log lines' },
|
||||
h('div', { class: 'cardhead' }, h('span', { class: 'muted' }, 'Newest first · refreshes every 10 s'), pills),
|
||||
h('div', { class: 'section' }, box)));
|
||||
every(10000, draw);
|
||||
await draw();
|
||||
}
|
||||
|
||||
// ---------- updates ----------
|
||||
|
||||
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
|
||||
|
||||
// updateBanner tells the Dashboard about a newer release until it is
|
||||
// hidden for that version.
|
||||
function updateBanner() {
|
||||
const v = me.updateAvailable;
|
||||
let hidden = null;
|
||||
try { hidden = localStorage.getItem(HIDE_UPDATE); } catch { /* storage blocked */ }
|
||||
if (!v || hidden === v) return null;
|
||||
const box = h('div', { class: 'notice new' },
|
||||
h('div', null, h('strong', null, 'GHOSTWIRE ' + v + ' is available. '), 'You\'re on v' + me.version.replace(/^v/, '') + '.'),
|
||||
h('div', { class: 'actions' },
|
||||
h('a', { class: 'btn small', href: '#/settings#updates' }, 'How to update'),
|
||||
h('button', { type: 'button', class: 'btn small ghost', onClick: () => {
|
||||
try { localStorage.setItem(HIDE_UPDATE, v); } catch { /* storage blocked */ }
|
||||
box.remove();
|
||||
} }, 'Hide until the next version')));
|
||||
return box;
|
||||
}
|
||||
|
||||
// mdInline turns **bold** and `code` into elements; everything else stays
|
||||
// text.
|
||||
const mdInline = (text) => text.split(/(\*\*[^*]+\*\*|`[^`]+`)/).filter(Boolean).map((t) =>
|
||||
t.startsWith('**') ? h('strong', null, t.slice(2, -2)) : t.startsWith('`') ? h('code', null, t.slice(1, -1)) : t);
|
||||
|
||||
// releaseSummary picks the opening paragraph and the first bullet list out
|
||||
// of the release notes; the full notes are a link away.
|
||||
function releaseSummary(md) {
|
||||
const lines = md.replace(/\r/g, '').split('\n');
|
||||
const para = [];
|
||||
for (const l of lines) {
|
||||
if (!l.trim()) { if (para.length) break; continue; }
|
||||
if (/^(#|- |\* |```|\|)/.test(l)) break;
|
||||
para.push(l.trim());
|
||||
}
|
||||
const items = [];
|
||||
let started = false;
|
||||
for (const l of lines) {
|
||||
const m = /^[-*] (.+)$/.exec(l);
|
||||
if (m) { started = true; items.push(m[1]); } else if (started && l.trim()) break;
|
||||
}
|
||||
return { summary: para.join(' '), items };
|
||||
}
|
||||
|
||||
function updatesCard(initial) {
|
||||
let st = initial;
|
||||
const card = h('section', { class: 'card', id: 'updates', 'aria-labelledby': 'upd' });
|
||||
const setStatus = (next) => {
|
||||
st = next;
|
||||
me.updateAvailable = st.enabled && st.available ? st.latest.version : undefined;
|
||||
drawUpdateHint();
|
||||
draw();
|
||||
};
|
||||
const checkNow = async (btn) => {
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
|
||||
try { setStatus(await api('POST', '/updates/check')); } catch (x) { toast(x.message, true); draw(); }
|
||||
};
|
||||
const save = async (updates) => {
|
||||
try {
|
||||
await api('PATCH', '/settings', { updates });
|
||||
const s = await api('GET', '/settings');
|
||||
if (s.updates.enabled) await checkNow(); else setStatus(s.updates);
|
||||
} catch (x) { toast(x.message, true); draw(); }
|
||||
};
|
||||
const SOURCES = [['gitea', 'Gitea', 'git.redetzke.aero/Redetzke/GHOSTWIRE'], ['github', 'GitHub', 'github.com/danielredetzke/GHOSTWIRE']];
|
||||
const srcName = () => SOURCES.find(([k]) => k === st.source)[1];
|
||||
|
||||
function draw() {
|
||||
const cur = 'v' + st.current.replace(/^v/, '');
|
||||
const rel = st.latest;
|
||||
const notes = rel && st.available ? releaseSummary(rel.notes || '') : null;
|
||||
const cmds = st.available && st.file ? [
|
||||
'curl -fLO ' + st.fileUrl,
|
||||
'curl -fLO ' + st.sumsUrl,
|
||||
'sha256sum -c --ignore-missing SHA256SUMS',
|
||||
'chmod +x ' + st.file,
|
||||
'sudo ./' + st.file + ' update',
|
||||
].join('\n') : null;
|
||||
fill(card,
|
||||
h('div', { class: 'cardhead' },
|
||||
h('h3', { id: 'upd' }, 'Updates'),
|
||||
st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null),
|
||||
h('div', { class: 'upvers' },
|
||||
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
|
||||
rel ? h('div', { class: st.available ? 'upbox new' : 'upbox' }, h('span', null, 'Latest release'), h('strong', { class: 'mono' }, rel.version)) : null,
|
||||
h('div', { class: 'upbox' }, h('span', null, 'This server'), h('strong', null, st.arch ? 'Linux · ' + st.arch : 'No release file for this platform'))),
|
||||
st.enabled && st.error ? h('div', { class: 'notice err', role: 'alert' },
|
||||
h('div', null, 'The last check failed: ' + st.error + '. ' + (st.lastOk ? 'Last worked ' + ago(st.lastOk) + '. ' : '') + 'Try the other source.')) : null,
|
||||
rel && !st.available ? h('p', { class: 'uptodate' }, h('span', { class: 'dot ok' }), 'GHOSTWIRE is up to date.') : null,
|
||||
notes ? h('div', { class: 'upnotes' },
|
||||
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
|
||||
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
|
||||
h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())),
|
||||
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
|
||||
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
|
||||
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
|
||||
cmds ? h('div', { class: 'upcmd' },
|
||||
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
|
||||
h('pre', { class: 'code' }, cmds),
|
||||
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
|
||||
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null,
|
||||
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
|
||||
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
|
||||
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
|
||||
h('span', null, h('strong', null, name), h('br'), h('span', { class: 'hint mono' }, where))))),
|
||||
h('span', { class: 'hint' }, 'Both carry the same releases and files. The check, the release notes and the download links use the source you pick.')),
|
||||
h('div', { class: 'uprow' },
|
||||
h('label', { class: 'check' },
|
||||
h('input', { type: 'checkbox', checked: st.enabled, onChange: (e) => save({ check: e.target.checked }) }),
|
||||
h('span', null, 'Check for updates once a day', h('br'),
|
||||
h('span', { class: 'hint' }, 'Asks ' + new URL(st.sourceUrl).host + ' for the latest release. Nothing about this server is sent.'))),
|
||||
st.enabled ? h('button', { type: 'button', class: 'btn small', onClick: (e) => checkNow(e.currentTarget) }, 'Check now') : null));
|
||||
}
|
||||
draw();
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
// ---------- my account ----------
|
||||
@@ -1800,7 +1965,6 @@
|
||||
return;
|
||||
}
|
||||
const [s, tk, us] = await Promise.all([api('GET', '/settings'), api('GET', '/tokens'), api('GET', '/users')]);
|
||||
let logLevelFilter = 'all';
|
||||
|
||||
// users
|
||||
const userBody = h('tbody');
|
||||
@@ -1965,20 +2129,13 @@
|
||||
try { await api('DELETE', '/tokens/' + t.id); toast('Revoked ' + t.name); reloadTokens(); } catch (e) { toast(e.message, true); }
|
||||
};
|
||||
|
||||
// logs
|
||||
const logBox = h('pre', { class: 'log', tabindex: '0', 'aria-label': 'Log lines, newest first' });
|
||||
const logPills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Level filter' });
|
||||
const drawLogs = async () => {
|
||||
logPills.replaceChildren(...[['all', 'All'], ['info', 'Info'], ['warn', 'Warn'], ['error', 'Error']].map(([k, t]) =>
|
||||
h('button', { type: 'button', class: logLevelFilter === k ? 'pill on' : 'pill', 'aria-pressed': String(logLevelFilter === k), onClick: () => { logLevelFilter = k; drawLogs(); } }, t)));
|
||||
try {
|
||||
const r = await api('GET', '/logs?limit=200&level=' + logLevelFilter);
|
||||
logBox.textContent = r.lines.length ? r.lines.map(fmtLogLine).join('\n') : 'No entries at this level.';
|
||||
} catch (e) { logBox.textContent = e.message; }
|
||||
};
|
||||
const levelSel = h('select', { id: 'lv', onChange: async (e) => {
|
||||
try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); }
|
||||
} }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l)));
|
||||
const levelSel = h('select', { id: 'lv' }, [['debug', 'Debug: everything'], ['info', 'Info'], ['warn', 'Warnings and errors'], ['error', 'Errors only']].map(([l, t]) => h('option', { value: l, selected: s.log.level === l }, t)));
|
||||
const sessSel = h('select', { id: 'st', onChange: async (e) => {
|
||||
try { await api('PATCH', '/settings', { web: { ...s.web, sessionHours: Number(e.target.value) } }); s.web.sessionHours = Number(e.target.value); toast('Session length saved'); } catch (x) { toast(x.message, true); }
|
||||
} }, [[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: s.web.sessionHours === v }, t)));
|
||||
const geoBox = h('input', { type: 'checkbox', id: 'geo', checked: s.stats.geoip !== false, onChange: async (e) => {
|
||||
try { await api('PATCH', '/settings', { stats: { ...s.stats, geoip: e.target.checked } }); toast(e.target.checked ? 'Country lookup on' : 'Country lookup off'); } catch (x) { e.target.checked = !e.target.checked; toast(x.message, true); }
|
||||
} });
|
||||
|
||||
// sign-in rules
|
||||
const requireBox = h('input', { type: 'checkbox', id: 'rq', checked: s.signin.requireMfa, onChange: async (e) => {
|
||||
@@ -2028,7 +2185,6 @@
|
||||
const logFiles = h('input', { id: 'rf', type: 'number', min: '1', max: '100', value: s.log.maxFiles, inputMode: 'numeric' });
|
||||
const hourly = presetSelect('rh', s.stats.hourlyHours, [[24, '1 day'], [48, '2 days'], [168, '7 days'], [336, '14 days'], [744, '31 days']], 'hours');
|
||||
const daily = presetSelect('rd', s.stats.dailyDays, [[30, '30 days'], [90, '90 days'], [180, '6 months'], [400, '13 months'], [730, '2 years'], [1825, '5 years'], [3660, '10 years']], 'days');
|
||||
const geo = h('input', { type: 'checkbox', checked: s.stats.geoip !== false });
|
||||
const geoStatus = s.geo && s.geo.updated ? 'Database from ' + fmtDate(s.geo.updated) + '.' : 'Not downloaded yet.';
|
||||
const diskHint = h('span', { class: 'hint' });
|
||||
const drawDiskHint = () => {
|
||||
@@ -2047,14 +2203,45 @@
|
||||
if (shrinks && !await confirmDialog({ title: 'Delete older data?', text: 'The new limits are lower: older log files and traffic history beyond them are deleted. This cannot be undone.', ok: 'Save and delete', danger: true })) return;
|
||||
try {
|
||||
await api('PATCH', '/settings', {
|
||||
log: { ...s.log, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
|
||||
stats: { hourlyHours: next.hourlyHours, dailyDays: next.dailyDays, geoip: geo.checked },
|
||||
log: { ...s.log, level: levelSel.value, maxSizeMB: next.maxSizeMB, maxFiles: next.maxFiles },
|
||||
stats: { ...s.stats, hourlyHours: next.hourlyHours, dailyDays: next.dailyDays },
|
||||
});
|
||||
toast('Retention saved');
|
||||
toast('Logs & history saved');
|
||||
render();
|
||||
} catch (x) { retErr.textContent = x.message; }
|
||||
};
|
||||
|
||||
// copies of config.json that updates leave behind
|
||||
const copies = h('div', { class: 'section' });
|
||||
const drawCopies = async () => {
|
||||
let list;
|
||||
try { list = (await api('GET', '/update-backups')).backups; } catch (x) { fill(copies, h('p', { class: 'err-text' }, x.message)); return; }
|
||||
const remove = async (b) => {
|
||||
if (!await confirmDialog({ title: 'Remove ' + b.name + '?', text: 'This copy of your settings from ' + b.version + ' is deleted from the server. It cannot be restored.', ok: 'Remove', danger: true })) return;
|
||||
try { await api('DELETE', '/update-backups/' + encodeURIComponent(b.name)); toast('Removed ' + b.name); drawCopies(); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const removeAll = async () => {
|
||||
const n = list.length;
|
||||
if (!await confirmDialog({ title: n === 1 ? 'Remove the copy?' : 'Remove all ' + n + ' copies?', text: 'They are deleted from the server and cannot be restored. Your current settings are not affected.', ok: 'Remove all', danger: true })) return;
|
||||
try { await api('DELETE', '/update-backups'); toast(n === 1 ? 'Removed 1 copy' : 'Removed ' + n + ' copies'); drawCopies(); } catch (x) { toast(x.message, true); }
|
||||
};
|
||||
const total = list.reduce((t, b) => t + b.size, 0);
|
||||
fill(copies,
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('strong', null, 'Copies made by updates'),
|
||||
h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Each update saves the previous config.json next to it. They hold the same secrets as a backup.')),
|
||||
list.length ? h('button', { type: 'button', class: 'btn', onClick: removeAll }, 'Remove all') : null),
|
||||
list.length ? h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
|
||||
h('thead', null, h('tr', null, h('th', null, 'File'), h('th', null, 'From version'), h('th', null, 'Saved'), h('th', { class: 'num' }, 'Size'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
h('tbody', null, list.map((b, i) => h('tr', null,
|
||||
h('td', null, h('span', { class: 'mono' }, b.name), i === 0 ? h('span', { class: 'tag plain' }, 'Newest') : null),
|
||||
h('td', { class: 'mono' }, b.version),
|
||||
h('td', null, fmtStamp(b.modified)),
|
||||
h('td', { class: 'num' }, fmtBytes(b.size)),
|
||||
h('td', { class: 'num' }, h('button', { type: 'button', class: 'btn small', onClick: () => remove(b) }, 'Remove'))))))) : h('p', { class: 'muted', style: { margin: '12px 0 0' } }, 'No copies from updates.'),
|
||||
list.length ? h('p', { class: 'hint', style: { margin: '8px 0 0' } }, list.length + (list.length === 1 ? ' copy, ' : ' copies, ') + fmtBytes(total) + ' next to config.json. After each update, only the newest 3 are kept.') : null);
|
||||
};
|
||||
|
||||
// backup
|
||||
const restoreInput = h('input', { type: 'file', accept: 'application/json,.json', hidden: true, onChange: async (e) => {
|
||||
const f = e.target.files[0];
|
||||
@@ -2070,85 +2257,94 @@
|
||||
} catch (x) { toast(x.message, true); }
|
||||
} });
|
||||
|
||||
const groupHead = (id, title, text) => h('div', { class: 'group', id }, h('h2', null, title), h('p', null, text));
|
||||
fill(wrap,
|
||||
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')),
|
||||
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Who can sign in, the web interface, logs and history, updates and backups')),
|
||||
restartBox,
|
||||
|
||||
groupHead('g-access', 'Access', 'Who can sign in, and how.'),
|
||||
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
|
||||
h('div', null, h('h3', { id: 'usr' }, 'Users'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'Everyone here is an admin. You cannot delete yourself, so one user always remains.')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: addUser }, 'Add user')),
|
||||
h('div', { class: 'tbl' }, h('table', null,
|
||||
h('thead', null, h('tr', null, h('th', null, 'User'), h('th', null, 'Status'), h('th', null, 'Two-step'), h('th', null, 'Last sign-in'), h('th', null, 'App tokens'), h('th', null, 'Created'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
userBody))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'sgn' },
|
||||
h('h2', { id: 'sgn' }, 'Sign-in'),
|
||||
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey. Changes apply immediately.'),
|
||||
h('div', { class: 'cardhead' }, h('h3', { id: 'sgn' }, 'Sign-in'), h('span', { class: 'saves' }, 'Saves right away')),
|
||||
h('p', { class: 'lead' }, 'Everyone sets up two-step sign-in under My account: an authenticator app or passkeys, including on a YubiKey.'),
|
||||
h('label', { class: 'check' }, requireBox, h('span', null, 'Require two-step sign-in for everyone', h('br'),
|
||||
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.')))),
|
||||
h('span', { class: 'hint' }, 'Users without it are asked to set it up right after their password. To help someone who lost their phone or key, use Edit → Reset two-step sign-in.'))),
|
||||
h('div', { class: 'grid section' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Stay signed in for'), sessSel, h('span', { class: 'hint' }, 'Applies to new sign-ins')),
|
||||
h('div', { class: 'field' }), h('div', { class: 'field' }))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'api' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h3', { id: 'api' }, 'iOS app and API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
|
||||
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
|
||||
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
tbody))),
|
||||
|
||||
groupHead('g-web', 'Web interface', 'Where this interface listens and what strangers see.'),
|
||||
h('form', { class: 'card', onSubmit: saveWeb, 'aria-labelledby': 'web' },
|
||||
h('h2', { id: 'web' }, 'Web interface'),
|
||||
h('p', { class: 'lead' }, 'Changes take effect after the service restarts.'),
|
||||
h('div', { class: 'cardhead' }, h('h3', { id: 'web' }, 'Address and HTTPS'), h('span', { class: 'saves' }, 'Save, then restart')),
|
||||
h('p', { class: 'lead' }, 'Usually set once during install.'),
|
||||
h('div', { class: 'grid' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'la' }, 'Listen address'), h('input', { id: 'la', class: 'mono', value: web.listen, onInput: (e) => { web.listen = e.target.value.trim(); } })),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'hl' }, 'HTTP listen address'), h('input', { id: 'hl', class: 'mono', value: web.httpListen, placeholder: 'off', onInput: (e) => { web.httpListen = e.target.value.trim(); } }), h('span', { class: 'hint' }, 'Redirects to HTTPS and answers Let\'s Encrypt http-01 checks. Empty turns it off')),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'tls' }, 'HTTPS'), modeSel),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'st' }, 'Session length'), h('select', { id: 'st', onChange: (e) => { web.sessionHours = Number(e.target.value); } },
|
||||
[[1, '1 hour'], [12, '12 hours'], [24, '1 day'], [168, '7 days']].map(([v, t]) => h('option', { value: String(v), selected: web.sessionHours === v }, t)))),
|
||||
s.fingerprint ? h('div', { class: 'field' }, h('label', { htmlFor: 'fp' }, 'Certificate fingerprint (SHA-256)'), h('input', { id: 'fp', class: 'mono', value: s.fingerprint, readOnly: true }), h('span', { class: 'hint' }, 'The iOS app pins this when pairing')) : null),
|
||||
h('div', { class: 'section' }, fAcme, fFiles),
|
||||
webErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'dcy' },
|
||||
h('h2', { id: 'dcy' }, 'Decoy'),
|
||||
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working. Changes apply immediately.'),
|
||||
h('div', { class: 'cardhead' }, h('h3', { id: 'dcy' }, 'Decoy'), h('span', { class: 'saves' }, 'Saves right away')),
|
||||
h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working.'),
|
||||
h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'),
|
||||
h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))),
|
||||
h('div', { class: 'grid section' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'api' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')),
|
||||
h('button', { type: 'button', class: 'btn primary', onClick: () => pairDialog(reloadTokens) }, 'Pair iOS app')),
|
||||
h('div', { class: 'tbl section' }, h('table', { class: 'narrow' },
|
||||
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Owner'), h('th', null, 'Access'), h('th', null, 'Created'), h('th', null, 'Last used'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||
tbody))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'lg' },
|
||||
h('div', { class: 'cardhead' },
|
||||
h('div', null, h('h2', { id: 'lg' }, 'Log'), h('p', { class: 'lead', style: { marginBottom: '0' } }, h('span', { class: 'mono' }, s.logPath), ' · rotates at ' + s.log.maxSizeMB + ' MB, keeps ' + s.log.maxFiles + ' files')),
|
||||
logPills),
|
||||
h('div', { class: 'section' }, logBox),
|
||||
h('div', { class: 'grid section' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
|
||||
h('div', { class: 'field', style: { justifyContent: 'flex-end' } }, h('a', { class: 'btn', href: '/api/v1/logs/download' }, 'Download log')))),
|
||||
|
||||
groupHead('logs', 'Logs & history', 'What the server records and for how long. The log itself is on the Log page.'),
|
||||
h('form', { class: 'card', onSubmit: saveRetention, 'aria-labelledby': 'ret' },
|
||||
h('h2', { id: 'ret' }, 'Data retention'),
|
||||
h('p', { class: 'lead' }, 'How much log and traffic history is kept. Changes apply immediately, without a restart.'),
|
||||
h('div', { class: 'cardhead' }, h('h3', { id: 'ret' }, 'Log and traffic history'), h('span', { class: 'saves' }, 'Save, no restart')),
|
||||
h('p', { class: 'lead' }, 'Lower limits delete older data when you save. All-time totals are always kept.'),
|
||||
h('div', { class: 'grid' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'lv' }, 'Log level'), levelSel),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'rs' }, 'Log file size (MB)'), logSize, h('span', { class: 'hint' }, 'The log starts a new file at this size. 1–1000')),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'rf' }, 'Old log files kept'), logFiles, diskHint)),
|
||||
h('div', { class: 'grid section' },
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'rh' }, 'Hourly traffic history'), hourly, h('span', { class: 'hint' }, 'Used by the 24-hour charts')),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history. All-time totals are always kept'))),
|
||||
h('label', { class: 'check section' }, geo, h('span', null, 'Show country and network of peer addresses', h('br'),
|
||||
h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus))),
|
||||
h('div', { class: 'field' }, h('label', { htmlFor: 'rd' }, 'Daily traffic history'), daily, h('span', { class: 'hint' }, 'Used by the 7- and 30-day charts and the connection history')),
|
||||
h('div', { class: 'field' })),
|
||||
retErr,
|
||||
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save retention'))),
|
||||
h('div', { class: 'formfoot' }, h('a', { class: 'btn', href: '#/log' }, 'Open the log'), h('button', { type: 'submit', class: 'btn primary' }, 'Save'))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'geo-h' },
|
||||
h('div', { class: 'cardhead' }, h('h3', { id: 'geo-h' }, 'Country and network lookup'), h('span', { class: 'saves' }, 'Saves right away')),
|
||||
h('label', { class: 'check' }, geoBox, h('span', null, 'Show country and network of peer addresses', h('br'),
|
||||
h('span', { class: 'hint' }, 'Downloads the free DB-IP Lite databases (about 20 MB) once a month and looks addresses up on this server only. ' + geoStatus)))),
|
||||
|
||||
groupHead('g-upkeep', 'Upkeep', 'New versions and copies of your settings.'),
|
||||
updatesCard(s.updates),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'bk' },
|
||||
h('h2', { id: 'bk' }, 'Backup & restore'),
|
||||
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key.'),
|
||||
h('h3', { id: 'bk' }, 'Backup & restore'),
|
||||
h('p', { class: 'lead' }, 'A backup is a copy of config.json with server key, peers, tokens and settings. Keep it safe: it contains the server\'s private key, preshared keys and authenticator app secrets.'),
|
||||
h('div', { class: 'actions' },
|
||||
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
||||
restoreInput)));
|
||||
await drawLogs();
|
||||
restoreInput),
|
||||
copies));
|
||||
drawCopies();
|
||||
const jumpTo = location.hash.split('#')[2];
|
||||
if (jumpTo) document.getElementById(jumpTo)?.scrollIntoView();
|
||||
}
|
||||
|
||||
|
||||
render();
|
||||
})();
|
||||
|
||||
@@ -34,8 +34,17 @@ type Config struct {
|
||||
Stats StatsConfig `json:"stats"`
|
||||
Decoy DecoyConfig `json:"decoy"`
|
||||
SignIn SignInConfig `json:"signin"`
|
||||
Updates UpdatesConfig `json:"updates"`
|
||||
}
|
||||
|
||||
// UpdatesConfig sets the daily check for a newer release.
|
||||
type UpdatesConfig struct {
|
||||
Check *bool `json:"check,omitempty"` // default on
|
||||
Source string `json:"source"` // gitea | github, see updateSources
|
||||
}
|
||||
|
||||
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
|
||||
|
||||
// SignInConfig holds the rules for signing in to the web interface.
|
||||
type SignInConfig struct {
|
||||
// RequireMFA sends users without two-step sign-in to set it up before
|
||||
@@ -246,6 +255,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.Decoy.Page == "" {
|
||||
c.Decoy.Page = "nginx"
|
||||
}
|
||||
if c.Updates.Source == "" {
|
||||
c.Updates.Source = "gitea"
|
||||
}
|
||||
if c.APITokens == nil {
|
||||
c.APITokens = []APIToken{}
|
||||
}
|
||||
@@ -382,6 +394,9 @@ func (c *Config) validate() error {
|
||||
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
||||
}
|
||||
if _, ok := updateSources[c.Updates.Source]; !ok {
|
||||
return fmt.Errorf("update source must be gitea or github")
|
||||
}
|
||||
switch c.Web.TLS.Mode {
|
||||
case "acme":
|
||||
if c.Web.TLS.Domain == "" {
|
||||
|
||||
@@ -222,15 +222,16 @@ func run(configPath string) error {
|
||||
auth := newAuth(store)
|
||||
app := &App{
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
||||
logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown,
|
||||
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
wg.Add(5)
|
||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.Run(stop) }()
|
||||
go func() { defer wg.Done(); stats.RunPings(stop) }()
|
||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||
go func() { defer wg.Done(); app.updates.Run(stop) }()
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestValidate(t *testing.T) {
|
||||
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
||||
} {
|
||||
cc := c.clone()
|
||||
mutate(cc)
|
||||
@@ -350,6 +351,30 @@ func TestAPI(t *testing.T) {
|
||||
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
||||
bearer("GET", "/backup", 403)
|
||||
bearer("GET", "/update-backups", 403)
|
||||
bearer("DELETE", "/update-backups", 403)
|
||||
|
||||
// Config copies made by update: listed newest first, removed one by
|
||||
// one or all at once; nothing else in the folder can be removed.
|
||||
for i, v := range []string{"v0.3.2", "v0.4.0"} {
|
||||
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||
_ = os.Chtimes(f, time.Now(), time.Now().Add(time.Duration(i-2)*time.Hour))
|
||||
}
|
||||
list := call("GET", "/update-backups", nil, 200)["backups"].([]any)
|
||||
if len(list) != 2 || list[0].(map[string]any)["version"] != "v0.4.0" {
|
||||
t.Fatalf("update backups: %v", list)
|
||||
}
|
||||
call("DELETE", "/update-backups/config.json", nil, 400)
|
||||
call("DELETE", "/update-backups/stats.json", nil, 400)
|
||||
call("DELETE", "/update-backups/config.json.bak-v9.9.9", nil, 400)
|
||||
call("DELETE", "/update-backups/config.json.bak-v0.3.2", nil, 200)
|
||||
if r := call("DELETE", "/update-backups", nil, 200); r["removed"] != float64(1) {
|
||||
t.Fatalf("remove all: %v", r)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config.json")); err != nil {
|
||||
t.Fatal("config.json is gone:", err)
|
||||
}
|
||||
|
||||
call("DELETE", "/peers/"+id, nil, 200)
|
||||
if len(store.Get().Peers) != 0 {
|
||||
@@ -1194,6 +1219,17 @@ func TestMFA(t *testing.T) {
|
||||
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[0].(string)}, 401)
|
||||
c3("POST", "/auth/login/recovery", map[string]string{"ticket": ticket, "code": codes[1].(string)}, 200)
|
||||
|
||||
// Session length needs no restart; the listen address does.
|
||||
web := store.Get().Web
|
||||
web.SessionHours = 24
|
||||
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != false || store.Get().Web.SessionHours != 24 {
|
||||
t.Fatalf("session length: %v", r)
|
||||
}
|
||||
web.Listen = "127.0.0.1:9443"
|
||||
if r := adm("PATCH", "/settings", map[string]any{"web": web}, 200); r["restartRequired"] != true {
|
||||
t.Fatalf("listen address: %v", r)
|
||||
}
|
||||
|
||||
// Required for everyone: a user without it can only set it up.
|
||||
adm("PATCH", "/settings", map[string]any{"signin": map[string]bool{"requireMfa": true}}, 200)
|
||||
u := adm("POST", "/users", map[string]any{"username": "eve", "password": "eve's password 1", "mustChangePassword": false}, 201)["user"].(map[string]any)
|
||||
|
||||
@@ -589,7 +589,7 @@ func cmdUpdate(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup := configFile + ".bak-" + oldVersion
|
||||
backup := newUpdateBackupPath(configFile, oldVersion, time.Now())
|
||||
step("Backing up config to %s", backup)
|
||||
if err := copyFile(configFile, backup, 0o600, uid, gid); err != nil {
|
||||
return err
|
||||
@@ -627,6 +627,11 @@ func cmdUpdate(args []string) error {
|
||||
}
|
||||
return fmt.Errorf("update failed, %s %s is running again: %w", appName, oldVersion, err)
|
||||
}
|
||||
if n, err := pruneUpdateBackups(configFile, keepUpdateBackups); err != nil {
|
||||
fmt.Fprintln(os.Stderr, " Could not remove older config backups:", err)
|
||||
} else if n > 0 {
|
||||
step("Removed %d older config backups, kept the newest %d", n, keepUpdateBackups)
|
||||
}
|
||||
fmt.Printf("\nUpdated %s %s → %s.\n", appName, oldVersion, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The update check asks one of the two places releases are published for
|
||||
// the latest one. Both carry the same tags and files.
|
||||
var updateSources = map[string]struct {
|
||||
Name string // shown in the web interface
|
||||
API string // latest release, as JSON
|
||||
Repo string // web page of the repository; downloads are under it
|
||||
}{
|
||||
"gitea": {"Gitea", "https://git.redetzke.aero/api/v1/repos/Redetzke/GHOSTWIRE/releases/latest", "https://git.redetzke.aero/Redetzke/GHOSTWIRE"},
|
||||
"github": {"GitHub", "https://api.github.com/repos/danielredetzke/GHOSTWIRE/releases/latest", "https://github.com/danielredetzke/GHOSTWIRE"},
|
||||
}
|
||||
|
||||
const updateCheckFreq = 24 * time.Hour
|
||||
|
||||
// Release is the latest published release as the source reports it.
|
||||
type Release struct {
|
||||
Version string `json:"version"` // tag, e.g. "v0.4.0"
|
||||
Published time.Time `json:"published"`
|
||||
Notes string `json:"notes"` // Markdown
|
||||
URL string `json:"url"` // release page
|
||||
}
|
||||
|
||||
// UpdateStatus is shown in the settings; Available also reaches the sidebar
|
||||
// and the Dashboard through /auth/me.
|
||||
type UpdateStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source"`
|
||||
Current string `json:"current"`
|
||||
Latest *Release `json:"latest"`
|
||||
Available bool `json:"available"` // Latest is newer than Current
|
||||
Checked *time.Time `json:"checked"` // last attempt
|
||||
Error string `json:"error,omitempty"`
|
||||
LastOK *time.Time `json:"lastOk"` // last attempt that worked
|
||||
// Download links for this server's platform; empty when no release
|
||||
// file is built for it.
|
||||
Arch string `json:"arch"`
|
||||
File string `json:"file,omitempty"`
|
||||
FileURL string `json:"fileUrl,omitempty"`
|
||||
SumsURL string `json:"sumsUrl,omitempty"`
|
||||
SourceURL string `json:"sourceUrl"` // repository page of the source
|
||||
}
|
||||
|
||||
type Updater struct {
|
||||
enabled atomic.Bool
|
||||
kick chan struct{}
|
||||
fetch func(ctx context.Context, url string) (*Release, error) // replaced in tests
|
||||
|
||||
mu sync.Mutex
|
||||
source string
|
||||
latest *Release
|
||||
checked *time.Time
|
||||
lastOK *time.Time
|
||||
err string
|
||||
}
|
||||
|
||||
func newUpdater(c UpdatesConfig) *Updater {
|
||||
u := &Updater{kick: make(chan struct{}, 1), fetch: fetchRelease, source: c.Source}
|
||||
u.enabled.Store(c.checkEnabled())
|
||||
return u
|
||||
}
|
||||
|
||||
// Set applies the settings. A new source or switching the check on checks
|
||||
// at once; switching it off forgets what the last check found.
|
||||
func (u *Updater) Set(c UpdatesConfig) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
on := c.checkEnabled()
|
||||
u.mu.Lock()
|
||||
changed := u.source != c.Source || u.enabled.Load() != on
|
||||
if u.source != c.Source || !on {
|
||||
u.latest, u.checked, u.lastOK, u.err = nil, nil, nil, ""
|
||||
}
|
||||
u.source = c.Source
|
||||
u.enabled.Store(on)
|
||||
u.mu.Unlock()
|
||||
if changed && on {
|
||||
select {
|
||||
case u.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks once a day while the check is on.
|
||||
func (u *Updater) Run(stop <-chan struct{}) {
|
||||
t := time.NewTicker(updateCheckFreq)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if u.enabled.Load() {
|
||||
u.Check(context.Background())
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
case <-u.kick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check asks the source for the latest release now.
|
||||
func (u *Updater) Check(ctx context.Context) {
|
||||
u.mu.Lock()
|
||||
source := u.source
|
||||
u.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
rel, err := u.fetch(ctx, updateSources[source].API)
|
||||
now := time.Now()
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if u.source != source { // the source changed meanwhile; that check counts
|
||||
return
|
||||
}
|
||||
u.checked = &now
|
||||
if err != nil {
|
||||
u.err = err.Error()
|
||||
slog.Warn("update check failed", "source", source, "err", err)
|
||||
return
|
||||
}
|
||||
u.latest, u.lastOK, u.err = rel, &now, ""
|
||||
if newerVersion(rel.Version, version) {
|
||||
slog.Info("update available", "version", rel.Version, "running", version)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) Status() UpdateStatus {
|
||||
if u == nil {
|
||||
return UpdateStatus{Current: version}
|
||||
}
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
src := updateSources[u.source]
|
||||
st := UpdateStatus{
|
||||
Enabled: u.enabled.Load(), Source: u.source, Current: version, Latest: u.latest,
|
||||
Checked: u.checked, Error: u.err, LastOK: u.lastOK, Arch: releaseArch(), SourceURL: src.Repo,
|
||||
}
|
||||
if u.latest != nil {
|
||||
st.Available = newerVersion(u.latest.Version, version)
|
||||
if st.Arch != "" {
|
||||
st.File = fmt.Sprintf("%s-%s-linux-%s", appName, u.latest.Version, st.Arch)
|
||||
base := src.Repo + "/releases/download/" + u.latest.Version + "/"
|
||||
st.FileURL, st.SumsURL = base+st.File, base+"SHA256SUMS"
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Available returns the newer release's version, or "".
|
||||
func (u *Updater) Available() string {
|
||||
if st := u.Status(); st.Enabled && st.Available {
|
||||
return st.Latest.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// releaseArch names this platform the way the release files do, or "" when
|
||||
// no file is built for it.
|
||||
func releaseArch() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
return ""
|
||||
}
|
||||
switch runtime.GOARCH {
|
||||
case "amd64", "arm64":
|
||||
return runtime.GOARCH
|
||||
case "arm":
|
||||
return "armv7"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchRelease(ctx context.Context, url string) (*Release, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", appName+"/"+strings.TrimPrefix(version, "v"))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, req.URL.Host)
|
||||
}
|
||||
// GitHub and Gitea name these fields the same.
|
||||
var r struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
Published time.Time `json:"published_at"`
|
||||
URL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
|
||||
}
|
||||
if _, ok := compareVersions(r.Tag, r.Tag); r.Draft || r.Prerelease || !ok {
|
||||
return nil, errors.New("the latest release is not a published version")
|
||||
}
|
||||
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
|
||||
}
|
||||
|
||||
// newerVersion reports whether latest is a higher version than running.
|
||||
// A running version that is not a version number is never out of date.
|
||||
func newerVersion(latest, running string) bool {
|
||||
c, ok := compareVersions(latest, running)
|
||||
return ok && c > 0
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewerVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
latest, running string
|
||||
want bool
|
||||
}{
|
||||
{"v0.4.0", "v0.3.2", true},
|
||||
{"v0.4.0", "0.3.2", true},
|
||||
{"v0.10.0", "v0.9.9", true},
|
||||
{"v1.0.0", "v0.99.0", true},
|
||||
{"v0.4.0", "v0.4.0", false},
|
||||
{"v0.4.0", "v0.4.0-3-gb18d16a", false}, // a build after the release
|
||||
{"v0.3.2", "v0.4.0", false},
|
||||
{"v0.4.0", "dev", false}, // not a version: never out of date
|
||||
{"latest", "v0.3.2", false},
|
||||
} {
|
||||
if got := newerVersion(tc.latest, tc.running); got != tc.want {
|
||||
t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.running, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRelease(t *testing.T) {
|
||||
var body string
|
||||
var status int
|
||||
var ua string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ua = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
status, body = 200, `{"tag_name":"v0.4.0","body":"Fixes.","published_at":"2026-10-05T06:15:28Z","html_url":"https://example.net/r/v0.4.0","draft":false,"prerelease":false}`
|
||||
r, err := fetchRelease(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Version != "v0.4.0" || r.Notes != "Fixes." || r.URL != "https://example.net/r/v0.4.0" || r.Published.IsZero() {
|
||||
t.Fatalf("release = %+v", r)
|
||||
}
|
||||
if !strings.HasPrefix(ua, appName+"/") {
|
||||
t.Errorf("User-Agent = %q", ua)
|
||||
}
|
||||
|
||||
status, body = 200, `{"tag_name":"v0.5.0-rc1","prerelease":true}`
|
||||
if _, err := fetchRelease(context.Background(), srv.URL); err == nil {
|
||||
t.Error("a pre-release was accepted")
|
||||
}
|
||||
status, body = 404, `{}`
|
||||
if _, err := fetchRelease(context.Background(), srv.URL); err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("HTTP 404: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdater(t *testing.T) {
|
||||
old := version
|
||||
version = "v0.3.2"
|
||||
defer func() { version = old }()
|
||||
|
||||
u := newUpdater(UpdatesConfig{Source: "gitea"})
|
||||
var asked string
|
||||
u.fetch = func(_ context.Context, url string) (*Release, error) {
|
||||
asked = url
|
||||
return &Release{Version: "v0.4.0"}, nil
|
||||
}
|
||||
u.Check(context.Background())
|
||||
if asked != updateSources["gitea"].API {
|
||||
t.Errorf("asked %q", asked)
|
||||
}
|
||||
st := u.Status()
|
||||
if !st.Available || u.Available() != "v0.4.0" || st.Checked == nil || st.LastOK == nil {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
if st.Arch != "" {
|
||||
want := "https://git.redetzke.aero/Redetzke/GHOSTWIRE/releases/download/v0.4.0/GHOSTWIRE-v0.4.0-linux-" + st.Arch
|
||||
if st.FileURL != want || !strings.HasSuffix(st.SumsURL, "/v0.4.0/SHA256SUMS") {
|
||||
t.Errorf("downloads = %q, %q", st.FileURL, st.SumsURL)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed check keeps the last good answer and reports the error.
|
||||
u.fetch = func(context.Context, string) (*Release, error) { return nil, errors.New("no route to host") }
|
||||
u.Check(context.Background())
|
||||
if st := u.Status(); st.Error != "no route to host" || st.Latest == nil {
|
||||
t.Errorf("after a failed check: %+v", st)
|
||||
}
|
||||
|
||||
// Another source forgets what the old one said; switching off hides it.
|
||||
u.Set(UpdatesConfig{Source: "github"})
|
||||
if st := u.Status(); st.Latest != nil || st.Error != "" || st.SourceURL != updateSources["github"].Repo {
|
||||
t.Errorf("after changing the source: %+v", st)
|
||||
}
|
||||
off := false
|
||||
u.fetch = func(context.Context, string) (*Release, error) { return &Release{Version: "v0.4.0"}, nil }
|
||||
u.Check(context.Background())
|
||||
u.Set(UpdatesConfig{Source: "github", Check: &off})
|
||||
if u.Available() != "" || u.Status().Enabled {
|
||||
t.Error("still reports an update with the check off")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Each update copies config.json to config.json.bak-<old version> next to
|
||||
// it, in case the new version must be rolled back. The copies hold the same
|
||||
// secrets as a backup, so the web interface lists them and can remove them,
|
||||
// and update keeps only the newest few.
|
||||
|
||||
const keepUpdateBackups = 3
|
||||
|
||||
type UpdateBackup struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Modified time.Time `json:"modified"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// A copy that would overwrite an older one gets the time appended.
|
||||
var backupStampRe = regexp.MustCompile(`-\d{8}-\d{4}$`)
|
||||
|
||||
func updateBackupPrefix(configPath string) string { return filepath.Base(configPath) + ".bak-" }
|
||||
|
||||
// newUpdateBackupPath names the copy update makes of configPath.
|
||||
func newUpdateBackupPath(configPath, version string, now time.Time) string {
|
||||
p := configPath + ".bak-" + version
|
||||
if _, err := os.Lstat(p); err == nil {
|
||||
p += now.Format("-20060102-1504")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// listUpdateBackups returns the copies next to configPath, newest first.
|
||||
func listUpdateBackups(configPath string) ([]UpdateBackup, error) {
|
||||
entries, err := os.ReadDir(filepath.Dir(configPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := updateBackupPrefix(configPath)
|
||||
out := []UpdateBackup{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !e.Type().IsRegular() || !strings.HasPrefix(name, prefix) || name == prefix {
|
||||
continue
|
||||
}
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, UpdateBackup{Name: name, Version: backupStampRe.ReplaceAllString(strings.TrimPrefix(name, prefix), ""),
|
||||
Modified: fi.ModTime(), Size: fi.Size()})
|
||||
}
|
||||
slices.SortFunc(out, func(a, b UpdateBackup) int { return b.Modified.Compare(a.Modified) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removeUpdateBackup deletes one copy; any other name is refused.
|
||||
func removeUpdateBackup(configPath, name string) error {
|
||||
prefix := updateBackupPrefix(configPath)
|
||||
path := filepath.Join(filepath.Dir(configPath), name)
|
||||
fi, err := os.Lstat(path)
|
||||
if !strings.HasPrefix(name, prefix) || name == prefix || strings.ContainsAny(name, `/\`) ||
|
||||
errors.Is(err, fs.ErrNotExist) || (err == nil && !fi.Mode().IsRegular()) {
|
||||
return badRequest("no copy named %q", name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// pruneUpdateBackups keeps the newest keep copies and deletes the rest.
|
||||
func pruneUpdateBackups(configPath string, keep int) (int, error) {
|
||||
list, err := listUpdateBackups(configPath)
|
||||
if err != nil || len(list) <= keep {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, b := range list[keep:] {
|
||||
if err := removeUpdateBackup(configPath, b.Name); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (a *App) listUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listUpdateBackups(a.store.path)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"backups": list})
|
||||
}
|
||||
|
||||
func (a *App) removeUpdateBackup(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if err := removeUpdateBackup(a.store.path, name); err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "update backup removed", "file", name)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) removeUpdateBackups(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := pruneUpdateBackups(a.store.path, 0)
|
||||
if err != nil {
|
||||
writeErr(w, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, "update backups removed", "count", n)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": n})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpdateBackups(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := filepath.Join(dir, "config.json")
|
||||
_ = os.WriteFile(cfg, []byte("{}"), 0o600)
|
||||
now := time.Date(2026, 10, 5, 12, 9, 0, 0, time.UTC)
|
||||
|
||||
// A second copy of the same version gets the time appended instead of
|
||||
// overwriting the first.
|
||||
first := newUpdateBackupPath(cfg, "unknown", now)
|
||||
if filepath.Base(first) != "config.json.bak-unknown" {
|
||||
t.Fatalf("first copy: %s", first)
|
||||
}
|
||||
_ = os.WriteFile(first, []byte("{}"), 0o600)
|
||||
second := newUpdateBackupPath(cfg, "unknown", now)
|
||||
if filepath.Base(second) != "config.json.bak-unknown-20261005-1209" {
|
||||
t.Fatalf("second copy: %s", second)
|
||||
}
|
||||
_ = os.WriteFile(second, []byte("{}"), 0o600)
|
||||
for i, v := range []string{"v0.2.0", "v0.3.0", "v0.4.0"} {
|
||||
f := filepath.Join(dir, "config.json.bak-"+v)
|
||||
_ = os.WriteFile(f, []byte("{}"), 0o600)
|
||||
_ = os.Chtimes(f, now, now.Add(time.Duration(i+1)*time.Hour))
|
||||
}
|
||||
_ = os.Chtimes(first, now, now.Add(-2*time.Hour))
|
||||
_ = os.Chtimes(second, now, now.Add(-time.Hour))
|
||||
_ = os.Mkdir(filepath.Join(dir, "config.json.bak-dir"), 0o700) // not a file: ignored
|
||||
|
||||
list, err := listUpdateBackups(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, b := range list {
|
||||
got = append(got, b.Version)
|
||||
}
|
||||
if strings.Join(got, " ") != "v0.4.0 v0.3.0 v0.2.0 unknown unknown" {
|
||||
t.Fatalf("versions, newest first: %v", got)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"config.json", "config.json.bak-", "config.json.bak-dir", "../config.json.bak-v0.4.0", "config.json.bak-v0.4.0/x"} {
|
||||
if err := removeUpdateBackup(cfg, bad); err == nil {
|
||||
t.Errorf("removed %q", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if n, err := pruneUpdateBackups(cfg, keepUpdateBackups); err != nil || n != 2 {
|
||||
t.Fatalf("prune: %d, %v", n, err)
|
||||
}
|
||||
if list, _ = listUpdateBackups(cfg); len(list) != 3 || list[2].Version != "v0.2.0" {
|
||||
t.Fatalf("after prune: %v", list)
|
||||
}
|
||||
if _, err := os.Stat(cfg); err != nil {
|
||||
t.Fatal("config.json is gone")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user