* feat: add --context to forward only selected contexts
kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.
--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:
kportal --context development-team-a
kportal --context team-a,team-b -headless
kportal --context team-a --context team-b
An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.
Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.
Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.
Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.
* fix(config): keep the running config when no selected context remains
A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.
LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.
Refs #80
* fix(ui): apply new table columns after a successful SIGHUP reload
The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.
Refs #80
* style: gofmt run_test.go
---------
Co-authored-by: André Cruz <[email protected]>
19 KiB
Kubernetes port-forward manager with interactive terminal UI
kportal manages multiple Kubernetes port-forwards with an interactive terminal interface. It provides real-time status updates, automatic reconnection, hot-reload configuration, and mDNS hostname publishing.
✨ Features
- Interactive TUI - Terminal interface with keyboard navigation
- Live management - Add, edit, and delete port-forwards without restarting
- Auto-reconnect - Exponential backoff retry on connection failures
- Hot-reload - Configuration changes applied automatically
- Health monitoring - Multiple check methods with stale connection detection
- Multi-context - Support for multiple Kubernetes contexts and namespaces
- Pod restart handling - Automatic reconnection when pods restart
- Label selectors - Dynamic pod targeting using label selectors
- Port conflict detection - Validates port availability with PID information
- mDNS hostnames - Access forwards via
.localhostnames - HTTP traffic logging - Real-time HTTP request/response logging for debugging
- Connection benchmarking - Built-in HTTP benchmarking with latency statistics
- Headless mode - Background operation for scripting and automation
🔄 Comparison with Other Tools
| Feature | kportal | k9s | Kube Forwarder | kftray |
|---|---|---|---|---|
| Interface | Terminal TUI | Terminal TUI | Desktop GUI (Electron) | Desktop GUI + TUI |
| Persistent Config | ✅ YAML file | ❌ Session only | ✅ JSON bookmarks | ✅ JSON + Git sync |
| Auto-reconnect | ✅ Exponential backoff | ❌ Manual | ✅ Basic | ✅ Watch API |
| Hot-reload Config | ✅ File watch + SIGHUP | ❌ | ❌ | ❌ |
| Health Checks | ✅ TCP + data-transfer | ❌ | ❌ | ❌ |
| Stale Connection Detection | ✅ Age + idle tracking | ❌ | ❌ | ❌ |
| HTTP Traffic Logging | ✅ Built-in viewer | ❌ | ❌ | ✅ |
| Connection Benchmarking | ✅ Built-in | ✅ Via Hey | ❌ | ❌ |
| mDNS Hostnames | ✅ .local domains |
❌ | ❌ | ❌ |
| Label Selectors | ✅ | ✅ | ❌ | ✅ |
| Multi-context | ✅ | ✅ | ✅ | ✅ |
| Headless Mode | ✅ | ❌ | ❌ | ❌ |
| System Tray | ❌ | ❌ | ❌ | ✅ |
| UDP Support | ❌ | ❌ | ❌ | ✅ Proxy relay |
| Dependencies | Single binary | Single binary | Electron | Tauri + kubectl |
📦 Installation
Homebrew (macOS)
brew install --cask lukaszraczylo/taps/kportal
Note
: If you previously installed via
brew install lukaszraczylo/taps/kportal(formula), uninstall first:brew uninstall kportal
Quick Install
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
The installer downloads kportal-<version>-checksums.txt from the same release and verifies the archive's SHA-256 before installing. If cosign is on your PATH, the checksums file's keyless cosign signature is also verified against the shared-actions reusable workflow identity.
| Variable | Effect |
|---|---|
DRY_RUN=1 |
Download and verify only; do not install |
SKIP_COSIGN=1 |
Skip cosign signature verification (SHA-256 is still enforced) |
Manual Download
Download binaries from the releases page.
Build from Source
git clone https://github.com/lukaszraczylo/kportal.git
cd kportal
make build && make install
Verifying Release Signatures
All release checksums are signed with cosign using keyless signing. To verify:
# Download the checksum file and its sigstore bundle from the release
cosign verify-blob \
--certificate-identity-regexp "^https://github\.com/lukaszraczylo/shared-actions/\.github/workflows/go-release\.yaml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle "kportal-<version>-checksums.txt.sigstore.json" \
kportal-<version>-checksums.txt
🚀 Quick Start
Create .kportal.yaml:
contexts:
- name: production
namespaces:
- name: backend
forwards:
- resource: service/postgres
protocol: tcp
port: 5432
localPort: 5432
alias: prod-db
- resource: service/api
protocol: tcp
port: 8080
localPort: 8080
alias: api
httpLog: true # Enable HTTP traffic logging
Run:
kportal
Keyboard Controls
| Key | Action |
|---|---|
↑↓ / j/k |
Navigate |
Space / Enter |
Toggle forward |
n |
Add new forward |
e |
Edit forward |
d |
Delete forward |
b |
Benchmark connection |
l |
View HTTP logs |
q |
Quit |
📖 Configuration
Basic Structure
contexts:
- name: <context-name>
namespaces:
- name: <namespace-name>
forwards:
- resource: <type>/<name>
protocol: tcp
port: <remote-port>
localPort: <local-port>
alias: <display-name> # optional
selector: <label-selector> # optional
httpLog: true # optional - enable HTTP logging
Forward Options
| Field | Required | Description |
|---|---|---|
resource |
Yes | Resource type and name (e.g., service/postgres, pod/my-app) |
protocol |
Yes | Protocol (tcp) |
port |
Yes | Remote port |
localPort |
Yes | Local port |
alias |
No | Display name and mDNS hostname |
selector |
No | Label selector for pod resolution |
httpLog |
No | Enable HTTP traffic logging (true/false) |
Resource Formats
| Format | Description |
|---|---|
service/name |
Service forwarding |
pod/name |
Direct pod by name |
pod/prefix |
Pod by prefix (matches prefix-*) |
pod + selector |
Pod by label selector |
deployment/name |
Deployment |
Health Check Configuration
healthCheck:
interval: "3s" # Check frequency
timeout: "2s" # Check timeout
method: "data-transfer" # tcp-dial or data-transfer
maxConnectionAge: "25m" # Reconnect before k8s timeout
maxIdleTime: "10m" # Detect idle connections
reliability:
tcpKeepalive: "30s"
dialTimeout: "30s"
retryOnStale: true
Health check methods:
tcp-dial- Fast TCP connection testdata-transfer- Verifies tunnel functionality by attempting data read
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps.
mDNS Hostnames
Enable mDNS to access forwards via .local hostnames:
mdns:
enabled: true
contexts:
- name: production
namespaces:
- name: default
forwards:
- resource: service/postgres
port: 5432
localPort: 5432
alias: prod-db # Accessible via prod-db.local:5432
- Explicit
aliasbecomes<alias>.local - Without alias, hostname is generated from resource name (
service/redis→redis.local) - Works on macOS (Bonjour) and Linux (avahi-daemon)
Verify registration:
dns-sd -B _kportal._tcp local # macOS
avahi-browse -t _kportal._tcp # Linux
Table Columns
Choose which columns the forwards table shows, and in what order. The setting applies to the interactive UI and the -verbose table:
tui:
columns:
- name: alias
width: 30 # optional, 1-200
- name: resource
- name: local
- name: status
- Valid names:
context,namespace,alias,type,resource,remote,local,status - The list order is the render order, and unlisted columns are hidden
widthis the maximum text width, from 1 to 200. If you omit it, the column keeps its default width- If you omit
tui, the table keeps its default columns and order - Changes apply on hot-reload
Usage
Interactive Mode
kportal
Verbose Mode
kportal -v
Headless Mode
Run without TUI for scripting and automation:
kportal -headless
Headless mode emits both structured and standard-library logs to stderr by default
(suitable for redirecting to a log file or systemd journal). The -v flag controls
log level (debug vs info), not destination.
Combines well with verbose mode for background operation:
kportal -headless -v 2>kportal.log &
Validate Configuration
kportal --check
Custom Config File
kportal -c /path/to/config.yaml
Select Contexts
By default every context in the config file is forwarded. --context limits the
run to the contexts you name, which is useful when the file describes several
clusters but you only work against one at a time:
kportal --context development-team-a
The flag is repeatable and accepts comma-separated values, so these are equivalent:
kportal --context team-a --context team-b
kportal --context team-a,team-b
It composes with the other modes, and works well as a per-cluster shell alias:
alias kp-a='kportal --context development-team-a'
kportal --context development-team-a -headless
An unknown context name is an error that lists the contexts the file defines, so a typo fails loudly instead of silently forwarding nothing.
Reusing local ports across contexts
Local-port conflicts are only reported between forwards that could run at the
same time. Contexts that are never selected together may therefore reuse the
same localPort, so the same service keeps the same local port in every
cluster and the app you are developing never needs reconfiguring:
contexts:
- name: development-team-a
namespaces:
- name: rate-service
forwards:
- &rate-service
resource: pod/rpc-server
protocol: tcp
port: 50051
localPort: 3004
alias: rate-service
- name: development-team-b
namespaces:
- name: rate-service
forwards:
- <<: *rate-service # same local port, no conflict
This requires passing --context. Without it every context is active, so the
duplicate ports above would genuinely collide and are still reported as an
error - as is naming both contexts at once (--context team-a,team-b).
Generate Forwards from a Cluster
The generate subcommand discovers services in a Kubernetes context and lets you
interactively pick which ones to forward. Selected entries are appended to the
config file with consecutive local ports starting from a value you choose.
kportal generate --context=my-cluster
kportal generate --context=my-cluster --config=/path/to/.kportal.yaml
kportal generate --context=my-cluster --dry-run
| Flag | Description |
|---|---|
--context |
(required) Kubernetes context to scan |
--config |
Path to kportal config file (default: .kportal.yaml) |
--dry-run |
Print the planned forwards but do not modify the config |
The interactive flow has three steps:
- Namespaces — multi-select with
space, toggle-all witha, filter with/. - Services — same controls; rows already present in the config are locked off, and non-TCP ports are skipped (UDP is not supported by kportal's forward layer).
- Port assignment — choose a starting local port (default
10000, must be ≥1024). Local ports are assigned consecutively in stable order, skipping any already in use.
Press enter on the final step to save (or to print and exit when --dry-run is set), b to go back, or esc to cancel.
Status Indicators
| Indicator | Description |
|---|---|
● Active |
Connection healthy |
○ Starting |
Initial connection (10s grace period) |
◐ Reconnecting |
Reconnecting after failure |
✗ Error |
Connection failed |
○ Disabled |
Manually disabled |
Advanced Features
HTTP Traffic Logging
Press l in the TUI to view real-time HTTP traffic for a selected forward. The log viewer shows:
| Column | Description |
|---|---|
| TIME | Request timestamp |
| METHOD | HTTP method (GET, POST, etc.) |
| STATUS | Response status code |
| LATENCY | Request duration |
| PATH | Request path |
List view shortcuts:
| Key | Action |
|---|---|
↑/↓ |
Navigate entries |
Enter |
View request details |
g/G |
Jump to top/bottom |
a |
Toggle auto-scroll |
f |
Cycle filter mode (All → Non-2xx → Errors) |
/ |
Search by path or method |
c |
Clear all filters |
q |
Close log viewer |
Detail view:
Press Enter on any entry to see full request/response details including:
- Request and response headers (alphabetically sorted)
- Request and response bodies
- Timing information and status codes
| Key | Action |
|---|---|
↑/↓ |
Scroll content |
PgUp/PgDn |
Scroll by page |
g |
Jump to top |
c |
Copy response body to clipboard |
Esc/q |
Return to list |
Body display features:
- JSON formatting - JSON bodies are pretty-printed with syntax highlighting
- Compression handling - gzip/deflate content is automatically decompressed
- Binary detection - Binary content shows a placeholder instead of garbled data
Filter modes:
- All - Show all entries
- Non-2xx - Hide successful (2xx) responses
- Errors - Show only 4xx and 5xx responses
Toggling per-forward logging:
In the add/edit wizard, press h on the confirmation step to toggle httpLog on or
off for the current forward. The wizard preserves any advanced httpLog keys
(logFile, includeHeaders, maxBodySize, filterPath) you set in YAML.
Header redaction:
When httpLog.includeHeaders: true is set, sensitive header values are
automatically replaced with [REDACTED]. The header name is preserved so you can
see that an Authorization header was present without exposing its value. Redacted
headers include Authorization, Cookie, Set-Cookie, X-Api-Key,
X-Auth-Token, X-Csrf-Token, Proxy-Authorization, X-Access-Token, and any
header whose name contains token, secret, password, or apikey. This is
always on and cannot be disabled.
Advanced configuration:
forwards:
- resource: service/api
port: 8080
localPort: 8080
httpLog:
enabled: true
includeHeaders: true # values of sensitive headers are redacted
maxBodySize: 65536 # bytes; 0 = unlimited
filterPath: "/api/" # only log paths matching this substring
logFile: "api.log" # append entries to a file in addition to the in-memory ring
Connection Benchmarking
Press b in the TUI to benchmark a selected forward. Configure:
- URL Path - Target endpoint (default:
/) - Method - HTTP method (GET, POST, etc.)
- Concurrency - Number of parallel workers
- Requests - Total number of requests
Results include:
- Success/failure counts
- Min/Max/Avg latency
- P50/P95/P99 percentiles
- Throughput (requests/sec)
- Status code distribution
Hot-Reload
Configuration changes are applied automatically. Manual reload:
kill -HUP $(pgrep kportal)
Port Conflict Detection
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports.
Conflicts are only reported between forwards that could be running together.
With --context, forwards in contexts outside the selection are ignored, which
is what lets each context reuse the same localPort values - see
Reusing local ports across contexts.
Retry Strategy
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds.
Migration from kftray
kportal --convert configs.json --convert-output .kportal.yaml
Signal Handling
Ctrl+C/SIGTERM- Graceful shutdownSIGHUP- Reload configuration
🐛 Troubleshooting
Port Already in Use
lsof -i :<port>
kill <pid>
Connection Refused
- Verify pod is running:
kubectl get pods -n <namespace> - Verify port is correct:
kubectl describe pod <pod> - Check service endpoints:
kubectl get endpoints <service>
Context Not Found
kubectl config get-contexts
Context names containing @, ., :, or / (e.g. admin@home,
[email protected], GKE dotted names, EKS ARNs) are accepted by the
config validator.
If --context reports unknown context "...", the name does not match any
contexts[].name in the config file. The error lists the names that are
defined - note these are the contexts in your .kportal.yaml, which need not be
every context in your kubeconfig.
🔧 Development
Prerequisites
- Go 1.23+
- Kubernetes cluster access
- kubectl configured
Build
make build # Build binary
make test # Run tests
make all # fmt, vet, staticcheck, test
make install # Install locally
Contributing
See CONTRIBUTING.md for guidelines.
Telemetry
On startup this binary sends a single anonymous adoption ping — project name, version, timestamp; no identifiers, no command output, no payload contents. Fire-and-forget with a 2-second timeout; cannot block startup or panic.
See oss-telemetry — Disabling telemetry for the exact wire format, source, and full opt-out documentation.
Quick opt-out: set any of DO_NOT_TRACK=1, OSS_TELEMETRY_DISABLED=1,
or KPORTAL_DISABLE_TELEMETRY=1.
License
MIT License - see LICENSE.
Acknowledgments
- Bubble Tea - Terminal UI framework
- Lipgloss - Terminal styling
- client-go - Kubernetes client
- kftray - Inspiration
