@@ -192,6 +192,27 @@ def _int_env(name, default):
192192# client talking to the backend directly cannot mint identities; with
193193# the feature on, an untrusted peer is simply unauthenticated (401).
194194WEBSH_AUTH_HEADER = os .environ .get ("WEBSH_AUTH_HEADER" , "" ).strip ()
195+ # Opt-in session recording (asciicast v2, one .cast file per session,
196+ # created 0600). OFF unless WEBSH_RECORD_DIR names a directory the
197+ # server user can write. Recording is OUTPUT-ONLY; keystroke/input
198+ # recording is hard-disabled (see WEBSH_RECORD_INPUT below). Recording is
199+ # best-effort: a write failure disables it for that session, never the PTY.
200+ WEBSH_RECORD_DIR = os .environ .get ("WEBSH_RECORD_DIR" , "" ).strip ()
201+ # Input recording (keystrokes — which include any password typed at a
202+ # sudo/login prompt INSIDE the session) is HARD-DISABLED. Capturing
203+ # keystrokes to disk on a public-facing deployment is a liability we do
204+ # not accept, so recording is always output-only regardless of the
205+ # environment. The env var is read ONLY to warn an operator who sets it
206+ # (see main()). The browser-form ssh password was never recorded either
207+ # way (it is auto-typed below the input tee).
208+ _WEBSH_RECORD_INPUT_REQUESTED = os .environ .get ("WEBSH_RECORD_INPUT" ) == "1"
209+ WEBSH_RECORD_INPUT = False
210+ # Per-file ceiling. A `cat /dev/urandom` session would otherwise write
211+ # unbounded (and invalid UTF-8 inflates ~3-5x through replacement +
212+ # JSON escaping). Hitting the cap stops the recording with one WARN;
213+ # the session itself is never touched. 0 disables the cap.
214+ WEBSH_RECORD_MAX_BYTES = max (0 , _int_env ("WEBSH_RECORD_MAX_BYTES" ,
215+ str (64 * 1024 * 1024 )))
195216SESSION_TIMEOUT = _int_env ("SESSION_TIMEOUT" , "300" )
196217MAX_SESSIONS = _int_env ("MAX_SESSIONS" , "50" )
197218# Per-source-IP active session cap. 0 disables the check (preserve legacy
@@ -1583,6 +1604,10 @@ def __init__(self, session_id, host, port, username, password, cols, rows,
15831604 self .pid = None
15841605 self .output_buf = b""
15851606 self .buf_lock = Lock ()
1607+ # Session recording (asciicast v2); None when disabled.
1608+ self ._rec = None
1609+ self ._rec_t0 = 0.0
1610+ self ._rec_lock = Lock ()
15861611 # Cross-thread wake signal: the PTY read-loop calls _signal() (which
15871612 # is _data_event.set()) after every output_buf update; consumers
15881613 # (_stream / _output) park in wait_for_data() which waits on the
@@ -1769,6 +1794,10 @@ def _spawn(self, host, port, username, cols, rows):
17691794
17701795 ssh_cmd = self ._build_ssh_cmd (host , port , username )
17711796
1797+ # Open the recording before the fork so even the earliest PTY
1798+ # bytes (banner, password prompt) land in the file.
1799+ self ._rec_open (cols , rows )
1800+
17721801 pid , fd = pty .fork ()
17731802 if pid == 0 :
17741803 # In the forked child: only call os._exit on execvpe failure.
@@ -1802,6 +1831,98 @@ def _set_winsize(self, cols, rows):
18021831
18031832 # ── Read loop / output buffer ───────────────────────────────────
18041833
1834+ # ── Session recording (asciicast v2) ────────────────────────────
1835+
1836+ def _rec_open (self , cols , rows ):
1837+ """Start recording when WEBSH_RECORD_DIR is set. Best-effort."""
1838+ if not WEBSH_RECORD_DIR :
1839+ return
1840+ try :
1841+ # 0700 + explicit chmod (makedirs mode is ignored for an
1842+ # existing dir): the FILENAME carries the live session id —
1843+ # the API's only bearer credential — so a world-listable
1844+ # directory would let any local user hijack sessions.
1845+ os .makedirs (WEBSH_RECORD_DIR , mode = 0o700 , exist_ok = True )
1846+ os .chmod (WEBSH_RECORD_DIR , 0o700 )
1847+ name = "%s-%s.cast" % (time .strftime ("%Y%m%d-%H%M%S" ), self .id )
1848+ path = os .path .join (WEBSH_RECORD_DIR , name )
1849+ # O_EXCL: the sid is unique, so a collision means something
1850+ # is impersonating the path — refuse rather than truncate.
1851+ fd = os .open (path , os .O_WRONLY | os .O_CREAT | os .O_EXCL , 0o600 )
1852+ # Line-buffered so a crash loses at most one event and the
1853+ # file can be live-tailed, matching asciinema's behavior.
1854+ self ._rec = os .fdopen (fd , "w" , encoding = "utf-8" , buffering = 1 )
1855+ self ._rec_t0 = time .monotonic ()
1856+ self ._rec_bytes = 0
1857+ # Incremental decoders (one per direction): a multibyte
1858+ # UTF-8 sequence split across PTY reads must not become
1859+ # U+FFFD at every chunk boundary.
1860+ import codecs as _codecs
1861+ self ._rec_dec = {}
1862+ self ._rec_mkdec = lambda : _codecs .getincrementaldecoder (
1863+ "utf-8" )("replace" )
1864+ self ._rec .write (json .dumps ({
1865+ "version" : 2 ,
1866+ "width" : cols ,
1867+ "height" : rows ,
1868+ "timestamp" : int (time .time ()),
1869+ "env" : {"TERM" : "xterm-256color" },
1870+ }) + "\n " )
1871+ except Exception as e :
1872+ _log ("WARN" , "session {} recording disabled: {}" .format (
1873+ self .id , e ))
1874+ self ._rec = None
1875+
1876+ def _record (self , kind , text ):
1877+ """Append one event ([t, kind, text] JSONL). Never raises: a
1878+ broken recording must not kill the session — it logs once and
1879+ disables itself. getattr: tests (and the cleanup loop) touch
1880+ sessions built via __new__ that never ran __init__."""
1881+ if getattr (self , "_rec" , None ) is None :
1882+ return
1883+ try :
1884+ if isinstance (text , bytes ):
1885+ dec = self ._rec_dec .get (kind )
1886+ if dec is None :
1887+ dec = self ._rec_dec [kind ] = self ._rec_mkdec ()
1888+ text = dec .decode (text )
1889+ if not text :
1890+ return # mid-sequence; flushed with the next chunk
1891+ with self ._rec_lock :
1892+ f = self ._rec
1893+ if f is None :
1894+ return
1895+ # Timestamp under the lock so concurrent writers (the
1896+ # PTY reader vs input/resize handlers) cannot produce
1897+ # out-of-order events.
1898+ line = json .dumps (
1899+ [round (time .monotonic () - self ._rec_t0 , 6 ), kind , text ])
1900+ f .write (line + "\n " )
1901+ self ._rec_bytes += len (line ) + 1
1902+ if (WEBSH_RECORD_MAX_BYTES
1903+ and self ._rec_bytes >= WEBSH_RECORD_MAX_BYTES ):
1904+ self ._rec = None
1905+ f .close ()
1906+ _log ("WARN" , "session {} recording capped at {} bytes"
1907+ .format (self .id , WEBSH_RECORD_MAX_BYTES ))
1908+ except Exception as e :
1909+ _log ("WARN" , "session {} recording stopped: {}" .format (
1910+ self .id , e ))
1911+ self ._rec = None
1912+
1913+ def _rec_close (self ):
1914+ if getattr (self , "_rec" , None ) is None :
1915+ self ._rec = None
1916+ return
1917+ with self ._rec_lock :
1918+ f = self ._rec
1919+ self ._rec = None
1920+ if f is not None :
1921+ try :
1922+ f .close ()
1923+ except Exception :
1924+ pass
1925+
18051926 def _read_loop (self ):
18061927 """Background thread: reads PTY output into buffer."""
18071928 # Entry-guard: pty.fork() may have failed in __init__ (or a test
@@ -1836,6 +1957,7 @@ def _read_loop(self):
18361957 break
18371958 if not data :
18381959 break
1960+ self ._record ("o" , data )
18391961
18401962 # Auto-type password on prompt (accumulate to handle split reads)
18411963 if self ._password and not self ._password_sent :
@@ -1920,6 +2042,7 @@ def _read_loop(self):
19202042 if r :
19212043 leftover = os .read (self .master_fd , PTY_READ_SIZE )
19222044 if leftover :
2045+ self ._record ("o" , leftover )
19232046 with self .buf_lock :
19242047 self .output_buf += leftover
19252048 self ._signal ()
@@ -1965,6 +2088,11 @@ def _read_loop(self):
19652088 # Wake any consumer parked in wait_for_data so it observes
19662089 # alive=False without waiting up to KEEPALIVE_INTERVAL.
19672090 self ._signal ()
2091+ # The reader owns the recording tail: closing here (after
2092+ # the drain above) captures the session's final output.
2093+ # close()'s _rec_close stays as a backstop for sessions
2094+ # whose reader never ran.
2095+ self ._rec_close ()
19682096
19692097 def _poll_child_exit (self ):
19702098 """Non-blocking WNOHANG check for the ssh child's self-exit, run from
@@ -2236,6 +2364,9 @@ def write(self, data):
22362364 self .alive = False
22372365 return False
22382366 self .last_activity = time .time ()
2367+ # Input recording (keystrokes — which include passwords typed at
2368+ # prompts inside the session) is hard-disabled; see WEBSH_RECORD_INPUT.
2369+ # The 'i' event is never written — recording is output-only.
22392370 try :
22402371 os .write (self .master_fd , data )
22412372 return True
@@ -2245,6 +2376,7 @@ def write(self, data):
22452376
22462377 def resize (self , cols , rows ):
22472378 self .last_activity = time .time ()
2379+ self ._record ("r" , "%dx%d" % (cols , rows ))
22482380 self ._set_winsize (cols , rows )
22492381
22502382 # ── Persistent tmux (terminate / capture / push options) ────────
@@ -2736,6 +2868,7 @@ def download_file(self, remote_path):
27362868
27372869 def close (self ):
27382870 self .alive = False
2871+ self ._rec_close ()
27392872 # Wake any consumer parked in wait_for_data so it observes
27402873 # alive=False and exits via the normal
27412874 # `if not session.alive: break` path. Setting an Event whose
@@ -4586,6 +4719,10 @@ def _request_stop(signum, frame):
45864719 _log ("INFO" , "credential vault: disabled (set WEBSH_VAULT_ENABLE=1 to opt in)" )
45874720 else :
45884721 _log ("INFO" , "credential vault: enabled" )
4722+ if _WEBSH_RECORD_INPUT_REQUESTED :
4723+ _log ("WARN" , "WEBSH_RECORD_INPUT is set but IGNORED: keystroke/input "
4724+ "recording is hard-disabled (recording is output-only). "
4725+ "Unset it to silence this warning." )
45894726
45904727 stop_event .wait ()
45914728 _log ("INFO" , "shutting down" )
0 commit comments