Compare commits
9 Commits
2817417eca
...
master
| 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