Compare commits
	
		
			11 Commits
		
	
	
		
			4590fdadbe
			...
			v0.0.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 70788ba261 | |||
| e7994824a5 | |||
| 1d292ec21a | |||
| ee828c3e24 | |||
| a9fdf0a053 | |||
| ca02d9b609 | |||
| 792dfdba78 | |||
| 4f7a8f0b04 | |||
| 6d6ed30227 | |||
| 2817417eca | |||
| fa60e18c22 | 
							
								
								
									
										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"> | ||||
| <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> | ||||
|  | ||||
| --- | ||||
| @@ -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. | ||||
|  | ||||
| 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 | ||||
| ssh user:myproxy@ssh.example.com | ||||
|   | ||||
							
								
								
									
										21
									
								
								auth.go
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								auth.go
									
									
									
									
									
								
							| @@ -1,3 +1,24 @@ | ||||
| /* | ||||
|  * Seashell - SSH server with virtual hosts and username-based routing | ||||
|  * | ||||
|  * Copyright (C) 2024 Elara6331 <elara@elara.ws> | ||||
|  * | ||||
|  * This file is part of Seashell. | ||||
|  * | ||||
|  * Seashell is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU Affero General Public License as | ||||
|  * published by the Free Software Foundation, either version 3 of the | ||||
|  * License, or (at your option) any later version. | ||||
|  * | ||||
|  * Seashell is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|  * GNU Affero General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU Affero General Public License | ||||
|  * along with Seashell.  If not, see <http://www.gnu.org/licenses/>. | ||||
|  */ | ||||
|  | ||||
| package main | ||||
|  | ||||
| import ( | ||||
|   | ||||
| @@ -22,8 +22,6 @@ | ||||
| package backends | ||||
|  | ||||
| import ( | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/zclconf/go-cty/cty" | ||||
| 	"go.elara.ws/seashell/internal/config" | ||||
| 	"go.elara.ws/seashell/internal/router" | ||||
| @@ -84,17 +82,6 @@ func ctyObjToStringMap(o *cty.Value) map[string]string { | ||||
| 	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 | ||||
| // or a default value if v is nil. | ||||
| func valueOr[T any](v *T, or T) T { | ||||
|   | ||||
| @@ -41,6 +41,7 @@ type dockerSettings struct { | ||||
| 	Command    *cty.Value `cty:"command"` | ||||
| 	Privileged *bool      `cty:"privileged"` | ||||
| 	User       *string    `cty:"user"` | ||||
| 	UserMap    *cty.Value `cty:"user_map"` | ||||
| } | ||||
|  | ||||
| // Docker is the docker backend. It returns a handler that connects | ||||
| @@ -62,6 +63,17 @@ func Docker(route config.Route) router.Handler { | ||||
| 		if !ok { | ||||
| 			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( | ||||
| 			client.WithHostFromEnv(), | ||||
| @@ -72,11 +84,6 @@ func Docker(route config.Route) router.Handler { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if opts.User == nil { | ||||
| 			envUser := sshGetenv(sess.Environ(), "DOCKER_USER") | ||||
| 			opts.User = &envUser | ||||
| 		} | ||||
|  | ||||
| 		cmd := sess.Command() | ||||
| 		if len(cmd) == 0 { | ||||
| 			cmd = ctyTupleToStrings(opts.Command) | ||||
|   | ||||
| @@ -150,7 +150,7 @@ func Nomad(route config.Route) router.Handler { | ||||
| 				return errors.New("task group not found") | ||||
| 			} | ||||
|  | ||||
| 			var taskName = args[2] | ||||
| 			taskName := args[2] | ||||
| 			if taskName == "" { | ||||
| 				taskName = group.Tasks[0].Name | ||||
| 			} | ||||
| @@ -189,7 +189,7 @@ func Nomad(route config.Route) router.Handler { | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			var taskName = args[3] | ||||
| 			taskName := args[3] | ||||
| 			if taskName == "" { | ||||
| 				taskName = group.Tasks[0].Name | ||||
| 			} | ||||
|   | ||||
| @@ -27,6 +27,8 @@ import ( | ||||
| 	"io" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"github.com/gliderlabs/ssh" | ||||
| @@ -41,20 +43,18 @@ import ( | ||||
|  | ||||
| // proxySettings represents settings for the proxy backend. | ||||
| type proxySettings struct { | ||||
| 	Server      string     `cty:"server"` | ||||
| 	Host        *string    `cty:"host"` | ||||
| 	Hosts       *cty.Value `cty:"hosts"` | ||||
| 	User        *string    `cty:"user"` | ||||
| 	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 | ||||
| // session to a remote server based on the provided configuration. | ||||
| func Proxy(route config.Route) router.Handler { | ||||
| 	return func(sess ssh.Session, arg string) error { | ||||
| 		user, _ := sshctx.GetUser(sess.Context()) | ||||
| 		if !route.Permissions.IsAllowed(user, "*") { | ||||
| 			return router.ErrUnauthorized | ||||
| 		} | ||||
|  | ||||
| 		var opts proxySettings | ||||
| 		err := gocty.FromCtyValue(route.Settings, &opts) | ||||
| @@ -70,7 +70,7 @@ func Proxy(route config.Route) router.Handler { | ||||
| 		if opts.User == nil { | ||||
| 			userMap := ctyObjToStringMap(opts.UserMap) | ||||
| 			user, _ := sshctx.GetUser(sess.Context()) | ||||
| 			 | ||||
|  | ||||
| 			if muser, ok := userMap[user.Name]; ok { | ||||
| 				opts.User = &muser | ||||
| 			} else { | ||||
| @@ -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{ | ||||
| 			gossh.PasswordCallback(requestPassword(opts, sess)), | ||||
| 			gossh.PasswordCallback(requestPassword(opts, sess, addr)), | ||||
| 		} | ||||
|  | ||||
| 		if opts.PrivkeyPath != nil { | ||||
| @@ -96,24 +143,26 @@ func Proxy(route config.Route) router.Handler { | ||||
| 			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 { | ||||
| 			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() | ||||
|  | ||||
| @@ -165,9 +214,9 @@ func Proxy(route config.Route) router.Handler { | ||||
| } | ||||
|  | ||||
| // 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) { | ||||
| 		_, 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 { | ||||
| 			return "", err | ||||
| 		} | ||||
| @@ -187,7 +236,7 @@ func sshHandleResize(resizeCh <-chan ssh.Window, cmd *goph.Cmd) { | ||||
|  | ||||
| // readPassword reads a password from the SSH session, sending an asterisk | ||||
| // for each character typed. | ||||
| //  | ||||
| // | ||||
| // It handles interrupts (Ctrl+C), EOF (Ctrl+D), and backspace. | ||||
| // It returns what it read once it receives a carriage return or a newline. | ||||
| func readPassword(sess ssh.Session) (string, error) { | ||||
|   | ||||
| @@ -95,7 +95,7 @@ func Serial(route config.Route) router.Handler { | ||||
| 				file, baudRate, config = filepath.Join(*opts.Directory, args[0]), args[1], args[2] | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
|  | ||||
| 		if !route.Permissions.IsAllowed(user, filepath.Base(file)) { | ||||
| 			return router.ErrUnauthorized | ||||
| 		} | ||||
|   | ||||
| @@ -29,7 +29,7 @@ import ( | ||||
| type PermissionsMap map[string]map[string][]string | ||||
|  | ||||
| // IsAllowed checks if the user has permissions for all the specified items. | ||||
| //  | ||||
| // | ||||
| // The default policy is deny, and denials take priority, so if one item | ||||
| // in items is set to deny, IsAllowed will always return false, even if | ||||
| // other items are explicitly allowed. | ||||
|   | ||||
							
								
								
									
										21
									
								
								keys.go
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								keys.go
									
									
									
									
									
								
							| @@ -1,3 +1,24 @@ | ||||
| /* | ||||
|  * Seashell - SSH server with virtual hosts and username-based routing | ||||
|  * | ||||
|  * Copyright (C) 2024 Elara6331 <elara@elara.ws> | ||||
|  * | ||||
|  * This file is part of Seashell. | ||||
|  * | ||||
|  * Seashell is free software: you can redistribute it and/or modify | ||||
|  * it under the terms of the GNU Affero General Public License as | ||||
|  * published by the Free Software Foundation, either version 3 of the | ||||
|  * License, or (at your option) any later version. | ||||
|  * | ||||
|  * Seashell is distributed in the hope that it will be useful, | ||||
|  * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|  * GNU Affero General Public License for more details. | ||||
|  * | ||||
|  * You should have received a copy of the GNU Affero General Public License | ||||
|  * along with Seashell.  If not, see <http://www.gnu.org/licenses/>. | ||||
|  */ | ||||
|  | ||||
| package main | ||||
|  | ||||
| import ( | ||||
|   | ||||
| @@ -20,7 +20,16 @@ route "srv" { | ||||
|     backend = "proxy" | ||||
|     match = "srv" | ||||
|     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" | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										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