main pico / pkg / apps / pipe / conn.go
Antonio Mika  ·  2026-10-10
  1package pipe
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"net"
  8	"os"
  9	"strings"
 10	"sync"
 11	"time"
 12
 13	"github.com/picosh/utils/pipe"
 14	"golang.org/x/crypto/ssh"
 15)
 16
 17// dialTimeout bounds connecting to pipe, handshake included, and starting a
 18// command on it, so a server that stops answering can't hold up requests.
 19const dialTimeout = 10 * time.Second
 20
 21// pipeConn is pipe-web's ssh connection to pipe. Each request runs its
 22// command in a session of its own, which is never reopened, so the command
 23// ends when the request does.
 24type pipeConn struct {
 25	info    *pipe.SSHClientInfo
 26	timeout time.Duration
 27
 28	mu      sync.Mutex
 29	client  *ssh.Client
 30	dialing *dial
 31}
 32
 33// dial is a connection being opened, shared by the requests waiting on it.
 34type dial struct {
 35	done   chan struct{}
 36	client *ssh.Client
 37	err    error
 38}
 39
 40func newPipeConn(info *pipe.SSHClientInfo) *pipeConn {
 41	return &pipeConn{info: info, timeout: dialTimeout}
 42}
 43
 44// connect returns the open connection, dialing a new one when there is none
 45// or when the open one is dead. A request that ends stops waiting, and the
 46// dial carries on for the requests after it.
 47func (c *pipeConn) connect(ctx context.Context, dead *ssh.Client) (*ssh.Client, error) {
 48	c.mu.Lock()
 49	if c.client != nil && c.client != dead {
 50		client := c.client
 51		c.mu.Unlock()
 52		return client, nil
 53	}
 54	if c.client != nil {
 55		_ = c.client.Close()
 56		c.client = nil
 57	}
 58	d := c.dialing
 59	if d == nil {
 60		d = &dial{done: make(chan struct{})}
 61		c.dialing = d
 62		go c.dial(d)
 63	}
 64	c.mu.Unlock()
 65
 66	select {
 67	case <-d.done:
 68		return d.client, d.err
 69	case <-ctx.Done():
 70		return nil, ctx.Err()
 71	}
 72}
 73
 74func (c *pipeConn) dial(d *dial) {
 75	d.client, d.err = dialSSH(c.info, c.timeout)
 76	c.mu.Lock()
 77	c.dialing = nil
 78	if d.err == nil {
 79		c.client = d.client
 80	}
 81	c.mu.Unlock()
 82	close(d.done)
 83}
 84
 85// run starts cmd on pipe. A connection that can't open a session is dialed
 86// again once.
 87func (c *pipeConn) run(ctx context.Context, cmd string) (*cmdSession, error) {
 88	client, err := c.connect(ctx, nil)
 89	if err != nil {
 90		return nil, err
 91	}
 92	s, err := c.start(ctx, client, cmd)
 93	if err == nil || ctx.Err() != nil {
 94		return s, err
 95	}
 96	client, err = c.connect(ctx, client)
 97	if err != nil {
 98		return nil, err
 99	}
100	return c.start(ctx, client, cmd)
101}
102
103type started struct {
104	s   *cmdSession
105	err error
106}
107
108// start runs startCmd until the request ends. A connection that can't
109// start a command in time is closed, which also ends the startCmd waiting
110// on it, and that deadline holds after the request has gone.
111func (c *pipeConn) start(ctx context.Context, client *ssh.Client, cmd string) (*cmdSession, error) {
112	done := make(chan started, 1)
113	go func() {
114		s, err := startCmd(client, cmd)
115		done <- started{s, err}
116	}()
117	timer := time.NewTimer(c.timeout)
118	select {
119	case res := <-done:
120		timer.Stop()
121		return res.s, res.err
122	case <-ctx.Done():
123		go abandon(done, timer, client)
124		return nil, ctx.Err()
125	case <-timer.C:
126		_ = client.Close()
127		go closeStarted(done)
128		return nil, fmt.Errorf("starting %q on pipe timed out", cmd)
129	}
130}
131
132// abandon closes a session no request is waiting for, or the connection if
133// the session doesn't start in time.
134func abandon(done <-chan started, timer *time.Timer, client *ssh.Client) {
135	select {
136	case res := <-done:
137		timer.Stop()
138		if res.s != nil {
139			_ = res.s.Close()
140		}
141	case <-timer.C:
142		_ = client.Close()
143		closeStarted(done)
144	}
145}
146
147func closeStarted(done <-chan started) {
148	if res := <-done; res.s != nil {
149		_ = res.s.Close()
150	}
151}
152
153func dialSSH(info *pipe.SSHClientInfo, timeout time.Duration) (*ssh.Client, error) {
154	signer, err := clientSigner(info)
155	if err != nil {
156		return nil, err
157	}
158	var auth []ssh.AuthMethod
159	if signer != nil {
160		auth = append(auth, ssh.PublicKeys(signer))
161	}
162	addr := info.RemoteHost
163	if !strings.Contains(addr, ":") {
164		addr += ":22"
165	}
166
167	deadline := time.Now().Add(timeout)
168	conn, err := (&net.Dialer{Deadline: deadline}).Dial("tcp", addr)
169	if err != nil {
170		return nil, err
171	}
172	_ = conn.SetDeadline(deadline)
173	sshConn, chans, reqs, err := ssh.NewClientConn(conn, info.RemoteHostname, &ssh.ClientConfig{
174		Auth:            auth,
175		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
176		User:            info.RemoteUser,
177	})
178	if err != nil {
179		_ = conn.Close()
180		return nil, err
181	}
182	_ = conn.SetDeadline(time.Time{})
183	return ssh.NewClient(sshConn, chans, reqs), nil
184}
185
186// clientSigner loads the key, and the certificate for it if there is one.
187// It returns nil when there is no key.
188func clientSigner(info *pipe.SSHClientInfo) (ssh.Signer, error) {
189	if info.KeyLocation == "" {
190		return nil, nil
191	}
192	data, err := os.ReadFile(info.KeyLocation)
193	if err != nil {
194		return nil, err
195	}
196	var signer ssh.Signer
197	if info.KeyPassphrase != "" {
198		signer, err = ssh.ParsePrivateKeyWithPassphrase(data, []byte(info.KeyPassphrase))
199	} else {
200		signer, err = ssh.ParsePrivateKey(data)
201	}
202	if err != nil || info.CertificateLocation == "" {
203		return signer, err
204	}
205	certData, err := os.ReadFile(info.CertificateLocation)
206	if err != nil {
207		return nil, err
208	}
209	pub, _, _, _, err := ssh.ParseAuthorizedKey(certData)
210	if err != nil {
211		return nil, fmt.Errorf("failed to parse certificate: %w", err)
212	}
213	cert, ok := pub.(*ssh.Certificate)
214	if !ok {
215		return nil, fmt.Errorf("%s is not an ssh certificate", info.CertificateLocation)
216	}
217	return ssh.NewCertSigner(cert, signer)
218}
219
220// cmdSession is a command running on pipe, read from its stdout and written
221// to its stdin.
222type cmdSession struct {
223	*ssh.Session
224	stdin  io.WriteCloser
225	stdout io.Reader
226}
227
228func startCmd(client *ssh.Client, cmd string) (*cmdSession, error) {
229	session, err := client.NewSession()
230	if err != nil {
231		return nil, err
232	}
233	s := &cmdSession{Session: session}
234	if s.stdin, err = session.StdinPipe(); err == nil {
235		if s.stdout, err = session.StdoutPipe(); err == nil {
236			err = session.Start(cmd)
237		}
238	}
239	if err != nil {
240		_ = session.Close()
241		return nil, err
242	}
243	return s, nil
244}
245
246func (s *cmdSession) Read(p []byte) (int, error) {
247	return s.stdout.Read(p)
248}
249
250func (s *cmdSession) Write(p []byte) (int, error) {
251	return s.stdin.Write(p)
252}
253
254// CloseWrite sends the command EOF on its stdin.
255func (s *cmdSession) CloseWrite() error {
256	return s.stdin.Close()
257}