Compare commits

..

9 Commits

Author SHA1 Message Date
70788ba261 Expose proxy host to permissions system
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/release/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-08-12 17:43:12 -07:00
e7994824a5 Add the ability to specify multiple proxy hosts
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-08-12 17:20:14 -07:00
1d292ec21a Fix CI settings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/release/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-08-08 14:54:22 -07:00
ee828c3e24 Switch to Ko for building docker images
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-08-08 14:49:09 -07:00
a9fdf0a053 Add CI configuration
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2024-08-08 14:34:42 -07:00
ca02d9b609 Fix known hosts handling in proxy backend 2024-08-04 19:49:43 -07:00
792dfdba78 Add user_map setting to docker backend 2024-08-04 18:27:21 -07:00
4f7a8f0b04 Rename userMap to user_map for consistency 2024-08-04 18:25:20 -07:00
6d6ed30227 Add README badges 2024-08-04 23:45:25 +00:00
8 changed files with 204 additions and 44 deletions

70
.goreleaser.yaml Normal file
View 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
View 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

View File

@@ -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>&nbsp;
<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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
} }

View File

@@ -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
View File

@@ -0,0 +1,11 @@
[Unit]
Description=Seashell SSH Server
After=network.target
[Service]
ExecStart=seashell
Restart=always
StandardOutput=journal
[Install]
WantedBy=default.target