tailscale: Add tailssh server
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
//go:build with_gvisor
|
||||
|
||||
package tailssh
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gliderssh "github.com/sagernet/gliderssh"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
"github.com/sagernet/tailscale/sessionrecording"
|
||||
"github.com/sagernet/tailscale/tailcfg"
|
||||
"github.com/sagernet/tailscale/types/key"
|
||||
)
|
||||
|
||||
type recordingRejectedError struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *recordingRejectedError) Error() string {
|
||||
if e.cause != nil {
|
||||
return e.cause.Error()
|
||||
}
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e *recordingRejectedError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func recorders(connInfo *sshConnInfo) ([]netip.AddrPort, *tailcfg.SSHRecorderFailureAction) {
|
||||
if len(connInfo.action.Recorders) > 0 {
|
||||
return connInfo.action.Recorders, connInfo.action.OnRecordingFailure
|
||||
}
|
||||
return connInfo.action0.Recorders, connInfo.action0.OnRecordingFailure
|
||||
}
|
||||
|
||||
func newConnID() string {
|
||||
random := make([]byte, 5)
|
||||
rand.Read(random)
|
||||
return fmt.Sprintf("ssh-conn-%s-%02x", time.Now().UTC().Format("20060102T150405"), random)
|
||||
}
|
||||
|
||||
func (s *Server) startNewRecording(sessionCtx context.Context, cancel context.CancelFunc, session gliderssh.Session, connInfo *sshConnInfo, localUser *adapter.PlatformUser, recorderList []netip.AddrPort, onFailure *tailcfg.SSHRecorderFailureAction) (*recording, error) {
|
||||
localBackend := s.tsnetServer.ExportLocalBackend()
|
||||
// Capture before any blocking call, in case the user switches mid-setup.
|
||||
nodeKey := localBackend.NodeKey()
|
||||
if nodeKey.IsZero() {
|
||||
return nil, E.New("ssh server is unavailable: no node key")
|
||||
}
|
||||
|
||||
var window gliderssh.Window
|
||||
ptyReq, _, isPty := session.Pty()
|
||||
if isPty {
|
||||
window = ptyReq.Window
|
||||
}
|
||||
term := ptyReq.Term
|
||||
if term == "" {
|
||||
term = "xterm-256color"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rec := &recording{
|
||||
start: now,
|
||||
failOpen: onFailure == nil || onFailure.TerminateSessionWithMessage == "",
|
||||
}
|
||||
|
||||
// Tied to the server lifetime rather than the session, so the upload survives a
|
||||
// normal session close but the bounded recorder dial is still aborted on
|
||||
// Server.Close() instead of stalling shutdown for up to its 30s timeout. Finished
|
||||
// by closing rec.out.
|
||||
uploadCtx := s.serverCtx
|
||||
out, attempts, errChan, err := sessionrecording.ConnectToRecorder(uploadCtx, recorderList, localBackend.Dialer().UserDial)
|
||||
if err != nil {
|
||||
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
||||
eventType := tailcfg.SSHSessionRecordingFailed
|
||||
if onFailure.RejectSessionWithMessage != "" {
|
||||
eventType = tailcfg.SSHSessionRecordingRejected
|
||||
}
|
||||
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
|
||||
}
|
||||
if onFailure != nil && onFailure.RejectSessionWithMessage != "" {
|
||||
s.logger.Error("recording: error starting recording (rejecting session): ", err)
|
||||
return nil, &recordingRejectedError{message: onFailure.RejectSessionWithMessage, cause: err}
|
||||
}
|
||||
s.logger.Warn("recording: error starting recording (failing open): ", err)
|
||||
return nil, nil
|
||||
}
|
||||
rec.out = out
|
||||
|
||||
go func() {
|
||||
uploadErr := <-errChan
|
||||
if uploadErr == nil {
|
||||
select {
|
||||
case <-sessionCtx.Done():
|
||||
s.logger.Debug("recording: finished uploading recording")
|
||||
return
|
||||
default:
|
||||
uploadErr = E.New("recording upload ended before the SSH session")
|
||||
}
|
||||
}
|
||||
if onFailure != nil && onFailure.NotifyURL != "" && len(attempts) > 0 {
|
||||
lastAttempt := attempts[len(attempts)-1]
|
||||
lastAttempt.FailureMessage = uploadErr.Error()
|
||||
eventType := tailcfg.SSHSessionRecordingFailed
|
||||
if onFailure.TerminateSessionWithMessage != "" {
|
||||
eventType = tailcfg.SSHSessionRecordingTerminated
|
||||
}
|
||||
s.notifyControl(uploadCtx, nodeKey, eventType, attempts, onFailure.NotifyURL, connInfo, localUser)
|
||||
}
|
||||
if onFailure != nil && onFailure.TerminateSessionWithMessage != "" {
|
||||
s.logger.Error("recording: error uploading recording (closing session): ", uploadErr)
|
||||
io.WriteString(session.Stderr(), onFailure.TerminateSessionWithMessage+"\r\n")
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
s.logger.Warn("recording: error uploading recording (failing open): ", uploadErr)
|
||||
}()
|
||||
|
||||
castHeader := sessionrecording.CastHeader{
|
||||
Version: 2,
|
||||
Width: window.Width,
|
||||
Height: window.Height,
|
||||
Timestamp: now.Unix(),
|
||||
Command: session.RawCommand(),
|
||||
Env: map[string]string{"TERM": term},
|
||||
SSHUser: connInfo.sshUser,
|
||||
LocalUser: localUser.Username,
|
||||
SrcNode: strings.TrimSuffix(connInfo.node.Name(), "."),
|
||||
SrcNodeID: connInfo.node.StableID(),
|
||||
ConnectionID: connInfo.connID,
|
||||
}
|
||||
if !connInfo.node.IsTagged() {
|
||||
castHeader.SrcNodeUser = connInfo.userProfile.LoginName
|
||||
castHeader.SrcNodeUserID = connInfo.node.User()
|
||||
} else {
|
||||
castHeader.SrcNodeTags = connInfo.node.Tags().AsSlice()
|
||||
}
|
||||
headerLine, err := json.Marshal(castHeader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headerLine = append(headerLine, '\n')
|
||||
_, err = rec.out.Write(headerLine)
|
||||
if err != nil {
|
||||
// Recorder closed the pipe from the watcher goroutine; surface that cause.
|
||||
if errors.Is(err, io.ErrClosedPipe) && sessionCtx.Err() != nil {
|
||||
return nil, context.Cause(sessionCtx)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Server) notifyControl(ctx context.Context, nodeKey key.NodePublic, eventType tailcfg.SSHEventType, attempts []*tailcfg.SSHRecordingAttempt, notifyURL string, connInfo *sshConnInfo, localUser *adapter.PlatformUser) {
|
||||
request := tailcfg.SSHEventNotifyRequest{
|
||||
EventType: eventType,
|
||||
ConnectionID: connInfo.connID,
|
||||
CapVersion: tailcfg.CurrentCapabilityVersion,
|
||||
NodeKey: nodeKey,
|
||||
SrcNode: connInfo.node.ID(),
|
||||
SSHUser: connInfo.sshUser,
|
||||
LocalUser: localUser.Username,
|
||||
RecordingAttempts: attempts,
|
||||
}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
s.logger.Warn("notifyControl: marshal request: ", err)
|
||||
return
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, notifyURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
s.logger.Warn("notifyControl: create request: ", err)
|
||||
return
|
||||
}
|
||||
response, err := s.tsnetServer.ExportLocalBackend().DoNoiseRequest(httpRequest)
|
||||
if err != nil {
|
||||
s.logger.Warn("notifyControl: send noise request: ", err)
|
||||
return
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
s.logger.Warn("notifyControl: noise request returned status ", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type recording struct {
|
||||
start time.Time
|
||||
failOpen bool
|
||||
|
||||
access sync.Mutex // guards out
|
||||
out io.WriteCloser
|
||||
}
|
||||
|
||||
func (r *recording) Close() error {
|
||||
r.access.Lock()
|
||||
defer r.access.Unlock()
|
||||
if r.out == nil {
|
||||
return nil
|
||||
}
|
||||
err := r.out.Close()
|
||||
r.out = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Only output is wrapped; input is never recorded since it may contain passwords.
|
||||
func (r *recording) writer(w io.Writer) io.Writer {
|
||||
if r == nil {
|
||||
return w
|
||||
}
|
||||
return &loggingWriter{rec: r, target: w}
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
rec *recording
|
||||
target io.Writer
|
||||
failedOpen bool
|
||||
}
|
||||
|
||||
func (l *loggingWriter) Write(p []byte) (int, error) {
|
||||
if !l.failedOpen {
|
||||
castLine, err := json.Marshal([]any{
|
||||
time.Since(l.rec.start).Seconds(),
|
||||
"o",
|
||||
string(p),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
castLine = append(castLine, '\n')
|
||||
writeErr := l.writeCastLine(castLine)
|
||||
if writeErr != nil {
|
||||
if !l.rec.failOpen {
|
||||
return 0, writeErr
|
||||
}
|
||||
l.failedOpen = true
|
||||
}
|
||||
}
|
||||
return l.target.Write(p)
|
||||
}
|
||||
|
||||
func (l *loggingWriter) writeCastLine(castLine []byte) error {
|
||||
l.rec.access.Lock()
|
||||
defer l.rec.access.Unlock()
|
||||
if l.rec.out == nil {
|
||||
return E.New("recording closed")
|
||||
}
|
||||
_, err := l.rec.out.Write(castLine)
|
||||
if err != nil {
|
||||
return E.Cause(err, "write recording")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user