Compare commits
11 Commits
4590fdadbe
...
master
| 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
|
||||
@@ -63,6 +64,17 @@ func Docker(route config.Route) router.Handler {
|
||||
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(),
|
||||
client.WithVersionFromEnv(),
|
||||
@@ -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,10 +43,11 @@ 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
|
||||
@@ -52,9 +55,6 @@ type proxySettings struct {
|
||||
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)
|
||||
@@ -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,25 +143,27 @@ 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()
|
||||
|
||||
var userCmd string
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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