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
2 changes: 1 addition & 1 deletion .github/instructions/ARCHITECTURE.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ graph TB
| **Database** | SQLite | 3.x | Embedded database |
| **ORM** | GORM | Latest | Database abstraction layer |
| **Reverse Proxy** | Caddy Server | 2.11.2 | Embedded HTTP/HTTPS proxy |
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
| **WebSocket** | coder/websocket | Latest | Real-time log streaming |
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
| **Metrics** | Prometheus Client | Latest | Application metrics |
| **Notifications** | Notify | Latest | Multi-platform alerts |
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ graph TB
| **Database** | SQLite | 3.x | Embedded database |
| **ORM** | GORM | Latest | Database abstraction layer |
| **Reverse Proxy** | Caddy Server | 2.11.2 | Embedded HTTP/HTTPS proxy |
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
| **WebSocket** | coder/websocket | Latest | Real-time log streaming |
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
| **Metrics** | Prometheus Client | Latest | Application metrics |
| **Notifications** | github.com/Wikid82/Hermes | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, Web Push, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below |
Expand Down
2 changes: 1 addition & 1 deletion agent/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/Wikid82/charon/agent
go 1.27.4

require (
github.com/gorilla/websocket v1.5.3
github.com/coder/websocket v1.8.15
github.com/hashicorp/yamux v0.1.2
github.com/sirupsen/logrus v1.10.2
github.com/stretchr/testify v1.12.1
Expand Down
4 changes: 2 additions & 2 deletions agent/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
Expand Down
34 changes: 19 additions & 15 deletions agent/leash/leash.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
"strings"
"time"

"github.com/gorilla/websocket"
"github.com/coder/websocket"
"github.com/hashicorp/yamux"
"github.com/sirupsen/logrus"

Expand Down Expand Up @@ -117,24 +117,27 @@ func (l *Leash) Run(ctx context.Context) error {
// connect performs a single WebSocket dial and yamux session. It blocks until
// the session is closed or ctx is cancelled.
func (l *Leash) connect(ctx context.Context) error {
dialer := websocket.Dialer{
HandshakeTimeout: wsDialTimeout,
}

l.log.WithField("server_url", l.serverURL).Info("leash: connecting to server")

wsConn, resp, err := dialer.DialContext(ctx, l.serverURL, http.Header{
"Authorization": {"Bearer " + l.authKey},
"X-Orthrus-Version": {"1.0.0"},
"X-Orthrus-Name": {l.agentID},
// The dial context bounds only the handshake; the established connection
// outlives it.
dialCtx, cancelDial := context.WithTimeout(ctx, wsDialTimeout)
defer cancelDial()

wsConn, resp, err := websocket.Dial(dialCtx, l.serverURL, &websocket.DialOptions{ //nolint:bodyclose // coder/websocket closes the handshake body itself; resp.Body is nil on success
HTTPClient: newDialHTTPClient(),
HTTPHeader: http.Header{
"Authorization": {"Bearer " + l.authKey},
"X-Orthrus-Version": {"1.0.0"},
"X-Orthrus-Name": {l.agentID},
},
// Compression has never been negotiated by this agent; keep it off.
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
return fmt.Errorf("leash: websocket dial: %w", err)
}
writeEnabled := resp != nil && resp.Header.Get("X-Orthrus-Write-Enabled") == "true"
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
writeEnabled := resp.Header.Get("X-Orthrus-Write-Enabled") == "true"

// X-Orthrus-Write-Enabled is delivered atomically in the 101 Switching
// Protocols response that completes the handshake (server.go sets it via
Expand All @@ -145,9 +148,10 @@ func (l *Leash) connect(ctx context.Context) error {
cfg := yamux.DefaultConfig()
cfg.LogOutput = io.Discard

session, err := yamux.Client(NewWSNetConn(wsConn), cfg)
netConn := NewWSNetConn(wsConn)
session, err := yamux.Client(netConn, cfg)
if err != nil {
_ = wsConn.Close()
_ = netConn.Close()
return fmt.Errorf("leash: yamux client: %w", err)
}
defer func() { _ = session.Close() }()
Expand Down
70 changes: 50 additions & 20 deletions agent/leash/leash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"time"

"github.com/gorilla/websocket"
"github.com/coder/websocket"
"github.com/hashicorp/yamux"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -47,20 +48,21 @@ func newYamuxServerSession(t *testing.T, wsConn *websocket.Conn) *yamux.Session
return session
}

var testUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
// acceptWS upgrades a test request to a WebSocket connection, accepting any origin.
func acceptWS(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
return websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
}

// TestLeash_Reconnect verifies that the Leash attempts to reconnect after the server
// closes the connection immediately.
func TestLeash_Reconnect(t *testing.T) {
connectCount := 0
var connectCount atomic.Int32

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
connectCount++
conn, err := testUpgrader.Upgrade(w, r, nil)
connectCount.Add(1)
conn, err := acceptWS(w, r)
require.NoError(t, err)
_ = conn.Close()
_ = conn.CloseNow()
}))
defer srv.Close()

Expand All @@ -83,28 +85,26 @@ func TestLeash_Reconnect(t *testing.T) {

_ = l.Run(ctx)

assert.GreaterOrEqual(t, connectCount, 2, "expected at least 2 connection attempts within the timeout")
assert.GreaterOrEqual(t, connectCount.Load(), int32(2), "expected at least 2 connection attempts within the timeout")
}

// TestWsNetConn_ReadWrite verifies that the net.Conn wrapper correctly transfers
// binary data through a WebSocket echo server.
func TestWsNetConn_ReadWrite(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := testUpgrader.Upgrade(w, r, nil)
conn, err := acceptWS(w, r)
require.NoError(t, err)
defer func() { _ = conn.Close() }()
defer func() { _ = conn.CloseNow() }()

_, msg, err := conn.ReadMessage()
_, msg, err := conn.Read(context.Background())
require.NoError(t, err)
require.NoError(t, conn.WriteMessage(websocket.BinaryMessage, msg))
require.NoError(t, conn.Write(context.Background(), websocket.MessageBinary, msg))
}))
defer srv.Close()

dialer := websocket.Dialer{}
wsConn, resp, err := dialer.Dial("ws"+srv.URL[4:], nil)
wsConn, _, err := websocket.Dial(context.Background(), "ws"+srv.URL[4:], nil) //nolint:bodyclose // coder/websocket closes the handshake body itself; resp.Body is nil on success
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
defer func() { _ = wsConn.Close() }()
defer func() { _ = wsConn.CloseNow() }()

netConn := leash.NewWSNetConn(wsConn)

Expand All @@ -119,6 +119,36 @@ func TestWsNetConn_ReadWrite(t *testing.T) {
assert.Equal(t, sent, buf)
}

// TestWsNetConn_CloseIsAbrupt verifies that closing the net.Conn tears the
// socket down without a WebSocket close frame, matching the agent's previous
// raw-socket close. The peer must observe an error that carries no close status.
func TestWsNetConn_CloseIsAbrupt(t *testing.T) {
peerErr := make(chan error, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := acceptWS(w, r)
require.NoError(t, err)
defer func() { _ = conn.CloseNow() }()

_, _, readErr := conn.Read(context.Background())
peerErr <- readErr
}))
defer srv.Close()

wsConn, _, err := websocket.Dial(context.Background(), "ws"+srv.URL[4:], nil) //nolint:bodyclose // coder/websocket closes the handshake body itself; resp.Body is nil on success
require.NoError(t, err)

netConn := leash.NewWSNetConn(wsConn)
require.NoError(t, netConn.Close())

select {
case readErr := <-peerErr:
require.Error(t, readErr)
assert.Equal(t, websocket.StatusCode(-1), websocket.CloseStatus(readErr), "peer must not receive a close frame")
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the peer to observe the close")
}
}

// TestLeash_Connect_DockerStreamDispatchesThroughFilter proves the full
// write-mode wiring chain actually executes for a real accepted stream:
// connect()'s AcceptStream loop -> handleStream's streamTypeDocker dispatch
Expand Down Expand Up @@ -160,9 +190,9 @@ func TestLeash_Connect_DockerStreamDispatchesThroughFilter(t *testing.T) {
defer close(serverDone)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wsConn, upgradeErr := testUpgrader.Upgrade(w, r, nil)
wsConn, upgradeErr := acceptWS(w, r)
require.NoError(t, upgradeErr)
defer func() { _ = wsConn.Close() }()
defer func() { _ = wsConn.CloseNow() }()

session := newYamuxServerSession(t, wsConn)
defer func() { _ = session.Close() }()
Expand Down Expand Up @@ -234,9 +264,9 @@ func TestLeash_Connect_PortForwardStreamDialsTarget(t *testing.T) {
defer close(serverDone)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wsConn, upgradeErr := testUpgrader.Upgrade(w, r, nil)
wsConn, upgradeErr := acceptWS(w, r)
require.NoError(t, upgradeErr)
defer func() { _ = wsConn.Close() }()
defer func() { _ = wsConn.CloseNow() }()

session := newYamuxServerSession(t, wsConn)
defer func() { _ = session.Close() }()
Expand Down
90 changes: 28 additions & 62 deletions agent/leash/wsconn.go
Original file line number Diff line number Diff line change
@@ -1,80 +1,46 @@
package leash

import (
"io"
"context"
"crypto/tls"
"net"
"sync"
"time"
"net/http"

"github.com/gorilla/websocket"
"github.com/coder/websocket"
)

// wsNetConn adapts a gorilla WebSocket connection to the net.Conn interface
// so that yamux can use it as a byte-stream transport.
// gorilla websocket supports one concurrent reader and one concurrent writer;
// rMu serialises reads and wMu serialises writes accordingly.
// wsNetConn exposes a WebSocket connection as a net.Conn so that yamux can use
// it as a byte-stream transport. Each Write is sent as one binary message and
// reads stream the payload of incoming data messages.
type wsNetConn struct {
conn *websocket.Conn
rMu sync.Mutex
wMu sync.Mutex
reader io.Reader
net.Conn
ws *websocket.Conn
}

// NewWSNetConn wraps conn as a net.Conn suitable for use with yamux.
func NewWSNetConn(conn *websocket.Conn) net.Conn {
return &wsNetConn{conn: conn}
}

func (c *wsNetConn) Read(b []byte) (int, error) {
c.rMu.Lock()
defer c.rMu.Unlock()

for {
if c.reader != nil {
n, err := c.reader.Read(b)
if err == io.EOF {
c.reader = nil
continue
}
return n, err
}

_, msg, err := c.conn.NextReader()
if err != nil {
return 0, err
}
c.reader = msg
}
}

func (c *wsNetConn) Write(b []byte) (int, error) {
c.wMu.Lock()
defer c.wMu.Unlock()

if err := c.conn.WriteMessage(websocket.BinaryMessage, b); err != nil {
return 0, err
return &wsNetConn{
Conn: websocket.NetConn(context.Background(), conn, websocket.MessageBinary),
ws: conn,
}
return len(b), nil
}

// Close tears the connection down immediately without the WebSocket closing
// handshake. This matches the agent's previous raw-socket close and avoids
// blocking session teardown on a peer that has stopped responding.
func (c *wsNetConn) Close() error {
return c.conn.Close()
}

func (c *wsNetConn) LocalAddr() net.Addr { return c.conn.NetConn().LocalAddr() }
func (c *wsNetConn) RemoteAddr() net.Addr { return c.conn.NetConn().RemoteAddr() }

func (c *wsNetConn) SetDeadline(t time.Time) error {
if err := c.conn.SetReadDeadline(t); err != nil {
return err
return c.ws.CloseNow()
}

// newDialHTTPClient returns the HTTP client used for the WebSocket handshake.
// It dials directly, ignoring proxy environment variables, as the previous
// dialer did, and disables HTTP/2 negotiation because the Upgrade handshake
// requires HTTP/1.1.
func newDialHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{}).DialContext,
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
},
}
return c.conn.SetWriteDeadline(t)
}

func (c *wsNetConn) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}

func (c *wsNetConn) SetWriteDeadline(t time.Time) error {
return c.conn.SetWriteDeadline(t)
}
20 changes: 20 additions & 0 deletions agent/leash/wsconn_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package leash

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestNewDialHTTPClient_BypassesProxyAndDisablesHTTP2 verifies the handshake
// transport dials directly and never negotiates HTTP/2, which would break the
// WebSocket Upgrade.
func TestNewDialHTTPClient_BypassesProxyAndDisablesHTTP2(t *testing.T) {
tr, ok := newDialHTTPClient().Transport.(*http.Transport)
require.True(t, ok, "dial client must use an *http.Transport")
assert.Nil(t, tr.Proxy, "handshake must not honor proxy environment variables")
assert.NotNil(t, tr.TLSNextProto, "HTTP/2 must be disabled via an empty TLSNextProto map")
assert.Empty(t, tr.TLSNextProto)
}
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ go 1.27.4
require (
filippo.io/age v1.3.2
github.com/Wikid82/Hermes v0.3.7
github.com/coder/websocket v1.8.15
github.com/gin-contrib/gzip v1.2.10
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/hashicorp/yamux v0.1.2
github.com/minio/minio-go/v7 v7.4.1
Expand Down
4 changes: 2 additions & 2 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
Expand Down Expand Up @@ -75,8 +77,6 @@ github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqf
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
Expand Down
Loading
Loading