plain.portal
Remote Python shell and file transfer via encrypted tunnel.
- Overview
- Quick start
- CLI reference
- How it works
- Read-only mode
- File transfer
- Output
- Security model
- Platform compatibility
- Relay server
- Activity log
- Installation
Overview
Plain Portal opens a Python shell on a remote machine -- production dyno, container, VM -- and lets you send commands to it from your local terminal through an encrypted, ephemeral tunnel. It also supports transferring files in both directions through the same connection.
Both sides make outbound WebSocket connections to a relay server, find each other via a short human-readable code, establish end-to-end encryption, and communicate through the tunnel. The remote side runs Python with the full app loaded and database connected.
Why not just SSH or heroku run?
heroku runper command has 10-30s cold start and no state between invocations.- SSH and port forwarding are platform-specific and configured differently on every provider.
- An HTTP debug endpoint adds public attack surface to production.
- A direct database connection loses app context (environment, services, model layer) and risks code version mismatch.
Portal requires only outbound internet access on both sides. No firewall rules, no SSH keys, no exposed ports.
Quick start
1. Start a session on the remote machine (via whatever mechanism your platform provides):
$ heroku run plain portal start
Portal code: 7-crossword-pineapple
Session mode: read-only
Waiting for connection...
2. Connect from your local machine:
$ plain portal connect 7-crossword-pineapple
Connected to remote. Session active.
3. Run commands through the tunnel:
$ plain portal exec "User.query.count()"
-> 4827
$ plain portal exec "User.query.filter(active=False).count()"
-> 312
4. Disconnect when done:
$ plain portal disconnect
Portal session disconnected.
CLI reference
plain portal start
Start a portal session on the remote machine. Connects to the relay, prints a portal code, and waits for a local client to connect.
$ plain portal start
$ plain portal start --writable
$ plain portal start --timeout 60
| Option | Description | Default |
|---|---|---|
--writable |
Allow database writes (prompts for confirmation) | Off (read-only) |
--timeout |
Idle timeout in minutes (0 to disable) | 30 |
plain portal connect <code>
Connect to a remote portal session. Establishes the encrypted tunnel and backgrounds itself.
$ plain portal connect 7-crossword-pineapple
$ plain portal connect 7-crossword-pineapple --foreground
| Option | Description | Default |
|---|---|---|
--foreground |
Run in foreground instead of backgrounding | Off |
plain portal exec <code>
Execute Python code on the remote machine. Requires an active connection.
$ plain portal exec "User.query.count()"
$ plain portal exec --json "User.query.filter(active=False).values('email')[:5]"
| Option | Description | Default |
|---|---|---|
--json |
Serialize the return value as JSON | Off |
--timeout |
Execution timeout in seconds | 120 |
plain portal pull <remote_path> <local_path>
Pull a file from the remote machine to your local machine.
$ plain portal pull /tmp/export.csv ./export.csv
Pulled /tmp/export.csv -> ./export.csv (12400 bytes)
plain portal push <local_path> <remote_path>
Push a file from your local machine to the remote machine. Writes are restricted to /tmp/ on the remote side.
$ plain portal push ./fix.py /tmp/fix.py
Pushed ./fix.py -> /tmp/fix.py (892 bytes)
plain portal disconnect
Kill the background daemon and clean up the local session.
plain portal status
Show whether a portal session is active and its process ID.
How it works
Production (heroku run, fly ssh, kubectl exec, etc.) Local machine
+----------------------+ +------------------+
| plain portal start |----- outbound WSS ----> | |
| | +--------+ | plain portal |
| Python REPL with |<=== encrypted msgs ==>| Relay |<| connect |
| app loaded, DB | | Server | | 7-crossword- |
| connected | +--------+ | pineapple |
+----------------------+ +------------------+
- The remote side runs
plain portal start-- connects to the relay via WebSocket and prints a short code (e.g.7-crossword-pineapple). - The local side runs
plain portal connect <code>-- connects to the same relay with the matching code. - Both sides perform a SPAKE2 key exchange through the relay, deriving a shared secret from the human-readable code.
- All subsequent messages are encrypted with NaCl SecretBox (XSalsa20-Poly1305). The relay never sees plaintext.
- The local side sends commands (
exec,pull,push) through the persistent tunnel. - Each
execruns in a fresh Python namespace -- no state carries over between commands. - Both sides send periodic keepalive pings every 30 seconds to keep the WebSocket connection alive through proxies and NATs.
- The session stays alive until
plain portal disconnector the remote process exits.
Connection model
The local side uses a background daemon and Unix socket:
plain portal connect <code>establishes the WebSocket connection, performs the key exchange, then forks into the background and listens on a Unix socket (/tmp/plain-portal.sock).exec,pull, andpushconnect to the local Unix socket, send a request through the tunnel, and print the response.plain portal disconnectkills the background process and cleans up the socket.
The tunnel stays open across commands, but each exec gets a fresh Python namespace on the remote side. If you need setup code, put it all in one code block. Users who want a stateful interactive REPL should use plain shell directly on the remote machine.
Encryption
- Key exchange: SPAKE2 -- a password-authenticated key exchange that derives a shared secret from the human-readable portal code. An eavesdropper observing the relay traffic cannot brute-force the code offline.
- Message encryption: NaCl SecretBox (XSalsa20-Poly1305) -- every message after the handshake is encrypted with the shared secret.
- Channel ID: The portal code is never sent to the relay. A SHA-256 hash of the code is used as the channel ID for pairing. The raw code is only used locally for SPAKE2.
- Libraries:
spake2andpynacl.
Read-only mode
By default, the remote session enforces a read-only database connection. Any INSERT, UPDATE, DELETE, or DDL statement raises a database error.
$ plain portal start
To allow writes, pass --writable. This prompts for confirmation before starting:
$ plain portal start --writable
This session allows writes to the production database. Continue? [y/N]
Read-only mode only restricts database writes. Code can still read the filesystem, call external APIs, and perform other non-database operations -- just like a normal shell session.
File transfer
Pull files from production or push files to it through the same encrypted tunnel.
# Pull a file from the remote machine
$ plain portal pull /tmp/export.csv ./export.csv
# Push a file to the remote machine (restricted to /tmp/)
$ plain portal push ./fix_data.py /tmp/fix_data.py
Generate a file remotely, then pull it
$ plain portal exec "
import csv
qs = User.query.filter(active=False)
with open('/tmp/inactive.csv', 'w') as f:
writer = csv.writer(f)
writer.writerow(['email', 'date_joined'])
for u in qs.values_list('email', 'date_joined'):
writer.writerow(u)
"
$ plain portal pull /tmp/inactive.csv ./inactive.csv
Push a script and run it
$ plain portal push ./backfill.py /tmp/backfill.py
$ plain portal exec "exec(open('/tmp/backfill.py').read())"
Limits
- Max file size: 50 MB per transfer. Files are chunked into 256 KB messages so individual WebSocket frames stay small.
- Push destination:
pushonly writes to/tmp/on the remote side. Attempts to write outside/tmp/are rejected. --writableis independent:pushalways works regardless of read-only mode. Pushing a script to/tmp/and running it read-only is a valid workflow.
Output
Streaming stdout
Stdout streams line-by-line in real time through the tunnel. Output from print() statements and other writes to stdout/stderr appears on the local side as it is produced, not buffered until the command finishes.
$ plain portal exec "
import time
for i in range(5):
print(f'Step {i}...')
time.sleep(1)
"
Step 0...
Step 1...
Step 2...
Step 3...
Step 4...
Each line appears one second apart, as produced on the remote side.
Exec timeout
Each exec has a timeout (default: 120 seconds). If the code runs longer, the command is interrupted and an error is returned. Override the timeout for long-running operations:
$ plain portal exec --timeout 300 "run_slow_migration()"
The timeout is per-command, not per-session. Set it to 0 to disable.
Output truncation
Return values over 1 MB are truncated. If you need to extract large data, write it to a file on the remote side and use plain portal pull to transfer it.
JSON mode
Pass --json to get structured output for scripting and automation:
$ plain portal exec --json "User.query.count()"
{"stdout": "", "return_value": "4827", "error": null}
When --json is set, the return value is serialized with json.dumps instead of repr. If JSON serialization fails, it falls back to repr.
Human-readable mode
Without --json, output follows a human-readable format:
# Return value only
$ plain portal exec "User.query.count()"
-> 4827
# Stdout from print statements
$ plain portal exec "for u in User.query.all()[:3]: print(u.email)"
[email protected]
[email protected]
[email protected]
# Both stdout and return value
$ plain portal exec "
print('checking...')
User.query.filter(active=False).count()
"
checking...
-> 312
# Tracebacks on errors
$ plain portal exec "1/0"
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
Security model
The portal does not add its own authorization layer. Security comes from three boundaries:
- Platform access -- You need
heroku run,kubectl exec, SSH, or equivalent access to start the remote side. If you can do that, you already have full shell access. - Portal code -- Short-lived, randomly generated (
number-word-wordformat, ~20 bits of entropy). SPAKE2 prevents an eavesdropper from brute-forcing the code offline. - End-to-end encryption -- The relay server never sees message contents. All traffic is encrypted with NaCl SecretBox.
The portal is intentionally unrestricted once connected -- it can run any Python code, just like plain shell. The access control question is "can you start the remote process?" If you can, you already have full access anyway.
--writable controls database write access only, not general code execution.
Platform compatibility
Portal works anywhere you can run a process with outbound internet access:
| Platform | How to start the remote side |
|---|---|
| Heroku | heroku run plain portal start |
| Fly.io | fly ssh console -C "plain portal start" |
| Kubernetes | kubectl exec -it deploy/app -- plain portal start |
| Docker | docker exec -it container plain portal start |
| Any VM/server | ssh myserver plain portal start |
On the local side, run plain portal connect <code> in your normal terminal. No special setup needed.
Relay server
The relay server is hosted at portal.plainframework.com. It runs on Cloudflare Workers with Durable Objects.
The relay is minimal:
- Each portal session is one Durable Object holding two WebSocket connections.
- The first connection arrives and waits. The second connection with the same channel ID arrives and they are paired.
- The relay forwards encrypted bytes between the two connections.
- When either side disconnects, the session is cleaned up.
The relay never sees plaintext. It has no knowledge of what commands are being run or what files are being transferred.
The relay host can be overridden with the PLAIN_PORTAL_RELAY_HOST environment variable.
Activity log
The remote side logs every command to its terminal as it arrives. The person who started the portal session sees the full activity in real time:
Portal code: 7-crossword-pineapple
Session mode: read-only
Waiting for connection...
[14:32:01] Connected from remote client.
[14:32:05] exec: User.query.count()
-> 4827
[14:32:12] exec: User.query.filter(active=False).values('email')[:5]
-> [{'email': '[email protected]'}, ...]
[14:33:01] pull: /tmp/export.csv
sending export.csv (12400 bytes, 1 chunks)
[14:35:44] push: /tmp/fix.py (1 chunks)
received 892 bytes
[14:50:01] Client disconnected.
This is important for the support use case: a customer running a self-hosted app can start a portal and share the code with the developer. The developer connects and debugs, but the customer watches the full session on their terminal. They see every command executed and every file transferred, and can Ctrl-C to kill the session at any time.
The customer does not need to grant SSH access, open firewall ports, or share credentials. They run plain portal start, share the code, and supervise.
Idle timeout
The remote side auto-disconnects after 30 minutes of inactivity (no commands received). A warning is printed before disconnecting. The timeout is configurable:
$ plain portal start --timeout 60 # 60 minutes
$ plain portal start --timeout 0 # no timeout
Installation
Install from PyPI:
uv add plain.portal
No additional configuration is needed. The plain portal CLI commands are available immediately after installation.