v9.1 fixes the v9 source archive packaging. The v9 ZIP accidentally omitted `internal/stacks/`, which could leave an older local copy of that package in place when extracting over an existing checkout and cause method-signature build errors. v9.1 is a complete source archive and includes `internal/stacks/stacks.go` and its tests. Always extract it into a fresh directory.
# Dockwatch v9.1
# Dockwatch v9.3.1
> Go module: `git.send.nrw/sendnrw/dockwatch`
@@ -57,6 +53,64 @@ Symlink stack destinations and symlink paths inside Git-managed writes are rejec
- operator-only inspect
- one-shot CPU/memory/network/block stats
### Container identity / host UID-GID checks
Container rows include an **Identity** action. Dockwatch inspects the selected container and reports:
- configured Compose/image user and the effective runtime UID/GID where resolvable
- whether the process currently runs as UID 0
- a conservative root assessment based on `privileged`, Docker socket mounts, passed-through devices and added Linux capabilities
- bind-mount sources and their host UID/GID ownership when host access is configured
- single-container detail checks plus a throttled **Identity audit** across all containers in the selected environment
- whether the container UID/GID already maps to a local host account/group
Dockwatch deliberately does **not** claim that a root container can always be converted to non-root. Application-internal filesystem permissions, entrypoints and image-specific `PUID`/`PGID` conventions cannot be proven from Docker metadata alone. It also never rewrites Compose `user:` automatically.
A matching local host username is **not required by Docker**. Linux file ownership is numeric; creating a host account can nevertheless make bind-mount ownership, backups and administration easier.
Host inspection is opt-in. For read-only inspection, set:
```env
HOST_ROOT=/host
ALLOW_HOST_USER_MANAGEMENT=false
ALLOW_HOST_PERMISSION_MANAGEMENT=false
```
and mount the host root read-only at `/host`. `examples/compose-host-identity.override.yml` shows this setup.
Admins may optionally create a locked/non-login local host account using the container's server-side re-resolved UID/GID. This requires both a writable host-root mount and:
```env
ALLOW_HOST_USER_MANAGEMENT=true
```
See `examples/compose-host-user-management.override.yml`. The browser cannot supply an arbitrary UID/GID: Dockwatch re-inspects the container immediately before the change and derives the IDs itself. Existing numeric users/groups are reused and UID/name collisions are refused. Docker rootless/user-namespace remapping is detected where possible; automatic same-numbered host-account creation is refused when IDs are remapped. This operation is intentionally admin-only and disabled by default.
### Identity & Bind Mount Permissions
Dockwatch can now diagnose the actual bind-mount permission problem instead of stopping at "container runs as UID X". The container **Identity** dialog and each stack's **Permissions** tab show, per bind mount:
- effective PID 1 UID/GID and a separate expected **bind UID/GID**
-`PUID/PGID` or `USER_ID/GROUP_ID` when the image exposes those paired conventions
- host owner UID/GID and POSIX mode bits
- static writeability (`w+x` for directories, `w` for files)
- extended POSIX ACL detection when `getfacl` is available
- an optional non-mutating runtime `test -w` using the expected numeric identity
- read-only mounts, rootless/userns remapping and unsafe symlinked host paths as hard repair blockers
The repair flow is **Analyze → Preview → Repair → Verify**. For recursive ownership repair Dockwatch scans the tree first and shows how many files/directories differ. Automatic recursive repair is refused above 200,000 entries. Symlinks are never followed or chowned. `:ro` mounts are never repaired automatically.
Permission repair is a separate high-trust opt-in from host-user creation:
```env
HOST_ROOT=/host
ALLOW_HOST_PERMISSION_MANAGEMENT=true
```
and `/` must intentionally be mounted read-write at `/host`. See `examples/compose-host-permissions.override.yml`. The browser supplies only the container and its mount destination (for example `/config`); the backend re-reads `docker inspect` and resolves the real host source itself. Arbitrary host paths and arbitrary UID/GID values cannot be submitted for repair.
Ownership repair can operate on only the bind root or recursively. `chmod` is separate, optional, explicit, and only applies to the bind root; Dockwatch never automatically applies `chmod 777` and never recursively rewrites modes. After a repair Dockwatch re-runs the ownership/writeability analysis and reports the result.
**Images**
- list/filter
@@ -197,6 +251,7 @@ Remote-capable features include:
- Compose graph
- image update checks
- containers/images/networks/volumes
- container identity analysis, bind-mount permission repair and optional host-account creation on the agent host
- monitoring probes
- Git clone/sync/deploy
@@ -215,7 +270,10 @@ The designer is not limited to a hard-coded subset: arbitrary maps, arrays and s
Server-side save validation still uses Docker Compose itself after all related `.env`, secret, env-file and config files have been staged.
## Reliability and security work in v9
## Reliability and security work in v9 / v9.2
The v9.2 identity extension is opt-in, admin-gated and preserves the existing least-surprise rule: diagnostics are read-only by default and no container user or host account is changed automatically.
The v9 review includes, among other changes:
@@ -262,6 +320,9 @@ CHECK_CONCURRENCY=8
CHECK_RETENTION_DAYS=30
HTTP_TIMEOUT_SECONDS=10
AUDIT_RETENTION_DAYS=180
HOST_ROOT=
ALLOW_HOST_USER_MANAGEMENT=false
ALLOW_HOST_PERMISSION_MANAGEMENT=false
```
`AUTH_DISABLED=true` is for local development only. Do not expose that configuration publicly.
Giving Dockwatch access to the Docker socket grants highly privileged control of that Docker host. Protect the UI and agent endpoint accordingly.
## v9.3.1 build-context fix
v9.3 accidentally used the broad ignore pattern `dockwatch` in both `.gitignore` and `.dockerignore`. Because patterns without a slash match path components recursively, that could hide `cmd/dockwatch/` from Git and from the Docker build context. v9.3.1 removes that pattern, writes local Makefile builds to `bin/dockwatch`, ignores only `bin/`/`dist/`, and makes the Dockerfile fail early with a clear message if `cmd/dockwatch/main.go` is ever missing from the build context.
## Build from source
The pinned OIDC/OAuth2 releases require **Go 1.25**. The Docker build uses `golang:1.25-alpine`.
nt.Broadcast(ctx,notify.Message{Title:"Monitor "+ev.To+": "+ev.Name,Body:ev.Target+" changed from "+ev.From+" to "+ev.To+". "+ev.Check.Message,Status:ev.To,MonitorID:ev.MonitorID})
functionconsoleTab(sv){return`<div class="notice" style="margin-bottom:8px">Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Connect</button><button class="btn danger" id="closeTerminal">Disconnect</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
</div>`}
functiondangerTab(st){returnst.name?`<div class="notice"><b>Safe delete is the default.</b> It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose down</button><button class="btn" data-act="recreate">Force recreate</button><button class="btn danger" id="deleteStack">Delete definition</button><button class="btn danger" id="purgeStack">Purge entire folder…</button></div>`:'<div class="empty">Save the stack first.</div>'}
functionrestoreDraft(){try{constd=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('Draft could not be restored.')}}
@@ -130,18 +131,48 @@ function renderNodes(){setCrumb('System / Environments');const local=`<tr><td><d
asyncfunctiondockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action}${kind}? This can delete Docker resources.`))return;try{constr=awaitapi(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);awaitloadInventory(kind)}catch(e){toast(e.message)}}
functionregistryLoginModal(){modal(`<div class="modalhead"><h2>Registry login</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Username</label><input id="regUser"></div><div class="field"><label>Password / token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">Credentials are written by Docker CLI to the persistent Docker config on this environment. The password is passed through stdin, not a command-line argument.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="regSave">Login</button></div>`);$('#regSave').onclick=async()=>{constbody={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)returntoast('Registry, username and password required.');closeModal();awaitdockerResourceAction('images','login',body)}}
asyncfunctioncontainerIdentityAudit(){constrows=asArray(window.__inventoryRows);if(!rows.length)returntoast('No containers to inspect.');constbtn=$('#identityAudit');setBusy(btn,true,'Scanning…');try{constresults=[];for(leti=0;i<rows.length;i+=4){constbatch=rows.slice(i,i+4);results.push(...awaitPromise.allSettled(batch.map(r=>{constid=r.ID||r.Names||r.Name;returnapi(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}constok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');constrootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`<div class="modalhead"><h2>Container identity audit · ${esc(nodeName())}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="stats"><div class="stat"><small>Containers checked</small><strong>${ok.length}</strong></div><div class="stat"><small>Root PID 1</small><strong class="${rootCount?'amber':''}">${rootCount}</strong></div><div class="stat"><small>Non-root PID 1</small><strong class="green">${nonRoot}</strong></div><div class="stat"><small>Unknown / failed</small><strong>${unknown+failed.length}</strong></div></div><div class="notice" style="margin-top:12px">“Root” means the effective UID of PID 1 where Dockwatch could read it. This is a review signal, not proof that the application can safely be converted to non-root.</div><div class="tablewrap" style="margin-top:12px"><table class="table"><thead><tr><th>Container</th><th>UID:GID</th><th>Assessment</th><th>Host account</th><th>Bind mounts</th></tr></thead><tbody>${ok.map(x=>{constd=x.report;return`<tr><td><b>${esc(d.container_name||x.row.Names||x.row.ID)}</b><div class="muted">${esc(d.image||'')}</div></td><td class="mono"><div>PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}</div><div class="muted">bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</div></td><td>${esc(identityAssessmentLabel(d.root_assessment))}</td><td>${d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)}</span>`:(d.host_access?.available&&d.bind_uid>0?'<span class="warn">missing</span>':'—')}</td><td>${asArray(d.bind_mounts).length}</td></tr>`}).join('')}${failed.map((x,i)=>`<tr><td colspan="5" class="red">Check ${i+1} failed: ${esc(x.reason?.message||x.reason)}</td></tr>`).join('')}</tbody></table></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}}
if(canRepair)$('#repairBind').onclick=async()=>{constrecursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)returntoast('Run a recursive re-scan first so the affected file count is known.');constwhat=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;constbtn=$('#repairBind');setBusy(btn,true,'Repairing…');try{constout=awaitapi(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}}
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.