Skip to content

Commit ce10abb

Browse files
jheaton141claude
andcommitted
feat(dockhand): support API token (Bearer) authentication
Dockhand added user-scoped API token auth in v1.0.25, but the widget only supported local username/password login. Storing full account credentials in services.yaml is heavier than needed for a read-only status widget. Add an optional `key` that is sent as `Authorization: Bearer <key>`. When set, it takes precedence over username/password and the 401 session-login fallback is skipped (a 401 with a token means the token itself is invalid). The existing username/password flow is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1b02791 commit ce10abb

3 files changed

Lines changed: 60 additions & 3 deletions

File tree

docs/widgets/services/dockhand.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@ description: Dockhand Widget Configuration
55

66
Learn more about [Dockhand](https://dockhand.pro/).
77

8-
Note: The widget currently supports Dockhand's **local** authentication only.
8+
Authenticate with either a Dockhand [API token](https://dockhand.pro/manual/#api-authentication) (recommended) or Dockhand's local username/password. A token is scoped to the user that created it and can be revoked without changing your password, so it's preferable to storing account credentials in your config. If both are provided, the token is used.
99

1010
**Allowed fields:** (max 4): `running`, `stopped`, `paused`, `total`, `cpu`, `memory`, `images`, `volumes`, `events_today`, `pending_updates`, `stacks`.
1111
**Default fields:** `running`, `total`, `cpu`, `memory`.
1212

13+
Using an API token:
14+
15+
```yaml
16+
widget:
17+
type: dockhand
18+
url: http://localhost:3001
19+
environment: local # optional: name or id; aggregates all when omitted
20+
key: dockhandapitoken # generate under Settings in Dockhand
21+
```
22+
23+
Using local authentication:
24+
1325
```yaml
1426
widget:
1527
type: dockhand

src/widgets/dockhand/proxy.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,18 @@ export default async function dockhandProxyHandler(req, res) {
3838

3939
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
4040

41+
// A Dockhand API token (Bearer) takes precedence over username/password. See
42+
// https://dockhand.pro/manual/#api-authentication
43+
const headers = widget.key ? { Authorization: `Bearer ${widget.key}` } : {};
44+
4145
let [status, contentType, data] = await httpProxy(url, {
4246
method: req.method,
47+
headers,
4348
});
4449

45-
// Attempt login and retrying once
46-
if (status === 401) {
50+
// Fall back to a username/password session login and retry once. Skipped when a
51+
// token is configured, since a 401 there means the token itself is invalid.
52+
if (status === 401 && !widget.key) {
4753
const loggedIn = await login(widget);
4854
if (loggedIn) {
4955
[status, contentType, data] = await httpProxy(url, {

src/widgets/dockhand/proxy.test.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,45 @@ describe("widgets/dockhand/proxy", () => {
5959
expect(res.body).toEqual(Buffer.from("data"));
6060
});
6161

62+
it("sends a bearer token and does not log in when a key is configured", async () => {
63+
getServiceWidget.mockResolvedValue({
64+
type: "dockhand",
65+
url: "http://dockhand/",
66+
key: "dh_abc123",
67+
});
68+
69+
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
70+
71+
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api/v1/status", index: "0" } };
72+
const res = createMockRes();
73+
74+
await dockhandProxyHandler(req, res);
75+
76+
expect(httpProxy).toHaveBeenCalledTimes(1);
77+
expect(httpProxy.mock.calls[0][1].headers).toEqual({ Authorization: "Bearer dh_abc123" });
78+
expect(res.statusCode).toBe(200);
79+
expect(res.body).toEqual(Buffer.from("data"));
80+
});
81+
82+
it("does not fall back to login on a 401 when a key is configured", async () => {
83+
getServiceWidget.mockResolvedValue({
84+
type: "dockhand",
85+
url: "http://dockhand/",
86+
key: "dh_bad",
87+
});
88+
89+
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")]);
90+
91+
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api/v1/status", index: "0" } };
92+
const res = createMockRes();
93+
94+
await dockhandProxyHandler(req, res);
95+
96+
// Only the initial request — no login attempt, no retry.
97+
expect(httpProxy).toHaveBeenCalledTimes(1);
98+
expect(res.statusCode).toBe(401);
99+
});
100+
62101
it("returns a sanitized error response for HTTP errors", async () => {
63102
getServiceWidget.mockResolvedValue({
64103
type: "dockhand",

0 commit comments

Comments
 (0)