Files

331 lines
12 KiB
Go

package chclient
import (
"context"
"crypto/md5"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/gorilla/websocket"
chshare "github.com/jpillora/chisel/share"
"github.com/jpillora/chisel/share/ccrypto"
"github.com/jpillora/chisel/share/cio"
"github.com/jpillora/chisel/share/cnet"
"github.com/jpillora/chisel/share/settings"
"github.com/jpillora/chisel/share/tunnel"
"golang.org/x/crypto/ssh"
"golang.org/x/net/proxy"
"golang.org/x/sync/errgroup"
)
// Config represents a client configuration
type Config struct {
Fingerprint string `opts:"name=fingerprint,short=-" help:"A *strongly recommended* fingerprint string to perform host-key validation against the server's public key. Fingerprint mismatches will close the connection. Fingerprints are generated by hashing the ECDSA public key using SHA256 and encoding the result in base64. Fingerprints must be 44 characters containing a trailing equals (=). Legacy MD5 colon fingerprints (deprecated) are still accepted, but only in their full 16-octet form; truncated prefixes are rejected."`
Auth string `opts:"name=auth,short=-" help:"An optional username and password (client authentication) in the form: \"<user>:<pass>\". These credentials are compared to the credentials inside the server's --authfile. defaults to the AUTH environment variable."`
KeepAlive time.Duration `opts:"name=keepalive,short=-" help:"An optional keepalive interval. Since the underlying transport is HTTP, in many instances we'll be traversing through proxies, often these proxies will close idle connections. You must specify a time with a unit, for example '5s' or '2m'. Defaults to '25s' (set to 0s to disable)."`
MaxRetryCount int `opts:"name=max-retry-count,short=-" help:"Maximum number of times to retry before exiting. Defaults to unlimited."`
MinRetryInterval time.Duration `opts:"name=min-retry-interval,short=-" help:"Minimum wait time before retrying after a disconnection. Defaults to 1 second."`
MaxRetryInterval time.Duration `opts:"name=max-retry-interval,short=-" help:"Maximum wait time before retrying after a disconnection. Defaults to 5 minutes."`
Server string `opts:"name=server,mode=arg"`
Proxy string `opts:"name=proxy,short=-" help:"An optional HTTP CONNECT or SOCKS5 proxy which will be used to reach the chisel server. Authentication can be specified inside the URL. Credentials must be URL-encoded; for example a \"#\" in the password must be written as \"%23\".\nFor example, http://admin:[email protected]:8081\n or: socks://admin:[email protected]:1080\nThe socks://, socks5:// and socks5h:// schemes are equivalent: DNS is always resolved by the proxy."`
Remotes []string `opts:"name=remote,mode=arg,min=1"`
Headers http.Header `opts:"-"`
TLS TLSConfig `opts:"mode=embedded"`
DialContext func(ctx context.Context, network, addr string) (net.Conn, error) `opts:"-"`
Verbose bool `opts:"name=verbose,short=v" help:"Enable verbose logging"`
}
// TLSConfig for a Client
type TLSConfig struct {
SkipVerify bool `opts:"name=tls-skip-verify,short=-" help:"Skip server TLS certificate verification of chain and host name (if TLS is used for transport connections to server). If set, client accepts any TLS certificate presented by the server and any host name in that certificate. This only affects transport https (wss) connection. Chisel server's public key may be still verified (see --fingerprint) after inner connection is established."`
CA string `opts:"name=tls-ca,short=-" help:"An optional root certificate bundle used to verify the chisel server. Only valid when connecting to the server with \"https\" or \"wss\". By default, the operating system CAs will be used."`
Cert string `opts:"name=tls-cert,short=-" help:"a path to a PEM encoded certificate matching the provided private key. The certificate must have client authentication enabled (mutual-TLS)."`
Key string `opts:"name=tls-key,short=-" help:"a path to a PEM encoded private key used for client authentication (mutual-TLS)."`
ServerName string `opts:"-"`
}
// Client represents a client instance
type Client struct {
*cio.Logger
config *Config
computed settings.Config
sshConfig *ssh.ClientConfig
tlsConfig *tls.Config
proxyURL *url.URL
server string
connCount cnet.ConnCount
stop func()
eg *errgroup.Group
tunnel *tunnel.Tunnel
}
// NewClient creates a new client instance
func NewClient(c *Config) (*Client, error) {
//apply default scheme
if !strings.HasPrefix(c.Server, "http") {
c.Server = "http://" + c.Server
}
u, err := url.Parse(c.Server)
if err != nil {
return nil, err
}
//swap to websockets scheme
u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1)
//apply default port
if !regexp.MustCompile(`:\d+$`).MatchString(u.Host) {
if u.Scheme == "wss" {
u.Host = u.Host + ":443"
} else {
u.Host = u.Host + ":80"
}
}
hasReverse := false
hasSocks := false
hasStdio := false
client := &Client{
Logger: cio.NewLogger("client"),
config: c,
computed: settings.Config{
Version: chshare.BuildVersion,
},
server: u.String(),
tlsConfig: nil,
}
//set default log level
client.Logger.Info = true
//retry interval defaults; explicit values are honored
if c.MinRetryInterval <= 0 {
c.MinRetryInterval = time.Second
}
if c.MaxRetryInterval <= 0 {
c.MaxRetryInterval = 5 * time.Minute
}
if c.MaxRetryInterval < c.MinRetryInterval {
client.Infof("max-retry-interval (%s) raised to min-retry-interval (%s)",
c.MaxRetryInterval, c.MinRetryInterval)
c.MaxRetryInterval = c.MinRetryInterval
}
//configure tls
if u.Scheme == "wss" {
tc := &tls.Config{}
if c.TLS.ServerName != "" {
tc.ServerName = c.TLS.ServerName
}
//certificate verification config
if c.TLS.SkipVerify {
client.Infof("TLS verification disabled")
tc.InsecureSkipVerify = true
} else if c.TLS.CA != "" {
rootCAs := x509.NewCertPool()
if b, err := os.ReadFile(c.TLS.CA); err != nil {
return nil, fmt.Errorf("Failed to load file: %s", c.TLS.CA)
} else if ok := rootCAs.AppendCertsFromPEM(b); !ok {
return nil, fmt.Errorf("Failed to decode PEM: %s", c.TLS.CA)
} else {
client.Infof("TLS verification using CA %s", c.TLS.CA)
tc.RootCAs = rootCAs
}
}
//provide client cert and key pair for mtls
if c.TLS.Cert != "" && c.TLS.Key != "" {
c, err := tls.LoadX509KeyPair(c.TLS.Cert, c.TLS.Key)
if err != nil {
return nil, fmt.Errorf("Error loading client cert and key pair: %v", err)
}
tc.Certificates = []tls.Certificate{c}
} else if c.TLS.Cert != "" || c.TLS.Key != "" {
return nil, fmt.Errorf("Please specify client BOTH cert and key")
}
client.tlsConfig = tc
}
//validate remotes
for _, s := range c.Remotes {
r, err := settings.DecodeRemote(s)
if err != nil {
return nil, fmt.Errorf("Failed to decode remote '%s': %s", s, err)
}
if r.Socks {
hasSocks = true
}
if r.Reverse {
hasReverse = true
}
if r.Stdio {
if hasStdio {
return nil, errors.New("Only one stdio is allowed")
}
hasStdio = true
}
//confirm non-reverse tunnel is available
if !r.Reverse && !r.Stdio && !r.CanListen() {
return nil, fmt.Errorf("Client cannot listen on %s", r.String())
}
client.computed.Remotes = append(client.computed.Remotes, r)
}
//outbound proxy
if p := c.Proxy; p != "" {
client.proxyURL, err = url.Parse(p)
if err != nil {
return nil, fmt.Errorf("Invalid proxy URL (%s)", err)
}
}
//ssh auth and config
user, pass := settings.ParseAuth(c.Auth)
if c.Auth != "" && user == "" {
return nil, fmt.Errorf("invalid auth string, expected <user>:<pass>")
}
client.sshConfig = &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.Password(pass)},
ClientVersion: "SSH-" + chshare.ProtocolVersion + "-client",
HostKeyCallback: client.verifyServer,
Timeout: settings.EnvDuration("SSH_TIMEOUT", 30*time.Second),
}
//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,
})
return client, nil
}
// Run starts client and blocks while connected
func (c *Client) Run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := c.Start(ctx); err != nil {
return err
}
return c.Wait()
}
func (c *Client) verifyServer(hostname string, remote net.Addr, key ssh.PublicKey) error {
expect := c.config.Fingerprint
if expect == "" {
return nil
}
got := ccrypto.FingerprintKey(key)
_, err := base64.StdEncoding.DecodeString(expect)
if _, ok := err.(base64.CorruptInputError); ok {
c.Logger.Infof("Specified deprecated MD5 fingerprint (%s), please update to the new SHA256 fingerprint: %s", expect, got)
return c.verifyLegacyFingerprint(key)
} else if err != nil {
return fmt.Errorf("Error decoding fingerprint: %w", err)
}
if got != expect {
return fmt.Errorf("Invalid fingerprint (%s)", got)
}
//overwrite with complete fingerprint
c.Infof("Fingerprint %s", got)
return nil
}
// verifyLegacyFingerprint calculates and compares legacy MD5 fingerprints,
// requiring the full 16-octet colon form (a prefix match would let a
// truncated fingerprint "verify" against ~1 in 65k keys)
func (c *Client) verifyLegacyFingerprint(key ssh.PublicKey) error {
bytes := md5.Sum(key.Marshal())
strbytes := make([]string, len(bytes))
for i, b := range bytes {
strbytes[i] = fmt.Sprintf("%02x", b)
}
got := strings.Join(strbytes, ":")
expect := c.config.Fingerprint
if got != expect {
return fmt.Errorf("Invalid fingerprint (%s)", got)
}
return nil
}
// Start client and does not block
func (c *Client) Start(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
c.stop = cancel
eg, ctx := errgroup.WithContext(ctx)
c.eg = eg
via := ""
if c.proxyURL != nil {
via = " via " + c.proxyURL.String()
}
c.Infof("Connecting to %s%s\n", c.server, via)
//connect to chisel server
eg.Go(func() error {
return c.connectionLoop(ctx)
})
//listen sockets
eg.Go(func() error {
clientInbound := c.computed.Remotes.Reversed(false)
if len(clientInbound) == 0 {
return nil
}
return c.tunnel.BindRemotes(ctx, clientInbound)
})
return nil
}
func (c *Client) setProxy(u *url.URL, d *websocket.Dialer) error {
// CONNECT proxy
if !strings.HasPrefix(u.Scheme, "socks") {
d.Proxy = func(*http.Request) (*url.URL, error) {
return u, nil
}
return nil
}
// SOCKS5 proxy. all variants behave identically: DNS is
// resolved by the proxy (golang.org/x/net/proxy.SOCKS5)
if u.Scheme != "socks" && u.Scheme != "socks5" && u.Scheme != "socks5h" {
return fmt.Errorf(
"unsupported socks proxy type: %s:// (only socks://, socks5:// or socks5h:// are supported)",
u.Scheme,
)
}
var auth *proxy.Auth
if u.User != nil {
pass, _ := u.User.Password()
auth = &proxy.Auth{
User: u.User.Username(),
Password: pass,
}
}
socksDialer, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct)
if err != nil {
return err
}
d.NetDial = socksDialer.Dial
return nil
}
// Ready blocks until the client has an active connection to the
// server, returning false if the context is cancelled or the
// connection wait times out first
func (c *Client) Ready(ctx context.Context) bool {
return c.tunnel.Ready(ctx)
}
// Wait blocks while the client is running.
func (c *Client) Wait() error {
return c.eg.Wait()
}
// Close manually stops the client
func (c *Client) Close() error {
if c.stop != nil {
c.stop()
}
return nil
}