← SSH: secure access and production diagnosis
11 / 12 · 60 MIN

SSH forwarding and service evidence

Experiment with local and remote forwarding, identify policy rejections, and distinguish an available listener from a functioning service.

Map the endpoints before opening the tunnel

An SSH forward has at least three elements: the listener consumed by the application, the SSH connection, and the final service. With -L, the client creates the listener and the SSH server connects to the requested destination. With -R, the listener is on the server side and the SSH client connects to the backend. The same text 127.0.0.1 can therefore refer to different machines in a real design. In this lab, everything runs on loopback on one machine; processes and ports provide separation. Draw request direction and identify who resolves the final name before investigating DNS, firewalls, or availability. An authenticated session does not demonstrate that the consumer receives a response.

Compare setup, policy, and availability

The runner creates an original TCP fixture returning DR-FORWARDING-OK. localAllowed and remoteAllowed receive that marker through the expected path. bindCollision deliberately occupies the entry port before starting SSH; ExitOnForwardFailure=yes terminates the client when it cannot create the listener. backendDown differs: the listener exists, but the final service is not listening and the channel records Connection refused. The client stays alive. This option does not turn a tunnel into an application availability monitor. localDenied also retains the listener but records administratively prohibited because policy refuses the destination. Compare stage, message, effective configuration, and result before choosing a correction. The same application symptom of no response can have different causes and owners.

Keep authorization and exposure explicit

PermitOpen limits destinations requested from the server by forwarding channels. In literalMismatch, policy allows the address 127.0.0.1 but the request uses localhost. Rejection does not prove a resolution failure: the rule does not resolve names to establish equivalence. PermitListen handles listening addresses and ports requested by remote forwarding. remoteDenied demonstrates setup refusal when that list is none. This lab’s listeners explicitly use loopback and GatewayPorts no. Exposure on external interfaces was not exercised. In a project, binding 0.0.0.0 changes who can reach the entry point; it requires analysis of consumers, access controls, and authorized scope. Avoid replacing an exact permission with any to conceal a configuration mismatch.

Run, interpret, and hand over useful evidence

Copy the complete code into run.py in a disposable working directory. Supply the five matching OpenSSH 10.5p1 executables as described in the next lesson’s guide. The build used was compiled without PAM, and the existing local account must be able to authenticate; no account is created. The program starts eleven experiments, fifteen temporary sshd processes, and a TCP fixture, stopping them afterward. It uses disposable keys, dedicated trust files, and a fixed target command. All 58 checks passed. Repeat with new ports and keys, comparing results without requiring literally identical logs. For RUN, add an authorized synthetic operation and distinguish process health, channel success, and functional outcome. The marker does not validate TLS or real application authentication. Also record the relationship between the entry port and consumer configuration, so recovery does not leave the job using its previous destination.

#!/usr/bin/env python3
"""Original SSH forwarding and jump-host lab: disposable keys, loopback only, fixed target command."""
import argparse,contextlib,hashlib,json,os,pwd,re,shlex,signal,socket,socketserver,subprocess,sys,tempfile,threading,time
from pathlib import Path

def main:
 ap=argparse.ArgumentParser(description=__doc__)
 for x in ['ssh','sshd','sshd-session','sshd-auth','keygen']:ap.add_argument('--'+x,required=True)
 ap.add_argument('--output',default='ssh-forwarding-evidence.json');a=ap.parse_args
 user=pwd.getpwuid(os.getuid).pw_name;checks=[];observations={};configs=[];count=0
 def call(argv):
 x=subprocess.run(list(map(str,argv)),capture_output=True,text=True,timeout=15)
 assert x.returncode==0,(argv,x.returncode,x.stderr);return x.stdout+x.stderr
 version=call([a.ssh,'-V']).strip;assert 'OpenSSH_10.5p1' in version
 def check(name,ok):assert ok,name;checks.append(name)
 def port:
 with socket.socketas s:s.bind(('127.0.0.1',0));return s.getsockname[1]
 def await_log(proc,log,term):
 for _ in range(200):
 if term in log.read_text:return
 assert proc.pollis None,(term,log.read_text)
 time.sleep(.02)
 raise AssertionError((term,log.read_text))
 @contextlib.contextmanager
 def running(argv,log,label):
 with log.open('w')as out:
 proc=subprocess.Popen(list(map(str,argv)),stdout=out,stderr=out,start_new_session=True)
 try:yield proc
 finally:
 if proc.pollis None:
 os.killpg(proc.pid,signal.SIGTERM)
 try:proc.wait(timeout=5)
 except subprocess.TimeoutExpired:os.killpg(proc.pid,signal.SIGKILL);proc.wait(timeout=5)
 check(label+': process stopped',proc.pollis not None)
 with tempfile.TemporaryDirectory(prefix='dr-ssh-forwarding-')as tmp:
 root=Path(tmp);root.chmod(0o700)
 def key(name):
 f=root/name;call([a.keygen,'-q','-t','ed25519','-N','','-C','dr-disposable-'+name,'-f',f]);return f
 host=key('host');jumpkey=key('jump');targetkey=key('target');wrong=key('wrong')
 forced=root/'forced.py'forced.write_text('import json\nprint(json.dumps({"fixedTarget":True}))\n')
 class Backend(socketserver.BaseRequestHandler):
 def handle(self):self.request.sendall(b'DR-FORWARDING-OK\n')
 backend=socketserver.ThreadingTCPServer(('127.0.0.1',0),Backend);backend.daemon_threads=True
 thread=threading.Thread(target=backend.serve_forever,daemon=True);thread.start;backend_port=backend.server_address[1]
 @contextlib.contextmanager
 def daemon(label,identity,allow='local',permitopen='none',permitlisten='none'):
 nonlocal count
 count+=1;folder=root/label;folder.mkdir;p=port;authorized=folder/'authorized_keys'authorized.write_text(Path(str(identity)+'.pub').read_text);authorized.chmod(0o600)
 conf=folder/'sshd.conf'log=folder/'daemon.log'conf.write_text(f'''ListenAddress 127.0.0.1
Port {p}
HostKey {host}
PidFile {folder}/pid
SshdSessionPath {a.sshd_session}
SshdAuthPath {a.sshd_auth}
AuthorizedKeysFile {authorized}
AllowUsers {user}
AuthenticationMethods publickey
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PermitUserEnvironment no
PermitUserRC no
PermitTTY no
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding {allow}
AllowStreamLocalForwarding no
PermitTunnel no
GatewayPorts no
PermitOpen {permitopen}
PermitListen {permitlisten}
StrictModes yes
UseDNS no
LogLevel VERBOSE
ForceCommand {shlex.quote(sys.executable)} {shlex.quote(str(forced))}
SetEnv ZDOTDIR={folder}
''')
 call([a.sshd,'-t','-f',conf]);effective=call([a.sshd,'-T','-f',conf]);configs.append(dict(label=label,configuration=conf.read_text,effective=effective))
 with running([a.sshd,'-D','-e','-f',conf],log,label+' daemon')as proc:
 await_log(proc,log,'Server listening on ');yield p,log
 known=root/'known_hosts'pub=Path(str(host)+'.pub').read_text.split;known.write_text('lab-host '+pub[0]+' '+pub[1]+'\n')
 common=f'''Host *
 User {user}
 HostName 127.0.0.1
 HostKeyAlias lab-host
 UserKnownHostsFile {known}
 GlobalKnownHostsFile /dev/null
 StrictHostKeyChecking yes
 UpdateHostKeys no
 IdentityAgent none
 IdentitiesOnly yes
 CertificateFile none
 BatchMode yes
 PasswordAuthentication no
 KbdInteractiveAuthentication no
 PreferredAuthentications publickey
 ConnectTimeout 5
 ConnectionAttempts 1
 ControlMaster no
 ControlPath none
 ForwardAgent no
 RequestTTY no
 ExitOnForwardFailure yes
'''
 def clientconfig(label,entries):
 f=root/(label+'.conf');f.write_text(''.join(f'Host {name}\n Port {p}\n IdentityFile {identity}\n'for name,p,identity in entries)+common);return f
 def read_marker(p):
 try:
 with socket.create_connection(('127.0.0.1',p),timeout=3)as s:return s.recv(100).decode
 except (ConnectionResetError,OSError):return ''
 try:
 for label in ['localAllowed','localDenied','backendDown','bindCollision','literalMismatch','remoteAllowed','remoteDenied']:
 listen=port;dest=portif label=='backendDown'else backend_port
 remote=label.startswith('remote');allowed=label not in ['localDenied','remoteDenied']
 allow='remote'if remote else 'local'po='127.0.0.1:'+str(dest)if allowed and not remote else 'none'pl='127.0.0.1:'+str(listen)if allowed and remote else 'none'
 occupied=None
 if label=='bindCollision':occupied=socket.socket;occupied.bind(('127.0.0.1',listen));occupied.listen
 try:
 with daemon(label,jumpkey,allow,po,pl)as(p,serverlog):
 cfg=clientconfig(label,[('endpoint',p,jumpkey)]);log=root/(label+'.client.log');targethost='localhost'if label=='literalMismatch'else '127.0.0.1'
 spec='127.0.0.1:'+str(listen)+':'+targethost+':'+str(dest)
 argv=[a.ssh,'-F',cfg,'-vv','-N','-R'if remote else '-L',spec,'endpoint']
 with running(argv,log,label+' client')as proc:
 if label in ['remoteDenied','bindCollision']:
 code=proc.wait(timeout=10);check(label+': setup fails',code==255)
 term='remote port forwarding failed'if remote else 'Address already in use'check(label+': setup reason recorded',term in log.read_text)
 marker=''alive=False
 else:
 await_log(proc,log,'remote forward success'if remote else 'Local forwarding listening on 127.0.0.1')
 marker=read_marker(listen);time.sleep(.05);alive=proc.pollis None
 expected=label in ['localAllowed','remoteAllowed'];check(label+': payload matches expectation',(marker=='DR-FORWARDING-OK\n')==expected)
 check(label+': client remains running',alive)
 if label in ['localDenied','literalMismatch']:check(label+': policy denial recorded','administratively prohibited' in log.read_text)
 if label=='backendDown':check(label+': backend refusal recorded','Connection refused' in log.read_text)
 observations[label]=dict(marker=marker,clientAliveBeforeCleanup=alive,clientLog=log.read_text,serverLog=serverlog.read_text,requestedDestination=targethost+':'+str(dest))
 finally:
 if occupied:occupied.close
 for label in ['jumpAllowed','jumpWrongKey','targetWrongKey','jumpForwardDenied']:
 with daemon(label+'-target',targetkey,'no')as(tp,tlog):
 permitted='none'if label=='jumpForwardDenied'else '127.0.0.1:'+str(tp)
 with daemon(label+'-jump',jumpkey,'local',permitted)as(jp,jlog):
 cfg=clientconfig(label,[('jump',jp,wrong if label=='jumpWrongKey'else jumpkey),('target',tp,wrong if label=='targetWrongKey'else targetkey)])
 x=subprocess.run([a.ssh,'-F',str(cfg),'-vv','-J','jump','target','requested-check'],capture_output=True,text=True,timeout=15)
 good=label=='jumpAllowed'check(label+': expected exit',x.returncode==(0 if good else 255))
 check(label+': target output',(x.stdout.strip=='{"fixedTarget": true}')==good)
 jumpaccepted='Accepted publickey' in jlog.read_text;targetaccepted='Accepted publickey' in tlog.read_text
 check(label+': jump authentication',jumpaccepted==(label!='jumpWrongKey'))
 check(label+': target authentication',targetaccepted==good)
 if label=='jumpForwardDenied':check(label+': channel denial','administratively prohibited' in x.stderr)
 observations[label]=dict(exit=x.returncode,stdout=x.stdout,stderr=x.stderr,jumpAuthenticated=jumpaccepted,targetAuthenticated=targetaccepted,jumpLog=jlog.read_text,targetLog=tlog.read_text)
 finally:
 backend.shutdown;backend.server_close;thread.join(timeout=5);check('backend stopped',not thread.is_alive)
 check('temporary keys removed',not root.exists)
 report=dict(sshVersion=version,checks=checks,experiments=len(observations),serverProcesses=count,observations=observations,configurations=configs,runnerSha256=hashlib.sha256(Path(__file__).read_bytes).hexdigest,scope='Eleven loopback forwarding and ProxyJump experiments. Current local account, disposable keys, fixed target command, no PAM, no personal configuration or agents. No external bastion, production network segmentation, TLS application, dynamic SOCKS, IPv6 or CA rotation tested.')
 Path(a.output).write_text(json.dumps(report,indent=2).replace(user,'lab-account')+'\n');print(json.dumps(dict(experiments=len(observations),serverProcesses=count,checks=len(checks),output=a.output)))
if __name__=='__main__':main
IN PRACTICE

Ria retains a live tunnel, but reconciliation fails because the backend is not listening. The team restores the service and confirms an authorized query before retrying processing.

Common pitfalls

Confusing -L with -R, treating a PID as application health, attributing administrative rejection to DNS, or broadening PermitOpen without authorization to resolve a different name.

Related topics: Monitoring and observability · TCP/IP and access control

Take this idea with you

Prove listener creation, channel authorization, destination availability, and consumer result separately. Record the scope each experiment actually covers.

Create account

Reference: OpenSSH client forwarding configuration · OpenSSH concepts and OpenBSD-current manuals consulted 2026-09-29; distribution defaults vary