Skip to content

Running a shared instance

Everything here is about the same situation: 3270Web on a port more than one person can reach. On a laptop almost none of it applies — the controls below are off until you turn them on, and the two that are always on (data separation and the audit trail) have nothing to do on an instance with one user.

Start with accounts — without them there is only one identity, and nothing below has anybody to attribute anything to. Then work down this page: what people are separated from, what the connection itself exposes, what an automated client may do, which hosts the terminal may be pointed at, and what is written down.

Control Default What it decides
AUTH_MODE none Whether there are accounts at all, and whether an identity provider issues them
Assigned hosts everyone Which mainframes each account is offered, and the screen it chooses from
Session and data separation on with accounts What one account can see of another's
TRUST_PROXY_HEADERS off Whether cookies are marked Secure behind a TLS proxy
TLS_TERMINATED_UPSTREAM off Same, for an edge that forwards no headers
API tokens What an automated client may reach
ALLOWED_HOSTS unset Which mainframes the terminal may be pointed at
RATE_LIMIT_* see below How fast one caller may use what the instance pays for
The audit trail always on What is recorded, and who may read it
DATA_DIR beside the program Where accounts and everyone's work are kept

Keeping the state

Accounts, API tokens, the audit trail and everyone's saved work are files. By default they sit beside the program, which is right for a desktop install and wrong for a container: the image is replaced on every deploy.

In Docker, keep them in a folder on the host. The published image sets DATA_DIR=/data, and the compose file bind-mounts a folder beside itself:

services:
  3270Web:
    image: ghcr.io/3270io/3270web:latest
    environment:
      - AUTH_MODE=local
    volumes:
      - ./data:/data

A host folder rather than a named volume, so the accounts and the audit trail can be backed up, inspected and copied with ordinary tools instead of through docker volume.

Create it and hand it to the user the server runs as, before the first start:

mkdir -p ./data && sudo chown -R 10001:10001 ./data

A bind mount keeps the host's ownership — unlike a named volume, Docker does not adjust it — and the server runs unprivileged as uid 10001. Skip this and the container refuses to start, naming the directory and the chown. That is deliberate: the alternative is falling back to the image layer, where everything works until the deploy that silently deletes every account.

Without a data folder of some kind, docker compose pull && docker compose up -d — the ordinary way to take an upgrade — takes every account with it. The instance comes back with no accounts, which means it comes back in first-run setup, waiting for whoever reaches it first to claim it. The audit trail of what happened before the upgrade goes at the same time, and issued API tokens stop working.

DATA_DIR names the directory; the program's own directory keeps the binary and the web assets, which is why the folder is mounted somewhere else rather than over the top of them. The account and token CLIs read the same setting, so docker compose exec 3270Web /app/3270Web user add alice edits the accounts the running server is using.

Backing it up

It is a directory of small files, so a copy is a backup:

tar czf 3270web-$(date +%F).tar.gz -C ./data .

It holds password hashes and token hashes — no plaintext of either — plus the audit trail and everyone's saved work. Treat it as you would any file naming your users and the hosts they reach.


What is separated, and what is not

Terminal sessions are private to the account that opened them. Holding a session's ID is no longer enough to use it: every route resolves the session through one ownership check, and a session belonging to somebody else is reported as not found rather than refused, so the difference cannot be used to discover which IDs are real. This includes administrators — administering the instance means managing settings, logs and accounts, not typing into another person's authenticated terminal.

Each account is also capped at MAX_SESSIONS_PER_USER concurrent sessions (default 6), with MAX_TOTAL_SESSIONS (default 64) bounding the instance. Every session is an s3270 subprocess, so these are process limits rather than tidiness.

Instance-wide administration — settings, logs, restart and account management — requires the admin role.

Chaos runs, hints and saved tasks are private too. A saved run holds captured screens and the field values that produced them — a record of a real application's contents — so each account keeps its own. Under AUTH_MODE=local these live in users/<account-id>/ beneath the data directory, with published files in shared/. A single-operator instance keeps the flat layout it always had.

Connection profiles and themes are shared, deliberately. Everyone connects to the same mainframes, so a host list only has to be entered once. An administrator publishes a profile or theme with the Share with everyone option; everybody sees it, and only an administrator can change or remove it.

Saving without that option gives you your own copy. If your copy has the same name as a published one, yours is what you see — the same way overriding a setting works — and everybody else keeps the published version.

Saved tasks are private, like chaos runs. A task is a recorded procedure somebody is working on rather than infrastructure the team shares.

When authentication is switched on for an existing instance, the migration follows the same reasoning: connection profiles and themes become the published set so nobody loses the host list they were using, while chaos runs, hints and saved tasks go to the first administrator.

API tokens belong to accounts too — see API tokens below. A token reaches exactly what its owner reaches and nothing else, so an automated client is no way around any of the above.

For genuine separation between people who should not see each other's work at all, run one container and one volume per user.


Running without TLS

3270Web does not terminate TLS itself. On a private network many deployments run it over plain HTTP, which is workable but has consequences worth stating plainly.

Anyone who can observe traffic on the network segment can read the password as it is submitted, and can copy the session cookie afterwards. No setting changes that. The sign-in page says so when the connection is not encrypted.

Two controls reduce what a copied cookie is worth:

  • Address pinning (AUTH_BIND_SESSION_IP, on by default without TLS) refuses a session presented from a different address, so a cookie captured passively cannot simply be replayed from the attacker's own machine.
  • Short lifetimes bound how long a captured cookie stays valid.

Neither makes plain HTTP equivalent to TLS. An attacker positioned on the path — rather than merely listening — can still work around both.

If you can put a TLS-terminating reverse proxy in front, do; it is the single largest improvement available. Then set:

TRUST_PROXY_HEADERS=true

so 3270Web reads X-Forwarded-Proto and X-Forwarded-For, marks its cookies Secure, and sees real client addresses instead of the proxy's. Only enable it behind a proxy you control: those headers are set by whoever sends the request, so a directly-reachable instance would let any client assert its own connection is secure and choose its own apparent address.

When the sign-in page still says the connection is not encrypted

Behind a CDN or a reverse proxy that terminates TLS, the hop into 3270Web is plain HTTP. That is normal and fine — but the server cannot see the hop the browser actually made, so until it is told, it assumes the worst and says so.

The sign-in page distinguishes two cases:

  • Something in front says HTTPS and this server has not been told to believe it. X-Forwarded-Proto: https arrived, which most edges send. Set TRUST_PROXY_HEADERS=true and restart. This is the answer for almost every deployment, including one behind a CDN.
  • Nothing says anything. No forwarding headers arrive at all, which is the shape of a tunnel daemon dialling out to the edge, or a sidecar. There is no header to trust, so the assertion has to be made directly:
TLS_TERMINATED_UPSTREAM=true

This says "the browser reached the edge over HTTPS" for every request, no matter what the headers say. It marks cookies Secure, turns off the plain-HTTP default for address pinning, and removes the warning.

Both are claims about the deployment that 3270Web cannot check, so make them only when they are true. TLS_TERMINATED_UPSTREAM in particular fails loudly if it is not: cookies are minted Secure, a browser on plain HTTP refuses to store them, and nobody can sign in. That is the intended direction for this kind of mistake to break in — the alternative is an instance that looks fine and is not.

Neither setting is a way to silence the warning on an instance that really is served over plain HTTP. There, the warning is true, and the fix is TLS.


API tokens

Automated clients — CI jobs, RPA bots, AI clients over MCP — authenticate with a Bearer token rather than a password.

Which token depends on whether the instance has accounts:

Credential Reaches
Single operator (AUTH_MODE=none) the API_TOKEN environment variable everything, because there is one person
Accounts (AUTH_MODE=local or oidc) a token issued to an account exactly what that account reaches

An account that signs in through an identity provider issues tokens the same way. The token is this instance's own credential — the provider is asked who somebody is when they sign in, not on every API call — so an automated client keeps working without a browser round trip, and stops the moment the account is disabled or deleted here.

With accounts on, API_TOKEN is refused at startup. One credential held by every client would reach every account's sessions, which is the thing the mode was turned on to prevent; starting anyway would leave you believing users were separated while one environment variable said otherwise.

Issuing one

3270Web token add alice "ci pipeline"
3270Web token add alice scraper --read-only
3270Web token add alice deploy --expires 720h
3270Web token list
3270Web token list alice
3270Web token revoke 3f1c8a24b90de7c5
3270Web token revoke-all alice

Inside a container:

docker compose exec 3270Web /app/3270Web token add alice "ci pipeline"

The token is printed once, when it is issued:

issued 3f1c8a24b90de7c5 for alice (read+write)

  3270w_3f1c8a24b90de7c5_kzq4…

This is the only time the token is shown.

Only a hash is stored, so a copy of the token file yields nothing usable — and a lost token is replaced rather than recovered. The 3270w_ prefix is there so a leaked credential is recognisable to a secret scanner.

Scopes

--read-only issues a token that can read but not change anything: it may fetch screens, list sessions and read catalogues, but not type into a field, press a key, or open or close a session. Anything that changes state is refused with 403.

Scope follows the HTTP method — GET, HEAD and OPTIONS are reads, everything else is a write. MCP over HTTP is a POST for every tool call, including read-only ones, so an MCP client needs a full token.

Lifetime

A token works until it is revoked, until --expires passes, or until its account is disabled or deleted — the owner is looked up on every call, so disabling somebody stops their automated clients at the same moment it stops them signing in. Re-enabling the account brings its tokens back rather than making everything be reissued.

Refusals are deliberately identical whether a token is unknown, revoked or expired. Saying which would confirm that a presented token is real.

With MCP

An AI client that launches 3270Web mcp itself cannot sign in, so on an instance with accounts it needs a token and an explicit URL:

3270Web mcp --url http://127.0.0.1:3270 --token "$MY_TOKEN"

Tool calls then act as that account: list_sessions shows its sessions, and use_session reaches its sessions only.


Limiting what an instance can reach

A 3270 terminal is a client for arbitrary TCP. Whoever can open a session can point it at anything the server can reach, which on a hosted instance means the terminal is a route into the network it sits in.

ALLOWED_HOSTS fences that:

ALLOWED_HOSTS=*.mainframe.corp.example,10.20.30.*

Comma-separated shell globs, matched against the host part so the port does not have to be written out. It applies on every path — the connect form, the tab bar, the REST API, workflow playback, and MCP — because a fence with one gate open is not a fence. A refusal is recorded in the audit trail.

Unset means unrestricted. That is the historical behaviour and the right default for a laptop or a lab; an allowlist you must configure before the product works at all is one people switch off.

Two things are deliberately outside it. Hostname validity is a separate, always-on check that refuses loopback, link-local and the unspecified address — those are never dialled on a caller's behalf, allowlist or not. And the bundled sample apps are exempt: they are this process talking to itself, and they already have their own switch in ALLOW_SAMPLE_APPS.

MCP_ALLOWED_HOSTS still exists and is now a narrower fence for AI clients specifically, on top of ALLOWED_HOSTS. A deployment can be willing to reach its whole estate from a browser while letting a model near only the test LPAR.


Rate limits

A handful of routes cost the instance something rather than the caller: opening a session starts a subprocess, chaos exploration presses keys at a mainframe unattended, a transfer moves a file, AI chat spends an upstream quota. The AI control plane costs something less obvious: each of those requests makes this server fetch from an endpoint the caller nominated and read the answer into memory, so a flood of them is a flood of allocations this process pays for.

Variable Default Applies to
RATE_LIMIT_CONNECT 20/min Opening a session, on every path
RATE_LIMIT_CHAOS 10/min Starting or resuming chaos exploration
RATE_LIMIT_TRANSFER 20/min IND$FILE send and receive
RATE_LIMIT_AI 60/min The AI chat endpoint
RATE_LIMIT_AICONTROL 120/min Signing in to an AI provider, polling that sign-in, listing its models, naming the endpoint they go to

Counted per account — per address where there are no accounts — so one busy person cannot throttle everybody, and nobody gets a fresh allowance by opening another tab. 0 turns a limit off.

RATE_LIMIT_AICONTROL is higher than the rest because one of the routes it covers is a poll: the device sign-in flow asks every few seconds whether the code has been entered yet, for up to fifteen minutes.

The defaults are generous on purpose. These exist to stop a runaway loop or a deliberate flood, not to pace ordinary work: a limit that honest use runs into is a limit somebody removes entirely.

Everything else is unlimited. Reading a screen is cheap and constant, and a general request limiter would be the change that gets the whole idea thrown out. Signing in has its own throttle, which is a different problem — that one is about guessing, not about cost.


The audit trail

Sign-ins, sessions and changes to the instance are recorded in a file of their own, separate from the debug log. An administrator reads it at /admin/audit, or downloads it whole.

The debug log is for diagnosing the server: verbose, written by whatever code happens to be running, and its wording changes whenever a message reads badly. That does not suit the question an audit answers — who opened a session against that host, and when — which has to survive being asked months later.

What is recorded

Event Recorded with
Sign-in succeeded, failed, or throttled account, address, and why it failed
Sign-out, password changed account, address
First administrator created, or a wrong setup code address
Account created, changed, deleted who did it, and to whom
Group role assigned or cleared who did it, the group, the role it now grants
Group created, changed, deleted who did it, the group, and what changed — its name, description, membership or hosts
Host preset published or removed who did it, the preset, who it is offered to
Library exported or imported who did it, how many tasks and presets, and which host list they went into
API token issued, revoked, or refused token id, account, scopes
Session opened, or refused account, target host, and why it was refused
Session disconnected by an administrator who did it, whose session, target host
File transfer direction and the host-side dataset name
Settings changed which keys — never their values
Server restarted, log access changed who did it

A refused sign-in is recorded with the real reason — a disabled account is distinguished from a wrong password — even though the reply to the browser never says which. The person at the keyboard must not learn which usernames exist; the administrator reading the trail is entitled to know.

What is never recorded

Passwords, tokens, screen contents, and the values typed into fields. Settings appear as the keys that changed, because one of them holds a keyfile password. The file is read by every administrator and may be shipped elsewhere, so anything written to it is disclosed for as long as it exists.

Successful API calls are not recorded either — only refused ones. A line per request would turn the trail into an access log and bury everything else in it. The exceptions are the calls that are administration rather than use: a library export is the moment a copy of the host list leaves the instance, and a library import is a bulk change to what operators are offered, so both are recorded whichever door they came in by.

The file

One JSON object per line, appended, mode 0600, at audit.log beside the account store (AUDIT_LOG_PATH moves it). It can be read with the tools you already have:

jq -r 'select(.event == "session.opened") | [.time, .actor.username, .target] | @tsv' audit.log

It rolls over at 8 MiB, keeping one previous generation — enough that a restart or a busy afternoon does not lose the morning. A deployment that needs real retention should ship the lines somewhere else; two files on the same disk are a bound on size, not an archive.

There is no switch to turn it off. A trail somebody can disable before acting is not a trail — which is also why the file is admin-readable but the ALLOW_LOG_ACCESS toggle that gates the debug log does not apply to it, and why turning that toggle on is itself recorded here.

Writing is best-effort: if the disk fills, the event is lost and the failure goes to the debug log, but the request still succeeds. An audit that can refuse a sign-in because a disk filled up is a denial of service dressed as a safeguard.


Checking it works

The Go tests cover each control on this page. What they cannot cover is whether the parts compose in a browser against a real server, so there is a script that walks the whole thing:

AUTH_MODE=local ALLOW_SAMPLE_APPS=1 go run ./cmd/3270Web    # another terminal
node scripts/check-multi-user.mjs --code EJWQ-RUYN-7XL3-PT3O

Run it against a fresh data directory — it starts from an instance with no accounts. It completes first-run setup, creates a second account, signs in as that account and is made to choose a new password, opens a terminal, confirms the account is refused administration and cannot use the other's session, and checks the trail recorded all of it. It names whichever step fails and leaves screenshots behind.