* chore: Formatting * chore: Formatting (fix indent) * refactor: Remove entrypoint * chore: Remove `entrypoint.sh` from `.goreleaser.yaml` * tests: Replace `entrypoint.sh` script usage in `e2eentry.sh` * Set default plugin argument from environment variable in main function and update Dockerfile to remove CMD instruction * Add user and group setup for testing in Dockerfile and update e2e entry script * Update cmd/sshpiperd/main.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update cmd/sshpiperd/main.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove test user and group creation from Dockerfile and move it to e2e entry script; update plugin argument handling in main function * Update Dockerfile Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Skip empty plugin directory entries in main function --------- Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
378 lines
10 KiB
Go
378 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/pires/go-proxyproto"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tg123/sshpiper/cmd/sshpiperd/internal/plugin"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
var mainver string = "(devel)"
|
|
|
|
func version() string {
|
|
|
|
var v = mainver
|
|
|
|
bi, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
return v
|
|
}
|
|
|
|
for _, s := range bi.Settings {
|
|
switch s.Key {
|
|
case "vcs.revision":
|
|
v = fmt.Sprintf("%v, %v", v, s.Value[:9])
|
|
case "vcs.time":
|
|
v = fmt.Sprintf("%v, %v", v, s.Value)
|
|
}
|
|
}
|
|
|
|
v = fmt.Sprintf("%v, %v", v, bi.GoVersion)
|
|
|
|
return v
|
|
}
|
|
|
|
func splitByDash(args []string) ([]string, []string) {
|
|
for i, arg := range args {
|
|
if arg == "--" {
|
|
return args[:i], args[i+1:]
|
|
}
|
|
}
|
|
|
|
return args, nil
|
|
}
|
|
|
|
func createCmdPlugin(args []string) (*plugin.CmdPlugin, error) {
|
|
exe := args[0]
|
|
|
|
cmd := exec.Command(exe)
|
|
cmd.Args = args
|
|
setPdeathsig(cmd)
|
|
|
|
log.Info("starting child process plugin: ", cmd.Args)
|
|
|
|
p, err := plugin.DialCmd(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := addProcessToJob(cmd); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p.Name = exe
|
|
|
|
return p, nil
|
|
}
|
|
|
|
func isValidLogFormat(logFormat string) bool {
|
|
validFormats := []string{"text", "json"}
|
|
return slices.Contains(validFormats, logFormat)
|
|
}
|
|
|
|
func main() {
|
|
|
|
app := &cli.App{
|
|
Name: "sshpiperd",
|
|
Usage: "the missing reverse proxy for ssh scp",
|
|
UsageText: "sshpiperd [options] <plugin1> [plugin options] [-- [plugin2] [plugin options] [-- ...]]",
|
|
Description: "sshpiperd works as a proxy-like ware, and route connections by username, src ip , etc.\nhttps://github.com/tg123/sshpiper",
|
|
Version: version(),
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "address",
|
|
Aliases: []string{"l"},
|
|
Value: "0.0.0.0",
|
|
Usage: "listening address",
|
|
EnvVars: []string{"SSHPIPERD_ADDRESS"},
|
|
},
|
|
&cli.IntFlag{
|
|
Name: "port",
|
|
Aliases: []string{"p"},
|
|
Value: 2222,
|
|
Usage: "listening port",
|
|
EnvVars: []string{"SSHPIPERD_PORT"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "server-key",
|
|
Aliases: []string{"i"},
|
|
Usage: "server key files, support wildcard",
|
|
Value: "/etc/ssh/ssh_host_ed25519_key",
|
|
EnvVars: []string{"SSHPIPERD_SERVER_KEY"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "server-key-data",
|
|
Usage: "server key in base64 format, server-key, server-key-generate-mode will be ignored if set",
|
|
EnvVars: []string{"SSHPIPERD_SERVER_KEY_DATA"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "server-key-generate-mode",
|
|
Usage: "server key generate mode, one of: disable, notexist, always. generated key will be written to `server-key` if notexist or always",
|
|
Value: "disable",
|
|
EnvVars: []string{"SSHPIPERD_SERVER_KEY_GENERATE_MODE"},
|
|
},
|
|
&cli.DurationFlag{
|
|
Name: "login-grace-time",
|
|
Value: 30 * time.Second,
|
|
Usage: "sshpiperd forcely close the connection after this time if the pipe has not successfully established",
|
|
EnvVars: []string{"SSHPIPERD_LOGIN_GRACE_TIME"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "log-level",
|
|
Value: "info",
|
|
Usage: "log level, one of: trace, debug, info, warn, error, fatal, panic",
|
|
EnvVars: []string{"SSHPIPERD_LOG_LEVEL"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "log-format",
|
|
Value: "text",
|
|
Usage: "log format, one of: text, json",
|
|
EnvVars: []string{"SSHPIPERD_LOG_FORMAT"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "screen-recording-dir",
|
|
Value: "",
|
|
Usage: "the directory to save screen recording files",
|
|
EnvVars: []string{"SSHPIPERD_SCREEN_RECORDING_DIR"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "screen-recording-format",
|
|
Value: "asciicast",
|
|
Usage: "the format of screen recording files, one of: typescript (https://linux.die.net/man/1/script), asciicast (https://docs.asciinema.org/manual/asciicast/v2)",
|
|
EnvVars: []string{"SSHPIPERD_SCREEN_RECORDING_FORMAT"},
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "username-as-recorddir",
|
|
Value: false,
|
|
Usage: "use the username as the directory name for saving screen recording files",
|
|
EnvVars: []string{"SSHPIPERD_USERNAME_AS_RECORDDIR"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "banner-text",
|
|
Value: "",
|
|
Usage: "display a banner before authentication, would be ignored if banner file was set",
|
|
EnvVars: []string{"SSHPIPERD_BANNERTEXT"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "banner-file",
|
|
Value: "",
|
|
Usage: "display a banner from file before authentication",
|
|
EnvVars: []string{"SSHPIPERD_BANNERFILE"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "upstream-banner-mode",
|
|
Value: "passthrough",
|
|
Usage: "upstream banner mode, allowed values: 'passthrough' (pass the banner from upstream to downstream), 'ignore' (ignore the banner from upstream), 'dedup' (deduplicate the banner from upstream, only pass same banner once to downstream), 'first-only' (only pass the first banner from upstream to downstream)",
|
|
EnvVars: []string{"SSHPIPERD_UPSTREAM_BANNER_MODE"},
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "drop-hostkeys-message",
|
|
Value: false,
|
|
Usage: "filter out hostkeys-00@openssh.com which cause client side warnings",
|
|
EnvVars: []string{"SSHPIPERD_DROP_HOSTKEYS_MESSAGE"},
|
|
},
|
|
&cli.BoolFlag{
|
|
Name: "reply-ping",
|
|
Value: true,
|
|
Usage: "reply to ping@openssh instead of passing it to upstream, this is useful for old sshd which doesn't support ping@openssh",
|
|
EnvVars: []string{"SSHPIPERD_REPLY_PING"},
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "allowed-proxy-addresses",
|
|
Value: cli.NewStringSlice(),
|
|
Usage: "allowed proxy addresses, only connections from these ip ranges are allowed to send a proxy header based on the PROXY protocol, empty will disable the PROXY protocol support",
|
|
EnvVars: []string{"SSHPIPERD_ALLOWED_PROXY_ADDRESSES"},
|
|
},
|
|
&cli.DurationFlag{
|
|
Name: "proxy-read-header-timeout",
|
|
Value: 200 * time.Millisecond,
|
|
Usage: "timeout for reading the PROXY protocol header, only used when --allowed-proxy-addresses is set",
|
|
EnvVars: []string{"SSHPIPERD_PROXY_READ_HEADER_TIMEOUT"},
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "allowed-downstream-keyexchange-algos",
|
|
Value: cli.NewStringSlice(),
|
|
Usage: "allowed key exchange algorithms for downstream connections, empty will allow default algorithms",
|
|
EnvVars: []string{"SSHPIPERD_ALLOWED_DOWNSTREAM_KEYEXCHANGE_ALGOS"},
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "allowed-downstream-ciphers-algos",
|
|
Value: cli.NewStringSlice(),
|
|
Usage: "allowed ciphers algorithms for downstream connections, empty will allow default algorithms",
|
|
EnvVars: []string{"SSHPIPERD_ALLOWED_DOWNSTREAM_CIPHERS_ALGOS"},
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "allowed-downstream-macs-algos",
|
|
Value: cli.NewStringSlice(),
|
|
Usage: "allowed macs algorithms for downstream connections, empty will allow default algorithms",
|
|
EnvVars: []string{"SSHPIPERD_ALLOWED_DOWNSTREAM_MACS_ALGOS"},
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "allowed-downstream-pubkey-algos",
|
|
Value: cli.NewStringSlice(),
|
|
Usage: "allowed public key algorithms for downstream connections, empty will allow default algorithms",
|
|
EnvVars: []string{"SSHPIPERD_ALLOWED_DOWNSTREAM_PUBKEY_ALGOS"},
|
|
},
|
|
},
|
|
Action: func(ctx *cli.Context) error {
|
|
level, err := log.ParseLevel(ctx.String("log-level"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.SetLevel(level)
|
|
|
|
logFormat := ctx.String("log-format")
|
|
if !isValidLogFormat(logFormat) {
|
|
return fmt.Errorf("not a valid log-format: %v", logFormat)
|
|
}
|
|
if logFormat == "json" {
|
|
log.SetFormatter(&log.JSONFormatter{})
|
|
}
|
|
|
|
log.Info("starting sshpiperd version: ", version())
|
|
d, err := newDaemon(ctx)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
quit := make(chan error)
|
|
|
|
allowedproxyaddresses := ctx.StringSlice("allowed-proxy-addresses")
|
|
|
|
if len(allowedproxyaddresses) > 0 {
|
|
proxypolicy, err := proxyproto.LaxWhiteListPolicy(allowedproxyaddresses)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d.lis = &proxyproto.Listener{
|
|
Listener: d.lis,
|
|
Policy: proxypolicy,
|
|
ReadHeaderTimeout: ctx.Duration("proxy-read-header-timeout"),
|
|
}
|
|
}
|
|
|
|
var plugins []*plugin.GrpcPlugin
|
|
|
|
args := ctx.Args().Slice()
|
|
|
|
// If no command-line arguments are provided, fall back to the PLUGIN environment variable.
|
|
if len(args) == 0 {
|
|
pluginEnv := os.Getenv("PLUGIN")
|
|
if pluginEnv != "" {
|
|
|
|
exePath, err := os.Executable()
|
|
exeDir := ""
|
|
if err == nil {
|
|
exeDir = fmt.Sprintf("%s/", filepath.Dir(exePath))
|
|
}
|
|
|
|
pluginDirs := []string{
|
|
filepath.Join(exeDir, "plugins"),
|
|
os.Getenv("SSHPIPERD_PLUGIN_PATH"),
|
|
}
|
|
|
|
found := false
|
|
|
|
for _, dir := range pluginDirs {
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
|
|
pluginexe := filepath.Join(dir, pluginEnv)
|
|
if _, err := os.Stat(pluginexe); err == nil {
|
|
args = append(args, pluginexe)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
if path, err := exec.LookPath(pluginEnv); err == nil {
|
|
args = append(args, path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
remain := args
|
|
|
|
for len(remain) > 0 {
|
|
|
|
args, remain = splitByDash(remain)
|
|
|
|
if len(args) <= 0 {
|
|
continue
|
|
}
|
|
|
|
var p *plugin.GrpcPlugin
|
|
|
|
switch args[0] {
|
|
case "grpc":
|
|
log.Info("starting net grpc plugin: ")
|
|
|
|
grpcplugin, err := createNetGrpcPlugin(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p = grpcplugin
|
|
|
|
default:
|
|
cmdplugin, err := createCmdPlugin(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
go func() {
|
|
quit <- <-cmdplugin.Quit
|
|
}()
|
|
|
|
p = &cmdplugin.GrpcPlugin
|
|
}
|
|
|
|
go func() {
|
|
if err := p.RecvLogs(log.StandardLogger().Out); err != nil {
|
|
log.Errorf("plugin %v recv logs error: %v", p.Name, err)
|
|
}
|
|
}()
|
|
|
|
plugins = append(plugins, p)
|
|
}
|
|
|
|
if err := d.install(plugins...); err != nil {
|
|
return err
|
|
}
|
|
|
|
d.recorddir = ctx.String("screen-recording-dir")
|
|
d.recordfmt = ctx.String("screen-recording-format")
|
|
d.usernameAsRecorddir = ctx.Bool("username-as-recorddir")
|
|
d.filterHostkeysReqeust = ctx.Bool("drop-hostkeys-message")
|
|
d.replyPing = ctx.Bool("reply-ping")
|
|
|
|
if d.recordfmt != "typescript" && d.recordfmt != "asciicast" {
|
|
return fmt.Errorf("invalid screen recording format: %v", d.recordfmt)
|
|
}
|
|
|
|
go func() {
|
|
quit <- d.run()
|
|
}()
|
|
|
|
return <-quit
|
|
},
|
|
}
|
|
|
|
if err := app.Run(os.Args); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|