Skip to content
Open
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
12 changes: 12 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ chisel/
│ ├── cnet/ # WebSocket-to-net.Conn adapter, HTTP server with graceful shutdown
│ ├── cos/ # OS signals (SIGUSR2 stats, SIGHUP reconnect), context helpers
│ ├── settings/ # Config encoding, remote parsing, user/auth management, env helpers
│ ├── metrics/ # Optional Prometheus metrics (counters, gauges, histograms) + /metrics HTTP server
│ └── tunnel/ # Core tunnel engine — proxy, SSH channel handling, keepalive, UDP
├── test/
│ ├── e2e/ # End-to-end tests (auth, TLS, SOCKS, UDP, proxy)
Expand Down Expand Up @@ -99,6 +100,16 @@ The core data-plane, shared by both client and server:
- **`UserIndex`** -- Loads and watches the auth file, manages user permissions with regex-based address ACLs.
- **`Env`** -- Reads `CHISEL_*` environment variables for tuning (timeouts, buffer sizes, UDP settings).

### `share/metrics/` -- Observability

Optional Prometheus instrumentation, shared by both `client.Client` and `server.Server`:

- **`Metrics`** -- Holds a private `prometheus.Registry` and the counters/gauges/histograms for connection attempts/errors, auth outcomes, session setup duration, tunnel bytes sent/received, active connections, and keepalive ping results.
- **`New(namespace string) (*Metrics, error)`** -- Builds and registers all metrics under the given namespace (defaults to `"chisel"` when empty). Validates the namespace against `^[a-zA-Z_][a-zA-Z0-9_]*$` and returns an error for an invalid one -- callers do not need to re-validate.
- **`Start(addr string) error`** -- Serves the registry at `/metrics` over plain HTTP on a background goroutine.

Metrics are opt-in: `client.Config`/`server.Config` carry `MetricsAddr`/`MetricsNamespace` fields, and `NewClient`/`NewServer` only construct a `*Metrics` when `MetricsAddr != ""`, passing it through to `tunnel.Config.Metrics` so `share/tunnel` can record per-connection byte counts and keepalive outcomes.

### `share/ccrypto/` -- Cryptography

Generates ECDSA P256 keys (deterministic from seed or random), converts between PEM and chisel key formats, and computes SHA256 fingerprints for host key verification.
Expand Down Expand Up @@ -176,6 +187,7 @@ This layering means chisel traffic looks like regular HTTP/WebSocket traffic to
| `fsnotify/fsnotify` | Hot-reload of the users auth file |
| `golang.org/x/net/proxy` | SOCKS5 outbound proxy dialer (client side) |
| `golang.org/x/sync/errgroup` | Concurrent goroutine lifecycle management |
| `prometheus/client_golang` | Optional Prometheus metrics registry, collectors, and `/metrics` HTTP handler |

## Key Design Decisions

Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for full build, test, and release detai
| `share/` | Shared libraries used by both client and server |
| `share/tunnel/` | Core tunnel engine -- proxy, SSH channels, keepalive, UDP mux |
| `share/settings/` | Config parsing, remote format, user/auth, `CHISEL_*` env vars |
| `share/metrics/` | Optional Prometheus metrics (counters/gauges/histograms) and `/metrics` HTTP server |
| `share/ccrypto/` | ECDSA key generation, SSH fingerprinting |
| `share/cnet/` | WebSocket-to-net.Conn adapter, HTTP server with graceful shutdown |
| `share/cio/` | Bidirectional pipe, logging, stdio |
Expand Down Expand Up @@ -78,6 +79,10 @@ Both client and server use `cos.InterruptContext()` for graceful shutdown on SIG

`go.mod` contains `replace github.com/jpillora/chisel => ../chisel`. This is a local development override -- do not remove it, but be aware it means `go mod tidy` expects a sibling directory.

### Metrics are opt-in and validate at construction time

`client.Config`/`server.Config` carry `MetricsAddr`/`MetricsNamespace`. `NewClient`/`NewServer` only build a `*metrics.Metrics` when `MetricsAddr != ""`. Namespace validation (`^[a-zA-Z_][a-zA-Z0-9_]*$`) happens inside `metrics.New()`, which returns `(*Metrics, error)` -- the error flows back through `NewClient`/`NewServer`'s existing `error` return. Do not duplicate namespace validation in callers (including `main.go` or any downstream CLI); rely on the error from `NewClient`/`NewServer` instead.

## Domain Glossary

| Term | Meaning |
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Chisel is a fast TCP/UDP tunnel, transported over HTTP, secured via SSH. Single
- [Install](#install)
- [Demo](#demo)
- [Usage](#usage)
- [Metrics](#metrics)
- [Contributing](#contributing)
- [Changelog](#changelog)
- [License](#license)
Expand All @@ -30,6 +31,7 @@ Chisel is a fast TCP/UDP tunnel, transported over HTTP, secured via SSH. Single
- Server optionally allows [SOCKS5](https://en.wikipedia.org/wiki/SOCKS) connections (See [guide below](#socks5-guide))
- Clients optionally allow [SOCKS5](https://en.wikipedia.org/wiki/SOCKS) connections from a reversed port forward
- Client connections over stdio which supports `ssh -o ProxyCommand` providing SSH over HTTP
- Optional [Prometheus metrics](#metrics) endpoint for connection and tunnel observability

## Install

Expand Down Expand Up @@ -98,6 +100,15 @@ $ chisel --help
server - runs chisel in server mode
client - runs chisel in client mode

Global Options (must precede the command):

--metrics, An optional "host:port" to serve Prometheus metrics on
at /metrics. When unset, no metrics are collected or served.

--metrics-namespace, An optional prefix applied to every metric name
(defaults to "chisel"). Must match [a-zA-Z_][a-zA-Z0-9_]*. Only used
when --metrics is set.

Read more:
https://github.com/jpillora/chisel

Expand Down Expand Up @@ -403,6 +414,25 @@ Since WebSockets support is required:
- Openshift has full support though connections are only accepted on ports 8443 and 8080
- Google App Engine has **no** support (Track this on [their repo](https://code.google.com/p/googleappengine/issues/detail?id=2535))

## Metrics

Both `chisel server` and `chisel client` can optionally expose a [Prometheus](https://prometheus.io) `/metrics` endpoint, covering connection attempts/errors, tunnel bytes transferred, active connections, auth outcomes, and keepalive ping results.

Metrics are opt-in and off by default. Pass `--metrics` (a global option, so it must precede the `server`/`client` subcommand):

```sh
chisel --metrics 127.0.0.1:9100 server --port 9312
chisel --metrics 127.0.0.1:9100 client https://my-server.com:9312 3000
```

Every exported metric name is prefixed with a namespace, which defaults to `chisel` and can be overridden with `--metrics-namespace` (must match `[a-zA-Z_][a-zA-Z0-9_]*`):

```sh
chisel --metrics 127.0.0.1:9100 --metrics-namespace myapp server --port 9312
```

The implementation lives in `share/metrics`; see [ARCHITECTURE.md](./ARCHITECTURE.md) for the full metric list and how it wires into the client/server.

## Contributing

- http://golang.org/doc/code.html
Expand Down
17 changes: 17 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/jpillora/chisel/share/ccrypto"
"github.com/jpillora/chisel/share/cio"
"github.com/jpillora/chisel/share/cnet"
"github.com/jpillora/chisel/share/metrics"
"github.com/jpillora/chisel/share/settings"
"github.com/jpillora/chisel/share/tunnel"

Expand All @@ -43,6 +44,8 @@ type Config struct {
TLS TLSConfig
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
Verbose bool
MetricsAddr string
MetricsNamespace string
}

// TLSConfig for a Client
Expand All @@ -67,6 +70,7 @@ type Client struct {
stop func()
eg *errgroup.Group
tunnel *tunnel.Tunnel
metrics *metrics.Metrics
}

// NewClient creates a new client instance
Expand Down Expand Up @@ -179,13 +183,26 @@ func NewClient(c *Config) (*Client, error) {
HostKeyCallback: client.verifyServer,
Timeout: settings.EnvDuration("SSH_TIMEOUT", 30*time.Second),
}
//initialize metrics if enabled
if c.MetricsAddr != "" {
m, err := metrics.New(c.MetricsNamespace)
if err != nil {
return nil, err
}
client.metrics = m
if err := client.metrics.Start(c.MetricsAddr); err != nil {
return nil, err
}
client.Infof("Metrics server started on %s", c.MetricsAddr)
}
//prepare client tunnel
client.tunnel = tunnel.New(tunnel.Config{
Logger: client.Logger,
Inbound: true, //client always accepts inbound
Outbound: hasReverse,
Socks: hasReverse && hasSocks,
KeepAlive: client.config.KeepAlive,
Metrics: client.metrics,
})
return client, nil
}
Expand Down
55 changes: 55 additions & 0 deletions client/client_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ func (c *Client) connectionLoop(ctx context.Context) error {

// connectionOnce connects to the chisel server and blocks
func (c *Client) connectionOnce(ctx context.Context) (connected bool, err error) {
// Record connection attempt
if c.metrics != nil {
(*c.metrics.ClientConnectionAttempts).Inc()
if c.Debug {
c.Debugf("[metrics] client_connection_attempts_total incremented")
}
}
//already closed?
select {
case <-ctx.Done():
Expand All @@ -92,6 +99,12 @@ func (c *Client) connectionOnce(ctx context.Context) (connected bool, err error)
}
wsConn, _, err := d.DialContext(ctx, c.server, c.config.Headers)
if err != nil {
if c.metrics != nil {
c.metrics.ClientConnectionErrors.WithLabelValues("handshake_error").Inc()
if c.Debug {
c.Debugf("[metrics] client_connection_errors_total{cause=\"handshake_error\"} incremented")
}
}
return false, err
}
conn := cnet.NewWebSocketConn(wsConn)
Expand All @@ -103,8 +116,20 @@ func (c *Client) connectionOnce(ctx context.Context) (connected bool, err error)
if strings.Contains(e, "unable to authenticate") {
c.Infof("Authentication failed")
c.Debugf(e)
if c.metrics != nil {
c.metrics.ClientConnectionErrors.WithLabelValues("auth_failure").Inc()
if c.Debug {
c.Debugf("[metrics] client_connection_errors_total{cause=\"auth_failure\"} incremented")
}
}
} else {
c.Infof(e)
if c.metrics != nil {
c.metrics.ClientConnectionErrors.WithLabelValues("handshake_error").Inc()
if c.Debug {
c.Debugf("[metrics] client_connection_errors_total{cause=\"handshake_error\"} incremented")
}
}
}
return false, err
}
Expand All @@ -123,12 +148,42 @@ func (c *Client) connectionOnce(ctx context.Context) (connected bool, err error)
return false, err
}
if len(configerr) > 0 {
if c.metrics != nil {
c.metrics.ClientConnectionErrors.WithLabelValues("handshake_error").Inc()
if c.Debug {
c.Debugf("[metrics] client_connection_errors_total{cause=\"handshake_error\"} incremented")
}
}
return false, errors.New(string(configerr))
}
// Record handshake duration and set connected status
if c.metrics != nil {
duration := time.Since(t0).Seconds()
c.metrics.ClientHandshakeDuration.Observe(duration)
c.metrics.ClientConnected.Set(1)
if c.Debug {
c.Debugf("[metrics] client_handshake_duration_seconds observed: %.6fs", duration)
c.Debugf("[metrics] client_connected set to 1")
}
}
c.Infof("Connected (Latency %s)", time.Since(t0))
//connected, handover ssh connection for tunnel to use, and block
err = c.tunnel.BindSSH(ctx, sshConn, reqs, chans)
c.Infof("Disconnected")
// Set disconnected status
if c.metrics != nil {
c.metrics.ClientConnected.Set(0)
if c.Debug {
c.Debugf("[metrics] client_connected set to 0")
}
// Record transport error if it's not EOF
if err != nil && err != io.EOF && !strings.HasSuffix(err.Error(), "EOF") {
c.metrics.ClientConnectionErrors.WithLabelValues("transport_error").Inc()
if c.Debug {
c.Debugf("[metrics] client_connection_errors_total{cause=\"transport_error\"} incremented")
}
}
}
connected = time.Since(t0) > 5*time.Second
return connected, err
}
9 changes: 9 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,26 @@ require (
github.com/jpillora/backoff v1.0.0
github.com/jpillora/requestlog v1.0.0
github.com/jpillora/sizestr v1.0.0
github.com/prometheus/client_golang v1.20.5
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
)

require (
github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jpillora/ansi v1.0.3 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)

replace github.com/jpillora/chisel => ../chisel
22 changes: 22 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 h1:axBiC50cNZ
github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2/go.mod h1:jnzFpU88PccN/tPPhCpnNU8mZphvKxYM9lLNkd8e+os=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/jpillora/ansi v1.0.3 h1:nn4Jzti0EmRfDxm7JtEs5LzCbNwd5sv+0aE+LdS9/ZQ=
Expand All @@ -14,6 +20,20 @@ github.com/jpillora/requestlog v1.0.0 h1:bg++eJ74T7DYL3DlIpiwknrtfdUA9oP/M4fL+Pp
github.com/jpillora/requestlog v1.0.0/go.mod h1:HTWQb7QfDc2jtHnWe2XEIEeJB7gJPnVdpNn52HXPvy8=
github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw=
github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
Expand All @@ -28,3 +48,5 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
23 changes: 19 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ var help = `
server - runs chisel in server mode
client - runs chisel in client mode

Global Options (must precede the command):

--metrics, An optional "host:port" to serve Prometheus metrics on
at /metrics. When unset, no metrics are collected or served.

--metrics-namespace, An optional prefix applied to every metric name
(defaults to "chisel"). Must match [a-zA-Z_][a-zA-Z0-9_]*. Only used
when --metrics is set.

Read more:
https://github.com/jpillora/chisel

Expand All @@ -37,6 +46,8 @@ func main() {

version := flag.Bool("version", false, "")
v := flag.Bool("v", false, "")
metricsAddr := flag.String("metrics", "", "")
metricsNamespace := flag.String("metrics-namespace", "", "")
flag.Bool("help", false, "")
flag.Bool("h", false, "")
flag.Usage = func() {}
Expand All @@ -57,9 +68,9 @@ func main() {

switch subcmd {
case "server":
server(args)
server(args, *metricsAddr, *metricsNamespace)
case "client":
client(args)
client(args, *metricsAddr, *metricsNamespace)
default:
fmt.Print(help)
os.Exit(0)
Expand Down Expand Up @@ -176,11 +187,13 @@ var serverHelp = `
instead of the system roots. This is commonly used to implement mutual-TLS.
` + commonHelp

func server(args []string) {
func server(args []string, metricsAddr, metricsNamespace string) {

flags := flag.NewFlagSet("server", flag.ContinueOnError)

config := &chserver.Config{}
config.MetricsAddr = metricsAddr
config.MetricsNamespace = metricsNamespace
flags.StringVar(&config.KeySeed, "key", "", "")
flags.StringVar(&config.KeyFile, "keyfile", "", "")
flags.StringVar(&config.AuthFile, "authfile", "", "")
Expand Down Expand Up @@ -421,9 +434,11 @@ var clientHelp = `
enabled (mutual-TLS).
` + commonHelp

func client(args []string) {
func client(args []string, metricsAddr, metricsNamespace string) {
flags := flag.NewFlagSet("client", flag.ContinueOnError)
config := chclient.Config{Headers: http.Header{}}
config.MetricsAddr = metricsAddr
config.MetricsNamespace = metricsNamespace
flags.StringVar(&config.Fingerprint, "fingerprint", "", "")
flags.StringVar(&config.Auth, "auth", "", "")
flags.DurationVar(&config.KeepAlive, "keepalive", 25*time.Second, "")
Expand Down
Loading