Initial Commit
This commit is contained in:
105
internal/backends/backends.go
Normal file
105
internal/backends/backends.go
Normal 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
131
internal/backends/docker.go
Normal 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
226
internal/backends/nomad.go
Normal 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
223
internal/backends/proxy.go
Normal 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
192
internal/backends/serial.go
Normal 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
79
internal/config/config.go
Normal 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
|
||||
}
|
||||
91
internal/config/permissions.go
Normal file
91
internal/config/permissions.go
Normal 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
|
||||
}
|
||||
99
internal/fail2ban/fail2ban.go
Normal file
99
internal/fail2ban/fail2ban.go
Normal 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]
|
||||
}
|
||||
}
|
||||
74
internal/router/logging.go
Normal file
74
internal/router/logging.go
Normal 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
122
internal/router/router.go
Normal 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
47
internal/sshctx/sshctx.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user