Skip to content

Commit b15f676

Browse files
authored
Merge pull request #1031 from Wikid82/feature/hecate
feat(orthrus): implement server-side Docker proxy listener
2 parents 9ae7380 + dfaece1 commit b15f676

42 files changed

Lines changed: 4281 additions & 1024 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/nightly-build.yml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,88 @@ jobs:
361361
cosign attach sbom --sbom sbom-nightly.json "${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.resolve_digest.outputs.digest }}"
362362
echo "✅ SBOM attached to Docker Hub nightly image"
363363
364+
build-and-push-nightly-orthrus:
365+
needs: sync-development-to-nightly
366+
if: needs.sync-development-to-nightly.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch'
367+
runs-on: ubuntu-latest
368+
env:
369+
ORTHRUS_IMAGE_NAME: wikid82/orthrus
370+
HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }}
371+
permissions:
372+
contents: read
373+
packages: write
374+
375+
steps:
376+
- name: Checkout nightly branch
377+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
378+
with:
379+
ref: ${{ github.event_name == 'workflow_dispatch' && github.ref || 'nightly' }}
380+
fetch-depth: 0
381+
382+
- name: Set lowercase image name
383+
run: echo "ORTHRUS_IMAGE_NAME_LC=${ORTHRUS_IMAGE_NAME,,}" >> "$GITHUB_ENV"
384+
385+
- name: Set up QEMU
386+
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
387+
388+
- name: Set up Docker Buildx
389+
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
390+
391+
- name: Log in to GitHub Container Registry
392+
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
393+
with:
394+
registry: ${{ env.GHCR_REGISTRY }}
395+
username: ${{ github.actor }}
396+
password: ${{ secrets.GITHUB_TOKEN }}
397+
398+
- name: Log in to Docker Hub
399+
if: env.HAS_DOCKERHUB_TOKEN == 'true'
400+
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
401+
with:
402+
registry: docker.io
403+
username: ${{ secrets.DOCKERHUB_USERNAME }}
404+
password: ${{ secrets.DOCKERHUB_TOKEN }}
405+
406+
- name: Extract metadata
407+
id: meta
408+
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
409+
with:
410+
images: |
411+
${{ env.GHCR_REGISTRY }}/${{ env.ORTHRUS_IMAGE_NAME_LC }}
412+
${{ env.DOCKERHUB_REGISTRY }}/${{ env.ORTHRUS_IMAGE_NAME_LC }}
413+
tags: |
414+
type=raw,value=nightly
415+
type=raw,value=nightly-{{date 'YYYY-MM-DD'}}
416+
type=sha,prefix=nightly-,format=short
417+
labels: |
418+
org.opencontainers.image.title=Orthrus Agent Nightly
419+
org.opencontainers.image.description=Nightly build of the Orthrus reverse-proxy agent
420+
421+
- name: Build and push Orthrus nightly image
422+
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
423+
with:
424+
context: ./agent
425+
file: ./agent/Dockerfile
426+
platforms: linux/amd64,linux/arm64
427+
push: true
428+
tags: ${{ steps.meta.outputs.tags }}
429+
labels: ${{ steps.meta.outputs.labels }}
430+
build-args: |
431+
VERSION=nightly-${{ github.sha }}
432+
GIT_COMMIT=${{ github.sha }}
433+
BUILD_DATE=${{ github.event.repository.pushed_at }}
434+
cache-from: type=gha
435+
cache-to: type=gha,mode=max
436+
437+
- name: Record Orthrus nightly image tags
438+
run: |
439+
{
440+
echo "## 🐕 Orthrus Nightly Image Tags"
441+
echo '```'
442+
echo "${{ steps.meta.outputs.tags }}"
443+
echo '```'
444+
} >> "$GITHUB_STEP_SUMMARY"
445+
364446
test-nightly-image:
365447
needs: build-and-push-nightly
366448
runs-on: ubuntu-latest

.github/workflows/orthrus-build.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ name: Orthrus Agent — Build & Publish
33
# Mirrors the trigger and tagging strategy from docker-build.yml so that the
44
# Orthrus agent image always receives the same identifiable tags as the main
55
# Charon image (e.g. feature-hecate-abc1234), enabling coordinated manual
6-
# testing on remote servers.
6+
# testing on remote servers. The push trigger has no paths filter (matching
7+
# docker-build.yml); paths filtering is only applied to pull_request events.
78

89
"on":
910
pull_request:
@@ -13,9 +14,6 @@ name: Orthrus Agent — Build & Publish
1314
push:
1415
branches: [main, development]
1516
tags: ['v*']
16-
paths:
17-
- 'agent/**'
18-
- '.github/workflows/orthrus-build.yml'
1917
workflow_dispatch:
2018
inputs:
2119
reason:

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"docker.host": "unix:///run/user/1001/docker.sock",
3+
"go.goroot": "/home/jeremy/sdk/go1.26.3",
34
"gopls": {
45
"buildFlags": ["-tags=integration"]
56
},

Dockerfile

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ ARG NPM_VERSION=11.11.1
3737
# avoid accidentally pulling a v3 major release. Renovate can still update
3838
# this ARG to a specific v2.x tag when desired.
3939
## Try to build the requested Caddy v2.x tag (Renovate can update this ARG).
40-
## If the requested tag isn't available, fall back to a known-good v2.11.2 build.
41-
ARG CADDY_VERSION=2.11.2
42-
ARG CADDY_CANDIDATE_VERSION=2.11.2
40+
## If the requested tag isn't available, fall back to a known-good v2.11.3 build.
41+
# renovate: datasource=go depName=https://github.com/caddyserver/caddy
42+
ARG CADDY_VERSION=2.11.3
43+
# renovate: datasource=go depName=https://github.com/caddyserver/caddy
44+
ARG CADDY_CANDIDATE_VERSION=2.11.3
4345
ARG CADDY_USE_CANDIDATE=0
4446
ARG CADDY_PATCH_SCENARIO=B
4547
# renovate: datasource=go depName=github.com/greenpau/caddy-security

SECURITY.md

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public disclosure.
2727

2828
## Known Vulnerabilities
2929

30-
Last reviewed: 2026-04-21
30+
Last reviewed: 2026-05-20
3131

3232
### [HIGH] CVE-2026-31790 · OpenSSL Vulnerability in Alpine Base Image
3333

@@ -195,6 +195,91 @@ attacker-controlled URLs. Charon's application logic does not use busybox wget.
195195
Monitor Alpine 3.23 for a patched busybox APK. No immediate action required. Practical risk to
196196
Charon users is negligible since the vulnerable code path is not exercised.
197197

198+
---
199+
200+
### [HIGH] CVE-2026-45135 · Caddy FastCGI Unsafe Unicode Handling in splitPos
201+
202+
| Field | Value |
203+
|--------------|-------|
204+
| **ID** | CVE-2026-45135 |
205+
| **Severity** | High · 8.1 |
206+
| **Status** | Fix in Dockerfile (v2.11.3) — Pending Image Rebuild |
207+
208+
**What**
209+
Caddy v2.11.2 contains unsafe Unicode handling in the FastCGI `splitPos` function. A
210+
network-reachable attacker (no privileges required, high attack complexity) can trigger
211+
potential confidentiality and integrity impact through malformed Unicode in FastCGI requests.
212+
213+
**Who**
214+
215+
- Discovered by: Automated scan (Trivy image scan)
216+
- Reported: 2026-05-20
217+
- Affects: Charon deployments using the FastCGI reverse proxy capability
218+
219+
**Where**
220+
221+
- Component: `github.com/caddyserver/caddy/v2` v2.11.2 (embedded in Charon binary via xcaddy)
222+
- Versions affected: Caddy < v2.11.3
223+
224+
**When**
225+
226+
- Discovered: 2026-05-20
227+
- Disclosed (if public): Public
228+
- Target fix: v2.11.3 (available)
229+
230+
**How**
231+
Caddy is not a Go module dependency — it is built from source via xcaddy in the Dockerfile
232+
multi-stage build. The `CADDY_VERSION` ARG in the Dockerfile **is already pinned to 2.11.3**,
233+
which contains the fix. The vulnerability only affects the stale `charon:local` image built
234+
before this Dockerfile change. A container image rebuild applies the fix.
235+
236+
**Planned Remediation**
237+
Rebuild the container image. No code changes required. The fix is already present in the
238+
Dockerfile (`ARG CADDY_VERSION=2.11.3`).
239+
240+
---
241+
242+
### [HIGH] CVE-2026-32286 · pgproto3 DoS via Negative DataRow Field Length
243+
244+
| Field | Value |
245+
|--------------|-------|
246+
| **ID** | CVE-2026-32286 |
247+
| **Severity** | High · 7.5 |
248+
| **Status** | Awaiting Upstream |
249+
250+
**What**
251+
`github.com/jackc/pgproto3/v2` v2.3.3 — `DataRow.Decode` does not validate field length
252+
before allocation. A malicious or compromised PostgreSQL server can send a DataRow message
253+
with a negative field length to trigger a panic or excessive memory allocation, causing denial
254+
of service (CWE-400).
255+
256+
**Who**
257+
258+
- Discovered by: Automated scan (Trivy image scan)
259+
- Reported: 2026-05-20
260+
- Affects: Charon deployments connecting to an untrusted PostgreSQL server (non-default)
261+
262+
**Where**
263+
264+
- Component: `github.com/jackc/pgproto3/v2` v2.3.3 (transitive Go dependency)
265+
- Versions affected: pgproto3/v2 < patched version
266+
267+
**When**
268+
269+
- Discovered: 2026-05-20
270+
- Disclosed (if public): Public
271+
- Target fix: When upstream publishes a patched release
272+
273+
**How**
274+
Exploitation requires a malicious or compromised PostgreSQL server to send a crafted DataRow
275+
message. Charon's default installation uses SQLite and does not connect to PostgreSQL. Users
276+
deploying Charon with a PostgreSQL backend (non-standard configuration) may be exposed if the
277+
database server is untrusted. EPSS score not yet available.
278+
279+
**Planned Remediation**
280+
Monitor https://github.com/jackc/pgproto3 for a fix release. Upgrade the indirect dependency
281+
once a patched version is available. Pre-existing; not introduced by PR #1031.
282+
198283
## Patched Vulnerabilities
199284

200285
### [HIGH] CVE-2026-34040 · Docker AuthZ Plugin Bypass via Oversized Request Body

backend/go.sum

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
114114
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
115115
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
116116
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
117-
github.com/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7LB23UBI2Amo=
118-
github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc=
119117
github.com/oschwald/geoip2-golang/v2 v2.2.0 h1:gdkhpnHQMiH9ymOI+zSB0QKFGH+n4TntNt7vz+TxGPY=
120118
github.com/oschwald/geoip2-golang/v2 v2.2.0/go.mod h1:xW4tCeQiNU1gqMD1x7zEH2CDNM3d796Ls50yxYDaX0U=
121-
github.com/oschwald/maxminddb-golang/v2 v2.2.0 h1:/2khmIiNvFxgfwGxitper3XBJBs5qTCPQ/H1iR9MgBw=
122-
github.com/oschwald/maxminddb-golang/v2 v2.2.0/go.mod h1:n/ctYVTFYQypkn5uO1CZnTmj8jdQKIVh/LX7gSaIl0w=
123119
github.com/oschwald/maxminddb-golang/v2 v2.3.0 h1:PnXjMGjkSQlwOBSyZ7hk6Fd75t7erkAhJNJgEhA3MQU=
124120
github.com/oschwald/maxminddb-golang/v2 v2.3.0/go.mod h1:NSQvgFwPxODpBTJI5+5Ns1AAucnx7ggW9PSRRifAT1s=
125121
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=

backend/internal/api/handlers/orthrus_handler.go

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,44 @@ package handlers
33
import (
44
"errors"
55
"net/http"
6+
"reflect"
67

78
"github.com/gin-gonic/gin"
89
"gorm.io/gorm"
910

11+
"github.com/Wikid82/charon/backend/internal/orthrus"
1012
"github.com/Wikid82/charon/backend/internal/services"
1113
)
1214

15+
// orthrusProxyStatusResolver is satisfied by *orthrus.OrthrusServer.
16+
type orthrusProxyStatusResolver interface {
17+
GetExternalProxyStatus(agentUUID string) (orthrus.ExternalProxyStatus, bool)
18+
}
19+
1320
// OrthrusHandler handles REST requests for Orthrus agent management.
1421
type OrthrusHandler struct {
15-
svc *services.OrthrusService
22+
svc *services.OrthrusService
23+
proxyResolver orthrusProxyStatusResolver
1624
}
1725

1826
// NewOrthrusHandler creates an OrthrusHandler backed by the given service.
1927
func NewOrthrusHandler(orthrsuSvc *services.OrthrusService) *OrthrusHandler {
2028
return &OrthrusHandler{svc: orthrsuSvc}
2129
}
2230

31+
// SetProxyResolver wires a live OrthrusServer so that GetProxyStatus can
32+
// return real-time external proxy state for connected agents.
33+
func (h *OrthrusHandler) SetProxyResolver(r orthrusProxyStatusResolver) {
34+
if r != nil {
35+
rv := reflect.ValueOf(r)
36+
if rv.Kind() == reflect.Ptr && rv.IsNil() {
37+
h.proxyResolver = nil
38+
return
39+
}
40+
}
41+
h.proxyResolver = r
42+
}
43+
2344
// RegisterRoutes wires all Orthrus management routes onto the given router group.
2445
func (h *OrthrusHandler) RegisterRoutes(rg *gin.RouterGroup) {
2546
rg.GET("/orthrus/agents", h.List)
@@ -29,6 +50,7 @@ func (h *OrthrusHandler) RegisterRoutes(rg *gin.RouterGroup) {
2950
rg.DELETE("/orthrus/agents/:uuid", h.Delete)
3051
rg.POST("/orthrus/agents/:uuid/revoke", h.Revoke)
3152
rg.GET("/orthrus/agents/:uuid/snippets", h.GetInstallSnippets)
53+
rg.GET("/orthrus/agents/:uuid/proxy-status", h.GetProxyStatus)
3254
}
3355

3456
// List returns all registered Orthrus agents.
@@ -77,10 +99,11 @@ func (h *OrthrusHandler) Get(c *gin.Context) {
7799

78100
// patchAgentRequest is the payload for partially updating an agent.
79101
type patchAgentRequest struct {
80-
Name *string `json:"name"`
81-
HecateTunnelUUID *string `json:"hecate_tunnel_uuid"`
82-
DeviceID *string `json:"device_id"`
83-
ResolvedAddress *string `json:"resolved_address"`
102+
Name *string `json:"name"`
103+
HecateTunnelUUID *string `json:"hecate_tunnel_uuid"`
104+
DeviceID *string `json:"device_id"`
105+
ResolvedAddress *string `json:"resolved_address"`
106+
ExternalProxyPort *int `json:"external_proxy_port"`
84107
}
85108

86109
// Patch applies a partial update to an Orthrus agent.
@@ -91,12 +114,12 @@ func (h *OrthrusHandler) Patch(c *gin.Context) {
91114
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
92115
return
93116
}
94-
agent, err := h.svc.Patch(uuid, req.Name, req.HecateTunnelUUID, req.DeviceID, req.ResolvedAddress)
117+
agent, err := h.svc.Patch(uuid, req.Name, req.HecateTunnelUUID, req.DeviceID, req.ResolvedAddress, req.ExternalProxyPort)
95118
if err != nil {
96119
if errors.Is(err, gorm.ErrRecordNotFound) {
97120
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
98121
} else {
99-
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
122+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
100123
}
101124
return
102125
}
@@ -150,3 +173,38 @@ func (h *OrthrusHandler) GetInstallSnippets(c *gin.Context) {
150173
}
151174
c.JSON(http.StatusOK, snippets)
152175
}
176+
177+
// GetProxyStatus returns the runtime external Docker proxy state for an agent.
178+
// 404 when the agent is not found in the database. When the agent exists but
179+
// is not currently connected, agent_online is false and live fields are zero.
180+
func (h *OrthrusHandler) GetProxyStatus(c *gin.Context) {
181+
uuid := c.Param("uuid")
182+
agent, err := h.svc.Get(uuid)
183+
if err != nil {
184+
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
185+
return
186+
}
187+
resp := gin.H{
188+
"agent_uuid": agent.UUID,
189+
"agent_online": false,
190+
"configured_port": agent.ExternalProxyPort,
191+
"active": false,
192+
"active_port": 0,
193+
"bind_address": "",
194+
"connection_string": "",
195+
"error": "",
196+
}
197+
if h.proxyResolver != nil {
198+
if status, ok := h.proxyResolver.GetExternalProxyStatus(uuid); ok {
199+
resp["agent_online"] = true
200+
resp["active"] = status.Active
201+
resp["active_port"] = status.ActivePort
202+
resp["bind_address"] = status.BoundAddress
203+
resp["connection_string"] = status.ConnectionString
204+
if status.Error != "" {
205+
resp["error"] = status.Error
206+
}
207+
}
208+
}
209+
c.JSON(http.StatusOK, resp)
210+
}

0 commit comments

Comments
 (0)