Compare commits

..

11 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
2817417eca Add copyright headers 2024-08-04 16:35:51 -07:00
fa60e18c22 Run formatter 2024-08-04 16:35:51 -07:00
13 changed files with 253 additions and 51 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">
<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>
---
@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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