Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
are allowlisted` reaches the caller instead of a bare status code. The
edge exec `failStart` path already forwards `err` into the `exec_end`
reason sent to the controller, so the enriched message now reaches Drydock
too.
too. `extractDockerErrorMessage` now also reads sockguard's `reason`
field β€” sockguard's detailed denial cause (populated only when
`deny_verbosity` is `verbose`) lives there, not in `message`, which is
always the generic `request denied by sockguard policy` sentence.

## [v0.8.1] - 2026-07-28

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ volumes:
portwing-stacks:
```

By design, the `sockguard.yaml` preset above (a copy of sockguard's `portwing.yaml`) denies all exec β€” `POST /containers/*/exec` matches no allow rule and hits the catch-all deny, returning a 403 with a `not allowed by portwing preset` reason. If Drydock's edge exec feature is in play, use [`examples/docker-compose.edge-with-exec.yml`](examples/docker-compose.edge-with-exec.yml) instead, which pairs edge mode with sockguard's `portwing-with-exec.yaml` preset (`examples/sockguard-with-exec.yaml`) so exec is actually allowed through the proxy; under that preset, policy denials carry specific reasons instead, like `exec denied: privileged exec is not allowed`. Either way, the denial reason now reaches Portwing's exec error and is forwarded in the `exec_end` frame's reason to the controller (drydock-side display of it is still pending).
By design, the `sockguard.yaml` preset above (a copy of sockguard's `portwing.yaml`) denies all exec β€” `POST /containers/*/exec` matches no allow rule and hits the catch-all deny, returning a 403 with a `not allowed by portwing preset` reason. If Drydock's edge exec feature is in play, use [`examples/docker-compose.edge-with-exec.yml`](examples/docker-compose.edge-with-exec.yml) instead, which pairs edge mode with sockguard's `portwing-with-exec.yaml` preset (`examples/sockguard-with-exec.yaml`) so exec is actually allowed through the proxy; under that preset, policy denials carry specific reasons instead, like `exec denied: privileged exec is not allowed`. Either way, the denial reason now reaches Portwing's exec error and is forwarded in the `exec_end` frame's reason to the controller (drydock-side display of it is still pending). Note sockguard only includes the detailed reason in its `reason` field when `deny_verbosity: verbose` is set in the preset; under the default `minimal` verbosity the surfaced message is just the generic `request denied by sockguard policy`.

**Upgrade to Ed25519 key auth (zero shared secrets):** generate a keypair with `portwing keygen`, mount the `authorized_keys` file, and set `AUTHORIZED_KEYS=/etc/portwing/authorized_keys` β€” see [Authentication](#authentication). Use `PRIVATE_KEY_FILE` for signed edge-mode hellos.

Expand Down
20 changes: 16 additions & 4 deletions internal/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,13 @@ func dockerError(action string, status int, body []byte) error {
}

// extractDockerErrorMessage pulls a human-readable message out of a Docker
// error response body. Docker's (and sockguard's) error bodies are usually
// {"message":"..."} JSON; when the body doesn't match that shape, or the
// message is empty, the raw trimmed body is used instead.
// error response body. Docker's error bodies are usually {"message":"..."}
// JSON. sockguard's denial bodies additionally carry a "reason" field with
// the detailed denial cause (populated only when sockguard's deny_verbosity
// is "verbose"; "message" is otherwise a generic constant). When both fields
// are present and differ, they're combined as "<message>: <reason>"; when
// only one is present, it's used alone. When the body doesn't match that
// shape, or both fields are empty, the raw trimmed body is used instead.
func extractDockerErrorMessage(body []byte) string {
trimmed := strings.TrimSpace(string(body))
if trimmed == "" {
Expand All @@ -318,10 +322,18 @@ func extractDockerErrorMessage(body []byte) string {

var parsed struct {
Message string `json:"message"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
if msg := strings.TrimSpace(parsed.Message); msg != "" {
msg := strings.TrimSpace(parsed.Message)
reason := strings.TrimSpace(parsed.Reason)
switch {
case msg != "" && reason != "" && msg != reason:
return msg + ": " + reason
case msg != "":
return msg
case reason != "":
return reason
}
}
return trimmed
Expand Down
37 changes: 37 additions & 0 deletions internal/docker/client_dockererror_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,21 @@ func TestExtractDockerErrorMessage(t *testing.T) {
body: []byte(` {"message":""} `),
want: `{"message":""}`,
},
{
name: "sockguard verbose denial: message and reason are combined",
body: []byte(`{"message":"request denied by sockguard policy","method":"POST","path":"/containers/create","reason":"not allowed by portwing preset"}`),
want: "request denied by sockguard policy: not allowed by portwing preset",
},
{
name: "reason-only body yields the reason",
body: []byte(`{"reason":"exec denied: privileged exec is not allowed"}`),
want: "exec denied: privileged exec is not allowed",
},
{
name: "identical message and reason are not duplicated",
body: []byte(`{"message":"same text","reason":"same text"}`),
want: "same text",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -166,6 +181,28 @@ func TestGetContainerLogs_DockerError_MessageBody(t *testing.T) {
}
}

func TestCreateExec_DockerError_MessageAndReasonBody(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message":"request denied by sockguard policy","method":"POST","path":"/containers/abc123/exec","reason":"exec denied: privileged exec is not allowed"}`)) //nolint:errcheck
}))
defer srv.Close()

c := newTestClient(srv)
_, err := c.CreateExec(context.Background(), "abc123", []string{"sh"}, "", false)
if err == nil {
t.Fatal("expected error on 403, got nil")
}
if !strings.Contains(err.Error(), "403") {
t.Errorf("error = %q, expected to contain status 403", err.Error())
}
if !strings.Contains(err.Error(), "request denied by sockguard policy: exec denied: privileged exec is not allowed") {
t.Errorf("error = %q, expected to contain the combined message and reason", err.Error())
}
}

// ---- Truncation: the error body is bounded to maxDockerErrorBodyBytes ----

func TestCreateExec_DockerError_BodyTruncated(t *testing.T) {
Expand Down
Loading