Initial Commit

This commit is contained in:
2024-08-03 22:31:40 -07:00
commit c9ea17a601
22 changed files with 3227 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
/*
* 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 backends
import (
"strings"
"github.com/zclconf/go-cty/cty"
"go.elara.ws/seashell/internal/config"
"go.elara.ws/seashell/internal/router"
)
// Backend represents a seashell backend
type Backend func(config.Route) router.Handler
// backends contains all the available backends
var backends = map[string]Backend{
"proxy": Proxy,
"nomad": Nomad,
"docker": Docker,
"serial": Serial,
}
// Get returns a backend given its name
func Get(name string) Backend {
return backends[name]
}
// ctyTupleToStrings converts a cty tuple type to a slice of strings
func ctyTupleToStrings(t *cty.Value) []string {
if t == nil {
return nil
}
i := 0
out := make([]string, t.LengthInt())
iter := t.ElementIterator()
for iter.Next() {
_, val := iter.Element()
if val.Type() == cty.String {
out[i] = val.AsString()
} else {
out[i] = val.GoString()
}
i++
}
return out
}
// ctyObjToStringMap convertys a cty object type to a map from strings to strings
func ctyObjToStringMap(o *cty.Value) map[string]string {
if o == nil {
return map[string]string{}
}
out := make(map[string]string, o.LengthInt())
iter := o.ElementIterator()
for iter.Next() {
key, val := iter.Element()
if key.Type() != cty.String || val.Type() != cty.String {
continue
}
out[key.AsString()] = val.AsString()
}
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 {
if v == nil {
return or
}
return *v
}

131
internal/backends/docker.go Normal file
View File

@@ -0,0 +1,131 @@
/*
* 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 backends
import (
"context"
"errors"
"io"
"github.com/docker/docker/api/types/container"
"github.com/gliderlabs/ssh"
"github.com/moby/moby/client"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
"go.elara.ws/seashell/internal/config"
"go.elara.ws/seashell/internal/router"
"go.elara.ws/seashell/internal/sshctx"
)
// dockerSettings represents settings for the docker backend.
type dockerSettings struct {
Command *cty.Value `cty:"command"`
Privileged *bool `cty:"privileged"`
User *string `cty:"user"`
}
// Docker is the docker backend. It returns a handler that connects
// to a Docker container and executes commands via an SSH session.
func Docker(route config.Route) router.Handler {
return func(sess ssh.Session, arg string) error {
user, _ := sshctx.GetUser(sess.Context())
if !route.Permissions.IsAllowed(user, arg) {
return router.ErrUnauthorized
}
var opts dockerSettings
err := gocty.FromCtyValue(route.Settings, &opts)
if err != nil {
return err
}
pty, resizeCh, ok := sess.Pty()
if !ok {
return errors.New("this route only accepts pty sessions (try adding the -t flag)")
}
c, err := client.NewClientWithOpts(
client.WithHostFromEnv(),
client.WithVersionFromEnv(),
client.WithTLSClientConfigFromEnv(),
)
if err != nil {
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)
if len(cmd) == 0 {
cmd = []string{"/bin/sh"}
}
}
idr, err := c.ContainerExecCreate(sess.Context(), arg, container.ExecOptions{
User: *opts.User,
Privileged: opts.Privileged != nil && *opts.Privileged,
Tty: true,
AttachStdin: true,
AttachStderr: true,
AttachStdout: true,
Env: append(sess.Environ(), "TERM="+pty.Term),
Cmd: cmd,
})
if err != nil {
return err
}
go dockerHandleResize(resizeCh, sess.Context(), c, idr.ID)
hr, err := c.ContainerExecAttach(sess.Context(), idr.ID, container.ExecAttachOptions{Tty: true})
if err != nil {
return err
}
defer hr.Close()
err = c.ContainerExecStart(sess.Context(), idr.ID, container.ExecStartOptions{Tty: true})
if err != nil {
return err
}
go io.Copy(hr.Conn, sess)
io.Copy(sess, hr.Reader)
return nil
}
}
// dockerHandleResize resizes the Docker pseudo-tty whenever it receives
// a client resize event over SSH.
func dockerHandleResize(resizeCh <-chan ssh.Window, ctx context.Context, c *client.Client, execID string) {
for newSize := range resizeCh {
c.ContainerExecResize(ctx, execID, container.ResizeOptions{
Height: uint(newSize.Height),
Width: uint(newSize.Width),
})
}
}

226
internal/backends/nomad.go Normal file
View File

@@ -0,0 +1,226 @@
/*
* 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 backends
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/gliderlabs/ssh"
"github.com/hashicorp/nomad/api"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
"go.elara.ws/seashell/internal/config"
"go.elara.ws/seashell/internal/router"
"go.elara.ws/seashell/internal/sshctx"
)
// nomadSettings represents settings for the nomad backend.
type nomadSettings struct {
Server string `cty:"server"`
Delimiter *string `cty:"delimeter"`
Region *string `cty:"region"`
Namespace *string `cty:"namespace"`
AuthToken *string `cty:"auth_token"`
Command *cty.Value `cty:"command"`
}
// Nomad is the nomad backend. It returns a handler that connects
// to a Nomad task and executes commands via an SSH session.
func Nomad(route config.Route) router.Handler {
return func(sess ssh.Session, arg string) error {
user, _ := sshctx.GetUser(sess.Context())
var opts nomadSettings
err := gocty.FromCtyValue(route.Settings, &opts)
if err != nil {
return err
}
_, resizeCh, ok := sess.Pty()
if !ok {
return errors.New("this route only accepts pty sessions (try adding the -t flag)")
}
c, err := api.NewClient(&api.Config{
Address: opts.Server,
Region: valueOr(opts.Region, ""),
Namespace: valueOr(opts.Namespace, ""),
})
if err != nil {
return err
}
delimeter := valueOr(opts.Delimiter, ".")
args := strings.Split(arg, delimeter)
allocList, _, err := c.Jobs().Allocations(args[0], false, nil)
if err != nil {
return err
}
if len(allocList) == 0 {
return fmt.Errorf("job %q has no allocations", args[0])
}
cmd := sess.Command()
if len(cmd) == 0 {
cmd = ctyTupleToStrings(opts.Command)
if len(cmd) == 0 {
cmd = []string{"/bin/sh"}
}
}
switch len(args) {
case 1:
alloc, _, err := c.Allocations().Info(allocList[0].ID, nil)
if err != nil {
return err
}
task := alloc.Job.TaskGroups[0].Tasks[0]
if !route.Permissions.IsAllowed(
user,
"job:"+args[0],
"task:"+task.Name,
"group:"+valueOr(alloc.Job.TaskGroups[0].Name, "unknown"),
) {
return router.ErrUnauthorized
}
sizeCh := make(chan api.TerminalSize)
go nomadHandleResize(resizeCh, sizeCh)
_, err = c.Allocations().Exec(sess.Context(), alloc, task.Name, true, cmd, sess, sess, sess.Stderr(), sizeCh, nil)
return err
case 2:
alloc, _, err := c.Allocations().Info(allocList[0].ID, nil)
if err != nil {
return err
}
group := alloc.Job.TaskGroups[0]
for _, task := range group.Tasks {
if task.Name != args[1] {
continue
}
if !route.Permissions.IsAllowed(
user,
"job:"+args[0],
"task:"+task.Name,
"group:"+valueOr(group.Name, "unknown"),
) {
return router.ErrUnauthorized
}
sizeCh := make(chan api.TerminalSize)
go nomadHandleResize(resizeCh, sizeCh)
_, err = c.Allocations().Exec(sess.Context(), alloc, task.Name, true, cmd, sess, sess, sess.Stderr(), sizeCh, nil)
return err
}
return errors.New("task not found")
case 3:
alloc, _, err := c.Allocations().Info(allocList[0].ID, nil)
if err != nil {
return err
}
group := alloc.Job.LookupTaskGroup(args[1])
if group == nil {
return errors.New("task group not found")
}
var taskName = args[2]
if taskName == "" {
taskName = group.Tasks[0].Name
}
if !route.Permissions.IsAllowed(
user,
"job:"+args[0],
"task:"+taskName,
"group:"+valueOr(group.Name, "unknown"),
) {
return router.ErrUnauthorized
}
sizeCh := make(chan api.TerminalSize)
go nomadHandleResize(resizeCh, sizeCh)
_, err = c.Allocations().Exec(sess.Context(), alloc, taskName, true, cmd, sess, sess, sess.Stderr(), sizeCh, nil)
return err
case 4:
allocID := args[1]
if index, err := strconv.Atoi(args[1]); err == nil && index < len(allocList) {
allocID = allocList[index].ID
}
alloc, _, err := c.Allocations().Info(allocID, nil)
if err != nil {
return err
}
var group *api.TaskGroup
if args[2] == "" {
group = alloc.Job.TaskGroups[0]
} else {
group = alloc.Job.LookupTaskGroup(args[2])
if group == nil {
return errors.New("task group not found")
}
}
var taskName = args[3]
if taskName == "" {
taskName = group.Tasks[0].Name
}
if !route.Permissions.IsAllowed(
user,
"job:"+args[0],
"task:"+taskName,
"group:"+valueOr(group.Name, "unknown"),
) {
return router.ErrUnauthorized
}
sizeCh := make(chan api.TerminalSize)
go nomadHandleResize(resizeCh, sizeCh)
_, err = c.Allocations().Exec(sess.Context(), alloc, taskName, true, cmd, sess, sess, sess.Stderr(), sizeCh, nil)
return err
}
return nil
}
}
// nomadHandleResize resizes the Nomad pseudo-tty whenever it receives
// a client resize event over SSH.
func nomadHandleResize(resizeCh <-chan ssh.Window, sizeCh chan<- api.TerminalSize) {
defer close(sizeCh)
for newSize := range resizeCh {
sizeCh <- api.TerminalSize{
Height: newSize.Height,
Width: newSize.Width,
}
}
}

223
internal/backends/proxy.go Normal file
View File

@@ -0,0 +1,223 @@
/*
* 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 backends
import (
"errors"
"fmt"
"io"
"net"
"os"
"strings"
"github.com/gliderlabs/ssh"
"github.com/melbahja/goph"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
"go.elara.ws/seashell/internal/config"
"go.elara.ws/seashell/internal/router"
"go.elara.ws/seashell/internal/sshctx"
gossh "golang.org/x/crypto/ssh"
)
// proxySettings represents settings for the proxy backend.
type proxySettings struct {
Server string `cty:"server"`
User *string `cty:"user"`
PrivkeyPath *string `cty:"privkey"`
UserMap *cty.Value `cty:"userMap"`
}
// 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)
if err != nil {
return err
}
pty, resizeCh, ok := sess.Pty()
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
}
}
auth := goph.Auth{
gossh.PasswordCallback(requestPassword(opts, sess)),
}
if opts.PrivkeyPath != nil {
data, err := os.ReadFile(*opts.PrivkeyPath)
if err != nil {
return err
}
pk, err := gossh.ParsePrivateKey(data)
if err != nil {
return err
}
auth = append(goph.Auth{gossh.PublicKeys(pk)}, auth...)
}
c, err := goph.New(*opts.User, opts.Server, auth)
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
if len(baseCmd) > 0 {
userCmd = baseCmd[0]
}
var userArgs []string
if len(baseCmd) > 1 {
userArgs = baseCmd[1:]
}
cmd, err := c.Command(userCmd, userArgs...)
if err != nil {
return err
}
err = cmd.RequestPty(pty.Term, pty.Window.Height, pty.Window.Width, nil)
if err != nil {
return err
}
go sshHandleResize(resizeCh, cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
defer stdin.Close()
go io.Copy(sess, stdout)
go io.Copy(stdin, sess)
if len(baseCmd) == 0 {
err = cmd.Shell()
} else {
err = cmd.Start()
}
if err != nil {
return err
}
return cmd.Wait()
}
}
// requestPassword asks the client for the remote server's password
func requestPassword(opts proxySettings, sess ssh.Session) func() (secret string, err error) {
return func() (secret string, err error) {
_, err = fmt.Fprintf(sess.Stderr(), "Password for %s@%s: ", *opts.User, opts.Server)
if err != nil {
return "", err
}
pwd, err := readPassword(sess)
sess.Write([]byte{'\n'})
return strings.TrimSpace(pwd), err
}
}
// nomadHandleResize resizes the remote SSH pseudo-tty whenever it
// receives a client resize event over SSH.
func sshHandleResize(resizeCh <-chan ssh.Window, cmd *goph.Cmd) {
for newSize := range resizeCh {
cmd.WindowChange(newSize.Height, newSize.Width)
}
}
// 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) {
var out []byte
for {
buf := make([]byte, 1)
_, err := sess.Read(buf)
if err != nil {
return "", err
}
switch buf[0] {
case '\r', '\n':
return string(out), nil
case '\x7F':
if len(out) != 0 {
out = out[:len(out)-1]
// Delete the last asterisk character
sess.Write([]byte("\x08 \x08"))
}
continue
case '\x03', '\x04':
sess.Close()
return "", errors.New("password entry canceled")
default:
// Give users some feedback that their password is being received
sess.Write([]byte{'*'})
}
out = append(out, buf[0])
}
}

192
internal/backends/serial.go Normal file
View File

@@ -0,0 +1,192 @@
/*
* 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 backends
import (
"errors"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"github.com/gliderlabs/ssh"
"github.com/zclconf/go-cty/cty/gocty"
"go.bug.st/serial"
"go.elara.ws/seashell/internal/config"
"go.elara.ws/seashell/internal/router"
"go.elara.ws/seashell/internal/sshctx"
)
// serialSettings represents settings for the serial backend.
type serialSettings struct {
Directory *string `cty:"directory"`
File *string `cty:"file"`
Delimiter *string `cty:"delimeter"`
BaudRate *int `cty:"baud_rate"`
Configuration *string `cty:"config"`
}
// Serial is the serial backend. It returns a handler that
// exposes a serial port on an SSH connection.
func Serial(route config.Route) router.Handler {
return func(sess ssh.Session, arg string) error {
user, _ := sshctx.GetUser(sess.Context())
var opts serialSettings
err := gocty.FromCtyValue(route.Settings, &opts)
if err != nil {
return err
}
if opts.Directory == nil && opts.File == nil {
return errors.New("either directory or file must be set in the server config")
}
// Since we can't specify the size of a physical serial port,
// we can discard the window size channel and the pty info.
_, _, ok := sess.Pty()
if !ok {
return errors.New("this route only accepts pty sessions")
}
delimeter := valueOr(opts.Delimiter, ".")
args := strings.Split(arg, delimeter)
if len(args) == 0 {
return errors.New("at least one argument required")
}
var file, baudRate, config string
if opts.File != nil {
file = *opts.File
switch len(args) {
case 1:
baudRate = args[0]
default:
baudRate, config = args[0], args[1]
}
} else if opts.Directory != nil {
switch len(args) {
case 1:
file = filepath.Join(*opts.Directory, args[0])
case 2:
file, baudRate = filepath.Join(*opts.Directory, args[0]), args[1]
default:
file, baudRate, config = filepath.Join(*opts.Directory, args[0]), args[1], args[2]
}
}
if !route.Permissions.IsAllowed(user, filepath.Base(file)) {
return router.ErrUnauthorized
}
mode, err := getSerialMode(opts, baudRate, config)
if err != nil {
return err
}
port, err := serial.Open(file, mode)
if err != nil {
return err
}
defer port.Close()
go io.Copy(sess, port)
io.Copy(port, sess)
return nil
}
}
// getSerialMode tries to get the serial mode configuration from the
// config or from the argument provided by the client.
func getSerialMode(opts serialSettings, baudRate, config string) (out *serial.Mode, err error) {
if config == "" {
if opts.Configuration == nil {
return nil, errors.New("no serial configuration provided")
}
out, err = parseSerialMode(*opts.Configuration)
if err != nil {
return nil, err
}
} else {
out, err = parseSerialMode(config)
if err != nil {
return nil, err
}
}
if baudRate == "" {
if opts.BaudRate == nil {
return nil, errors.New("no baud rate provided")
}
out.BaudRate = *opts.BaudRate
} else {
out.BaudRate, err = strconv.Atoi(baudRate)
if err != nil {
return nil, err
}
}
return out, nil
}
// parseSerialMode parses a serial mode string (e.x. 8n1)
func parseSerialMode(cfg string) (out *serial.Mode, err error) {
cfg = strings.ToLower(cfg)
out = &serial.Mode{}
out.DataBits, err = strconv.Atoi(cfg[:1])
if err != nil {
return nil, err
}
switch parity := cfg[1]; parity {
case 'n':
out.Parity = serial.NoParity
case 'e':
out.Parity = serial.EvenParity
case 'o':
out.Parity = serial.OddParity
case 'm':
out.Parity = serial.MarkParity
case 's':
out.Parity = serial.SpaceParity
default:
return nil, fmt.Errorf("unknown parity mode: %c", parity)
}
switch stop := cfg[2:]; stop {
case "1":
out.StopBits = serial.OneStopBit
case "1.5":
out.StopBits = serial.OnePointFiveStopBits
case "2":
out.StopBits = serial.TwoStopBits
default:
return nil, fmt.Errorf("unsupported stop bit amount: %s", stop)
}
return out, nil
}

79
internal/config/config.go Normal file
View File

@@ -0,0 +1,79 @@
/*
* 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 config
import (
"github.com/hashicorp/hcl/v2/hclsimple"
"github.com/zclconf/go-cty/cty"
)
// Config represents the main config structure.
type Config struct {
Settings *Settings `hcl:"settings,block"`
Routes []Route `hcl:"route,block"`
Auth Auth `hcl:"auth,block"`
}
// Settings represents settings for the SSH server.
type Settings struct {
SSHDir string `hcl:"ssh_dir,optional"`
ListenAddr string `hcl:"listen_addr,optional"`
Debug bool `hcl:"debug,optional"`
}
// Route represents a virtual host configuration.
type Route struct {
Name string `hcl:"name,label"`
Backend string `hcl:"backend"`
Match string `hcl:"match"`
Settings cty.Value `hcl:"settings"`
Permissions PermissionsMap `hcl:"permissions,optional"`
}
// Auth contains the authentication settings.
type Auth struct {
Fail2Ban *Fail2Ban `hcl:"fail2ban,block"`
Users []User `hcl:"user,block"`
}
// Fail2Ban contains the fail2ban rate limiter settings.
type Fail2Ban struct {
Limit string `hcl:"limit"`
Attempts int `hcl:"attempts"`
}
// User contains the configuration for a virtual user.
type User struct {
Name string `hcl:"name,label"`
Password string `hcl:"password,optional"`
Groups []string `hcl:"groups,optional"`
Pubkeys []string `hcl:"pubkeys,optional"`
}
// Load loads the configuration from the specified path.
func Load(path string) (cfg Config, err error) {
err = hclsimple.DecodeFile(path, nil, &cfg)
if cfg.Settings == nil {
cfg.Settings = &Settings{}
}
return cfg, err
}

View File

@@ -0,0 +1,91 @@
/*
* 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 config
import (
"strings"
)
// PermissionsMap defines the config structure for permissions.
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.
func (pm PermissionsMap) IsAllowed(u User, items ...string) bool {
if pm == nil {
return true
}
for _, item := range items {
allowed := false
denied := false
groups := append(u.Groups, "all")
for _, group := range groups {
perms, ok := pm[group]
if !ok {
continue
}
if denyList, found := perms["deny"]; found {
for _, denyItem := range denyList {
if matchPattern(denyItem, item) {
denied = true
break
}
}
}
if denied {
break
}
if allowList, found := perms["allow"]; found {
for _, allowItem := range allowList {
if matchPattern(allowItem, item) {
allowed = true
break
}
}
}
}
if denied || !allowed {
return false
}
}
return true
}
// matchPattern checks if an item matches a given pattern.
func matchPattern(pattern, item string) bool {
if pattern == "*" {
return true
}
if before, after, ok := strings.Cut(pattern, "*"); ok {
return strings.HasPrefix(item, before) && strings.HasSuffix(item, after)
}
return pattern == item
}

View File

@@ -0,0 +1,99 @@
/*
* 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 fail2ban
import (
"net"
"strings"
"sync"
"time"
)
// Fail2Ban represents a fail2ban-like rate limiter
type Fail2Ban struct {
limit time.Duration
amount int
mtx sync.Mutex
attempts map[string]int
}
// New creates a new [Fail2Ban] instance.
func New(limit time.Duration, attempts int) *Fail2Ban {
f := &Fail2Ban{
limit: limit,
amount: attempts,
attempts: map[string]int{},
}
go f.clear()
return f
}
// AddFailedLogin adds a failed login attempt from the given address.
func (f *Fail2Ban) AddFailedLogin(addr net.Addr) {
if f == nil {
return
}
f.mtx.Lock()
defer f.mtx.Unlock()
f.attempts[getAddrString(addr)]++
}
// LoginAllowed checks if login is allowed from the given address.
func (f *Fail2Ban) LoginAllowed(addr net.Addr) bool {
if f == nil {
return true
}
f.mtx.Lock()
defer f.mtx.Unlock()
return f.attempts[getAddrString(addr)] < f.amount
}
// clear resets the login attempts at regular intervals.
func (f *Fail2Ban) clear() {
for range time.Tick(f.limit) {
f.mtx.Lock()
clear(f.attempts)
f.attempts = map[string]int{}
f.mtx.Unlock()
}
}
// getAddrString gets an IP address string from a [net.Addr].
func getAddrString(addr net.Addr) string {
switch addr := addr.(type) {
case *net.TCPAddr:
return addr.IP.String()
case *net.IPAddr:
return addr.IP.String()
case *net.UDPAddr:
return addr.IP.String()
default:
addrstr := addr.String()
idx := strings.LastIndex(addrstr, ":")
if idx == -1 {
return addrstr
}
return addrstr[:idx]
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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 router
import (
"log/slog"
"time"
"github.com/gliderlabs/ssh"
"go.elara.ws/seashell/internal/sshctx"
)
// Logging returns a middleware that logs incoming session details,
// and closed connections, as well as any error that may have caused
// the connection to close.
func Logging(log *slog.Logger) Middleware {
return func(next Handler) Handler {
return func(sess ssh.Session, arg string) error {
user, _ := sshctx.GetUser(sess.Context())
route := sess.Context().Value(routeKey{}).(route)
log.Info(
"Incoming user session",
slog.String("user", user.Name),
slog.String("route", route.name),
slog.String("arg", arg),
slog.String("addr", sess.RemoteAddr().String()),
)
start := time.Now()
err := next(sess, arg)
duration := time.Since(start)
if err != nil {
log.Error(
"Connection closed",
slog.String("user", user.Name),
slog.String("route", route.name),
slog.Duration("duration", duration),
slog.String("addr", sess.RemoteAddr().String()),
slog.Any("error", err),
)
} else {
log.Info(
"Connection closed",
slog.String("user", user.Name),
slog.String("route", route.name),
slog.Duration("duration", duration),
)
}
return err
}
}
}

122
internal/router/router.go Normal file
View File

@@ -0,0 +1,122 @@
/*
* 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 router
import (
"errors"
"fmt"
"regexp"
"github.com/gliderlabs/ssh"
"go.elara.ws/seashell/internal/sshctx"
)
// ErrUnauthorized represents an unauthorized access error.
var ErrUnauthorized = errors.New("you are not authorized to access this resource")
// Handler defines a function type to handle SSH sessions.
type Handler func(sess ssh.Session, arg string) error
// Middleware defines a function type for middleware.
type Middleware func(next Handler) Handler
// Router manages routing and middleware for SSH sessions.
type Router struct {
routes map[string]route
middlewares []Middleware
}
// route represents a single route configuration.
type route struct {
name string
handler Handler
regex *regexp.Regexp
}
// New creates and returns a new [Router] instance.
func New() *Router {
return &Router{routes: map[string]route{}}
}
// Use adds a middleware to the router.
func (r *Router) Use(m Middleware) {
r.middlewares = append(r.middlewares, m)
}
// Handle registers a new route with the given name and pattern.
func (r *Router) Handle(name, pattern string, h Handler) error {
re, err := regexp.Compile(pattern)
if err != nil {
return err
}
r.routes[pattern] = route{
name: name,
handler: h,
regex: re,
}
return nil
}
// routeKey is a context key for storing route information.
type routeKey struct{}
// Handler handles an SSH session, routing it to the appropriate handler.
func (r *Router) Handler(sess ssh.Session) {
arg, _ := sshctx.GetArg(sess.Context())
for _, ro := range r.routes {
matches := ro.regex.FindStringSubmatch(arg)
if matches == nil {
continue
}
sess.Context().SetValue(routeKey{}, ro)
var cleanArg string
if idx := ro.regex.SubexpIndex("arg"); idx != -1 {
cleanArg = matches[idx]
} else if len(matches) >= 2 {
cleanArg = matches[1]
} else {
cleanArg = arg
}
handler := ro.handler
for _, middleware := range r.middlewares {
handler = middleware(handler)
}
err := handler(sess, cleanArg)
if err != nil {
writeError(sess, err.Error())
}
return
}
writeError(sess, "no matching route found for %q", arg)
}
// writeError writes a formatted error message to the SSH session.
func writeError(sess ssh.Session, format string, v ...any) {
fmt.Fprintf(sess.Stderr(), "\x1b[31;1m[ERROR]\x1b[0m "+format+"\r\n", v...)
}

47
internal/sshctx/sshctx.go Normal file
View File

@@ -0,0 +1,47 @@
/*
* 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 sshctx
import (
"context"
"github.com/gliderlabs/ssh"
"go.elara.ws/seashell/internal/config"
)
type (
argCtxKey struct{}
userCtxKey struct{}
)
func SetArg(ctx ssh.Context, arg string) { ctx.SetValue(argCtxKey{}, arg) }
func SetUser(ctx ssh.Context, user config.User) { ctx.SetValue(userCtxKey{}, user) }
func GetArg(ctx context.Context) (string, bool) {
arg, ok := ctx.Value(argCtxKey{}).(string)
return arg, ok
}
func GetUser(ctx context.Context) (config.User, bool) {
user, ok := ctx.Value(userCtxKey{}).(config.User)
return user, ok
}