SSH port forwards from the KDE Plasma system tray. One switch per tunnel.
Define a forward once, then turn it on and off from the tray instead of keeping an
ssh -L terminal open. Below it, every port listening on your machine, named after
its program and project.
Tunnels
- One switch per forward, with a live state: Active, Connecting…, Waiting for approval, Off, or the error ssh reported. Click a row to read the whole error.
- Tunnels outlive Plasma. Each runs as a transient systemd user service
(
porthole-<id>), not as a child of plasmashell, and is found again after a restart. - Active means listening: a row turns green only once its ssh accepts connections on the local port.
- Bounce a port between hosts: switching on a forward stops any other one on the same
local address and port, so moving
3000from staging to production is one click. - Or keep them side by side with a bind address:
127.0.1.1:3000to staging and127.0.1.2:3000to production run at the same time. An address other machines can reach (0.0.0.0,*, a LAN address) is allowed, and the form says so. - When ssh needs you: the Tailscale SSH approval link opens from the row; an unknown host key can be trusted and retried; a changed host key is never trusted automatically.
- Start with Plasma, per forward. The SSH host field suggests the hosts in
~/.ssh/config. - The tray icon counts active tunnels and marks a failure with a small red dot. It never bounces.
Local ports
- Dev servers, containers and services listening on TCP:
Vite · shop,Next.js · dashboard,Docker · postgres. The project is the process's git repository, or its folder. - Servers open to the network (
0.0.0.0,::, a LAN address) are marked Reachable from your network. - Open in Browser, Copy Address, and Stop for your own processes. Stop asks first, sends SIGTERM, and offers Force Stop if the process is still there after 3 seconds.
- System services are folded away. Ports are only read while the popup is open.
It follows your colour scheme and accent colour:
| Breeze Light | A failed tunnel, opened |
|---|---|
![]() |
![]() |
| Adding a forward | In the tray |
|---|---|
![]() |
![]() |
- KDE Plasma 6
- a systemd user session (
systemd-run --user). Distributions without systemd are not supported; the popup says so. - the OpenSSH client and iproute2 (
ss), present on almost every desktop - SSH that logs in without a prompt: a key without a passphrase, or one loaded in an agent (see SSH agent)
From the KDE Store. Right-click the desktop → Add Widgets… → Get New Widgets… → Download New Plasma Widgets, and search for Porthole. It is also on the KDE Store.
From a release. Download porthole.plasmoid from
Releases, then:
kpackagetool6 --type Plasma/Applet --install porthole.plasmoidUse --upgrade instead of --install to update.
From source.
git clone https://github.com/fatihaydost/porthole.git
cd porthole
./install.sh --reload--reload restarts Plasma so that a running copy picks up the new code.
Porthole shows up in the system tray. If it is hidden, right-click the tray's arrow → Configure System Tray… → Entries and set Porthole to Always shown.
Uninstall: kpackagetool6 --type Plasma/Applet --remove io.github.fatihaydost.porthole
Click Add, give the forward a name, a local port, an SSH host and the remote port, and flip its switch.
- Left click opens the list, middle click re-reads everything.
- With the popup open:
↑/↓select a tunnel,Space/Entertoggle,Eedit,Aadd,RorF5refresh.Deleteasks first;Enterconfirms,Esccancels.
Forwards are kept in ~/.config/porthole/forwards.json. You can edit it by hand:
{
"version": 1,
"forwards": [
{
"label": "Staging web",
"bindAddress": "",
"localPort": 3000,
"sshTarget": "staging",
"remoteHost": "localhost",
"remotePort": 3000,
"autostart": false,
"extraOptions": "-J bastion"
}
]
}localPortandsshTarget(aHostfrom~/.ssh/config, oruser@host) are required.remoteHostdefaults tolocalhost,remotePortto the local port.bindAddressis optional; empty meanslocalhost(127.0.0.1and::1). Every address in127.0.0.0/8is loopback on Linux and needs no setup, so127.0.1.1and127.0.1.2can each have their own tunnel on port3000. It takes an IPv4 or IPv6 address (brackets optional),localhost, or*for every interface; an entry with anything else, such as a host name, is kept as written but not used.extraOptionsis split on whitespace; anything that needs quoting belongs in~/.ssh/config.idmay be left out; Porthole derives a stable one.
Porthole never loses what you wrote: loading never writes the file, entries it cannot read are kept as they are, unknown fields survive a save, and writes are atomic (symlinks and permissions are kept). If the file is not valid JSON, Porthole shows an error and will not save until you fix it.
systemd-run --user --unit=porthole-<id> -- \
ssh -N -T -o BatchMode=yes -o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ConnectTimeout=10 \
-L [<bindAddress>:]<localPort>:<remoteHost>:<remotePort> [extraOptions] -- <sshTarget>
systemd is the single source of truth: every 4 seconds Porthole reads each unit's state, who listens on its address and port, and its journal when something is wrong. There is no automatic reconnect; a dropped connection shows as an error, and the switch retries it. The units are ordinary ones:
systemctl --user list-units 'porthole-*'
journalctl --user -u porthole-<id>Tunnels run with BatchMode=yes under the systemd user manager, which often has no
SSH_AUTH_SOCK. Porthole looks for a running agent itself (the systemd environment,
Plasma's environment, OpenSSH's ssh-agent.socket, GNOME Keyring, gpg-agent) and passes
it to the tunnel. Without one, ssh reads your key files directly, which works for keys
without a passphrase. For a key with a passphrase:
systemctl --user enable --now ssh-agent.socket
ssh-addDocker containers are named in Local ports only if docker ps works for your user
(the docker group). Otherwise their ports show as unnamed system services.
node tests/logic.test.js # 39 unit tests
tests/run-e2e.sh # 75 end-to-end steps against a throwaway sshd on 127.0.0.1:2222
/usr/lib/qt6/bin/qmllint -I /usr/lib/qt6/qml package/contents/ui/*.qml
plasmoidviewer -a packagerun-e2e.sh needs sshd, socat, curl and PyQt6. It drives the widget's own QML
through real tunnels and local servers, and stops only what it started.
MIT, see LICENSE.



