eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package tunnel
2
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/base64"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "strings"
12 "time"
13
14 "github.com/coder/websocket"
15)
16
17// Client keeps one tunnel open: a control connection to the gateway, and one
18// dialled-back data connection per backend connection the public side needs,
19// each bridged to the local instance with two plain io.Copy loops.
20type Client struct {
21 Gateway string // gateway origin, e.g. "https://tunnel.11ch.at" (or http:// in tests)
22 LocalAddr string // where the local instance listens, e.g. "127.0.0.1:52801"
23 Version string // informative, sent in the hello
24 // Relay mode: with Identity set the client says hello with Mode "relay"
25 // and answers the gateway's challenge, claiming (or reclaiming) the
26 // stable hostname that belongs to this keypair. Nil Identity = a trial.
27 Identity ed25519.PrivateKey
28}
29
30// Run registers, reports the assigned public host on ready, then serves dials
31// until the control connection dies or ctx is cancelled. The error return is
32// why the tunnel ended (nil on clean ctx cancellation).
33func (cl *Client) Run(ctx context.Context, ready func(host string)) error {
34 dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
35 c, _, err := websocket.Dial(dialCtx, cl.wsURL("/api/tunnel/register"), nil)
36 cancel()
37 if err != nil {
38 return fmt.Errorf("reach the tunnel gateway: %w", err)
39 }
40 defer c.CloseNow()
41 c.SetReadLimit(1 << 16)
42
43 hello := ControlMsg{Type: "hello", Version: cl.Version}
44 if cl.Identity != nil {
45 hello.Mode = "relay"
46 }
47 if err := writeJSON(ctx, c, hello); err != nil {
48 return err
49 }
50
51 // Keepalive: a ping every 30s keeps NATs and the router's idle timers
52 // friendly, and notices a dead gateway.
53 go func() {
54 t := time.NewTicker(30 * time.Second)
55 defer t.Stop()
56 for {
57 select {
58 case <-t.C:
59 pctx, pcancel := context.WithTimeout(ctx, 10*time.Second)
60 _ = c.Ping(pctx)
61 pcancel()
62 case <-ctx.Done():
63 return
64 }
65 }
66 }()
67
68 // One loop for the whole conversation. "dial" can arrive BEFORE "ready" —
69 // the gateway probes the backend through the tunnel before it publishes
70 // the hostname — so dials are served from the first message on.
71 gotReady := false
72 for {
73 var m ControlMsg
74 if err := readJSON(ctx, c, &m); err != nil {
75 if ctx.Err() != nil {
76 return nil
77 }
78 return fmt.Errorf("tunnel closed: %w", err)
79 }
80 switch m.Type {
81 case "challenge":
82 if cl.Identity == nil {
83 return fmt.Errorf("gateway sent a challenge but no relay identity is set")
84 }
85 pub := base64.RawURLEncoding.EncodeToString(cl.Identity.Public().(ed25519.PublicKey))
86 sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(cl.Identity, []byte(m.Nonce)))
87 if err := writeJSON(ctx, c, ControlMsg{Type: "claim", PubKey: pub, Sig: sig}); err != nil {
88 return err
89 }
90 case "dial":
91 go cl.serveDial(ctx, m.ID)
92 case "ready":
93 if !gotReady {
94 gotReady = true
95 ready(m.Host)
96 }
97 case "error":
98 return fmt.Errorf("%s", m.Error)
99 }
100 }
101}
102
103// serveDial opens the data connection for one id and splices it to the local
104// instance.
105func (cl *Client) serveDial(ctx context.Context, id string) {
106 dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
107 c, _, err := websocket.Dial(dialCtx, cl.wsURL("/api/tunnel/conn/"+id), nil)
108 cancel()
109 if err != nil {
110 return
111 }
112 c.SetReadLimit(-1)
113 remote := websocket.NetConn(context.Background(), c, websocket.MessageBinary)
114 local, err := net.DialTimeout("tcp", cl.LocalAddr, 10*time.Second)
115 if err != nil {
116 remote.Close()
117 return
118 }
119 go splice(local, remote)
120 splice(remote, local)
121}
122
123func splice(dst io.WriteCloser, src io.Reader) {
124 _, _ = io.Copy(dst, src)
125 dst.Close()
126}
127
128func (cl *Client) wsURL(path string) string {
129 origin := strings.TrimRight(cl.Gateway, "/")
130 origin = strings.Replace(origin, "https://", "wss://", 1)
131 origin = strings.Replace(origin, "http://", "ws://", 1)
132 return origin + path
133}
134
135// WaitReachable polls url until it answers or the timeout passes — used by
136// `lchat try` so the printed link is live before anyone can click it (the
137// router notices new routes within a couple of seconds).
138func WaitReachable(url string, timeout time.Duration) error {
139 deadline := time.Now().Add(timeout)
140 client := &http.Client{Timeout: 5 * time.Second}
141 var last error
142 for time.Now().Before(deadline) {
143 resp, err := client.Get(url)
144 if err == nil {
145 resp.Body.Close()
146 if resp.StatusCode < 500 {
147 return nil
148 }
149 last = fmt.Errorf("HTTP %d", resp.StatusCode)
150 } else {
151 last = err
152 }
153 time.Sleep(700 * time.Millisecond)
154 }
155 return fmt.Errorf("not reachable in %s: %v", timeout, last)
156}
157