You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1151 lines
36 KiB
1151 lines
36 KiB
|
2 months ago
|
// Package portservice exposes per-channel SSH and Telnet operations without
|
||
|
|
// granting network-facing processes direct access to raw UART device paths.
|
||
|
|
package portservice
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/subtle"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"reflect"
|
||
|
|
"sort"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"sync/atomic"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"golang.org/x/crypto/ssh"
|
||
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
||
|
|
|
||
|
|
"tera-clone/services/tera-api/internal/directstream"
|
||
|
|
"tera-clone/services/tera-api/internal/domain"
|
||
|
|
)
|
||
|
|
|
||
|
|
type AuthenticateFunc func(context.Context, string, string, string) (string, error)
|
||
|
|
type AuthenticateMethodFunc func(context.Context, string, string, string, string) (string, error)
|
||
|
|
type RecordFunc func(context.Context, domain.PortLogEvent) error
|
||
|
|
type ListenFunc func(string, string) (net.Listener, error)
|
||
|
|
type DialFunc func(context.Context, string, string) (net.Conn, error)
|
||
|
|
|
||
|
|
type Options struct {
|
||
|
|
TransportFactory directstream.TransportFactory
|
||
|
|
Authenticate AuthenticateFunc
|
||
|
|
AuthenticateMethod AuthenticateMethodFunc
|
||
|
|
Record RecordFunc
|
||
|
|
Listen ListenFunc
|
||
|
|
Dial DialFunc
|
||
|
|
ListenHost string
|
||
|
|
SSHSigner ssh.Signer
|
||
|
|
KnownHostsPath string
|
||
|
|
RetryDelay time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
type Controller interface {
|
||
|
|
Apply(context.Context, []domain.Port) error
|
||
|
|
}
|
||
|
|
|
||
|
|
type SnapshotProvider interface {
|
||
|
|
Snapshot() Snapshot
|
||
|
|
}
|
||
|
|
|
||
|
|
type NoopController struct{}
|
||
|
|
|
||
|
|
func (NoopController) Apply(_ context.Context, ports []domain.Port) error {
|
||
|
|
_, err := operationConfigs(ports)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
type Gateway struct {
|
||
|
|
options Options
|
||
|
|
broker *directstream.Broker
|
||
|
|
|
||
|
|
mu sync.Mutex
|
||
|
|
services map[string]*service
|
||
|
|
sequence atomic.Uint64
|
||
|
|
}
|
||
|
|
|
||
|
|
type serviceConfig struct {
|
||
|
|
key string
|
||
|
|
channel string
|
||
|
|
number int
|
||
|
|
operation domain.PortOperation
|
||
|
|
sources []string
|
||
|
|
targets []string
|
||
|
|
}
|
||
|
|
|
||
|
|
type service struct {
|
||
|
|
config serviceConfig
|
||
|
|
listener net.Listener
|
||
|
|
cancel context.CancelFunc
|
||
|
|
done chan struct{}
|
||
|
|
clients atomic.Int32
|
||
|
|
pending atomic.Int32
|
||
|
|
}
|
||
|
|
|
||
|
|
type Snapshot struct {
|
||
|
|
Services []ServiceSnapshot `json:"services"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ServiceSnapshot struct {
|
||
|
|
Channel string `json:"channel"`
|
||
|
|
Operation int `json:"operation"`
|
||
|
|
Mode string `json:"mode"`
|
||
|
|
Port int `json:"port"`
|
||
|
|
Host string `json:"host,omitempty"`
|
||
|
|
State string `json:"state"`
|
||
|
|
ActiveClients int `json:"active_clients"`
|
||
|
|
Sources []string `json:"sources,omitempty"`
|
||
|
|
Targets []string `json:"targets,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(options Options) (*Gateway, error) {
|
||
|
|
if options.TransportFactory == nil {
|
||
|
|
return nil, errors.New("port service transport factory is required")
|
||
|
|
}
|
||
|
|
if options.Listen == nil {
|
||
|
|
options.Listen = net.Listen
|
||
|
|
}
|
||
|
|
if options.Dial == nil {
|
||
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||
|
|
options.Dial = dialer.DialContext
|
||
|
|
}
|
||
|
|
if strings.TrimSpace(options.ListenHost) == "" {
|
||
|
|
options.ListenHost = "0.0.0.0"
|
||
|
|
}
|
||
|
|
if options.RetryDelay <= 0 {
|
||
|
|
options.RetryDelay = 5 * time.Second
|
||
|
|
}
|
||
|
|
broker, err := directstream.NewBroker(options.TransportFactory, directstream.BrokerOptions{MaxAttachmentsPerChannel: 16})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &Gateway{options: options, broker: broker, services: make(map[string]*service)}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply reconciles the active operation set. Unchanged services are retained so
|
||
|
|
// configuration changes on one channel do not interrupt sessions on another.
|
||
|
|
// Callers should pass the running configuration only after the repository
|
||
|
|
// transaction has selected it.
|
||
|
|
func (g *Gateway) Apply(ctx context.Context, ports []domain.Port) error {
|
||
|
|
desired, err := operationConfigs(ports)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
g.mu.Lock()
|
||
|
|
defer g.mu.Unlock()
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
desiredByKey := make(map[string]serviceConfig, len(desired))
|
||
|
|
for _, config := range desired {
|
||
|
|
desiredByKey[config.key] = config
|
||
|
|
}
|
||
|
|
oldConfigs := make([]serviceConfig, 0, len(g.services))
|
||
|
|
for _, current := range g.services {
|
||
|
|
oldConfigs = append(oldConfigs, current.config)
|
||
|
|
}
|
||
|
|
sort.Slice(oldConfigs, func(left, right int) bool { return oldConfigs[left].key < oldConfigs[right].key })
|
||
|
|
retained := make(map[string]*service, len(g.services))
|
||
|
|
stoppedConfigs := make([]serviceConfig, 0, len(oldConfigs))
|
||
|
|
for _, previous := range oldConfigs {
|
||
|
|
current := g.services[previous.key]
|
||
|
|
next, exists := desiredByKey[previous.key]
|
||
|
|
if exists && reflect.DeepEqual(previous, next) {
|
||
|
|
retained[previous.key] = current
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
current.stop()
|
||
|
|
stoppedConfigs = append(stoppedConfigs, previous)
|
||
|
|
}
|
||
|
|
toStart := make([]serviceConfig, 0, len(desired)-len(retained))
|
||
|
|
for _, config := range desired {
|
||
|
|
if _, exists := retained[config.key]; !exists {
|
||
|
|
toStart = append(toStart, config)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(stoppedConfigs) == 0 && len(toStart) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
nextServices := make(map[string]*service, len(desired))
|
||
|
|
for key, current := range retained {
|
||
|
|
nextServices[key] = current
|
||
|
|
}
|
||
|
|
started := make([]*service, 0, len(toStart))
|
||
|
|
for _, config := range toStart {
|
||
|
|
current, startErr := g.startLocked(config)
|
||
|
|
if startErr != nil {
|
||
|
|
for _, item := range started {
|
||
|
|
item.stop()
|
||
|
|
}
|
||
|
|
restoredServices := make(map[string]*service, len(oldConfigs))
|
||
|
|
for key, item := range retained {
|
||
|
|
restoredServices[key] = item
|
||
|
|
}
|
||
|
|
var rollbackErrors []error
|
||
|
|
for _, previous := range stoppedConfigs {
|
||
|
|
restored, restoreErr := g.startLocked(previous)
|
||
|
|
if restoreErr != nil {
|
||
|
|
rollbackErrors = append(rollbackErrors, fmt.Errorf("restore %s: %w", previous.key, restoreErr))
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
restoredServices[previous.key] = restored
|
||
|
|
}
|
||
|
|
g.services = restoredServices
|
||
|
|
applyErr := fmt.Errorf("start %s: %w", config.key, startErr)
|
||
|
|
if len(rollbackErrors) > 0 {
|
||
|
|
return errors.Join(append([]error{applyErr}, rollbackErrors...)...)
|
||
|
|
}
|
||
|
|
return applyErr
|
||
|
|
}
|
||
|
|
nextServices[config.key] = current
|
||
|
|
started = append(started, current)
|
||
|
|
}
|
||
|
|
g.services = nextServices
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) Snapshot() Snapshot {
|
||
|
|
g.mu.Lock()
|
||
|
|
defer g.mu.Unlock()
|
||
|
|
result := Snapshot{Services: make([]ServiceSnapshot, 0, len(g.services))}
|
||
|
|
for _, item := range g.services {
|
||
|
|
result.Services = append(result.Services, ServiceSnapshot{
|
||
|
|
Channel: item.config.channel, Operation: item.config.number, Mode: item.config.operation.Mode,
|
||
|
|
Port: item.config.operation.Port, Host: item.config.operation.Host, State: serviceState(item.config),
|
||
|
|
ActiveClients: int(item.clients.Load()),
|
||
|
|
Sources: append([]string(nil), item.config.sources...),
|
||
|
|
Targets: append([]string(nil), item.config.targets...),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
sort.Slice(result.Services, func(left, right int) bool {
|
||
|
|
if result.Services[left].Channel != result.Services[right].Channel {
|
||
|
|
return result.Services[left].Channel < result.Services[right].Channel
|
||
|
|
}
|
||
|
|
return result.Services[left].Operation < result.Services[right].Operation
|
||
|
|
})
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) Close() error {
|
||
|
|
g.mu.Lock()
|
||
|
|
for _, current := range g.services {
|
||
|
|
current.stop()
|
||
|
|
}
|
||
|
|
g.services = make(map[string]*service)
|
||
|
|
g.mu.Unlock()
|
||
|
|
return g.broker.Close()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *service) stop() {
|
||
|
|
if s.cancel != nil {
|
||
|
|
s.cancel()
|
||
|
|
}
|
||
|
|
if s.listener != nil {
|
||
|
|
_ = s.listener.Close()
|
||
|
|
}
|
||
|
|
if s.done != nil {
|
||
|
|
select {
|
||
|
|
case <-s.done:
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func operationConfigs(ports []domain.Port) ([]serviceConfig, error) {
|
||
|
|
result := make([]serviceConfig, 0, len(ports)*2)
|
||
|
|
listeners := make(map[int]string)
|
||
|
|
redirects := make(map[string][]string)
|
||
|
|
for _, port := range ports {
|
||
|
|
if directstream.ValidateChannel(port.Channel) != nil {
|
||
|
|
return nil, fmt.Errorf("invalid operation channel %q", port.Channel)
|
||
|
|
}
|
||
|
|
for index, operation := range []domain.PortOperation{port.Operation1, port.Operation2} {
|
||
|
|
if operation.Mode == "" || operation.Mode == "disabled" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
config := serviceConfig{key: fmt.Sprintf("%s-operation-%d", port.Channel, index+1), channel: port.Channel, number: index + 1, operation: operation}
|
||
|
|
switch operation.Mode {
|
||
|
|
case "reverse_telnet", "reverse_ssh":
|
||
|
|
if operation.Port < 1 || operation.Port > 65535 {
|
||
|
|
return nil, fmt.Errorf("%s listener port is invalid", config.key)
|
||
|
|
}
|
||
|
|
if previous := listeners[operation.Port]; previous != "" {
|
||
|
|
return nil, fmt.Errorf("listener port %d conflicts between %s and %s", operation.Port, previous, config.key)
|
||
|
|
}
|
||
|
|
listeners[operation.Port] = config.key
|
||
|
|
case "telnet", "ssh":
|
||
|
|
if strings.TrimSpace(operation.Host) == "" || operation.Port < 1 || operation.Port > 65535 {
|
||
|
|
return nil, fmt.Errorf("%s normal mode requires host and port", config.key)
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return nil, fmt.Errorf("%s mode is unsupported", config.key)
|
||
|
|
}
|
||
|
|
result = append(result, config)
|
||
|
|
}
|
||
|
|
if len(port.RedirectTo) > 0 {
|
||
|
|
targets := append([]string(nil), port.RedirectTo...)
|
||
|
|
sort.Strings(targets)
|
||
|
|
for _, target := range targets {
|
||
|
|
if directstream.ValidateChannel(target) != nil || target == port.Channel {
|
||
|
|
return nil, fmt.Errorf("%s redirect target %q is invalid", port.Channel, target)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
redirects[port.Channel] = targets
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := validateRedirectGraph(redirects); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
sourcesByTarget := make(map[string][]string)
|
||
|
|
for source, targets := range redirects {
|
||
|
|
for _, target := range targets {
|
||
|
|
sourcesByTarget[target] = append(sourcesByTarget[target], source)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for target, sources := range sourcesByTarget {
|
||
|
|
sort.Strings(sources)
|
||
|
|
result = append(result, serviceConfig{
|
||
|
|
key: "redirect-to-" + target, channel: target, operation: domain.PortOperation{Mode: "redirect"},
|
||
|
|
sources: sources, targets: []string{target},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
sort.Slice(result, func(left, right int) bool { return result[left].key < result[right].key })
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateRedirectGraph(graph map[string][]string) error {
|
||
|
|
state := make(map[string]uint8, len(graph))
|
||
|
|
var visit func(string) error
|
||
|
|
visit = func(channel string) error {
|
||
|
|
switch state[channel] {
|
||
|
|
case 1:
|
||
|
|
return fmt.Errorf("redirect graph contains a cycle at %s", channel)
|
||
|
|
case 2:
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
state[channel] = 1
|
||
|
|
for _, target := range graph[channel] {
|
||
|
|
if _, redirects := graph[target]; redirects {
|
||
|
|
if err := visit(target); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
state[channel] = 2
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
for channel := range graph {
|
||
|
|
if err := visit(channel); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func serviceState(config serviceConfig) string {
|
||
|
|
switch config.operation.Mode {
|
||
|
|
case "reverse_ssh", "reverse_telnet":
|
||
|
|
return "listening"
|
||
|
|
case "redirect":
|
||
|
|
return "forwarding"
|
||
|
|
default:
|
||
|
|
return "reconnecting"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) startLocked(config serviceConfig) (*service, error) {
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
current := &service{config: config, cancel: cancel, done: make(chan struct{})}
|
||
|
|
switch config.operation.Mode {
|
||
|
|
case "reverse_telnet", "reverse_ssh":
|
||
|
|
if config.operation.Mode == "reverse_ssh" && g.options.SSHSigner == nil {
|
||
|
|
cancel()
|
||
|
|
return nil, errors.New("reverse SSH host key is unavailable")
|
||
|
|
}
|
||
|
|
if err := validateOperationCredential(config); err != nil {
|
||
|
|
cancel()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
listener, err := g.options.Listen("tcp", net.JoinHostPort(g.options.ListenHost, strconv.Itoa(config.operation.Port)))
|
||
|
|
if err != nil {
|
||
|
|
cancel()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
current.listener = listener
|
||
|
|
go g.runListener(ctx, current)
|
||
|
|
case "telnet", "ssh":
|
||
|
|
if config.operation.Mode == "ssh" && strings.TrimSpace(g.options.KnownHostsPath) == "" {
|
||
|
|
cancel()
|
||
|
|
return nil, errors.New("normal SSH requires a known_hosts path")
|
||
|
|
}
|
||
|
|
if err := validateOperationCredential(config); err != nil {
|
||
|
|
cancel()
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
go g.runNormal(ctx, current)
|
||
|
|
case "redirect":
|
||
|
|
go g.runRedirect(ctx, current)
|
||
|
|
}
|
||
|
|
return current, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateOperationCredential(config serviceConfig) error {
|
||
|
|
operation := config.operation
|
||
|
|
if operation.Authentication == "password" && operation.Credential == "" {
|
||
|
|
return errors.New("password authentication credential is unavailable")
|
||
|
|
}
|
||
|
|
if operation.Authentication != "key" {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if operation.Credential == "" {
|
||
|
|
return errors.New("key authentication credential is unavailable")
|
||
|
|
}
|
||
|
|
if operation.Mode == "reverse_ssh" {
|
||
|
|
if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(operation.Credential)); err != nil {
|
||
|
|
return fmt.Errorf("parse authorized SSH key: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
if operation.Mode == "ssh" {
|
||
|
|
if _, err := ssh.ParsePrivateKey([]byte(operation.Credential)); err != nil {
|
||
|
|
return fmt.Errorf("parse SSH private key: %w", err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return errors.New("key authentication is only valid for SSH")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) runListener(ctx context.Context, current *service) {
|
||
|
|
defer close(current.done)
|
||
|
|
for {
|
||
|
|
connection, err := current.listener.Accept()
|
||
|
|
if err != nil {
|
||
|
|
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
// Authentication handshakes are not active terminal sessions and must not
|
||
|
|
// consume MaxClients. Keep a separate bounded pre-authentication ceiling
|
||
|
|
// so incomplete handshakes still cannot exhaust the process.
|
||
|
|
if current.pending.Add(1) > 16 {
|
||
|
|
current.pending.Add(-1)
|
||
|
|
_, _ = connection.Write([]byte("Too many pending connections.\r\n"))
|
||
|
|
_ = connection.Close()
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
go func() {
|
||
|
|
defer current.pending.Add(-1)
|
||
|
|
defer connection.Close()
|
||
|
|
if current.config.operation.Mode == "reverse_ssh" {
|
||
|
|
g.serveSSH(ctx, connection, current)
|
||
|
|
} else {
|
||
|
|
g.serveTelnet(ctx, connection, current)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) serveTelnet(ctx context.Context, connection net.Conn, current *service) {
|
||
|
|
config := current.config
|
||
|
|
actor := "anonymous"
|
||
|
|
reader := bufio.NewReader(&telnetReader{reader: connection})
|
||
|
|
if isManagedAuthentication(config.operation.Authentication) {
|
||
|
|
if g.options.Authenticate == nil && g.options.AuthenticateMethod == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, _ = connection.Write([]byte("TERA CORA-X48 PRO K " + strings.ToUpper(config.operation.Authentication) + " authentication\r\nUsername: "))
|
||
|
|
username, err := readCredentialLine(reader, 64)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, _ = connection.Write([]byte("Password: "))
|
||
|
|
password, err := readCredentialLine(reader, 256)
|
||
|
|
_, _ = connection.Write([]byte("\r\n"))
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
actor, err = g.authenticate(ctx, config.operation.Authentication, username, password, config.channel)
|
||
|
|
if err != nil {
|
||
|
|
_, _ = connection.Write([]byte("Authentication failed.\r\n"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
} else if config.operation.Authentication == "password" {
|
||
|
|
_, _ = connection.Write([]byte("TERA CORA-X48 PRO K password authentication\r\nUsername: "))
|
||
|
|
username, err := readCredentialLine(reader, 64)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, _ = connection.Write([]byte("Password: "))
|
||
|
|
password, err := readCredentialLine(reader, 256)
|
||
|
|
_, _ = connection.Write([]byte("\r\n"))
|
||
|
|
if err != nil || !credentialMatches(config.operation.Credential, password) || (config.operation.Username != "" && config.operation.Username != username) {
|
||
|
|
_, _ = connection.Write([]byte("Authentication failed.\r\n"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
actor = username
|
||
|
|
} else if config.operation.Authentication != "none" {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !beginTerminalSession(current) {
|
||
|
|
_, _ = connection.Write([]byte("Too many clients.\r\n"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer current.clients.Add(-1)
|
||
|
|
g.bridge(ctx, connection, reader, config, actor, remoteEndpoint(connection.RemoteAddr()), true)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) serveSSH(ctx context.Context, connection net.Conn, current *service) {
|
||
|
|
config := current.config
|
||
|
|
serverConfig := &ssh.ServerConfig{ServerVersion: "SSH-2.0-TERA-CORA-X48-PRO-K"}
|
||
|
|
serverConfig.AddHostKey(g.options.SSHSigner)
|
||
|
|
if config.operation.Authentication == "none" {
|
||
|
|
serverConfig.NoClientAuth = true
|
||
|
|
} else if isManagedAuthentication(config.operation.Authentication) && (g.options.Authenticate != nil || g.options.AuthenticateMethod != nil) {
|
||
|
|
serverConfig.PasswordCallback = func(metadata ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||
|
|
actor, err := g.authenticate(ctx, config.operation.Authentication, metadata.User(), string(password), config.channel)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &ssh.Permissions{Extensions: map[string]string{"actor": actor}}, nil
|
||
|
|
}
|
||
|
|
} else if config.operation.Authentication == "password" {
|
||
|
|
serverConfig.PasswordCallback = func(metadata ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||
|
|
if (config.operation.Username != "" && config.operation.Username != metadata.User()) || !credentialMatches(config.operation.Credential, string(password)) {
|
||
|
|
return nil, errors.New("authentication failed")
|
||
|
|
}
|
||
|
|
return &ssh.Permissions{Extensions: map[string]string{"actor": metadata.User()}}, nil
|
||
|
|
}
|
||
|
|
} else if config.operation.Authentication == "key" {
|
||
|
|
authorized, _, _, _, err := ssh.ParseAuthorizedKey([]byte(config.operation.Credential))
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
serverConfig.PublicKeyCallback = func(metadata ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
|
||
|
|
if (config.operation.Username != "" && config.operation.Username != metadata.User()) || !bytes.Equal(authorized.Marshal(), key.Marshal()) {
|
||
|
|
return nil, errors.New("authentication failed")
|
||
|
|
}
|
||
|
|
return &ssh.Permissions{Extensions: map[string]string{"actor": metadata.User()}}, nil
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
serverConnection, channels, requests, err := ssh.NewServerConn(connection, serverConfig)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer serverConnection.Close()
|
||
|
|
go ssh.DiscardRequests(requests)
|
||
|
|
for channelRequest := range channels {
|
||
|
|
if channelRequest.ChannelType() != "session" {
|
||
|
|
_ = channelRequest.Reject(ssh.UnknownChannelType, "session channel required")
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
channel, channelRequests, err := channelRequest.Accept()
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
go acceptShellRequests(channelRequests)
|
||
|
|
if !beginTerminalSession(current) {
|
||
|
|
_, _ = channel.Write([]byte("Too many clients.\r\n"))
|
||
|
|
_ = channel.Close()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
actor := serverConnection.User()
|
||
|
|
if serverConnection.Permissions != nil && serverConnection.Permissions.Extensions["actor"] != "" {
|
||
|
|
actor = serverConnection.Permissions.Extensions["actor"]
|
||
|
|
}
|
||
|
|
g.bridge(ctx, channel, channel, config, actor, remoteEndpoint(serverConnection.RemoteAddr()), true)
|
||
|
|
current.clients.Add(-1)
|
||
|
|
_ = channel.Close()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func beginTerminalSession(current *service) bool {
|
||
|
|
maximum := current.config.operation.MaxClients
|
||
|
|
if maximum < 1 {
|
||
|
|
maximum = 1
|
||
|
|
}
|
||
|
|
if current.clients.Add(1) <= int32(maximum) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
current.clients.Add(-1)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func acceptShellRequests(requests <-chan *ssh.Request) {
|
||
|
|
for request := range requests {
|
||
|
|
switch request.Type {
|
||
|
|
case "pty-req", "env", "window-change", "shell":
|
||
|
|
if request.WantReply {
|
||
|
|
_ = request.Reply(true, nil)
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
if request.WantReply {
|
||
|
|
_ = request.Reply(false, nil)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) runNormal(ctx context.Context, current *service) {
|
||
|
|
defer close(current.done)
|
||
|
|
for {
|
||
|
|
if ctx.Err() != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if current.config.operation.Mode == "ssh" {
|
||
|
|
g.connectNormalSSH(ctx, current.config)
|
||
|
|
} else {
|
||
|
|
g.connectNormalTelnet(ctx, current.config)
|
||
|
|
}
|
||
|
|
timer := time.NewTimer(g.options.RetryDelay)
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
timer.Stop()
|
||
|
|
return
|
||
|
|
case <-timer.C:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) connectNormalTelnet(ctx context.Context, config serviceConfig) {
|
||
|
|
connection, err := g.options.Dial(ctx, "tcp", net.JoinHostPort(config.operation.Host, strconv.Itoa(config.operation.Port)))
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer connection.Close()
|
||
|
|
if config.operation.Username != "" {
|
||
|
|
_, _ = connection.Write(append([]byte(config.operation.Username), operationNewline(config.operation.Newline)...))
|
||
|
|
}
|
||
|
|
if config.operation.Credential != "" {
|
||
|
|
_, _ = connection.Write(append([]byte(config.operation.Credential), operationNewline(config.operation.Newline)...))
|
||
|
|
}
|
||
|
|
g.bridge(ctx, connection, &telnetReader{reader: connection}, config, config.operation.Username, remoteEndpoint(connection.RemoteAddr()), false)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) connectNormalSSH(ctx context.Context, config serviceConfig) {
|
||
|
|
hostKeyCallback, err := knownhosts.New(g.options.KnownHostsPath)
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
clientConfig := &ssh.ClientConfig{User: config.operation.Username, HostKeyCallback: hostKeyCallback, Timeout: operationTimeout(config.operation)}
|
||
|
|
switch config.operation.Authentication {
|
||
|
|
case "password", "local":
|
||
|
|
if config.operation.Credential != "" {
|
||
|
|
clientConfig.Auth = []ssh.AuthMethod{ssh.Password(config.operation.Credential)}
|
||
|
|
}
|
||
|
|
case "key":
|
||
|
|
signer, parseErr := ssh.ParsePrivateKey([]byte(config.operation.Credential))
|
||
|
|
if parseErr != nil {
|
||
|
|
g.recordFailure(config, parseErr)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
clientConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||
|
|
}
|
||
|
|
address := net.JoinHostPort(config.operation.Host, strconv.Itoa(config.operation.Port))
|
||
|
|
connection, err := g.options.Dial(ctx, "tcp", address)
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer connection.Close()
|
||
|
|
clientConnection, channels, requests, err := ssh.NewClientConn(connection, address, clientConfig)
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
client := ssh.NewClient(clientConnection, channels, requests)
|
||
|
|
defer client.Close()
|
||
|
|
channel, channelRequests, err := client.OpenChannel("session", nil)
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer channel.Close()
|
||
|
|
go ssh.DiscardRequests(channelRequests)
|
||
|
|
_, err = channel.SendRequest("shell", true, nil)
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
g.bridge(ctx, channel, channel, config, config.operation.Username, remoteEndpoint(connection.RemoteAddr()), false)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) runRedirect(ctx context.Context, current *service) {
|
||
|
|
defer close(current.done)
|
||
|
|
for {
|
||
|
|
if ctx.Err() != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err := g.forwardRedirect(ctx, current.config); err != nil && ctx.Err() == nil {
|
||
|
|
g.recordFailure(current.config, err)
|
||
|
|
}
|
||
|
|
timer := time.NewTimer(g.options.RetryDelay)
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
timer.Stop()
|
||
|
|
return
|
||
|
|
case <-timer.C:
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) forwardRedirect(ctx context.Context, config serviceConfig) error {
|
||
|
|
sequence := g.sequence.Add(1)
|
||
|
|
targetChannel := config.channel
|
||
|
|
targetSession := fmt.Sprintf("redirect-to-%s-%d", strings.ToLower(targetChannel), sequence)
|
||
|
|
target, err := g.broker.Attach(ctx, directstream.AttachRequest{
|
||
|
|
SessionID: targetSession, Channel: targetChannel, Actor: "tera-redirect", Access: directstream.AccessWriter,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("attach target %s: %w", targetChannel, err)
|
||
|
|
}
|
||
|
|
defer target.Close()
|
||
|
|
type sourceAttachment struct {
|
||
|
|
channel string
|
||
|
|
attachment *directstream.Attachment
|
||
|
|
}
|
||
|
|
sources := make([]sourceAttachment, 0, len(config.sources))
|
||
|
|
defer func() {
|
||
|
|
for _, source := range sources {
|
||
|
|
_ = source.attachment.Close()
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
for _, channel := range config.sources {
|
||
|
|
source, attachErr := g.broker.Attach(ctx, directstream.AttachRequest{
|
||
|
|
SessionID: fmt.Sprintf("redirect-%s-to-%s-%d", strings.ToLower(channel), strings.ToLower(targetChannel), sequence),
|
||
|
|
Channel: channel, Actor: "tera-redirect", Access: directstream.AccessViewer,
|
||
|
|
})
|
||
|
|
if attachErr != nil {
|
||
|
|
return fmt.Errorf("attach source %s: %w", channel, attachErr)
|
||
|
|
}
|
||
|
|
sources = append(sources, sourceAttachment{channel: channel, attachment: source})
|
||
|
|
}
|
||
|
|
connectedAt := time.Now().UTC()
|
||
|
|
message := fmt.Sprintf("TERA CORA-X48 PRO K serial redirect connected\nSource channels: %s\nTarget channel: %s\nSession: %s\nConnected at: %s", strings.Join(config.sources, ","), targetChannel, targetSession, connectedAt.Format(time.RFC3339))
|
||
|
|
_ = g.recordSession(config, "tera-redirect", endpoint{host: "local"}, targetSession, "connected", message, connectedAt)
|
||
|
|
errorsChannel := make(chan error, len(sources))
|
||
|
|
for _, item := range sources {
|
||
|
|
go func(source sourceAttachment) {
|
||
|
|
for {
|
||
|
|
payload, receiveErr := source.attachment.Receive(ctx)
|
||
|
|
if receiveErr != nil {
|
||
|
|
errorsChannel <- receiveErr
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if writeErr := target.Write(ctx, payload); writeErr != nil {
|
||
|
|
errorsChannel <- fmt.Errorf("write %s to %s: %w", source.channel, targetChannel, writeErr)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}(item)
|
||
|
|
}
|
||
|
|
select {
|
||
|
|
case <-ctx.Done():
|
||
|
|
return ctx.Err()
|
||
|
|
case forwardErr := <-errorsChannel:
|
||
|
|
return forwardErr
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type readWriteCloser interface {
|
||
|
|
io.Reader
|
||
|
|
io.Writer
|
||
|
|
io.Closer
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) bridge(ctx context.Context, connection readWriteCloser, reader io.Reader, config serviceConfig, actor string, remote endpoint, welcome bool) {
|
||
|
|
if strings.TrimSpace(actor) == "" {
|
||
|
|
actor = "anonymous"
|
||
|
|
}
|
||
|
|
sessionID := fmt.Sprintf("operation-%s-%d-%d", strings.ToLower(config.channel), config.number, g.sequence.Add(1))
|
||
|
|
attachment, err := g.broker.Attach(ctx, directstream.AttachRequest{
|
||
|
|
SessionID: sessionID, Channel: config.channel, Actor: actor,
|
||
|
|
Access: directstream.AccessWriter, SharedWrite: true,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
g.recordFailure(config, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer attachment.Close()
|
||
|
|
connectedAt := time.Now().UTC()
|
||
|
|
message := welcomeMessage(config, actor, remote, sessionID, connectedAt)
|
||
|
|
_ = g.recordSession(config, actor, remote, sessionID, "connected", message, connectedAt)
|
||
|
|
if welcome {
|
||
|
|
_, _ = connection.Write([]byte("\r\n" + strings.ReplaceAll(message, "\n", "\r\n") + "\r\n\r\n"))
|
||
|
|
}
|
||
|
|
streamContext, cancel := context.WithCancel(ctx)
|
||
|
|
defer cancel()
|
||
|
|
shortcuts := terminalShortcutSet(config.operation)
|
||
|
|
var connectionWriteMu sync.Mutex
|
||
|
|
writeConnection := func(payload []byte) error {
|
||
|
|
connectionWriteMu.Lock()
|
||
|
|
defer connectionWriteMu.Unlock()
|
||
|
|
_, err := connection.Write(payload)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
writeNotice := func(message string) error {
|
||
|
|
return writeConnection([]byte("\r\n[TERA] " + message + "\r\n"))
|
||
|
|
}
|
||
|
|
done := make(chan struct{}, 2)
|
||
|
|
go func() {
|
||
|
|
defer func() { done <- struct{}{} }()
|
||
|
|
buffer := make([]byte, 32*1024)
|
||
|
|
controlMode := false
|
||
|
|
for {
|
||
|
|
count, readErr := reader.Read(buffer)
|
||
|
|
if count > 0 {
|
||
|
|
payload := make([]byte, 0, count)
|
||
|
|
for _, value := range buffer[:count] {
|
||
|
|
if controlMode {
|
||
|
|
switch value {
|
||
|
|
case '?':
|
||
|
|
if writeNotice(controlModeHelp(shortcuts)) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
case 'q', 'Q':
|
||
|
|
_ = writeNotice("current session closed")
|
||
|
|
return
|
||
|
|
case 't', 'T':
|
||
|
|
controlMode = false
|
||
|
|
if writeNotice(config.channel+" terminal resumed; all other Telnet sessions remain connected") != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
case 'b', 'B':
|
||
|
|
controlMode = false
|
||
|
|
if err := attachment.SendBreak(streamContext); err != nil {
|
||
|
|
if writeNotice("BREAK failed: "+err.Error()) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
} else if writeNotice("BREAK sent to "+config.channel) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
case 'i', 'I':
|
||
|
|
_ = writeNotice("session interrupted")
|
||
|
|
return
|
||
|
|
default:
|
||
|
|
controlMode = false
|
||
|
|
if writeNotice("unknown control command; terminal resumed") != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
switch {
|
||
|
|
case shortcuts.control.matches(value):
|
||
|
|
controlMode = true
|
||
|
|
if writeNotice(controlModeHelp(shortcuts)) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
case shortcuts.switchSession.matches(value):
|
||
|
|
if writeNotice(config.channel+" terminal active; all connected terminal sessions remain online") != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
case shortcuts.quit.matches(value):
|
||
|
|
_ = writeNotice("current session closed")
|
||
|
|
return
|
||
|
|
case shortcuts.sendBreak.matches(value):
|
||
|
|
if err := attachment.SendBreak(streamContext); err != nil {
|
||
|
|
if writeNotice("BREAK failed: "+err.Error()) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
} else if writeNotice("BREAK sent to "+config.channel) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
case shortcuts.interrupt.matches(value):
|
||
|
|
_ = writeNotice("session interrupted")
|
||
|
|
return
|
||
|
|
default:
|
||
|
|
payload = append(payload, value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(payload) > 0 {
|
||
|
|
payload = normalizeNewlines(payload, config.operation.Newline)
|
||
|
|
if writeErr := attachment.Write(streamContext, payload); writeErr != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if readErr != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
go func() {
|
||
|
|
defer func() { done <- struct{}{} }()
|
||
|
|
for {
|
||
|
|
payload, receiveErr := attachment.Receive(streamContext)
|
||
|
|
if receiveErr != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if writeErr := writeConnection(payload); writeErr != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
<-done
|
||
|
|
cancel()
|
||
|
|
_ = connection.Close()
|
||
|
|
<-done
|
||
|
|
disconnectedAt := time.Now().UTC()
|
||
|
|
disconnect := fmt.Sprintf("TERA CORA-X48 PRO K session disconnected\nConnected from: %s\nSerial channel: %s\nProtocol: %s\nUser: %s\nSession: %s\nDisconnected at: %s", remote.host, config.channel, config.operation.Mode, actor, sessionID, disconnectedAt.Format(time.RFC3339))
|
||
|
|
_ = g.recordSession(config, actor, remote, sessionID, "disconnected", disconnect, disconnectedAt)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) recordSession(config serviceConfig, actor string, remote endpoint, sessionID, eventType, message string, timestamp time.Time) error {
|
||
|
|
if g.options.Record == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return g.options.Record(context.Background(), domain.PortLogEvent{
|
||
|
|
Timestamp: timestamp, Channel: config.channel, EventType: "session",
|
||
|
|
Actor: actor + "/" + sessionID, Message: eventType + ": " + message, RemoteAddress: remote.host, RemotePort: remote.port,
|
||
|
|
ShowTimestamp: true, ShowUsername: true, ShowIPAddress: true, ShowPort: true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) recordFailure(config serviceConfig, err error) {
|
||
|
|
if g.options.Record == nil || err == nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, port := remoteEndpointString(config.operation.Host, config.operation.Port)
|
||
|
|
_ = g.options.Record(context.Background(), domain.PortLogEvent{
|
||
|
|
Timestamp: time.Now().UTC(), Channel: config.channel, EventType: "command",
|
||
|
|
Actor: "tera-api", Command: config.key, ReturnValue: err.Error(), Message: config.key + ": " + err.Error(), RemoteAddress: config.operation.Host, RemotePort: port,
|
||
|
|
ShowTimestamp: true, ShowIPAddress: true, ShowPort: true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func welcomeMessage(config serviceConfig, actor string, remote endpoint, sessionID string, timestamp time.Time) string {
|
||
|
|
shortcuts := terminalShortcutSet(config.operation)
|
||
|
|
return fmt.Sprintf("Welcome to TERA CORA-X48 PRO K\nConnected from: %s:%d\nSerial channel: %s\nOperation: %d\nProtocol: %s\nUser: %s\nAccess: shared writer\nSession: %s\nShortcuts: control %s, status/switch %s, quit %s, break %s, interrupt %s\nConnected at: %s", remote.host, remote.port, config.channel, config.number, config.operation.Mode, actor, sessionID, shortcuts.control.label(), shortcuts.switchSession.label(), shortcuts.quit.label(), shortcuts.sendBreak.label(), shortcuts.interrupt.label(), timestamp.Format(time.RFC3339))
|
||
|
|
}
|
||
|
|
|
||
|
|
type terminalShortcut struct {
|
||
|
|
notation string
|
||
|
|
value byte
|
||
|
|
enabled bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s terminalShortcut) matches(value byte) bool { return s.enabled && s.value == value }
|
||
|
|
func (s terminalShortcut) label() string {
|
||
|
|
if !s.enabled {
|
||
|
|
return "disabled"
|
||
|
|
}
|
||
|
|
return s.notation
|
||
|
|
}
|
||
|
|
|
||
|
|
type terminalShortcuts struct {
|
||
|
|
control terminalShortcut
|
||
|
|
switchSession terminalShortcut
|
||
|
|
quit terminalShortcut
|
||
|
|
sendBreak terminalShortcut
|
||
|
|
interrupt terminalShortcut
|
||
|
|
}
|
||
|
|
|
||
|
|
func terminalShortcutSet(operation domain.PortOperation) terminalShortcuts {
|
||
|
|
return terminalShortcuts{
|
||
|
|
control: makeTerminalShortcut(operation.ControlShortcut, "^]"),
|
||
|
|
switchSession: makeTerminalShortcut(operation.SwitchShortcut, "^T"),
|
||
|
|
quit: makeTerminalShortcut(operation.QuitShortcut, "^Q"),
|
||
|
|
sendBreak: makeTerminalShortcut(operation.BreakShortcut, ""),
|
||
|
|
interrupt: makeTerminalShortcut(operation.InterruptShortcut, ""),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func makeTerminalShortcut(notation, fallback string) terminalShortcut {
|
||
|
|
notation = strings.ToUpper(strings.TrimSpace(notation))
|
||
|
|
if notation == "" {
|
||
|
|
notation = fallback
|
||
|
|
}
|
||
|
|
if len(notation) != 2 || notation[0] != '^' || notation[1] < '@' || notation[1] > '_' {
|
||
|
|
return terminalShortcut{}
|
||
|
|
}
|
||
|
|
return terminalShortcut{notation: notation, value: notation[1] & 0x1f, enabled: true}
|
||
|
|
}
|
||
|
|
|
||
|
|
func controlModeHelp(shortcuts terminalShortcuts) string {
|
||
|
|
return fmt.Sprintf("control mode: T resume/status, Q close, B send BREAK, I interrupt, ? help (direct: switch %s, quit %s, break %s, interrupt %s)", shortcuts.switchSession.label(), shortcuts.quit.label(), shortcuts.sendBreak.label(), shortcuts.interrupt.label())
|
||
|
|
}
|
||
|
|
|
||
|
|
type endpoint struct {
|
||
|
|
host string
|
||
|
|
port int
|
||
|
|
}
|
||
|
|
|
||
|
|
func remoteEndpoint(address net.Addr) endpoint {
|
||
|
|
if address == nil {
|
||
|
|
return endpoint{host: "unknown"}
|
||
|
|
}
|
||
|
|
host, portText, err := net.SplitHostPort(address.String())
|
||
|
|
if err != nil {
|
||
|
|
return endpoint{host: address.String()}
|
||
|
|
}
|
||
|
|
port, _ := strconv.Atoi(portText)
|
||
|
|
return endpoint{host: strings.Trim(host, "[]"), port: port}
|
||
|
|
}
|
||
|
|
|
||
|
|
func remoteEndpointString(host string, port int) (string, int) { return host, port }
|
||
|
|
|
||
|
|
func operationTimeout(operation domain.PortOperation) time.Duration {
|
||
|
|
return 10 * time.Second
|
||
|
|
}
|
||
|
|
|
||
|
|
func isManagedAuthentication(method string) bool {
|
||
|
|
switch strings.ToLower(strings.TrimSpace(method)) {
|
||
|
|
case "local", "radius", "tacacs":
|
||
|
|
return true
|
||
|
|
default:
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (g *Gateway) authenticate(ctx context.Context, method, username, credential, channel string) (string, error) {
|
||
|
|
if g.options.AuthenticateMethod != nil {
|
||
|
|
return g.options.AuthenticateMethod(ctx, method, username, credential, channel)
|
||
|
|
}
|
||
|
|
if g.options.Authenticate != nil {
|
||
|
|
return g.options.Authenticate(ctx, username, credential, channel)
|
||
|
|
}
|
||
|
|
return "", errors.New("authentication service is unavailable")
|
||
|
|
}
|
||
|
|
|
||
|
|
func credentialMatches(expected, actual string) bool {
|
||
|
|
if len(expected) != len(actual) || len(expected) == 0 {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
|
||
|
|
}
|
||
|
|
|
||
|
|
func operationNewline(mode string) []byte {
|
||
|
|
switch mode {
|
||
|
|
case "lf":
|
||
|
|
return []byte{'\n'}
|
||
|
|
case "crlf":
|
||
|
|
return []byte{'\r', '\n'}
|
||
|
|
default:
|
||
|
|
return []byte{'\r'}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func readCredentialLine(reader *bufio.Reader, limit int) (string, error) {
|
||
|
|
line, err := reader.ReadString('\n')
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
line = strings.TrimRight(line, "\r\n")
|
||
|
|
if len(line) == 0 || len(line) > limit {
|
||
|
|
return "", errors.New("credential line is invalid")
|
||
|
|
}
|
||
|
|
return line, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func normalizeNewlines(payload []byte, mode string) []byte {
|
||
|
|
if mode == "" || mode == "none" {
|
||
|
|
return append([]byte(nil), payload...)
|
||
|
|
}
|
||
|
|
result := make([]byte, 0, len(payload)*2)
|
||
|
|
for index := 0; index < len(payload); index++ {
|
||
|
|
value := payload[index]
|
||
|
|
if value == '\r' && index+1 < len(payload) && payload[index+1] == '\n' {
|
||
|
|
index++
|
||
|
|
value = '\n'
|
||
|
|
}
|
||
|
|
if value != '\r' && value != '\n' {
|
||
|
|
result = append(result, value)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
switch mode {
|
||
|
|
case "cr":
|
||
|
|
result = append(result, '\r')
|
||
|
|
case "lf":
|
||
|
|
result = append(result, '\n')
|
||
|
|
case "crlf":
|
||
|
|
result = append(result, '\r', '\n')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
// telnetReader removes option negotiation bytes before they can reach a UART.
|
||
|
|
// The parser state is retained because IAC commands and subnegotiation frames
|
||
|
|
// are routinely split across TCP reads.
|
||
|
|
type telnetReader struct {
|
||
|
|
reader io.Reader
|
||
|
|
state uint8
|
||
|
|
}
|
||
|
|
|
||
|
|
const (
|
||
|
|
telnetData uint8 = iota
|
||
|
|
telnetIAC
|
||
|
|
telnetOption
|
||
|
|
telnetSubnegotiation
|
||
|
|
telnetSubnegotiationIAC
|
||
|
|
)
|
||
|
|
|
||
|
|
func (r *telnetReader) Read(output []byte) (int, error) {
|
||
|
|
if len(output) == 0 {
|
||
|
|
return 0, nil
|
||
|
|
}
|
||
|
|
temporary := make([]byte, len(output))
|
||
|
|
for {
|
||
|
|
count, err := r.reader.Read(temporary)
|
||
|
|
written := 0
|
||
|
|
for _, value := range temporary[:count] {
|
||
|
|
switch r.state {
|
||
|
|
case telnetData:
|
||
|
|
if value == 255 {
|
||
|
|
r.state = telnetIAC
|
||
|
|
} else if written < len(output) {
|
||
|
|
output[written] = value
|
||
|
|
written++
|
||
|
|
}
|
||
|
|
case telnetIAC:
|
||
|
|
switch value {
|
||
|
|
case 255:
|
||
|
|
if written < len(output) {
|
||
|
|
output[written] = value
|
||
|
|
written++
|
||
|
|
}
|
||
|
|
r.state = telnetData
|
||
|
|
case 251, 252, 253, 254:
|
||
|
|
r.state = telnetOption
|
||
|
|
case 250:
|
||
|
|
r.state = telnetSubnegotiation
|
||
|
|
default:
|
||
|
|
r.state = telnetData
|
||
|
|
}
|
||
|
|
case telnetOption:
|
||
|
|
r.state = telnetData
|
||
|
|
case telnetSubnegotiation:
|
||
|
|
if value == 255 {
|
||
|
|
r.state = telnetSubnegotiationIAC
|
||
|
|
}
|
||
|
|
case telnetSubnegotiationIAC:
|
||
|
|
if value == 240 {
|
||
|
|
r.state = telnetData
|
||
|
|
} else if value != 255 {
|
||
|
|
r.state = telnetSubnegotiation
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if written > 0 {
|
||
|
|
return written, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|