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
|
||||
}
|
||||
Reference in New Issue
Block a user