Compare commits
	
		
			9 Commits
		
	
	
		
			2817417eca
			...
			v0.0.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 70788ba261 | |||
| e7994824a5 | |||
| 1d292ec21a | |||
| ee828c3e24 | |||
| a9fdf0a053 | |||
| ca02d9b609 | |||
| 792dfdba78 | |||
| 4f7a8f0b04 | |||
| 6d6ed30227 | 
							
								
								
									
										70
									
								
								.goreleaser.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								.goreleaser.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | |||||||
|  | before: | ||||||
|  |   hooks: | ||||||
|  |     - go mod tidy | ||||||
|  | builds: | ||||||
|  |   - id: seashell | ||||||
|  |     env: | ||||||
|  |       - CGO_ENABLED=0 | ||||||
|  |     binary: seashell | ||||||
|  |     goos: | ||||||
|  |       - linux | ||||||
|  |     goarch: | ||||||
|  |       - amd64 | ||||||
|  |       - "386" | ||||||
|  |       - arm64 | ||||||
|  |       - arm | ||||||
|  |       - riscv64 | ||||||
|  | archives: | ||||||
|  |   - files: | ||||||
|  |       - seashell.service | ||||||
|  | nfpms: | ||||||
|  |   - id: seashell | ||||||
|  |     description: "SSH server with virtual hosts and username-based routing" | ||||||
|  |     homepage: 'https://gitea.elara.ws/Elara6331/seashell' | ||||||
|  |     maintainer: 'Elara Ivy <elara@elara.ws>' | ||||||
|  |     license: AGPLv3 | ||||||
|  |     formats: | ||||||
|  |       - deb | ||||||
|  |       - rpm | ||||||
|  |       - apk | ||||||
|  |       - archlinux | ||||||
|  |     provides: | ||||||
|  |       - seashell | ||||||
|  |     conflicts: | ||||||
|  |       - seashell | ||||||
|  |     contents: | ||||||
|  |       - src: seashell.service | ||||||
|  |         dst: /etc/systemd/system/seashell.service | ||||||
|  | aurs: | ||||||
|  |   - name: seashell-bin | ||||||
|  |     description: "SSH server with virtual hosts and username-based routing" | ||||||
|  |     homepage: 'https://gitea.elara.ws/Elara6331/seashell' | ||||||
|  |     maintainers: | ||||||
|  |       - 'Elara Ivy <elara@elara.ws>' | ||||||
|  |     license: AGPLv3 | ||||||
|  |     private_key: '{{ .Env.AUR_KEY }}' | ||||||
|  |     git_url: 'ssh://aur@aur.archlinux.org/seashell-bin.git' | ||||||
|  |     provides: | ||||||
|  |       - seashell | ||||||
|  |     conflicts: | ||||||
|  |       - seashell | ||||||
|  |     package: |- | ||||||
|  |       # binaries | ||||||
|  |       install -Dm755 ./seashell "${pkgdir}/usr/bin/seashell" | ||||||
|  |        | ||||||
|  |       # services | ||||||
|  |       install -Dm644 ./seashell.service "${pkgdir}/etc/systemd/system/seashell.service"       | ||||||
|  | release: | ||||||
|  |   gitea: | ||||||
|  |     owner: Elara6331 | ||||||
|  |     name: seashell | ||||||
|  | gitea_urls: | ||||||
|  |   api: 'https://gitea.elara.ws/api/v1/' | ||||||
|  |   download: 'https://gitea.elara.ws' | ||||||
|  |   skip_tls_verify: false | ||||||
|  | checksum: | ||||||
|  |   name_template: 'checksums.txt' | ||||||
|  | snapshot: | ||||||
|  |   name_template: "{{ incpatch .Version }}-next" | ||||||
|  | changelog: | ||||||
|  |   sort: asc | ||||||
							
								
								
									
										25
									
								
								.woodpecker.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								.woodpecker.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | labels: | ||||||
|  |   platform: linux/amd64 | ||||||
|  |  | ||||||
|  | steps: | ||||||
|  |   docker: | ||||||
|  |     image: gitea.elara.ws/elara6331/builder | ||||||
|  |     environment: | ||||||
|  |       - REGISTRY=gitea.elara.ws | ||||||
|  |       - REGISTRY_USERNAME=Elara6331 | ||||||
|  |       - KO_DOCKER_REPO=gitea.elara.ws/elara6331 | ||||||
|  |       - KO_DEFAULTBASEIMAGE=gitea.elara.ws/elara6331/static | ||||||
|  |     secrets: [ registry_password ] | ||||||
|  |     commands: | ||||||
|  |       - registry-login | ||||||
|  |       - ko build -B --platform=linux/amd64,linux/arm64,linux/riscv64 -t latest,${CI_COMMIT_TAG} --sbom=none | ||||||
|  |     when: | ||||||
|  |       event: tag | ||||||
|  |    | ||||||
|  |   release: | ||||||
|  |     image: goreleaser/goreleaser | ||||||
|  |     commands: | ||||||
|  |       - goreleaser release | ||||||
|  |     secrets: [ gitea_token, aur_key ] | ||||||
|  |     when: | ||||||
|  |       event: tag | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| <p align="center"> | <p align="center"> | ||||||
| <img src="assets/seashell-text.svg" width="250"> | <img src="assets/seashell-text.svg" width="250" alt="Seashell logo"><br><br> | ||||||
|  | <a href="https://goreportcard.com/report/go.elara.ws/seashell"><img src="https://goreportcard.com/badge/go.elara.ws/seashell?style=for-the-badge" alt="Go Report Card"></a>  | ||||||
|  | <a href="https://gitea.elara.ws/Elara6331/seashell/wiki/Home"><img src="https://img.shields.io/badge/read%20the-docs-purple?style=for-the-badge" alt="Read the Docs"></a> | ||||||
| </p> | </p> | ||||||
|  |  | ||||||
| --- | --- | ||||||
| @@ -64,7 +66,7 @@ See the [serial](https://gitea.elara.ws/Elara6331/seashell/wiki/Backends#serial) | |||||||
|  |  | ||||||
| Seashell can proxy another SSH server. In this case, your client will authenticate to seashell and then seashell will authenticate to the target server, so you should provide seashell with a private key to use for authentication and encryption. If you don't provide this, seashell will ask the authenticating user for the target server's password. | Seashell can proxy another SSH server. In this case, your client will authenticate to seashell and then seashell will authenticate to the target server, so you should provide seashell with a private key to use for authentication and encryption. If you don't provide this, seashell will ask the authenticating user for the target server's password. | ||||||
|  |  | ||||||
| The proxy backend takes no extra arguments, so the `ssh` command only requires your username and the routing path: | Here's an example command: | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
| ssh user:myproxy@ssh.example.com | ssh user:myproxy@ssh.example.com | ||||||
|   | |||||||
| @@ -22,8 +22,6 @@ | |||||||
| package backends | package backends | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"strings" |  | ||||||
|  |  | ||||||
| 	"github.com/zclconf/go-cty/cty" | 	"github.com/zclconf/go-cty/cty" | ||||||
| 	"go.elara.ws/seashell/internal/config" | 	"go.elara.ws/seashell/internal/config" | ||||||
| 	"go.elara.ws/seashell/internal/router" | 	"go.elara.ws/seashell/internal/router" | ||||||
| @@ -84,17 +82,6 @@ func ctyObjToStringMap(o *cty.Value) map[string]string { | |||||||
| 	return out | 	return out | ||||||
| } | } | ||||||
|  |  | ||||||
| // sshGetenv gets an environment variable from the SSH session |  | ||||||
| func sshGetenv(env []string, key string) string { |  | ||||||
| 	for _, kv := range env { |  | ||||||
| 		before, after, ok := strings.Cut(kv, "=") |  | ||||||
| 		if ok && before == key { |  | ||||||
| 			return after |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 	return "" |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // valueOr returns the value that v points to | // valueOr returns the value that v points to | ||||||
| // or a default value if v is nil. | // or a default value if v is nil. | ||||||
| func valueOr[T any](v *T, or T) T { | func valueOr[T any](v *T, or T) T { | ||||||
|   | |||||||
| @@ -41,6 +41,7 @@ type dockerSettings struct { | |||||||
| 	Command    *cty.Value `cty:"command"` | 	Command    *cty.Value `cty:"command"` | ||||||
| 	Privileged *bool      `cty:"privileged"` | 	Privileged *bool      `cty:"privileged"` | ||||||
| 	User       *string    `cty:"user"` | 	User       *string    `cty:"user"` | ||||||
|  | 	UserMap    *cty.Value `cty:"user_map"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Docker is the docker backend. It returns a handler that connects | // Docker is the docker backend. It returns a handler that connects | ||||||
| @@ -62,6 +63,17 @@ func Docker(route config.Route) router.Handler { | |||||||
| 		if !ok { | 		if !ok { | ||||||
| 			return errors.New("this route only accepts pty sessions (try adding the -t flag)") | 			return errors.New("this route only accepts pty sessions (try adding the -t flag)") | ||||||
| 		} | 		} | ||||||
|  | 		 | ||||||
|  | 		if opts.User == nil { | ||||||
|  | 			userMap := ctyObjToStringMap(opts.UserMap) | ||||||
|  | 			user, _ := sshctx.GetUser(sess.Context()) | ||||||
|  |  | ||||||
|  | 			if muser, ok := userMap[user.Name]; ok { | ||||||
|  | 				opts.User = &muser | ||||||
|  | 			} else { | ||||||
|  | 				opts.User = &user.Name | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		c, err := client.NewClientWithOpts( | 		c, err := client.NewClientWithOpts( | ||||||
| 			client.WithHostFromEnv(), | 			client.WithHostFromEnv(), | ||||||
| @@ -72,11 +84,6 @@ func Docker(route config.Route) router.Handler { | |||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if opts.User == nil { |  | ||||||
| 			envUser := sshGetenv(sess.Environ(), "DOCKER_USER") |  | ||||||
| 			opts.User = &envUser |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		cmd := sess.Command() | 		cmd := sess.Command() | ||||||
| 		if len(cmd) == 0 { | 		if len(cmd) == 0 { | ||||||
| 			cmd = ctyTupleToStrings(opts.Command) | 			cmd = ctyTupleToStrings(opts.Command) | ||||||
|   | |||||||
| @@ -27,6 +27,8 @@ import ( | |||||||
| 	"io" | 	"io" | ||||||
| 	"net" | 	"net" | ||||||
| 	"os" | 	"os" | ||||||
|  | 	"path" | ||||||
|  | 	"strconv" | ||||||
| 	"strings" | 	"strings" | ||||||
|  |  | ||||||
| 	"github.com/gliderlabs/ssh" | 	"github.com/gliderlabs/ssh" | ||||||
| @@ -41,10 +43,11 @@ import ( | |||||||
|  |  | ||||||
| // proxySettings represents settings for the proxy backend. | // proxySettings represents settings for the proxy backend. | ||||||
| type proxySettings struct { | type proxySettings struct { | ||||||
| 	Server      string     `cty:"server"` | 	Host        *string    `cty:"host"` | ||||||
|  | 	Hosts       *cty.Value `cty:"hosts"` | ||||||
| 	User        *string    `cty:"user"` | 	User        *string    `cty:"user"` | ||||||
| 	PrivkeyPath *string    `cty:"privkey"` | 	PrivkeyPath *string    `cty:"privkey"` | ||||||
| 	UserMap     *cty.Value `cty:"userMap"` | 	UserMap     *cty.Value `cty:"user_map"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Proxy is the proxy backend. It returns a handler that establishes a proxy | // Proxy is the proxy backend. It returns a handler that establishes a proxy | ||||||
| @@ -52,9 +55,6 @@ type proxySettings struct { | |||||||
| func Proxy(route config.Route) router.Handler { | func Proxy(route config.Route) router.Handler { | ||||||
| 	return func(sess ssh.Session, arg string) error { | 	return func(sess ssh.Session, arg string) error { | ||||||
| 		user, _ := sshctx.GetUser(sess.Context()) | 		user, _ := sshctx.GetUser(sess.Context()) | ||||||
| 		if !route.Permissions.IsAllowed(user, "*") { |  | ||||||
| 			return router.ErrUnauthorized |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var opts proxySettings | 		var opts proxySettings | ||||||
| 		err := gocty.FromCtyValue(route.Settings, &opts) | 		err := gocty.FromCtyValue(route.Settings, &opts) | ||||||
| @@ -78,8 +78,55 @@ func Proxy(route config.Route) router.Handler { | |||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		matched := false | ||||||
|  | 		addr := arg | ||||||
|  | 		var portstr, pattern string | ||||||
|  | 		if opts.Host == nil { | ||||||
|  | 			hosts := ctyTupleToStrings(opts.Hosts) | ||||||
|  | 			if len(hosts) == 0 { | ||||||
|  | 				return errors.New("no host configuration provided") | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			for _, hostPattern := range hosts { | ||||||
|  | 				pattern, portstr, ok = strings.Cut(hostPattern, ":") | ||||||
|  | 				if !ok { | ||||||
|  | 					// addr is already set by the above statement, so just set the default port | ||||||
|  | 					portstr = "22" | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				matched, err = path.Match(pattern, arg) | ||||||
|  | 				if err != nil { | ||||||
|  | 					return err | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				if matched { | ||||||
|  | 					addr = arg | ||||||
|  | 					break | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			addr, portstr, ok = strings.Cut(*opts.Host, ":") | ||||||
|  | 			if !ok { | ||||||
|  | 				// addr is already set by the above statement, so just set the default port | ||||||
|  | 				portstr = "22" | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if !route.Permissions.IsAllowed(user, addr) { | ||||||
|  | 			return router.ErrUnauthorized | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if !matched { | ||||||
|  | 			return errors.New("provided argument doesn't match any host patterns in configuration") | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		port, err := strconv.ParseUint(portstr, 10, 16) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		auth := goph.Auth{ | 		auth := goph.Auth{ | ||||||
| 			gossh.PasswordCallback(requestPassword(opts, sess)), | 			gossh.PasswordCallback(requestPassword(opts, sess, addr)), | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if opts.PrivkeyPath != nil { | 		if opts.PrivkeyPath != nil { | ||||||
| @@ -96,25 +143,27 @@ func Proxy(route config.Route) router.Handler { | |||||||
| 			auth = append(goph.Auth{gossh.PublicKeys(pk)}, auth...) | 			auth = append(goph.Auth{gossh.PublicKeys(pk)}, auth...) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c, err := goph.New(*opts.User, opts.Server, auth) | 		c, err := goph.NewConn(&goph.Config{ | ||||||
|  | 			Auth: auth, | ||||||
|  | 			User: *opts.User, | ||||||
|  | 			Addr: addr, | ||||||
|  | 			Port: uint(port), | ||||||
|  | 			Callback: func(host string, remote net.Addr, key gossh.PublicKey) error { | ||||||
|  | 				found, err := goph.CheckKnownHost(host, remote, key, "") | ||||||
|  | 				if !found { | ||||||
|  | 					if err = goph.AddKnownHost(host, remote, key, ""); err != nil { | ||||||
|  | 						return err | ||||||
|  | 					} | ||||||
|  | 				} else if err != nil { | ||||||
|  | 					return err | ||||||
|  | 				} | ||||||
|  | 				return nil | ||||||
|  | 			}, | ||||||
|  | 		}) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		knownHostHandler, err := goph.DefaultKnownHosts() |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		c.Config.Callback = func(host string, remote net.Addr, key gossh.PublicKey) error { |  | ||||||
| 			println("hi") |  | ||||||
| 			err = goph.AddKnownHost(host, remote, key, "") |  | ||||||
| 			if err != nil { |  | ||||||
| 				return err |  | ||||||
| 			} |  | ||||||
| 			return knownHostHandler(host, remote, key) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		baseCmd := sess.Command() | 		baseCmd := sess.Command() | ||||||
|  |  | ||||||
| 		var userCmd string | 		var userCmd string | ||||||
| @@ -165,9 +214,9 @@ func Proxy(route config.Route) router.Handler { | |||||||
| } | } | ||||||
|  |  | ||||||
| // requestPassword asks the client for the remote server's password | // requestPassword asks the client for the remote server's password | ||||||
| func requestPassword(opts proxySettings, sess ssh.Session) func() (secret string, err error) { | func requestPassword(opts proxySettings, sess ssh.Session, addr string) func() (secret string, err error) { | ||||||
| 	return func() (secret string, err error) { | 	return func() (secret string, err error) { | ||||||
| 		_, err = fmt.Fprintf(sess.Stderr(), "Password for %s@%s: ", *opts.User, opts.Server) | 		_, err = fmt.Fprintf(sess.Stderr(), "Password for %s@%s: ", *opts.User, addr) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return "", err | 			return "", err | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -20,7 +20,16 @@ route "srv" { | |||||||
|     backend = "proxy" |     backend = "proxy" | ||||||
|     match = "srv" |     match = "srv" | ||||||
|     settings = { |     settings = { | ||||||
|         server = "1.2.3.4" |         host = "1.2.3.4" | ||||||
|  |         privkey = "/home/elara/.ssh/id_ed25519" | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | route "cluster" { | ||||||
|  |     backend = "proxy" | ||||||
|  |     match = "cluster\\.(.+)" | ||||||
|  |     settings = { | ||||||
|  |         hosts = ["node*", "nas", "192.168.1.*"] | ||||||
|         privkey = "/home/elara/.ssh/id_ed25519" |         privkey = "/home/elara/.ssh/id_ed25519" | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								seashell.service
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								seashell.service
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | [Unit] | ||||||
|  | Description=Seashell SSH Server | ||||||
|  | After=network.target | ||||||
|  |  | ||||||
|  | [Service] | ||||||
|  | ExecStart=seashell | ||||||
|  | Restart=always | ||||||
|  | StandardOutput=journal | ||||||
|  |  | ||||||
|  | [Install] | ||||||
|  | WantedBy=default.target | ||||||
		Reference in New Issue
	
	Block a user