Skip to content

Commit b556459

Browse files
ssh: add proxyproto support (#318)
* add proxyproto support this is useful for scenarios in which uptermd is behind a proxy, in order for it to properly show IP addresses associated with ssh connections. Also see https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt * README: add traefik section * Bump go-proxyproto to the latest * Add comment about --proxy-protocol * Improve README on Traefik * Rename --proxy-protocol to --ssh-proxy-protocol to be clear Uptermd only supports PROXY protocol for ssh listener. Make this explicit to avoid confusion. --------- Co-authored-by: Owen Ou <[email protected]>
1 parent ed68635 commit b556459

File tree

5 files changed

+91
-26
lines changed

5 files changed

+91
-26
lines changed

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,60 @@ systemctl daemon-reload
237237
systemctl start uptermd
238238
```
239239

240+
### Traefik
241+
242+
Below is an example `docker-compose` configuration for deploying `uptermd` behind [Traefik](https://doc.traefik.io/traefik/), including support for both SSH and WebSocket connections:
243+
244+
```yaml
245+
services:
246+
upterm:
247+
build: https://github.com/owenthereal/upterm
248+
labels:
249+
- "traefik.enable=true"
250+
- "traefik.docker.network=web"
251+
# SSH over TCP (port 2222)
252+
- "traefik.tcp.services.uptermd.loadbalancer.server.port=2222"
253+
- "traefik.tcp.services.uptermd.loadbalancer.proxyProtocol.version=2" # required for real IP forwarding over TCP
254+
- "traefik.tcp.routers.uptermd.service=uptermd"
255+
- "traefik.tcp.routers.uptermd.rule=HostSNI(`*`)"
256+
- "traefik.tcp.routers.uptermd.entrypoints=uptermd"
257+
# WebSocket over HTTPS (port 8443)
258+
- "traefik.http.services.uptermd-wss.loadbalancer.server.port=8443"
259+
- "traefik.http.routers.uptermd-wss.service=uptermd-wss"
260+
- "traefik.http.routers.uptermd-wss.rule=Host(`upterm.example.com`)" # edit as needed
261+
- "traefik.http.routers.uptermd-wss.entrypoints=websecure"
262+
- "traefik.http.routers.uptermd-wss.tls.certresolver=<your cert resolver here>"
263+
264+
command:
265+
- --ssh-addr=0.0.0.0:2222
266+
- --ws-addr=0.0.0.0:8443
267+
- --ssh-proxy-protocol
268+
269+
networks:
270+
- web
271+
272+
networks:
273+
web:
274+
external: true
275+
```
276+
277+
**Important notes:**
278+
279+
- **Proxy Protocol:**
280+
The `--ssh-proxy-protocol` flag (or `UPTERMD_SSH_PROXY_PROTOCOL=true` environment variable) tells `uptermd` to expect the [PROXY protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) header on incoming SSH connections. This is essential when using Traefik (or other TCP proxies like HAProxy or AWS ELB) to preserve the real client IP address.
281+
**If you enable `--ssh-proxy-protocol`, all incoming SSH connections must come through a proxy that supports and is configured to use the PROXY protocol. Direct SSH connections will fail, as `uptermd` will expect the protocol header.**
282+
283+
- **Entrypoints:**
284+
Make sure to configure the appropriate [Traefik entrypoints](https://doc.traefik.io/traefik/routing/entrypoints/). This example uses two: one for SSH (`uptermd` on port `2222`) and one for WebSocket/HTTPS (`websecure` on port `443`).
285+
286+
- **WebSocket:**
287+
The WebSocket service allows clients to connect to `uptermd` over HTTPS, which is useful in restrictive network environments.
288+
289+
- **Certificates:**
290+
Replace `<your cert resolver here>` with your actual Traefik certificate resolver for TLS.
291+
292+
For more details on Traefik TCP and HTTP routing, see the [Traefik documentation](https://doc.traefik.io/traefik/routing/overview/).
293+
240294
## :balance_scale: Comparison with Prior Arts
241295

242296
Upterm stands as a modern alternative to [Tmate](https://tmate.io).

cmd/uptermd/command/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func Root(logger log.FieldLogger) *cobra.Command {
2828
cmd.PersistentFlags().StringP("node-addr", "", "", "node address")
2929
cmd.PersistentFlags().StringSliceP("private-key", "", nil, "server private key")
3030
cmd.PersistentFlags().StringSliceP("hostname", "", nil, "server hostname for public-key authentication certificate principals. If empty, public-key authentication is used instead.")
31+
cmd.PersistentFlags().BoolP("ssh-proxy-protocol", "", false, "enable PROXY protocol support for the SSH listener (for use behind TCP proxies like Traefik, HAProxy, or AWS ELB)")
3132

3233
cmd.PersistentFlags().StringP("network", "", "mem", "network provider")
3334
cmd.PersistentFlags().StringSliceP("network-opt", "", nil, "network provider option")

go.mod

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
module github.com/owenthereal/upterm
55

6-
go 1.22.2
6+
go 1.24
77

88
require (
99
github.com/VividCortex/gohistogram v1.0.0 // indirect
@@ -41,7 +41,7 @@ require (
4141
github.com/tj/go v1.8.7
4242
github.com/tj/go-update v2.2.5-0.20200519121640-62b4b798fd68+incompatible
4343
github.com/ulikunitz/xz v0.5.8 // indirect
44-
golang.org/x/crypto v0.26.0
44+
golang.org/x/crypto v0.37.0
4545
google.golang.org/grpc v1.67.0
4646
google.golang.org/protobuf v1.34.2
4747
)
@@ -51,11 +51,12 @@ require (
5151
github.com/cli/go-gh/v2 v2.10.0
5252
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
5353
github.com/google/go-github/v48 v48.2.0
54+
github.com/pires/go-proxyproto v0.8.1
5455
github.com/spf13/pflag v1.0.5
5556
github.com/spf13/viper v1.19.0
5657
github.com/stretchr/testify v1.9.0
5758
golang.org/x/exp v0.0.0-20230905200255-921286631fa9
58-
golang.org/x/term v0.24.0
59+
golang.org/x/term v0.31.0
5960
)
6061

6162
require (
@@ -106,10 +107,10 @@ require (
106107
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
107108
go.uber.org/atomic v1.9.0 // indirect
108109
go.uber.org/multierr v1.9.0 // indirect
109-
golang.org/x/net v0.28.0 // indirect
110-
golang.org/x/sync v0.8.0 // indirect
111-
golang.org/x/sys v0.25.0 // indirect
112-
golang.org/x/text v0.17.0 // indirect
110+
golang.org/x/net v0.39.0 // indirect
111+
golang.org/x/sync v0.13.0 // indirect
112+
golang.org/x/sys v0.32.0 // indirect
113+
golang.org/x/text v0.24.0 // indirect
113114
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
114115
gopkg.in/ini.v1 v1.67.0 // indirect
115116
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ github.com/pborman/ansi v1.0.0 h1:OqjHMhvlSuCCV5JT07yqPuJPQzQl+WXsiZ14gZsqOrQ=
173173
github.com/pborman/ansi v1.0.0/go.mod h1:SgWzwMAx1X/Ez7i90VqF8LRiQtx52pWDiQP+x3iGnzw=
174174
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
175175
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
176+
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
177+
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
176178
github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA=
177179
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
178180
github.com/pkg/errors v0.8.2-0.20190227000051-27936f6d90f9/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -280,14 +282,14 @@ golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLL
280282
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
281283
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
282284
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
283-
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
284-
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
285+
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
286+
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
285287
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
286288
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
287289
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
288290
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
289-
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
290-
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
291+
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
292+
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
291293
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
292294
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
293295
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -304,22 +306,22 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
304306
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
305307
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
306308
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
307-
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
308-
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
309+
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
310+
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
309311
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
310312
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
311313
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
312314
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
313-
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
314-
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
315+
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
316+
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
315317
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
316318
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
317319
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
318320
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
319321
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
320322
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
321-
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
322-
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
323+
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
324+
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
323325
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
324326
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
325327
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

server/server.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/owenthereal/upterm/host/api"
1717
"github.com/owenthereal/upterm/utils"
1818
"github.com/owenthereal/upterm/ws"
19+
"github.com/pires/go-proxyproto"
1920
log "github.com/sirupsen/logrus"
2021
"golang.org/x/crypto/ssh"
2122
"golang.org/x/exp/slices"
@@ -26,15 +27,16 @@ const (
2627
)
2728

2829
type Opt struct {
29-
SSHAddr string `mapstructure:"ssh-addr"`
30-
WSAddr string `mapstructure:"ws-addr"`
31-
NodeAddr string `mapstructure:"node-addr"`
32-
PrivateKeys []string `mapstructure:"private-key"`
33-
Hostnames []string `mapstructure:"hostname"`
34-
Network string `mapstructure:"network"`
35-
NetworkOpts []string `mapstructure:"network-opt"`
36-
MetricAddr string `mapstructure:"metric-addr"`
37-
Debug bool `mapstructure:"debug"`
30+
SSHAddr string `mapstructure:"ssh-addr"`
31+
SSHProxyProtocol bool `mapstructure:"ssh-proxy-protocol"`
32+
WSAddr string `mapstructure:"ws-addr"`
33+
NodeAddr string `mapstructure:"node-addr"`
34+
PrivateKeys []string `mapstructure:"private-key"`
35+
Hostnames []string `mapstructure:"hostname"`
36+
Network string `mapstructure:"network"`
37+
NetworkOpts []string `mapstructure:"network-opt"`
38+
MetricAddr string `mapstructure:"metric-addr"`
39+
Debug bool `mapstructure:"debug"`
3840
}
3941

4042
func Start(opt Opt) error {
@@ -99,6 +101,11 @@ func Start(opt Opt) error {
99101
return err
100102
}
101103
logger = logger.WithField("ssh-addr", sshln.Addr())
104+
if opt.SSHProxyProtocol {
105+
// Wrap the SSH listener with proxyproto.Listener to preserve the real client IP
106+
// when connections are coming through a TCP proxy (e.g., AWS ELB, HAProxy).
107+
sshln = &proxyproto.Listener{Listener: sshln}
108+
}
102109
}
103110

104111
if opt.WSAddr != "" {

0 commit comments

Comments
 (0)