Session log: Cloudflare Tunnel for azcomputerguru + Cox BGP diagnosis

Diagnosed azcomputerguru.com 521 errors: Cox's BGP route to specific
Cloudflare origin-pull prefixes (162.158.0.0/16, 172.64.0.0/13,
173.245.48.0/20, 141.101.64.0/18) is broken from 72.194.62.0/29.
Confirmed by TCP probe matrix from pfSense WAN, traceroute latency
comparison, and state-table showing 0 inbound CF connections while
direct-internet traffic still reached origin.

Deployed Cloudflare Tunnel 'acg-origin' on Jupiter Unraid as a
Docker container. Routes 4 proxied hostnames (azcomputerguru.com,
analytics., community., radio.) through the tunnel with HTTPS
backend to IX 172.16.3.10:443 with per-ingress SNI matching. All
4 hostnames return 200 OK through CF edge after the cutover.

Repo hygiene:
- Merged clients/ix-server/ into clients/internal-infrastructure/
  (IX is internal infra, not a paying-client account). Git detected
  the session-log files as renames so history is preserved. Updated
  4 stale path references in 2 files.
- Moved cox-bgp ticket draft out of projects/dataforth-dos/ (wrong
  project) to clients/internal-infrastructure/vendor-tickets/.
- Relocated tunnel-setup helper scripts from
  projects/dataforth-dos/datasheet-pipeline/implementation/ to
  clients/internal-infrastructure/scripts/cloudflared-tunnel-setup/.
  Deleted superseded/abandoned login attempts. Sanitized hardcoded
  Jupiter/pfSense SSH passwords to pull from SOPS vault at runtime;
  Cloudflare token reads from env var (tokens still in 1Password,
  vault entry is metadata-only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 10:30:51 -07:00
parent a32681321b
commit a78fb96f95
12 changed files with 940 additions and 4 deletions

View File

@@ -0,0 +1,58 @@
"""Pull CF Analytics via GraphQL to see origin-status per CF PoP."""
import json, os, sys, urllib.request
from datetime import datetime, timezone, timedelta
ZONE = '1beb9917c22b54be32e5215df2c227ce'
# CF API tokens live in 1Password (vault entry services/cloudflare.sops.yaml
# currently holds metadata only). Provide via env vars before running.
TOKENS = {
'full-dns': os.environ.get('CF_API_TOKEN_FULL_DNS', ''),
'legacy': os.environ.get('CF_API_TOKEN_LEGACY', ''),
}
since_30 = (datetime.now(timezone.utc) - timedelta(minutes=30)).strftime('%Y-%m-%dT%H:%M:%SZ')
QUERY = '''
query($zone:String!, $since:Time!){
viewer {
zones(filter:{zoneTag:$zone}){
httpRequestsAdaptiveGroups(limit:50, filter:{datetime_geq:$since}, orderBy:[count_DESC]){
count
dimensions { coloCode edgeResponseStatus originResponseStatus clientRequestHTTPHost }
}
}
}
}
'''
def gql(token, query, vars):
req = urllib.request.Request(
'https://api.cloudflare.com/client/v4/graphql',
data=json.dumps({'query': query, 'variables': vars}).encode(),
headers={'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'},
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
for name, tok in TOKENS.items():
print(f'\n===== Trying {name} token =====')
try:
r = gql(tok, QUERY, {'zone': ZONE, 'since': since_30})
if r.get('errors'):
print('errors:', json.dumps(r['errors'], indent=2)[:600])
else:
zones = r.get('data', {}).get('viewer', {}).get('zones', [])
if not zones:
print('no zones returned')
continue
groups = zones[0].get('httpRequestsAdaptiveGroups', [])
print(f'{len(groups)} groups returned')
print(f'{"count":>6} {"colo":5} {"edge":5} {"origin":6} host')
for g in groups:
d = g['dimensions']
print(f"{g['count']:>6} {d.get('coloCode','-'):5} "
f"{str(d.get('edgeResponseStatus','-')):5} "
f"{str(d.get('originResponseStatus','-')):6} "
f"{d.get('clientRequestHTTPHost','-')}")
except Exception as e:
print(f'FAIL: {e}')

View File

@@ -0,0 +1,153 @@
"""Complete the tunnel setup in one pass after cert.pem is in place.
Steps:
1. Stop cf-login container
2. Create tunnel 'acg-origin', capture UUID
3. Write config.yml
4. Flip DNS: A (proxied, 72.194.62.5) -> CNAME (proxied, <UUID>.cfargotunnel.com) for 4 hostnames
5. Start persistent container 'cloudflared'
6. Wait for 4 tunnel connections to register
7. Verify site returns 200 externally
"""
import json, os, re, socket, subprocess, time, urllib.request
import paramiko
HOST, USER = "172.16.3.20", "root"
import subprocess as _sp, yaml as _y
PWD = _y.safe_load(_sp.run(["sops","-d","D:/vault/infrastructure/jupiter-unraid-primary.sops.yaml"],capture_output=True,text=True,timeout=30,check=True).stdout)["credentials"]["password"]
APPDATA = '/mnt/cache/appdata/cloudflared'
import os as _os
CF_TOKEN = _os.environ.get('CF_API_TOKEN_FULL_DNS', '')
if not CF_TOKEN:
raise SystemExit('[FAIL] set CF_API_TOKEN_FULL_DNS env var (token lives in 1Password)')
ZONE = '1beb9917c22b54be32e5215df2c227ce'
HOSTNAMES = ['azcomputerguru.com','analytics.azcomputerguru.com','community.azcomputerguru.com','radio.azcomputerguru.com']
ORIGIN = 'http://172.16.3.10:80'
socket.setdefaulttimeout(60)
c = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PWD, timeout=30, look_for_keys=False, allow_agent=False)
def run(cmd, to=120):
_, o, e = c.exec_command(cmd, timeout=to)
out = o.read().decode('utf-8','replace')
err = e.read().decode('utf-8','replace')
rc = o.channel.recv_exit_status()
return out, err, rc
def cfapi(method, path, body=None):
req = urllib.request.Request(
f'https://api.cloudflare.com/client/v4{path}',
data=json.dumps(body).encode() if body else None,
method=method,
headers={'Authorization': f'Bearer {CF_TOKEN}', 'Content-Type':'application/json'},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
try: return json.loads(e.read())
except: return {'success':False,'errors':[{'message':str(e)}]}
try:
print('=== [1] stop cf-login ===', flush=True)
out, _, _ = run('docker rm -f cf-login 2>&1')
print(out.rstrip())
print('\n=== [2] create tunnel acg-origin ===', flush=True)
CREATE = (
f'docker run --rm '
f'-v {APPDATA}:/home/nonroot/.cloudflared '
f'cloudflare/cloudflared:latest tunnel create acg-origin'
)
out, err, rc = run(CREATE)
print(out.rstrip())
if err.strip(): print(f'[stderr] {err.rstrip()}')
m = re.search(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', out)
if not m: raise SystemExit(f'[FAIL] no UUID in output; rc={rc}')
UUID = m.group(1)
print(f'[OK] tunnel UUID: {UUID}')
print('\n=== [3] write config.yml ===', flush=True)
config = f'''tunnel: {UUID}
credentials-file: /home/nonroot/.cloudflared/{UUID}.json
ingress:
'''
for h in HOSTNAMES:
config += f' - hostname: {h}\n service: {ORIGIN}\n'
config += ' - service: http_status:404\n'
# Write via heredoc
HERE = "'EOF_CONFIG'"
out, err, rc = run(f"cat > {APPDATA}/config.yml <<{HERE}\n{config}\nEOF_CONFIG")
run(f'chown 65532:65532 {APPDATA}/config.yml')
out, _, _ = run(f'cat {APPDATA}/config.yml')
print(out.rstrip())
print('\n=== [4] DNS cutover (A -> CNAME) ===', flush=True)
tunnel_target = f'{UUID}.cfargotunnel.com'
for h in HOSTNAMES:
# Find existing record
r = cfapi('GET', f'/zones/{ZONE}/dns_records?name={h}')
if not r.get('success') or not r['result']:
print(f' [SKIP] {h}: no record found')
continue
rec = r['result'][0]
print(f' [{h}] current: type={rec["type"]} content={rec["content"]} proxied={rec["proxied"]} id={rec["id"]}')
if rec['type']=='CNAME' and rec['content']==tunnel_target:
print(f' already pointing at tunnel, skipping')
continue
# Delete
d = cfapi('DELETE', f'/zones/{ZONE}/dns_records/{rec["id"]}')
if not d.get('success'):
print(f' [FAIL delete] {d.get("errors")}')
continue
# Create CNAME
body = {'type':'CNAME','name':h,'content':tunnel_target,'proxied':True,'ttl':1}
cr = cfapi('POST', f'/zones/{ZONE}/dns_records', body)
if cr.get('success'):
print(f' [OK] -> CNAME {tunnel_target} proxied')
else:
print(f' [FAIL create] {cr.get("errors")}')
print('\n=== [5] start persistent cloudflared ===', flush=True)
run('docker rm -f cloudflared 2>&1')
START = (
'docker run -d --name cloudflared --restart=unless-stopped '
f'-v {APPDATA}:/home/nonroot/.cloudflared '
'cloudflare/cloudflared:latest '
'tunnel --config /home/nonroot/.cloudflared/config.yml run'
)
out, err, rc = run(START)
print(out.rstrip())
if err.strip(): print(f'[stderr] {err.rstrip()}')
print('\n=== [6] wait for tunnel connections ===', flush=True)
for i in range(20):
time.sleep(3)
out, _, _ = run('docker logs cloudflared 2>&1 | tail -30')
conns = out.count('Registered tunnel connection')
print(f' [try {i+1}] connections registered: {conns}')
if conns >= 4:
print(out.rstrip()[-800:])
break
print('\n=== [7] verify externally ===', flush=True)
finally:
c.close()
# Run external curl from this workstation
print('\n[EXTERNAL CHECK]', flush=True)
for h in HOSTNAMES:
try:
req = urllib.request.Request(f'https://{h}/', method='HEAD',
headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0'})
with urllib.request.urlopen(req, timeout=15) as r:
print(f' {h}: HTTP {r.status}')
except urllib.error.HTTPError as e:
print(f' {h}: HTTP {e.code}')
except Exception as e:
print(f' {h}: ERR {e}')
print(f'\n[DONE] tunnel UUID: {UUID}')
print(f'[DONE] config: {APPDATA}/config.yml')
print(f'[DONE] persistent container: cloudflared')

View File

@@ -0,0 +1,81 @@
"""Switch tunnel origin from http://172.16.3.10:80 to https://172.16.3.10:443.
Each ingress gets originRequest.originServerName=<hostname> so IX's Apache
serves the right vhost cert via SNI. noTLSVerify=true to tolerate cPanel's
self-signed or hostname-mismatch quirks (cloudflared still uses TLS).
"""
import socket
import paramiko
HOST, USER = "172.16.3.20", "root"
import subprocess as _sp, yaml as _y
PWD = _y.safe_load(_sp.run(["sops","-d","D:/vault/infrastructure/jupiter-unraid-primary.sops.yaml"],capture_output=True,text=True,timeout=30,check=True).stdout)["credentials"]["password"]
APPDATA = '/mnt/cache/appdata/cloudflared'
HOSTNAMES = ['azcomputerguru.com','analytics.azcomputerguru.com','community.azcomputerguru.com','radio.azcomputerguru.com']
socket.setdefaulttimeout(60)
c = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PWD, timeout=30, look_for_keys=False, allow_agent=False)
def run(cmd, to=60):
_, o, e = c.exec_command(cmd, timeout=to)
return o.read().decode('utf-8','replace'), e.read().decode('utf-8','replace'), o.channel.recv_exit_status()
# Read existing tunnel UUID from config
out, _, _ = run(f'grep "^tunnel:" {APPDATA}/config.yml')
UUID = out.split(':',1)[1].strip()
print(f'tunnel UUID: {UUID}')
config = f'''tunnel: {UUID}
credentials-file: /home/nonroot/.cloudflared/{UUID}.json
ingress:
'''
for h in HOSTNAMES:
config += (
f' - hostname: {h}\n'
f' service: https://172.16.3.10:443\n'
f' originRequest:\n'
f' originServerName: {h}\n'
f' noTLSVerify: true\n'
)
config += ' - service: http_status:404\n'
print('\n=== new config.yml ===')
print(config)
HEREDOC = "'EOF_CFG'"
out, err, rc = run(f"cat > {APPDATA}/config.yml <<{HEREDOC}\n{config}\nEOF_CFG")
run(f'chown 65532:65532 {APPDATA}/config.yml')
out, _, _ = run(f'cat {APPDATA}/config.yml')
print('=== written ===')
print(out)
print('\n=== restart cloudflared ===')
out, _, _ = run('docker restart cloudflared')
print(out.rstrip())
print('\n=== wait for reconnect ===')
import time
for i in range(15):
time.sleep(3)
out, _, _ = run('docker logs cloudflared 2>&1 | tail -30')
conns = out.count('Registered tunnel connection')
print(f' [try {i+1}] registered: {conns}')
if conns >= 4: break
print('\n=== external HEAD probes ===')
c.close()
# External test from this workstation
import urllib.request, urllib.error
for h in HOSTNAMES:
try:
req = urllib.request.Request(f'https://{h}/', method='HEAD',
headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0'})
with urllib.request.urlopen(req, timeout=15) as r:
server = r.headers.get('Server','-')
print(f' {h}: HTTP {r.status} Server={server}')
except urllib.error.HTTPError as e:
print(f' {h}: HTTP {e.code}')
except Exception as e:
print(f' {h}: ERR {e}')

View File

@@ -0,0 +1,25 @@
"""Launch login in detached mode, container persists independent of SSH."""
import paramiko, socket
HOST, USER = "172.16.3.20", "root"
import subprocess as _sp, yaml as _y
PWD = _y.safe_load(_sp.run(["sops","-d","D:/vault/infrastructure/jupiter-unraid-primary.sops.yaml"],capture_output=True,text=True,timeout=30,check=True).stdout)["credentials"]["password"]
APPDATA = '/mnt/cache/appdata/cloudflared'
SCRIPT = f'''
docker rm -f cf-login 2>/dev/null
docker run -d --name cf-login \\
-v {APPDATA}:/home/nonroot/.cloudflared \\
cloudflare/cloudflared:latest tunnel login
sleep 4
echo "=== logs ==="
docker logs cf-login 2>&1
'''
socket.setdefaulttimeout(60)
c = paramiko.SSHClient(); c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PWD, timeout=30, look_for_keys=False, allow_agent=False)
_, o, e = c.exec_command(SCRIPT, timeout=90)
print(o.read().decode('utf-8','replace').rstrip())
print(e.read().decode('utf-8','replace').rstrip())
c.close()

View File

@@ -0,0 +1,71 @@
"""pfSense diagnostic for azcomputerguru.com 521 — suspected CF IP blocks.
Runs a single SSH session with batched diagnostics targeted at identifying
why Cloudflare PHX PoP can't reach 72.194.62.5:443.
"""
import paramiko, socket
socket.setdefaulttimeout(60)
HOST = '172.16.0.1'
PORT = 2248
USER = 'admin'
import subprocess as _sp, yaml as _y
PWD = _y.safe_load(_sp.run(['sops','-d','D:/vault/infrastructure/pfsense-firewall.sops.yaml'],capture_output=True,text=True,timeout=30,check=True).stdout)['credentials']['password']
CMDS = [
('installed packages (IDS/IPS/blocker)',
'pkg info 2>/dev/null | egrep -i "suricata|snort|pfblocker|crowdsec" || echo "(none)"'),
('NAT rules for 72.194.62.5 / port 443',
'pfctl -s nat 2>/dev/null | grep -E "72\\.194\\.62\\.5|443" | head -30 || echo "(pfctl nat empty)"'),
('Rules in PF referencing .62.5',
'pfctl -sr 2>/dev/null | grep "72\\.194\\.62\\.5" | head -20 || echo "(none)"'),
('PF aliases referencing Cloudflare (case-insensitive)',
'pfctl -T show -a cloudflare 2>/dev/null | head -30 ; pfctl -sT 2>/dev/null | grep -i "cloudflare\\|cf_\\|_cf"'),
('Recent filter.log entries mentioning 72.194.62.5 (last 200 binary-decoded)',
'clog /var/log/filter.log | tail -2000 | grep "72\\.194\\.62\\.5" | tail -40 || echo "(no recent entries)"'),
('Recent BLOCK actions from filter.log (last 500 lines)',
'clog /var/log/filter.log | tail -500 | grep -E "block|reject" | head -40 || echo "(no blocks)"'),
('Current states for :443 dst (limit 15)',
'pfctl -s states 2>/dev/null | awk \'$6 ~ /:443$/\' | head -15 || echo "(no :443 states)"'),
('State table total count',
'pfctl -s info 2>/dev/null | grep -i "states\\|limit\\|current" | head -10'),
('Suricata status + alert log if installed',
'service suricata status 2>/dev/null ; ls -la /var/log/suricata/ 2>/dev/null | head'),
('pfBlockerNG log if installed',
'ls -la /var/log/pfblockerng/ 2>/dev/null | head ; cat /var/log/pfblockerng/block.log 2>/dev/null | tail -30'),
('IP reputation / GeoIP blocks on WAN',
'pfctl -sr 2>/dev/null | grep -iE "geoip|pfblocker|block in" | head -20'),
('Last 30 dropped packets to :443 (any dst)',
'clog /var/log/filter.log | tail -2000 | grep -E "port 443" | grep -E "block|reject" | tail -30 || echo "(none)"'),
]
def main():
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, port=PORT, username=USER, password=PWD,
timeout=30, banner_timeout=30, look_for_keys=False, allow_agent=False)
try:
for label, cmd in CMDS:
print(f'\n===== {label} =====', flush=True)
stdin, stdout, stderr = c.exec_command(cmd, timeout=60)
out = stdout.read().decode('utf-8','replace')
err = stderr.read().decode('utf-8','replace')
if out.strip(): print(out.rstrip())
if err.strip() and 'stty' not in err and 'terminal' not in err.lower():
print(f' [stderr] {err.rstrip()[:300]}')
finally:
c.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,65 @@
"""pfSense deeper diag — read filter log + check inbound 443 to 172.16.3.10."""
import paramiko, socket
socket.setdefaulttimeout(60)
HOST, PORT, USER = "172.16.0.1", 2248, "admin"
import subprocess as _sp, yaml as _y
PWD = _y.safe_load(_sp.run(["sops","-d","D:/vault/infrastructure/pfsense-firewall.sops.yaml"],capture_output=True,text=True,timeout=30,check=True).stdout)["credentials"]["password"]
CMDS = [
('clog binary locations',
'which clog 2>/dev/null; ls /usr/local/sbin/clog* /usr/sbin/clog* /sbin/clog* 2>/dev/null; pkg info clog 2>/dev/null | head -3'),
('filter log type + size',
'file /var/log/filter.log 2>/dev/null; ls -la /var/log/filter.log'),
('Try to read filter.log as text',
'tail -50 /var/log/filter.log | grep -v "^$" | tail -30'),
('Inbound :443 -> 172.16.3.10 states (right now)',
'pfctl -s states | grep "172.16.3.10:443\\|-> 172.16.3.10" | grep "443" | head -30'),
('Inbound :443 states total count',
'pfctl -s states | grep "172.16.3.10:443" | wc -l; pfctl -s states | grep ":443.*172\\.16\\.3\\.10" | wc -l'),
('State count broken out by direction',
'pfctl -s states | awk \'/172\\.16\\.3\\.10/ {print $0}\' | head -20'),
('Cloudflare PHX IPs sample (CF publishes these)',
'curl -s -m 10 https://www.cloudflare.com/ips-v4 2>/dev/null | head -5; echo "---"; curl -s -m 10 https://www.cloudflare.com/ips-v4 2>/dev/null | wc -l'),
('Test-send a SYN from pfSense to known CF edge IP (simulate return path)',
'nc -z -v -w 3 162.158.0.1 443 2>&1; echo "---"; nc -z -v -w 3 104.26.8.237 443 2>&1'),
('Check WAN interface health',
'ifconfig igc0 | grep -E "inet |status"; echo "---"; netstat -rn | grep default'),
('Recently-logged DROP/BLOCK (pf log format 5)',
'tcpdump -n -e -ttt -r /var/log/filter.log 2>&1 | head -30 || echo "(tcpdump cant read binary)"'),
('Try pfSsh.php for log',
'echo "exec;tail -30 /var/log/filter.log" | pfSsh.php 2>&1 | tail -40'),
('PF filter log read alt (pfctl loginterface / pflog0 dump)',
'tcpdump -n -e -ttt -i pflog0 -c 20 2>&1 | head -40 || echo "(no pflog0)"'),
]
def main():
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, port=PORT, username=USER, password=PWD,
timeout=30, banner_timeout=30, look_for_keys=False, allow_agent=False)
try:
for label, cmd in CMDS:
print(f'\n===== {label} =====', flush=True)
stdin, stdout, stderr = c.exec_command(cmd, timeout=60)
out = stdout.read().decode('utf-8','replace')
err = stderr.read().decode('utf-8','replace')
if out.strip(): print(out.rstrip())
if err.strip() and 'stty' not in err and 'terminal' not in err.lower():
print(f' [stderr] {err.rstrip()[:300]}')
finally:
c.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,42 @@
"""Confirm CF origin-pull IP range unreachable from pfSense WAN."""
import paramiko, socket
socket.setdefaulttimeout(60)
HOST, PORT, USER = "172.16.0.1", 2248, "admin"
import subprocess as _sp, yaml as _y
PWD = _y.safe_load(_sp.run(["sops","-d","D:/vault/infrastructure/pfsense-firewall.sops.yaml"],capture_output=True,text=True,timeout=30,check=True).stdout)["credentials"]["password"]
CMDS = [
('traceroute to 162.158.0.1 (CF origin-pull range)',
'traceroute -n -w 3 -m 12 162.158.0.1 2>&1 | head -20'),
('traceroute to 104.26.8.237 (CF client-facing, known working)',
'traceroute -n -w 3 -m 12 104.26.8.237 2>&1 | head -20'),
('traceroute to 172.67.72.147 (CF edge, working)',
'traceroute -n -w 3 -m 12 172.67.72.147 2>&1 | head -20'),
('More CF origin-pull IPs via nc',
'for ip in 162.158.0.1 162.158.100.1 162.158.200.1 162.159.0.1 162.159.100.1 108.162.192.1 108.162.250.1; do printf "%-16s " "$ip"; nc -z -v -w 3 $ip 443 2>&1 | head -1; done'),
('Route table: do we have a specific route for 162.158?',
'netstat -rn -f inet | grep -E "^162\\.|^default" | head -10'),
('BGP / gateway status',
'pfSsh.php playback gatewaystatus 2>&1 | head -20 || echo "(no playback)"; cat /tmp/gw_status 2>/dev/null | head -20'),
]
def main():
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, port=PORT, username=USER, password=PWD,
timeout=30, banner_timeout=30, look_for_keys=False, allow_agent=False)
try:
for label, cmd in CMDS:
print(f'\n===== {label} =====', flush=True)
stdin, stdout, stderr = c.exec_command(cmd, timeout=90)
out = stdout.read().decode('utf-8','replace')
err = stderr.read().decode('utf-8','replace')
if out.strip(): print(out.rstrip())
if err.strip() and 'stty' not in err:
print(f' [stderr] {err.rstrip()[:300]}')
finally:
c.close()
if __name__ == '__main__':
main()