mirror of
https://github.com/jpillora/chisel.git
synced 2026-10-12 02:16:57 +00:00
331 lines
12 KiB
Go
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
|
|
}
|