Compare commits

...
Author SHA1 Message Date
github-actions[bot]andGitHub 935962f6da chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-11 03:24:20 +00:00
github-actions[bot]andGitHub 789cc7626f chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-10 03:23:01 +00:00
lukaszraczyloandGitHub 8bd14cce3b fix(mdns): answer hostname A queries after TTL (#96)
* fix(mdns): answer hostname A queries after TTL

Zeroconf answers only service queries. The A record for <alias>.local
expired from the OS cache after 120 seconds and lookups then failed.

Vendor zeroconf v1.0.0 with one change: it now answers A and AAAA
queries for the registered hostname. Fixes #95.

* fix(deps): update golang.org/x/net to v0.60.0

govulncheck reports several x/net advisories fixed in v0.60.0.
The Security Scan job fails on them.
2026-10-09 15:21:35 +01:00
github-actions[bot]andGitHub 7e3c112738 chore(deps): update module github.com/fxamacker/cbor/v2 to v2.9.6
Automated dependency update. All tests passed.
2026-10-09 03:24:20 +00:00
lukaszraczylo f7287b3ef6 refactor(ui): migrate to bubbletea v2 and lipgloss v2
Switch imports to charm.land/bubbletea/v2 and charm.land/lipgloss/v2.
View now returns tea.View with AltScreen set, key handling uses
KeyPressMsg, and colour fields use image/color.
2026-10-04 10:14:28 +01:00
github-actions[bot]andGitHub 985220b6e6 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 04:21:13 +00:00
lukaszraczylo b940fa7f7a ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:42 +01:00
lukaszraczyloandGitHub 63c905dead Update go.mod and go.sum (#91) 2026-10-03 04:24:43 +01:00
lukaszraczyloandGitHub 1c419ab1c2 docs: rebuild the landing page and README with comparison, install and reference sections (#90)
The earlier rewrite dropped content the page needs. The landing page
has a hero with an install command, a terminal mock, the real
screenshot, feature rows with snippets from the repository, a dated
comparison with kubectl, k9s, kubefwd and kftray with source links,
install tabs, config and key references, troubleshooting and an FAQ.

The README restores the comparison, advanced features, retry strategy,
kftray migration, signal handling, signature verification and build
from source. Cells that a project does not document show as Not
documented.

It fixes four claims the code contradicts: the maxBodySize default, the
deployment resource type, the file watcher delay and the verbose table
status markers.
2026-10-02 03:25:29 +01:00
lukaszraczyloandGitHub 260a909e3d docs: rewrite the product page and README, update the feature list (#88)
* docs(site): rewrite the product page in plain language

The page lists what kportal does with the flags, keys and config keys
that exist in the code. It drops the CDN assets, gradients and icon
grids, and keeps the light and dark themes.

It now covers tui.columns, typed namespace entry, completion, the log
format flag and the --context flag. The claim about a 100ms file watcher
is removed because the code has no such debounce.

* docs: rewrite the README and update the feature list

The README now has a feature table, install steps, a flag table, a
config reference and troubleshooting. It documents tui.columns, typed
namespace entry, shell completion and the --context flag.

It drops the comparison table with other tools, which the repository
cannot back up, and corrects the supported resource types and the Go
version.
2026-10-02 02:54:50 +01:00
lukaszraczyloandGitHub 554f1e5ae0 Update go.mod and go.sum (#89) 2026-10-02 02:53:05 +01:00
27 changed files with 3402 additions and 1737 deletions
+7 -7
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum
name: Update dependencies
on:
workflow_dispatch:
@@ -6,15 +6,15 @@ on:
- cron: "0 3 * * *"
permissions:
contents: write
actions: write
contents: write
issues: write
pull-requests: write
security-events: write
jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
update:
uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: "release.yml"
secrets: inherit
node: false
release-workflow: release.yml
+336 -312
View File
@@ -8,51 +8,99 @@
<a href="https://goreportcard.com/report/github.com/lukaszraczylo/kportal"><img src="https://goreportcard.com/badge/github.com/lukaszraczylo/kportal" alt="Go Report Card"></a>
</p>
<p align="center">
<strong>Kubernetes port-forward manager with interactive terminal UI</strong>
</p>
# kportal
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.
kportal runs many Kubernetes port-forwards from one YAML file and shows their status in a terminal UI. It reconnects dropped forwards, reloads the file when it changes, and can run without the UI.
![kportal Screenshot](docs/kportal-screenshot.png)
## ✨ Features
Screenshot from kportal v0.2.17. The current key bar also lists `PgUp`/`PgDn`, `b` (Bench) and `l` (Logs).
- **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 `.local` hostnames
- **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
Website: <https://lukaszraczylo.github.io/kportal>
## 🔄 Comparison with Other Tools
## Contents
| Feature | kportal | [k9s](https://k9scli.io/) | [Kube Forwarder](https://kube-forwarder.pixelpoint.io/) | [kftray](https://kftray.app/) |
|---------|---------|------|----------------|--------|
| **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 |
- [Features](#features)
- [Comparison](#comparison-with-other-tools)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuration](#configuration)
- [Usage](#usage)
- [Status indicators](#status-indicators)
- [Advanced features](#advanced-features)
- [Migration from kftray](#migration-from-kftray)
- [Signal handling](#signal-handling)
- [Troubleshooting](#troubleshooting)
- [Development](#development)
- [Telemetry](#telemetry)
- [License](#license)
## 📦 Installation
## Features
| Area | What kportal does |
|------|-------------------|
| Forwards | Forwards to `service/<name>`, `pod/<name>` (exact name or name prefix), or `pod` with a label `selector`. Only TCP is supported. |
| Terminal UI | Add (`n`), edit (`e`), delete (`d`) and toggle (`Space`) forwards without a restart. The add wizard writes the config file. |
| Table columns | `tui.columns` sets which columns the forwards table shows, their order and their width. It applies to the UI and to the `-v` table. |
| Add wizard | If the cluster does not allow listing namespaces, the wizard asks you to type the namespace. |
| Reconnect | Retries with exponential backoff: 1s, 2s, 4s, 8s, then 10s, with 10% jitter. It does not stop retrying. |
| Pod restarts | Forwards by pod prefix or selector connect to a new pod after a restart. |
| Health checks | `tcp-dial` or `data-transfer` check every 3s by default. A connection is stale when it is older than `maxConnectionAge` and idle, or idle longer than `maxIdleTime`. |
| Hot reload | The file watcher and `SIGHUP` reload the config. An invalid config is rejected and the previous one stays active. |
| Contexts | Several Kubernetes contexts and namespaces in one file. `--context` selects which contexts run. |
| Port conflicts | Startup and reload checks report a busy local port and the process that holds it. |
| mDNS | With `mdns.enabled: true`, each forward is reachable as `<alias>.local`. Without an alias, the resource name is used. |
| HTTP log | Per-forward request and response log in the UI, with detail view, filter, search, JSON formatting, gzip/deflate decoding and clipboard copy. |
| Header redaction | With `includeHeaders: true`, values of sensitive headers such as `Authorization` and `Cookie` become `[REDACTED]`. You cannot turn this off. |
| Benchmark | Press `b` to send HTTP requests through a forward. Reports success and failure counts, min/max/avg latency, P50/P95/P99, requests per second and status codes. |
| Generate | `kportal generate` lists services in a cluster and appends the ones you pick to the config with consecutive local ports. |
| Headless | `-headless` runs without the UI and logs to stderr. `-log-format json` switches the log format. |
| Import | `-convert` turns a kftray JSON file into a kportal YAML file. |
| Shell completion | `kportal completion` prints a bash, zsh or fish completion script, or installs it. |
| Verified installer | `install.sh` checks the archive SHA-256 and, if `cosign` is on `PATH`, the keyless signature of the checksums file. |
| Updates | `-update` checks for a newer release. `-version` prints the version. |
## Comparison with other tools
Compared on 2026-10-02 against each project's README and docs. Check their repositories for changes. The kportal column comes from this repository's code. "Not documented" means the project's README and docs do not say.
| Feature | kportal | kubectl port-forward | k9s | kubefwd | kftray |
|---------|---------|----------------------|-----|---------|--------|
| Many forwards in one config file | Yes | No | Partial (annotations configure forwards; no forward list file) | Partial (`example.fwdconf.yml` exists) | Yes |
| Automatic reconnect | Yes | Not documented | Not documented | Yes | Yes |
| Follows pod restarts by label or name prefix | Yes | Not documented | Not documented | Yes (label filter) | Yes |
| Terminal UI of all forwards | Yes | No | Yes (PortForward view, alias `pf`; forwards last for the session) | Yes | Yes |
| GUI | No | No | No | No | Yes |
| Config hot reload | Yes (file watcher, `SIGHUP`) | No | Partial (`benchmarks.yaml` only) | Not documented | Not documented |
| HTTP request and response logging | Yes | No | Not documented | Not documented | Yes |
| Health checks | Yes (`tcp-dial`, `data-transfer`) | Not documented | Not documented | Not documented | Partial (README says it tracks pod health) |
| mDNS or friendly hostnames | Yes (mDNS) | No | No | Partial (edits `/etc/hosts`, not mDNS; needs root) | Not documented |
| Built-in benchmark | Yes | No | Yes (`hey`, key `b`) | Not documented | Not documented |
| Imports another tool's config | Yes (kftray JSON) | No | No | Not documented | Partial (JSON, GitHub repos, service annotations; not other tools' formats) |
| Several contexts in one config | Yes | No | Partial | Partial | Not documented |
| Local port conflict detection | Yes (reports the holding process) | Partial (`:remotePort` picks a free port) | Not documented | Yes | Not documented |
| Platforms | Linux, macOS, Windows | Not documented | macOS, Linux, Windows | macOS, Linux, Windows | macOS, Linux, Windows |
| Licence | MIT | Not documented | Apache-2.0 | Apache-2.0 | GPL-3.0 |
| Language | Go | Not documented | Go | Go | Rust (Tauri, Ratatui) |
| Latest release (2026-10-02) | [Releases](https://github.com/lukaszraczylo/kportal/releases) | Not documented | v0.51.0 (2026-06-06) | v1.25.16 (2026-06-20) | v0.29.2 (2026-09-29) |
When another tool fits better:
- kubectl port-forward for a one-off forward. It needs no config file and ships with kubectl.
- k9s for general cluster browsing and its built-in benchmark.
- kubefwd to forward every service in a namespace with local DNS names.
- kftray for a tray GUI, shared JSON configs and UDP.
Kube Forwarder is a GUI-only forwarder. Its last release was 2019-08-14.
Sources:
- kubectl: [Use Port Forwarding to Access Applications in a Cluster](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/)
- k9s: <https://github.com/derailed/k9s> and <https://k9scli.io/topics/bench/>
- kubefwd: <https://github.com/txn2/kubefwd>
- kftray: <https://github.com/hcavarsan/kftray>
## Installation
### Homebrew (macOS)
@@ -60,29 +108,37 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
brew install --cask lukaszraczylo/taps/kportal
```
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
> ```bash
> brew uninstall kportal
> ```
If you installed the formula (`brew install lukaszraczylo/taps/kportal`) earlier, run `brew uninstall kportal` first.
### Quick Install
### Quick install
```bash
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`](https://github.com/sigstore/cosign) is on your `PATH`, the checksums file's keyless cosign signature is also verified against the shared-actions reusable workflow identity.
The script downloads `kportal-<version>-checksums.txt` from the same release and verifies the archive SHA-256. If [`cosign`](https://github.com/sigstore/cosign) is on `PATH`, it also verifies the keyless signature of the checksums file.
| Variable | Effect |
|----------|--------|
| `DRY_RUN=1` | Download and verify only; do not install |
| `SKIP_COSIGN=1` | Skip cosign signature verification (SHA-256 is still enforced) |
| `DRY_RUN=1` | Download and verify only. Do not install. |
| `SKIP_COSIGN=1` | Skip the cosign check. The SHA-256 check still runs. |
| `INSTALL_DIR` | Target directory. Default `/usr/local/bin`. |
| `KPORTAL_VERSION` | Install a specific version, for example `1.2.3`, instead of the latest. |
| `COSIGN_CERT_IDENTITY_REGEXP` | Certificate identity to accept. Set it if you fork the release pipeline. |
### Manual Download
Example:
Download binaries from the [releases page](https://github.com/lukaszraczylo/kportal/releases).
```bash
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | DRY_RUN=1 bash
```
### Build from Source
### Manual download
The [releases page](https://github.com/lukaszraczylo/kportal/releases) has archives for Linux, macOS and Windows on amd64 and arm64. Archives are named `kportal-<version>-<os>-<arch>.tar.gz`, and `.zip` on Windows. Each release also has a `kportal-<version>-checksums.txt` file.
### Build from source
Go 1.26 or later is required (see `go.mod`).
```bash
git clone https://github.com/lukaszraczylo/kportal.git
@@ -90,12 +146,13 @@ cd kportal
make build && make install
```
### Verifying Release Signatures
`make install` copies the binary to `/opt/homebrew/bin` if that directory exists, otherwise to `~/.local/bin` on macOS and Linux.
All release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
### Verifying release signatures
Release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. Download the checksums file and its sigstore bundle from the release, then run:
```bash
# 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" \
@@ -103,7 +160,9 @@ cosign verify-blob \
kportal-<version>-checksums.txt
```
## 🚀 Quick Start
Replace `<version>` with the release version, for example `1.2.3`. Then check the archive against the verified checksums file with `shasum -a 256` or `sha256sum`.
## Quick start
Create `.kportal.yaml`:
@@ -124,31 +183,40 @@ contexts:
port: 8080
localPort: 8080
alias: api
httpLog: true # Enable HTTP traffic logging
httpLog: true
```
Each `name` under `contexts` must exist in your kubeconfig. List them with `kubectl config get-contexts`.
Run:
```bash
kportal
```
### Keyboard Controls
### Keyboard controls
Forwards list:
| 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 |
| `Up` `Down` / `j` `k` | Move the selection |
| `PgUp` `PgDn` / `Ctrl+u` `Ctrl+d` | Move by 10 rows |
| `Space` / `Enter` | Toggle the forward |
| `n` | Add a forward |
| `e` | Edit the forward |
| `d` | Delete the forward (asks to confirm) |
| `b` | Benchmark the forward |
| `l` | Open the HTTP log |
| `q` / `Ctrl+c` | Quit |
## 📖 Configuration
In the add and edit wizard, `h` on the confirmation step toggles `httpLog`. Advanced `httpLog` keys you set in YAML stay unchanged.
### Basic Structure
HTTP log keys are listed under [HTTP traffic logging](#http-traffic-logging).
## Configuration
### Basic structure
```yaml
contexts:
@@ -162,56 +230,56 @@ contexts:
localPort: <local-port>
alias: <display-name> # optional
selector: <label-selector> # optional
httpLog: true # optional - enable HTTP logging
httpLog: true # optional
```
### Forward Options
### Forward options
| Field | Required | Description |
|-------|----------|-------------|
| `resource` | Yes | Resource type and name (e.g., `service/postgres`, `pod/my-app`) |
| `protocol` | Yes | Protocol (`tcp`) |
| `resource` | Yes | `service/<name>`, `pod/<name>`, or `pod` with `selector` |
| `protocol` | No | `tcp`. No other value is accepted. |
| `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`) |
| `selector` | With bare `pod` | Label selector. Not allowed with `pod/<name>`. |
| `httpLog` | No | `true`, or a map of options (see [HTTP traffic logging](#http-traffic-logging)) |
### Resource Formats
### 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 |
| `service/name` | Forward to a service |
| `pod/name` | Forward to a pod by exact name, or by name prefix |
| `pod` with `selector` | Forward to a pod picked by label selector |
### Health Check Configuration
Valid resource types are `pod` and `service`. To reach a deployment, use `pod` with a `selector`.
### Health checks and reliability
```yaml
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
interval: "3s"
timeout: "2s"
method: "data-transfer" # or tcp-dial
maxConnectionAge: "25m"
maxIdleTime: "10m"
reliability:
tcpKeepalive: "30s"
dialTimeout: "30s"
retryOnStale: true
watchdogPeriod: "30s"
```
Health check methods:
- `tcp-dial` - Fast TCP connection test
- `data-transfer` - Verifies tunnel functionality by attempting data read
The values above are the defaults from `.kportal.yaml`.
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps.
- `tcp-dial` opens a TCP connection to the local port.
- `data-transfer` also tries to read data through the tunnel. It is the default.
- A connection past `maxConnectionAge` reconnects only when it is also idle, so a running transfer is not cut.
- `watchdogPeriod` is the interval of the check for hung workers.
### mDNS Hostnames
Enable mDNS to access forwards via `.local` hostnames:
### mDNS
```yaml
mdns:
@@ -225,22 +293,22 @@ contexts:
- resource: service/postgres
port: 5432
localPort: 5432
alias: prod-db # Accessible via prod-db.local:5432
alias: prod-db # reachable as prod-db.local:5432
```
- Explicit `alias` becomes `<alias>.local`
- Without alias, hostname is generated from resource name (`service/redis` → `redis.local`)
- Works on macOS (Bonjour) and Linux (avahi-daemon)
- `alias: prod-db` publishes `prod-db.local`.
- Without an alias, `service/redis` publishes `redis.local`.
- A bare `pod` with a selector and no alias is not published.
- Services register as `_kportal._tcp`.
Check registration:
Verify registration:
```bash
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:
### Table columns
```yaml
tui:
@@ -252,90 +320,83 @@ tui:
- name: status
```
- Valid names: `context`, `namespace`, `alias`, `type`, `resource`, `remote`, `local`, `status`
- The list order is the render order, and unlisted columns are hidden
- `width` is 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
- Valid names: `context`, `namespace`, `alias`, `type`, `resource`, `remote`, `local`, `status`.
- List order is render order. Unlisted columns are hidden.
- `width` is the maximum text width, 1 to 200. Without it, the column keeps its default width.
- Without `tui`, the table keeps its default columns and order.
- The setting applies to the UI and to the `-v` table, and reloads with the file.
## Usage
### Interactive Mode
### Command line
| Command or flag | Effect |
|-----------------|--------|
| `kportal` | Start the terminal UI. |
| `-c <file>` | Config file. Default `.kportal.yaml`. |
| `-v` | Verbose logging. In headless mode it sets the log level only. |
| `-headless` | No UI. Logs go to stderr. |
| `-check` | Validate the config and exit. |
| `-context <name>` | Forward only the named contexts. Repeatable and comma-separated. |
| `-log-format text\|json` | Log format. Default `text`. |
| `-convert <file>` | Convert a kftray JSON file to kportal YAML. |
| `-convert-output <file>` | Output of `-convert`. Default `.kportal.yaml`. |
| `-update` | Check for updates and exit. |
| `-version` | Print the version and exit. |
| `kportal generate` | Add forwards from a cluster. |
| `kportal completion` | Print or install a shell completion script. |
Each flag also accepts the double-dash form, for example `--check`.
### Interactive mode
```bash
kportal
```
### Verbose Mode
### Verbose mode
```bash
kportal -v
```
### Headless Mode
Without `-headless`, `-v` prints a plain-text table that redraws. The table uses the same status words as the UI. When stdout is not a terminal, it draws `✓ Active`, `⋯ Starting`, `↻ Reconnecting` and `✗ Error`.
Run without TUI for scripting and automation:
### Headless mode
```bash
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:
```bash
kportal -headless -v 2>kportal.log &
kportal -headless -log-format json
```
### Validate Configuration
Headless mode logs to stderr, so you can redirect it to a file or the systemd journal. `-v` sets the log level, not the destination.
### Validate configuration
```bash
kportal --check
kportal -check
```
### Custom Config File
### Custom config file
```bash
kportal -c /path/to/config.yaml
```
### Select Contexts
### 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:
By default kportal forwards every context in the file. `--context` limits the run to the contexts you name:
```bash
kportal --context development-team-a
```
The flag is repeatable and accepts comma-separated values, so these are
equivalent:
```bash
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:
An unknown name is an error that lists the contexts in the file. Those are the `contexts[].name` values in `.kportal.yaml`, not every context in your kubeconfig.
```bash
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:
Local-port conflicts are only reported between forwards that can run together. With `--context`, contexts you never select together can reuse a `localPort`:
```yaml
contexts:
@@ -357,15 +418,9 @@ contexts:
- <<: *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`).
Without `--context`, or with both contexts named, the duplicate port is an error.
### 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.
### Generate
```bash
kportal generate --context=my-cluster
@@ -375,97 +430,51 @@ 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 |
| `--context` | Required. Kubernetes context to scan. |
| `--config` | Config file to append to. Default `.kportal.yaml`. |
| `--dry-run` | Print the planned forwards. Do not change the config. |
The interactive flow has three steps:
Steps:
1. **Namespaces** — multi-select with `space`, toggle-all with `a`, filter with `/`.
2. **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).
3. **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.
1. Namespaces: select with `space`, toggle all with `a`, filter with `/`.
2. Services: same keys. Services already in the config are locked. Non-TCP ports are skipped.
3. Port assignment: choose a first local port (default `10000`, minimum `1024`). Ports are assigned in order and skip ports 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.
Press `enter` on the last step to save (or print, with `--dry-run`), `b` to go back, `esc` to cancel.
## Status Indicators
The `--dry-run` output has this form (example values):
| Indicator | Description |
|-----------|-------------|
```text
[dry-run] Would add 2 forwards to .kportal.yaml
10000 → my-cluster/default/service/api:8080
10001 → my-cluster/default/service/postgres:5432
```
### Shell completion
```bash
kportal completion --shell zsh # print the script
kportal completion --shell zsh --install # install it
kportal completion --uninstall # remove it
```
Without `--shell`, kportal detects the shell. Supported shells: bash, zsh, fish.
## Status indicators
| Indicator | Meaning |
|-----------|---------|
| `● Active` | Connection healthy |
| `○ Starting` | Initial connection (10s grace period) |
| `◐ Reconnecting` | Reconnecting after failure |
| `○ Starting` | First connection. Failed health checks show Starting for the first 10s. |
| `◐ Reconnecting` | Reconnecting after a failure |
| `✗ Error` | Connection failed |
| `○ Disabled` | Manually disabled |
| `○ Disabled` | Turned off in the UI |
## Advanced Features
## Advanced features
### HTTP Traffic Logging
### 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:**
Press `l` on a forward that has `httpLog` set.
```yaml
forwards:
@@ -474,48 +483,88 @@ forwards:
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
includeHeaders: true # sensitive values are redacted
maxBodySize: 65536 # bytes
filterPath: "/api/" # log only paths containing this text
logFile: "api.log" # also append entries to this file
```
### Connection Benchmarking
`maxBodySize` defaults to 1 MB (1048576 bytes) when you omit it or set a value of 0 or less.
Press `b` in the TUI to benchmark a selected forward. Configure:
List view columns: TIME, METHOD, STATUS, LATENCY, PATH.
- **URL Path** - Target endpoint (default: `/`)
- **Method** - HTTP method (GET, POST, etc.)
- **Concurrency** - Number of parallel workers
- **Requests** - Total number of requests
| Key | Action |
|-----|--------|
| `Up` `Down` | Move |
| `Enter` | Open details |
| `g` / `G` | Top / bottom |
| `a` | Toggle auto-scroll |
| `f` | Cycle filter: All, Non-2xx, Errors |
| `/` | Search path or method |
| `c` | Clear filters |
| `q` | Close |
Results include:
- Success/failure counts
- Min/Max/Avg latency
- P50/P95/P99 percentiles
- Throughput (requests/sec)
- Status code distribution
The detail view shows sorted request and response headers, bodies and timing. JSON bodies are pretty-printed, gzip and deflate bodies are decoded, and binary bodies show a placeholder.
### Hot-Reload
| Key | Action |
|-----|--------|
| `Up` `Down` / `PgUp` `PgDn` | Scroll |
| `g` | Top |
| `c` | Copy the response body |
| `Esc` / `q` / `Enter` | Back to the list |
Configuration changes are applied automatically. Manual reload:
#### Sensitive header redaction
With `includeHeaders: true`, kportal replaces these header values with `[REDACTED]` and keeps the name: `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `X-Csrf-Token`, `Proxy-Authorization`, `X-Access-Token`, and any name containing `token`, `secret`, `password` or `apikey` (any case). You cannot turn this off. Source: `internal/httplog/proxy.go`.
### Benchmarking
Press `b` on a forward. Set the URL path (default `/`), the HTTP method, the concurrency and the request count. The wizard starts at 10 workers and 100 requests.
The results screen has this layout (`x.xx` marks a measured value):
```text
Total Requests: 100
Successful: 100 (100.0%)
Failed: 0
Latency (ms)
Min: x.xx
Max: x.xx
Avg: x.xx
P50: x.xx
P95: x.xx
P99: x.xx
Throughput
Requests/sec: x.xx
Bytes read: n
Status Codes
200: n
```
Only 2xx responses count as successful.
### Hot reload
kportal reloads when the config file changes. To reload by hand:
```bash
kill -HUP $(pgrep kportal)
```
### Port Conflict Detection
New forwards start, removed forwards stop and unchanged forwards keep running. An invalid config is rejected and the previous one stays active.
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports.
### Port conflict detection
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](#reusing-local-ports-across-contexts).
kportal checks that local ports are free at startup and on reload. The report names the process that holds a busy port. kportal finds it with `lsof` on Linux and macOS and `tasklist` on Windows. A reload that would bind a busy port is rejected and the previous config stays active.
### Retry Strategy
Conflicts are only reported between forwards that can run together. See [Select contexts](#select-contexts).
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds.
### Retry strategy
Exponential backoff with 10% jitter: 1s, 2s, 4s, 8s, then 10s (cap). Retries continue until the connection succeeds.
## Migration from kftray
@@ -523,88 +572,63 @@ Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue ind
kportal --convert configs.json --convert-output .kportal.yaml
```
## Signal Handling
`-convert-output` defaults to `.kportal.yaml`.
- `Ctrl+C` / `SIGTERM` - Graceful shutdown
- `SIGHUP` - Reload configuration
## Signal handling
## 🐛 Troubleshooting
| Signal | Effect |
|--------|--------|
| `Ctrl+C`, `SIGTERM` | Graceful shutdown |
| `SIGHUP` | Reload the configuration |
### Port Already in Use
## Troubleshooting
Port already in use:
```bash
lsof -i :<port>
kill <pid>
```
### Connection Refused
Connection refused:
1. Verify pod is running: `kubectl get pods -n <namespace>`
2. Verify port is correct: `kubectl describe pod <pod>`
3. Check service endpoints: `kubectl get endpoints <service>`
1. Check the pod: `kubectl get pods -n <namespace>`
2. Check the port: `kubectl describe pod <pod>`
3. Check the service endpoints: `kubectl get endpoints <service>`
### Context Not Found
Context not found: list your contexts with `kubectl config get-contexts`. kportal reports `context <name> not found in kubeconfig` when a `contexts[].name` value is not in your kubeconfig. Names with `@`, `.`, `:` or `/` (for example `admin@home` or an EKS ARN) are valid. If `--context` reports `unknown context`, the name is missing from `contexts[].name` in the config file.
The add wizard shows a namespace prompt instead of a list: the cluster refused or returned an empty namespace list. Type the namespace name.
## Development
Requires Go 1.26 or later, kubectl and access to a cluster.
```bash
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
```bash
make build # Build binary
make test # Run tests
make build # build the binary
make test # run tests
make all # fmt, vet, staticcheck, test
make install # Install locally
make install # install locally
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. Release history is in [CHANGELOG.md](CHANGELOG.md).
## 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.
On startup kportal sends one anonymous adoption ping: project name, version and timestamp. It has a 2-second timeout and cannot block startup.
See **[oss-telemetry — Disabling telemetry](https://github.com/lukaszraczylo/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`.
To opt out, set `DO_NOT_TRACK=1`, `OSS_TELEMETRY_DISABLED=1` or `KPORTAL_DISABLE_TELEMETRY=1`. See [oss-telemetry](https://github.com/lukaszraczylo/oss-telemetry#disabling-telemetry) for the wire format and source.
## License
MIT License - see [LICENSE](LICENSE).
MIT. See [LICENSE](LICENSE).
## Acknowledgments
## Credits
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - Terminal UI framework
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling
- [client-go](https://github.com/kubernetes/client-go) - Kubernetes client
- [kftray](https://github.com/hcavarsan/kftray) - Inspiration
[Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lipgloss](https://github.com/charmbracelet/lipgloss), [client-go](https://github.com/kubernetes/client-go). [kftray](https://github.com/hcavarsan/kftray) was the inspiration.
## Links
- [Website](https://lukaszraczylo.github.io/kportal)
- [Issues](https://github.com/lukaszraczylo/kportal/issues)
- [Releases](https://github.com/lukaszraczylo/kportal/releases)
- [Changelog](CHANGELOG.md)
+1175 -1071
View File
File diff suppressed because it is too large Load Diff
+39 -41
View File
@@ -3,84 +3,82 @@ module github.com/lukaszraczylo/kportal
go 1.26.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6
github.com/fsnotify/fsnotify v1.10.1
github.com/go-logr/logr v1.4.4
github.com/grandcat/zeroconf v1.0.0
github.com/lukaszraczylo/oss-telemetry v0.2.3
github.com/stretchr/testify v1.11.1
github.com/lukaszraczylo/oss-telemetry v0.2.5
github.com/stretchr/testify v1.12.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
k8s.io/klog/v2 v2.140.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
github.com/charmbracelet/x/ansi v0.11.9 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.3 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect
github.com/go-openapi/swag v0.29.1 // indirect
github.com/go-openapi/swag/cmdutils v0.29.1 // indirect
github.com/go-openapi/swag/conv v0.29.1 // indirect
github.com/go-openapi/swag/fileutils v0.29.1 // indirect
github.com/go-openapi/swag/jsonutils v0.29.1 // indirect
github.com/go-openapi/swag/loading v0.29.1 // indirect
github.com/go-openapi/swag/mangling v0.29.1 // indirect
github.com/go-openapi/swag/netutils v0.29.1 // indirect
github.com/go-openapi/swag/pools v0.29.1 // indirect
github.com/go-openapi/swag/stringutils v0.29.1 // indirect
github.com/go-openapi/swag/typeutils v0.29.1 // indirect
github.com/go-openapi/swag/yamlutils v0.29.1 // indirect
github.com/emicklei/go-restful/v3 v3.14.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.6 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/swag v0.29.2 // indirect
github.com/go-openapi/swag/cmdutils v0.29.2 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/fileutils v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/mangling v0.29.2 // indirect
github.com/go-openapi/swag/netutils v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/moby/spdystream v0.5.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/xo/terminfo v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/time v0.16.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 // indirect
k8s.io/streaming v0.37.0 // indirect
k8s.io/streaming v0.37.1 // indirect
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
replace github.com/grandcat/zeroconf => ./third_party/zeroconf
+84 -114
View File
@@ -1,25 +1,27 @@
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
@@ -28,50 +30,48 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/emicklei/go-restful/v3 v3.14.0 h1:6V6Ba8/5W1Z7amPinklZeCMlHBMwj53thpfxazCLMX4=
github.com/emicklei/go-restful/v3 v3.14.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
github.com/go-openapi/swag v0.29.1 h1:C6EeWzUwQtcWEhE9eqBdUubGXxhWY4PlzHMLD7kLaiQ=
github.com/go-openapi/swag v0.29.1/go.mod h1:BzxEXKiPlSXRsRTv1KSBF/BpGKHxA/YciCnr4tv9bvA=
github.com/go-openapi/swag/cmdutils v0.29.1 h1:3DorPGfUdE80BogKY22EzoHBcHMrkVomZMoV7kS4ANY=
github.com/go-openapi/swag/cmdutils v0.29.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA=
github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM=
github.com/go-openapi/swag/fileutils v0.29.1 h1:ZcPzMceVhU1WPbK6N1G6sNQKdd1CWJlf3cA08UHuoM0=
github.com/go-openapi/swag/fileutils v0.29.1/go.mod h1:/wofKYckbtRl2p3+EwQsosie5CT1B38+dQ+PS579BzI=
github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c=
github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg=
github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc=
github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw=
github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I=
github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s=
github.com/go-openapi/swag/netutils v0.29.1 h1:IjIvdEP5duKcghFqJEPSUraRnkKYHoM65kTluTu+Jb4=
github.com/go-openapi/swag/netutils v0.29.1/go.mod h1:DUde7x4Bx00k5jYl2AdRpNAO0m7atUvD2x6X+bWkbno=
github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg=
github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc=
github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0=
github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc=
github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M=
github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE=
github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk=
github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1 h1:Jm+/ze2rMtbD98yen92AhATGLGREDYXG56Xr4gMjEtE=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.1/go.mod h1:YDPnwCRDu38/oJBVMBVXOUDiJ9cIeBHWvfImHaXqnv4=
github.com/go-openapi/testify/v2 v2.6.1 h1:6CNJhTjMzgaeaH8WhshcsZNPIvRemiOcFpU7seO/y7Q=
github.com/go-openapi/testify/v2 v2.6.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4=
github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo=
github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro=
github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw=
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI=
github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0=
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog=
github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8=
github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8=
github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE=
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -81,8 +81,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -91,15 +89,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lukaszraczylo/oss-telemetry v0.2.3 h1:xoDtBqeZGmXj7IteiE1M5WMuzeoqag58qEleI0Cf2Ms=
github.com/lukaszraczylo/oss-telemetry v0.2.3/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
github.com/lukaszraczylo/oss-telemetry v0.2.5/go.mod h1:+Cn78qZo8rc3T9eZt0v3oICYRdd75wORtSidc8lNjDQ=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
@@ -110,16 +103,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -133,47 +120,30 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xo/terminfo v1.2.0 h1:d0ZTOCpuGE0lwSAOs0zcJjwz3jWQyqcRt9XbGJpCOl4=
github.com/xo/terminfo v1.2.0/go.mod h1:lGzkSo8Fe7IRh/w+Gqz7n5mDog4FVXCj3gy/DYTBqio=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -185,18 +155,18 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.37.0 h1:Z//Vj9N7RA/yS2sDmxyeo7h+RR4zbUrd2vrd3Z0TbB4=
k8s.io/api v0.37.0/go.mod h1:LKXgcJWMc+f4OLbP5SFR8rulEg07zZhpi/zMULiBImk=
k8s.io/apimachinery v0.37.0 h1:Np2AbDtf8x6RDHiD8T9LbKJ9gaegeVNa8yNm5FuGKm0=
k8s.io/apimachinery v0.37.0/go.mod h1:RN3nhprFSCxOi5Selxd7oMTXOe/c+ZbcE7Im+TS2zkE=
k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ=
k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.1 h1:QTv/5ha4jAHtW9qxxVBkQVFBRDb4jHfFopQqqMdc+wM=
k8s.io/client-go v0.37.1/go.mod h1:dnAPtTnCNY38Ho04D2KdY1F4IKausa9UbqaAZKl60SY=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098 h1:z5+pcu1jTyKK5mNTe2/+x+U6Uuv9jRVOJQLaBJJMpeI=
k8s.io/kube-openapi v0.0.0-20260821135717-be32def86098/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/streaming v0.37.0 h1:iPBUZLZiKt5bV+lxJurASMOV07VuBhNpiwJt2//AWrM=
k8s.io/streaming v0.37.0/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
k8s.io/streaming v0.37.1 h1:TpzVfQeFuVndn2g9mFqxy1UcUYPwDzqjUmwR/IzJCWc=
k8s.io/streaming v0.37.1/go.mod h1:APlJR26ZWRcVy5bIEj0QRrKUXROtBHPcxl2NT7EAzPU=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+22 -15
View File
@@ -21,13 +21,14 @@ package ui
import (
"fmt"
"image/color"
"log"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -173,7 +174,7 @@ func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
// Start starts the bubbletea application
func (ui *BubbleTeaUI) Start() error {
m := model{ui: ui}
ui.program = tea.NewProgram(m, tea.WithAltScreen())
ui.program = tea.NewProgram(m)
_, err := ui.program.Run()
return err
}
@@ -325,7 +326,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.termHeight = msg.Height
return m, nil
case tea.KeyMsg:
case tea.KeyPressMsg:
// Route based on current view mode
switch viewMode {
case ViewModeMain:
@@ -390,7 +391,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m model) View() string {
func (m model) View() tea.View {
v := tea.NewView(m.view())
v.AltScreen = true
return v
}
func (m model) view() string {
m.ui.mu.RLock()
viewMode := m.ui.viewMode
deleteConfirming := m.ui.deleteConfirming
@@ -438,13 +445,13 @@ func (m model) View() string {
// mainViewColors holds the color palette for the main view
type mainViewColors struct {
header lipgloss.Color
active lipgloss.Color
warning lipgloss.Color
errorColor lipgloss.Color
muted lipgloss.Color
selectedBg lipgloss.Color
selectedFg lipgloss.Color
header color.Color
active color.Color
warning color.Color
errorColor color.Color
muted color.Color
selectedBg color.Color
selectedFg color.Color
}
// defaultMainViewColors returns the default color palette
@@ -526,7 +533,7 @@ func (m model) getTermDimensions() (width, height int) {
}
// renderTitle renders the title bar with version and optional update notification
func (m model) renderTitle(headerColor lipgloss.Color) string {
func (m model) renderTitle(headerColor color.Color) string {
var b strings.Builder
titleStyle := lipgloss.NewStyle().
@@ -552,7 +559,7 @@ func (m model) renderTitle(headerColor lipgloss.Color) string {
// renderEmptyMessage renders the message shown when no forwards are configured.
// It includes an actionable hint so a first-time user knows how to proceed.
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
func (m model) renderEmptyMessage(mutedColor color.Color) string {
mutedStyle := lipgloss.NewStyle().Foreground(mutedColor)
hintStyle := lipgloss.NewStyle().Foreground(highlightColor)
return mutedStyle.Render("No forwards configured") + "\n\n" +
+27 -23
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
)
@@ -323,13 +323,13 @@ func (m *GenerateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.step = GenerateStepDone
return m, tea.Quit
case tea.KeyMsg:
case tea.KeyPressMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.loading {
// Allow only ctrl+c / esc while loading
switch msg.String() {
@@ -354,12 +354,12 @@ func (m *GenerateModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// ---------- Namespace step ----------
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleNamespaceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.nsFiltering {
switch msg.Type {
case tea.KeyEnter, tea.KeyEsc:
switch msg.Code {
case tea.KeyEnter, tea.KeyEscape:
m.nsFiltering = false
if msg.Type == tea.KeyEsc {
if msg.Code == tea.KeyEscape {
m.nsFilter = ""
m.recomputeNamespaceFilter()
}
@@ -370,12 +370,11 @@ func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)
m.recomputeNamespaceFilter()
}
return m, nil
case tea.KeyRunes, tea.KeySpace:
m.nsFilter += string(msg.Runes)
default:
m.nsFilter += msg.Text
m.recomputeNamespaceFilter()
return m, nil
}
return m, nil
}
switch msg.String() {
@@ -391,7 +390,7 @@ func (m *GenerateModel) handleNamespaceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), -10)
case "pgdown":
m.moveCursor(&m.nsCursor, &m.nsScroll, len(m.nsFilteredView), 10)
case " ":
case "space":
if len(m.nsFilteredView) > 0 {
ns := m.nsFilteredView[m.nsCursor]
m.nsSelected[ns] = !m.nsSelected[ns]
@@ -475,12 +474,12 @@ func (m *GenerateModel) buildServiceOrder() {
}
}
func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handleServiceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.svcFiltering {
switch msg.Type {
case tea.KeyEnter, tea.KeyEsc:
switch msg.Code {
case tea.KeyEnter, tea.KeyEscape:
m.svcFiltering = false
if msg.Type == tea.KeyEsc {
if msg.Code == tea.KeyEscape {
m.svcFilter = ""
m.recomputeServiceFilter()
}
@@ -491,12 +490,11 @@ func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.recomputeServiceFilter()
}
return m, nil
case tea.KeyRunes, tea.KeySpace:
m.svcFilter += string(msg.Runes)
default:
m.svcFilter += msg.Text
m.recomputeServiceFilter()
return m, nil
}
return m, nil
}
switch msg.String() {
@@ -515,7 +513,7 @@ func (m *GenerateModel) handleServiceKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), -10)
case "pgdown":
m.moveCursor(&m.svcCursor, &m.svcScroll, len(m.svcFilteredView), 10)
case " ":
case "space":
if len(m.svcFilteredView) > 0 {
c := m.svcFilteredView[m.svcCursor]
if !m.svcLocked[c.Key()] && c.Protocol == "TCP" {
@@ -593,7 +591,7 @@ func (m *GenerateModel) selectedCandidates() []ServiceCandidate {
// ---------- Port assignment step ----------
func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *GenerateModel) handlePortKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
m.step = GenerateStepCancelled
@@ -627,7 +625,7 @@ func (m *GenerateModel) handlePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// Digit-only input
for _, r := range msg.Runes {
for _, r := range msg.Text {
if r >= '0' && r <= '9' && len(m.startingPortStr) < 5 {
m.startingPortStr += string(r)
m.portError = ""
@@ -716,7 +714,13 @@ func (m *GenerateModel) countSkippedNonTCP() int {
// ---------- View ----------
// View implements tea.Model.
func (m *GenerateModel) View() string {
func (m *GenerateModel) View() tea.View {
v := tea.NewView(m.view())
v.AltScreen = true
return v
}
func (m *GenerateModel) view() string {
var b strings.Builder
b.WriteString(wizardHeaderStyle.Render(fmt.Sprintf("kportal generate · context: %s", m.contextName)))
b.WriteString("\n")
@@ -975,7 +979,7 @@ func RunGenerate(
existingForwards []config.Forward,
) (GenerateResult, error) {
m := NewGenerateModel(discovery, mutator, contextName, configPath, dryRun, existingForwards)
prog := tea.NewProgram(m, tea.WithAltScreen())
prog := tea.NewProgram(m)
finalModel, err := prog.Run()
if err != nil {
return GenerateResult{}, err
+11 -11
View File
@@ -8,7 +8,7 @@ import (
"sync"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -72,22 +72,22 @@ func (f *fakeDiscovery) ListServices(_ context.Context, _, ns string) ([]k8s.Ser
return f.servicesByNS[ns], nil
}
// keyOf builds a tea.KeyMsg the same way bubbletea does for typed runes.
func keyOf(s string) tea.KeyMsg {
// keyOf builds a tea.KeyPressMsg the same way bubbletea does for typed runes.
func keyOf(s string) tea.KeyPressMsg {
switch s {
case "enter":
return tea.KeyMsg{Type: tea.KeyEnter}
return tea.KeyPressMsg{Code: tea.KeyEnter}
case "esc":
return tea.KeyMsg{Type: tea.KeyEsc}
return tea.KeyPressMsg{Code: tea.KeyEscape}
case "space":
return tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
case "backspace":
return tea.KeyMsg{Type: tea.KeyBackspace}
return tea.KeyPressMsg{Code: tea.KeyBackspace}
}
if len(s) == 1 {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
return tea.KeyPressMsg{Text: s}
}
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
return tea.KeyPressMsg{Text: s}
}
// drainModel applies a sequence of messages and returns the final model.
@@ -169,7 +169,7 @@ func TestGenerateModel_NamespaceFilter(t *testing.T) {
t.Fatalf("expected [beta], got %v", gm.nsFilteredView)
}
// Exit filter
gm = drainModel(t, gm, tea.KeyMsg{Type: tea.KeyEnter}).(*GenerateModel)
gm = drainModel(t, gm, tea.KeyPressMsg{Code: tea.KeyEnter}).(*GenerateModel)
if gm.nsFiltering {
t.Fatal("expected filtering to be off after enter")
}
@@ -496,7 +496,7 @@ func TestGenerateModel_PortStepView(t *testing.T) {
m.step = GenerateStepPortAssign
m.svcOrder = []ServiceCandidate{{Namespace: "ns", Service: "svc", Port: 80, Protocol: "TCP"}}
m.svcSelected[m.svcOrder[0].Key()] = true
view := m.View()
view := m.View().Content
if !contains(view, "Step 3 / 3") {
t.Fatalf("expected step header in view, got: %s", view)
}
+34 -34
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
@@ -44,10 +44,10 @@ func TestHandleMainViewKeys_Quit(t *testing.T) {
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
m := newTestModel()
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
_, cmd := m.handleMainViewKeys(tea.KeyPressMsg{Text: tt.key})
if tt.key == "ctrl+c" {
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
_, cmd = m.handleMainViewKeys(keyMsg)
}
@@ -76,14 +76,14 @@ func TestHandleMainViewKeys_Navigation(t *testing.T) {
tests := []struct {
name string
key string
keyType tea.KeyType
code rune
initialIndex int
expectedIndex int
}{
{"down arrow", "down", tea.KeyDown, 0, 1},
{"j key", "j", tea.KeyRunes, 0, 1},
{"j key", "j", 0, 0, 1},
{"up arrow", "up", tea.KeyUp, 2, 1},
{"k key", "k", tea.KeyRunes, 2, 1},
{"k key", "k", 0, 2, 1},
}
for _, tt := range tests {
@@ -92,11 +92,11 @@ func TestHandleMainViewKeys_Navigation(t *testing.T) {
m.ui.selectedIndex = tt.initialIndex
m.ui.mu.Unlock()
var keyMsg tea.KeyMsg
if tt.keyType == tea.KeyRunes {
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
var keyMsg tea.KeyPressMsg
if tt.code == 0 {
keyMsg = tea.KeyPressMsg{Text: tt.key}
} else {
keyMsg = tea.KeyMsg{Type: tt.keyType}
keyMsg = tea.KeyPressMsg{Code: tt.code}
}
m.handleMainViewKeys(keyMsg)
@@ -123,7 +123,7 @@ func TestHandleMainViewKeys_Toggle(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Toggle with space
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
keyMsg := tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
m.handleMainViewKeys(keyMsg)
// Check disabled state changed
@@ -151,7 +151,7 @@ func TestHandleMainViewKeys_NewWizard(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Without dependencies, 'n' should do nothing
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
@@ -180,7 +180,7 @@ func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' to show delete confirmation
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
keyMsg := tea.KeyPressMsg{Text: "d"}
m.handleMainViewKeys(keyMsg)
m.ui.mu.RLock()
@@ -204,7 +204,7 @@ func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'd' for first forward
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
keyMsg := tea.KeyPressMsg{Text: "d"}
m.handleMainViewKeys(keyMsg)
// Change selection
@@ -234,7 +234,7 @@ func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -257,7 +257,7 @@ func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Navigate left to "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
keyMsg := tea.KeyPressMsg{Code: tea.KeyLeft}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -265,7 +265,7 @@ func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
m.ui.mu.RUnlock()
// Navigate right back to "No"
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
keyMsg = tea.KeyPressMsg{Code: tea.KeyRight}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -288,7 +288,7 @@ func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Enter on "Yes"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleDeleteConfirmation(keyMsg)
// Should return a command to remove the forward
@@ -314,7 +314,7 @@ func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'y' - should confirm regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
keyMsg := tea.KeyPressMsg{Text: "y"}
_, cmd := m.handleDeleteConfirmation(keyMsg)
assert.NotNil(t, cmd)
@@ -336,7 +336,7 @@ func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'n' - should cancel regardless of cursor position
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleDeleteConfirmation(keyMsg)
m.ui.mu.RLock()
@@ -358,7 +358,7 @@ func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleBenchmarkKeys(keyMsg)
assert.True(t, cancelled, "Cancel function should be called")
@@ -385,7 +385,7 @@ func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
m.ui.mu.RUnlock()
// Move down
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
@@ -400,7 +400,7 @@ func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
m.ui.mu.RUnlock()
// Move up
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleBenchmarkKeys(keyMsg)
m.ui.mu.RLock()
@@ -422,7 +422,7 @@ func TestHandleHTTPLogKeys_Close(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press Esc
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -451,7 +451,7 @@ func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
m.ui.mu.RUnlock()
// Press 'f' to cycle - should skip Text mode and go to Non200
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
keyMsg := tea.KeyPressMsg{Text: "f"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -484,7 +484,7 @@ func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press '/'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
keyMsg := tea.KeyPressMsg{Text: "/"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -505,7 +505,7 @@ func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Press 'c'
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
keyMsg := tea.KeyPressMsg{Text: "c"}
m.handleHTTPLogKeys(keyMsg)
m.ui.mu.RLock()
@@ -869,7 +869,7 @@ func TestModel_Update_ViewModeRouting(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Send a key message - should not panic
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, _ = m.Update(keyMsg)
})
}
@@ -925,7 +925,7 @@ func TestHandleRemoveWizardKeys_EscInConfirmingCancels(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// No removal command must be dispatched.
@@ -962,7 +962,7 @@ func TestHandleRemoveWizardKeys_EscNotConfirmingExits(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
// Should return tea.ClearScreen command on full exit.
@@ -992,7 +992,7 @@ func TestHandleRemoveWizardKeys_EnterOnYesStillConfirms(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.NotNil(t, cmd, "Enter on Yes must still dispatch removeForwardsCmd")
@@ -1017,7 +1017,7 @@ func TestHandleAddWizardKeys_HToggleHTTPLog(t *testing.T) {
require.False(t, m.ui.addWizard.httpLog, "httpLog should default to false")
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
keyMsg := tea.KeyPressMsg{Text: "h"}
m.handleAddWizardKeys(keyMsg)
assert.True(t, m.ui.addWizard.httpLog, "first 'h' should enable httpLog")
@@ -1042,7 +1042,7 @@ func TestHandleAddWizardKeys_HOnAliasFocusIsTextInput(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}
keyMsg := tea.KeyPressMsg{Text: "h"}
m.handleAddWizardKeys(keyMsg)
assert.False(t, m.ui.addWizard.httpLog, "httpLog must NOT toggle when alias has focus")
@@ -1067,7 +1067,7 @@ func TestEditPrefill_PreservesHTTPLog(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}
keyMsg := tea.KeyPressMsg{Text: "e"}
m.handleMainViewKeys(keyMsg)
require.NotNil(t, m.ui.addWizard, "wizard should be active after 'e'")
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/benchmark"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
+9 -9
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
@@ -31,7 +31,7 @@ func isFilterableStep(step AddWizardStep) bool {
}
// handleMainViewKeys handles keyboard input in the main view
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleMainViewKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// If delete confirmation is showing, handle it separately
if m.ui.deleteConfirming {
return m.handleDeleteConfirmation(msg)
@@ -53,7 +53,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgdown", "ctrl+d":
m.ui.moveSelection(10)
case " ", "enter":
case "space", "enter":
m.ui.toggleSelected()
case "n": // Enter add wizard
@@ -294,7 +294,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleDeleteConfirmation handles keyboard input for delete confirmation dialog
func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleDeleteConfirmation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
switch msg.String() {
@@ -336,7 +336,7 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
// handleAddWizardKeys handles keyboard input in the add wizard
func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleAddWizardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -781,7 +781,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
}
// handleRemoveWizardKeys handles keyboard input in the remove wizard
func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleRemoveWizardKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -822,7 +822,7 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "pgdown", "ctrl+d":
wizard.moveCursor(10)
case " ":
case "space":
if !wizard.confirming {
wizard.toggleSelection()
}
@@ -1051,7 +1051,7 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd
}
// handleBenchmarkKeys handles keyboard input in the benchmark view
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleBenchmarkKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
@@ -1192,7 +1192,7 @@ func (m model) applyBenchmarkTextInput() {
}
// handleHTTPLogKeys handles keyboard input in the HTTP log view
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m model) handleHTTPLogKeys(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.ui.mu.Lock()
defer m.ui.mu.Unlock()
+85 -85
View File
@@ -4,7 +4,7 @@ import (
"os"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
"github.com/stretchr/testify/assert"
@@ -44,7 +44,7 @@ func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.selectedIndex)
@@ -54,7 +54,7 @@ func TestHandleMainViewKeys_PageUpDown(t *testing.T) {
ui.mu.Lock()
ui.selectedIndex = 0
ui.mu.Unlock()
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 10, ui.selectedIndex)
@@ -76,7 +76,7 @@ func TestHandleMainViewKeys_NewWizard_WithDiscovery(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
_, cmd := m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -99,7 +99,7 @@ func TestHandleMainViewKeys_NewWizard_AlreadyActive(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleMainViewKeys(keyMsg)
// Should still be the same wizard (not replaced).
@@ -116,7 +116,7 @@ func TestHandleMainViewKeys_HttpLog_NoSubscriber(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
keyMsg := tea.KeyPressMsg{Text: "l"}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -133,7 +133,7 @@ func TestHandleMainViewKeys_HttpLog_WithSubscriber(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}
keyMsg := tea.KeyPressMsg{Text: "l"}
m.handleMainViewKeys(keyMsg)
// Subscription should be established.
@@ -151,7 +151,7 @@ func TestHandleMainViewKeys_Benchmark(t *testing.T) {
ui.AddForward("id-1", fwd)
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
keyMsg := tea.KeyPressMsg{Text: "b"}
m.handleMainViewKeys(keyMsg)
ui.mu.RLock()
@@ -170,7 +170,7 @@ func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
prevState := ui.benchmarkState
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}
keyMsg := tea.KeyPressMsg{Text: "b"}
m.handleMainViewKeys(keyMsg)
// Should not have replaced the benchmark state.
@@ -184,7 +184,7 @@ func TestHandleMainViewKeys_Benchmark_BlocksWhenActive(t *testing.T) {
func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -197,7 +197,7 @@ func TestHandleAddWizardKeys_CtrlC(t *testing.T) {
func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -209,7 +209,7 @@ func TestHandleAddWizardKeys_Esc_FirstStep_Cancels(t *testing.T) {
func TestHandleAddWizardKeys_Esc_MiddleStep_GoesBack(t *testing.T) {
m := newModelWithWizard(StepSelectNamespace)
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -221,7 +221,7 @@ func TestHandleAddWizardKeys_Esc_ClearsSearchFilter_InsteadOfBack(t *testing.T)
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -235,7 +235,7 @@ func TestHandleAddWizardKeys_Esc_EditMode_AlwaysCancels(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.isEditing = true
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleAddWizardKeys(keyMsg)
m.ui.mu.RLock()
@@ -250,11 +250,11 @@ func TestHandleAddWizardKeys_Navigation(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.contexts = []string{"ctx-a", "ctx-b", "ctx-c"}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 1, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 0, m.ui.addWizard.cursor)
}
@@ -268,11 +268,11 @@ func TestHandleAddWizardKeys_PageNavigation(t *testing.T) {
m.ui.addWizard.contexts = contexts
m.ui.addWizard.cursor = 15
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 5, m.ui.addWizard.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, 15, m.ui.addWizard.cursor)
}
@@ -283,7 +283,7 @@ func TestHandleAddWizardKeys_ConfirmationStep_UpFocusAlias(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusButtons
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusAlias, m.ui.addWizard.confirmationFocus)
}
@@ -292,7 +292,7 @@ func TestHandleAddWizardKeys_ConfirmationStep_DownFocusButtons(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
}
@@ -301,7 +301,7 @@ func TestHandleAddWizardKeys_Tab_TogglesFocus(t *testing.T) {
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
@@ -316,7 +316,7 @@ func TestHandleAddWizardKeys_Backspace_TextMode(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "808"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "80", m.ui.addWizard.textInput)
}
@@ -325,7 +325,7 @@ func TestHandleAddWizardKeys_Backspace_SearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = "my"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "m", m.ui.addWizard.searchFilter)
}
@@ -334,7 +334,7 @@ func TestHandleAddWizardKeys_Backspace_EmptyFilter_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.searchFilter = ""
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
// Must not panic.
m.handleAddWizardKeys(keyMsg)
}
@@ -345,7 +345,7 @@ func TestHandleAddWizardKeys_TypeCharAddsToSearchFilter(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
// Context step is filterable in list mode.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("p")}
keyMsg := tea.KeyPressMsg{Text: "p"}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "p", m.ui.addWizard.searchFilter)
}
@@ -356,7 +356,7 @@ func TestHandleAddWizardKeys_TypeCharInTextMode(t *testing.T) {
m := newModelWithWizard(StepEnterRemotePort)
m.ui.addWizard.inputMode = InputModeText
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("8")}
keyMsg := tea.KeyPressMsg{Text: "8"}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, "8", m.ui.addWizard.textInput)
}
@@ -377,7 +377,7 @@ func TestHandleAddWizardEnter_SelectContext(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -393,7 +393,7 @@ func TestHandleAddWizardEnter_SelectContext_Loading_NoOp(t *testing.T) {
m := newModelWithWizard(StepSelectContext)
m.ui.addWizard.loading = true
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.Nil(t, cmd)
}
@@ -405,7 +405,7 @@ func TestHandleAddWizardEnter_SelectNamespace(t *testing.T) {
m.ui.addWizard.namespaces = []string{"default", "kube-system"}
m.ui.addWizard.cursor = 1 // kube-system
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepSelectResourceType, m.ui.addWizard.step)
@@ -428,7 +428,7 @@ func TestHandleAddWizardEnter_SelectResourceType_Service(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -454,7 +454,7 @@ func TestHandleAddWizardEnter_SelectResourceType_PodPrefix(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
ui.mu.RLock()
@@ -477,7 +477,7 @@ func TestHandleAddWizardEnter_EnterResource_PodPrefix(t *testing.T) {
Containers: []k8s.ContainerInfo{{Name: "main", Ports: []k8s.PortInfo{{Port: 8080}}}},
}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -490,7 +490,7 @@ func TestHandleAddWizardEnter_EnterResource_PodPrefix_EmptyInput_NoOp(t *testing
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = ""
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step) // should not advance
@@ -505,7 +505,7 @@ func TestHandleAddWizardEnter_EnterResource_PodSelector(t *testing.T) {
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = []k8s.PodInfo{{Name: "my-pod"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -518,7 +518,7 @@ func TestHandleAddWizardEnter_EnterResource_PodSelector_NoMatchingPods_NoOp(t *t
m.ui.addWizard.textInput = "app=my"
m.ui.addWizard.matchingPods = nil
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterResource, m.ui.addWizard.step)
@@ -535,7 +535,7 @@ func TestHandleAddWizardEnter_EnterResource_Service(t *testing.T) {
}
m.ui.addWizard.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -549,7 +549,7 @@ func TestHandleAddWizardEnter_RemotePort_TextMode_ValidPort(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "8080"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
@@ -561,7 +561,7 @@ func TestHandleAddWizardEnter_RemotePort_TextMode_InvalidPort(t *testing.T) {
m.ui.addWizard.inputMode = InputModeText
m.ui.addWizard.textInput = "invalid"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterRemotePort, m.ui.addWizard.step)
@@ -577,7 +577,7 @@ func TestHandleAddWizardEnter_RemotePort_ListMode_SelectPort(t *testing.T) {
}
m.ui.addWizard.cursor = 1 // 8080 → TargetPort 9090
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, StepEnterLocalPort, m.ui.addWizard.step)
@@ -590,7 +590,7 @@ func TestHandleAddWizardEnter_RemotePort_ListMode_ManualEntry(t *testing.T) {
m.ui.addWizard.detectedPorts = []k8s.PortInfo{{Port: 80}}
m.ui.addWizard.cursor = 1 // "Manual entry" option
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, InputModeText, m.ui.addWizard.inputMode)
@@ -612,7 +612,7 @@ func TestHandleAddWizardEnter_LocalPort_ValidPort(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// loading set, cmd generated.
@@ -627,7 +627,7 @@ func TestHandleAddWizardEnter_LocalPort_InvalidPort(t *testing.T) {
m := newModelWithWizard(StepEnterLocalPort)
m.ui.addWizard.textInput = "0" // invalid (must be > 0)
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
@@ -639,7 +639,7 @@ func TestHandleAddWizardEnter_Confirmation_FocusAlias_MoveToButtons(t *testing.T
m := newModelWithWizard(StepConfirmation)
m.ui.addWizard.confirmationFocus = FocusAlias
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.Equal(t, FocusButtons, m.ui.addWizard.confirmationFocus)
@@ -650,7 +650,7 @@ func TestHandleAddWizardEnter_Confirmation_CancelButton(t *testing.T) {
m.ui.addWizard.confirmationFocus = FocusButtons
m.ui.addWizard.cursor = 1 // Cancel
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -667,7 +667,7 @@ func TestHandleAddWizardEnter_Confirmation_PortNotAvailable_Error(t *testing.T)
m.ui.addWizard.portAvailable = false
m.ui.addWizard.localPort = 8080
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, m.ui.addWizard.error)
@@ -697,7 +697,7 @@ func TestHandleAddWizardEnter_Confirmation_Save_NewForward(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
@@ -731,7 +731,7 @@ func TestHandleAddWizardEnter_Confirmation_Save_NewForward_WithHTTPLog(t *testin
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
@@ -767,7 +767,7 @@ func TestHandleAddWizardEnter_Confirmation_Update_EditMode(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
require.NotNil(t, cmd)
msg := cmd()
@@ -790,7 +790,7 @@ func TestHandleAddWizardEnter_Success_AddAnother(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd (loadContextsCmd) must be returned.
@@ -801,7 +801,7 @@ func TestHandleAddWizardEnter_Success_ReturnToMain(t *testing.T) {
m := newModelWithWizard(StepSuccess)
m.ui.addWizard.cursor = 1 // "Return to main"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleAddWizardKeys(keyMsg)
assert.NotNil(t, cmd)
@@ -828,7 +828,7 @@ func TestHandleAddWizardKeys_SelectorValidation_OnChar(t *testing.T) {
m := model{ui: ui, termWidth: 120, termHeight: 40}
// Typing a char in PodSelector step should return a validation cmd.
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("-")}
keyMsg := tea.KeyPressMsg{Text: "-"}
_, cmd := m.handleAddWizardKeys(keyMsg)
// A cmd is returned (validateSelectorCmd); we don't invoke it as it needs a real cluster.
@@ -849,7 +849,7 @@ func TestHandleRemoveWizardKeys_SpaceToggle(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
keyMsg := tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -874,7 +874,7 @@ func TestHandleRemoveWizardKeys_SelectAll(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
keyMsg := tea.KeyPressMsg{Text: "a"}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -895,7 +895,7 @@ func TestHandleRemoveWizardKeys_SelectNone(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
keyMsg := tea.KeyPressMsg{Text: "n"}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -915,7 +915,7 @@ func TestHandleRemoveWizardKeys_CtrlC_ExitsAlways(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
keyMsg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -935,7 +935,7 @@ func TestHandleRemoveWizardKeys_Enter_NothingSelected_NoOp(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
@@ -955,7 +955,7 @@ func TestHandleRemoveWizardKeys_Enter_ShowConfirmation(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
@@ -976,7 +976,7 @@ func TestHandleRemoveWizardKeys_Enter_ConfirmNo(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleRemoveWizardKeys(keyMsg)
assert.Nil(t, cmd)
@@ -997,13 +997,13 @@ func TestHandleRemoveWizardKeys_Navigation_InSelection(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 1, ui.removeWizard.cursor)
ui.mu.RUnlock()
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
keyMsg = tea.KeyPressMsg{Code: tea.KeyUp}
m.handleRemoveWizardKeys(keyMsg)
ui.mu.RLock()
assert.Equal(t, 0, ui.removeWizard.cursor)
@@ -1017,7 +1017,7 @@ func TestHandleBenchmarkKeys_Tab(t *testing.T) {
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
@@ -1028,7 +1028,7 @@ func TestHandleBenchmarkKeys_Tab_Wraps(t *testing.T) {
m.ui.benchmarkState.step = BenchmarkStepConfig
m.ui.benchmarkState.cursor = 3
keyMsg := tea.KeyMsg{Type: tea.KeyTab}
keyMsg := tea.KeyPressMsg{Code: tea.KeyTab}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
@@ -1040,7 +1040,7 @@ func TestHandleBenchmarkKeys_Backspace(t *testing.T) {
m.ui.benchmarkState.cursor = 0
m.ui.benchmarkState.textInput = "/api"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "/ap", m.ui.benchmarkState.textInput)
@@ -1053,7 +1053,7 @@ func TestHandleBenchmarkKeys_TypeChar_UpdatesField(t *testing.T) {
m.ui.benchmarkState.cursor = 1 // Method field
m.ui.benchmarkState.textInput = "GE"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("T")}
keyMsg := tea.KeyPressMsg{Text: "T"}
m.handleBenchmarkKeys(keyMsg)
assert.Equal(t, "GET", m.ui.benchmarkState.method)
@@ -1063,7 +1063,7 @@ func TestHandleBenchmarkKeys_Enter_ResultsStep_ReturnsToMain(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepResults
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
assert.NotNil(t, cmd) // tea.ClearScreen
@@ -1077,7 +1077,7 @@ func TestHandleBenchmarkKeys_Enter_ConfigStep_StartsRunning(t *testing.T) {
m := newModelWithBenchmark()
m.ui.benchmarkState.step = BenchmarkStepConfig
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
_, cmd := m.handleBenchmarkKeys(keyMsg)
require.NotNil(t, cmd)
@@ -1157,7 +1157,7 @@ func TestHandleHTTPLogKeys_Detail_Esc(t *testing.T) {
m.ui.httpLogState.showingDetail = true
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test"}}
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.showingDetail)
@@ -1169,11 +1169,11 @@ func TestHandleHTTPLogKeys_Detail_UpDown(t *testing.T) {
m.ui.httpLogState.detailScroll = 5
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 4, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
}
@@ -1184,11 +1184,11 @@ func TestHandleHTTPLogKeys_Detail_PgUpDown(t *testing.T) {
m.ui.httpLogState.detailScroll = 25
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.detailScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 25, m.ui.httpLogState.detailScroll)
}
@@ -1199,7 +1199,7 @@ func TestHandleHTTPLogKeys_Detail_GoToTop(t *testing.T) {
m.ui.httpLogState.detailScroll = 99
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET"}}
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
keyMsg := tea.KeyPressMsg{Text: "g"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.detailScroll)
@@ -1213,7 +1213,7 @@ func TestHandleHTTPLogKeys_Enter_ShowDetail(t *testing.T) {
m.ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/test", StatusCode: 200}}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.showingDetail)
@@ -1227,11 +1227,11 @@ func TestHandleHTTPLogKeys_Navigate_UpDown(t *testing.T) {
}
m.ui.httpLogState.cursor = 1
keyMsg := tea.KeyMsg{Type: tea.KeyUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
keyMsg = tea.KeyMsg{Type: tea.KeyDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 1, m.ui.httpLogState.cursor)
}
@@ -1243,7 +1243,7 @@ func TestHandleHTTPLogKeys_Down_AtBottom_EnablesAutoScroll(t *testing.T) {
m.ui.httpLogState.cursor = 0
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
keyMsg := tea.KeyPressMsg{Code: tea.KeyDown}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
@@ -1257,7 +1257,7 @@ func TestHandleHTTPLogKeys_GoToTop_G(t *testing.T) {
}
m.ui.httpLogState.cursor = 2
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}
keyMsg := tea.KeyPressMsg{Text: "g"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 0, m.ui.httpLogState.cursor)
@@ -1272,7 +1272,7 @@ func TestHandleHTTPLogKeys_GoToBottom_CapitalG(t *testing.T) {
}
m.ui.httpLogState.cursor = 0
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}
keyMsg := tea.KeyPressMsg{Text: "G"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 2, m.ui.httpLogState.cursor)
@@ -1283,7 +1283,7 @@ func TestHandleHTTPLogKeys_ToggleAutoScroll(t *testing.T) {
m := newModelWithHTTPLog()
m.ui.httpLogState.autoScroll = false
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}
keyMsg := tea.KeyPressMsg{Text: "a"}
m.handleHTTPLogKeys(keyMsg)
assert.True(t, m.ui.httpLogState.autoScroll)
@@ -1301,12 +1301,12 @@ func TestHandleHTTPLogKeys_PgUpDown_List(t *testing.T) {
m.ui.httpLogState.entries = entries
m.ui.httpLogState.cursor = 25
keyMsg := tea.KeyMsg{Type: tea.KeyPgUp}
keyMsg := tea.KeyPressMsg{Code: tea.KeyPgUp}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, 5, m.ui.httpLogState.cursor)
assert.False(t, m.ui.httpLogState.autoScroll)
keyMsg = tea.KeyMsg{Type: tea.KeyPgDown}
keyMsg = tea.KeyPressMsg{Code: tea.KeyPgDown}
m.handleHTTPLogKeys(keyMsg)
// PgDown +20 from 5 = 25; index 25 is in range (30 items, indices 0-29).
assert.Equal(t, 25, m.ui.httpLogState.cursor)
@@ -1319,7 +1319,7 @@ func TestHandleHTTPLogKeys_FilterActive_Typing(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "te"
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("s")}
keyMsg := tea.KeyPressMsg{Text: "s"}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "tes", m.ui.httpLogState.filterText)
@@ -1330,7 +1330,7 @@ func TestHandleHTTPLogKeys_FilterActive_Backspace(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "tes"
keyMsg := tea.KeyMsg{Type: tea.KeyBackspace}
keyMsg := tea.KeyPressMsg{Code: tea.KeyBackspace}
m.handleHTTPLogKeys(keyMsg)
assert.Equal(t, "te", m.ui.httpLogState.filterText)
@@ -1341,7 +1341,7 @@ func TestHandleHTTPLogKeys_FilterActive_EscClearsFilter(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "test"
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEscape}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
@@ -1353,7 +1353,7 @@ func TestHandleHTTPLogKeys_FilterActive_EnterConfirms(t *testing.T) {
m.ui.httpLogState.filterActive = true
m.ui.httpLogState.filterText = "api"
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
keyMsg := tea.KeyPressMsg{Code: tea.KeyEnter}
m.handleHTTPLogKeys(keyMsg)
assert.False(t, m.ui.httpLogState.filterActive)
+4 -4
View File
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,7 +12,7 @@ import (
func typeKeys(t *testing.T, m model, text string) {
t.Helper()
for _, r := range text {
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m.handleAddWizardKeys(tea.KeyPressMsg{Text: string(r)})
}
}
@@ -87,7 +87,7 @@ func TestManualNamespace_BackFromLaterStepRestoresTextInput(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.namespaceManual = true
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyEsc})
m.handleAddWizardKeys(tea.KeyPressMsg{Code: tea.KeyEscape})
w := m.ui.addWizard
assert.Equal(t, StepSelectNamespace, w.step)
@@ -98,7 +98,7 @@ func TestKeysJK_StillNavigateInListMode(t *testing.T) {
m := newModelWithWizard(StepSelectResourceType)
m.ui.addWizard.inputMode = InputModeList
m.handleAddWizardKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
m.handleAddWizardKeys(tea.KeyPressMsg{Text: string('j')})
assert.Equal(t, 1, m.ui.addWizard.cursor)
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"charm.land/lipgloss/v2"
)
// Color palette for wizards
+9 -9
View File
@@ -987,7 +987,7 @@ func TestRenderDeleteConfirmation_NoSelected(t *testing.T) {
func TestModelView_MainViewDefault(t *testing.T) {
m := newTestModel()
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1001,7 +1001,7 @@ func TestModelView_DeleteConfirmationOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "Delete Port Forward")
}
@@ -1013,7 +1013,7 @@ func TestModelView_AddWizardOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1028,7 +1028,7 @@ func TestModelView_RemoveWizardOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "alpha")
}
@@ -1040,7 +1040,7 @@ func TestModelView_BenchmarkOverlay(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1052,7 +1052,7 @@ func TestModelView_HTTPLogFullScreen(t *testing.T) {
ui.mu.Unlock()
m := model{ui: ui, termWidth: 120, termHeight: 40}
result := m.View()
result := m.View().Content
assert.Contains(t, result, "HTTP Traffic Log")
}
@@ -1061,7 +1061,7 @@ func TestModelView_ZeroTermSize(t *testing.T) {
m.termWidth = 0
m.termHeight = 0
// Must not panic even with zero term dims.
result := m.View()
result := m.View().Content
assert.NotEmpty(t, result)
}
@@ -1406,8 +1406,8 @@ func TestGetStatusIconAndText(t *testing.T) {
func TestDefaultMainViewColors(t *testing.T) {
colors := defaultMainViewColors()
assert.NotEmpty(t, string(colors.header))
assert.NotEmpty(t, string(colors.active))
assert.NotNil(t, colors.header)
assert.NotNil(t, colors.active)
}
func TestMainViewKeyBindings(t *testing.T) {
+11
View File
@@ -0,0 +1,11 @@
version: "2"
# Vendored upstream code; only the documented patch is ours.
linters:
default: none
enable:
- govet
settings:
govet:
disable:
- composites
+2
View File
@@ -0,0 +1,2 @@
Vendored github.com/grandcat/zeroconf v1.0.0 (MIT, see LICENSE).
Patch: server.go handleQuestion answers A/AAAA queries for the registered hostname.
+27
View File
@@ -0,0 +1,27 @@
The MIT License (MIT)
Copyright (c) 2016 Stefan Smarzly
Copyright (c) 2014 Oleksandr Lobunets
Note: Copyright for portions of project zeroconf.sd are held by Oleksandr
Lobunets, 2014, as part of project bonjour. All other copyright for
project zeroconf.sd are held by Stefan Smarzly, 2016.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+456
View File
@@ -0,0 +1,456 @@
package zeroconf
import (
"context"
"fmt"
"net"
"strings"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"time"
"github.com/cenkalti/backoff"
"github.com/miekg/dns"
)
// IPType specifies the IP traffic the client listens for.
// This does not guarantee that only mDNS entries of this sepcific
// type passes. E.g. typical mDNS packets distributed via IPv4, often contain
// both DNS A and AAAA entries.
type IPType uint8
// Options for IPType.
const (
IPv4 = 0x01
IPv6 = 0x02
IPv4AndIPv6 = (IPv4 | IPv6) //< Default option.
)
type clientOpts struct {
listenOn IPType
ifaces []net.Interface
}
// ClientOption fills the option struct to configure intefaces, etc.
type ClientOption func(*clientOpts)
// SelectIPTraffic selects the type of IP packets (IPv4, IPv6, or both) this
// instance listens for.
// This does not guarantee that only mDNS entries of this sepcific
// type passes. E.g. typical mDNS packets distributed via IPv4, may contain
// both DNS A and AAAA entries.
func SelectIPTraffic(t IPType) ClientOption {
return func(o *clientOpts) {
o.listenOn = t
}
}
// SelectIfaces selects the interfaces to query for mDNS records
func SelectIfaces(ifaces []net.Interface) ClientOption {
return func(o *clientOpts) {
o.ifaces = ifaces
}
}
// Resolver acts as entry point for service lookups and to browse the DNS-SD.
type Resolver struct {
c *client
}
// NewResolver creates a new resolver and joins the UDP multicast groups to
// listen for mDNS messages.
func NewResolver(options ...ClientOption) (*Resolver, error) {
// Apply default configuration and load supplied options.
var conf = clientOpts{
listenOn: IPv4AndIPv6,
}
for _, o := range options {
if o != nil {
o(&conf)
}
}
c, err := newClient(conf)
if err != nil {
return nil, err
}
return &Resolver{
c: c,
}, nil
}
// Browse for all services of a given type in a given domain.
func (r *Resolver) Browse(ctx context.Context, service, domain string, entries chan<- *ServiceEntry) error {
params := defaultParams(service)
if domain != "" {
params.Domain = domain
}
params.Entries = entries
ctx, cancel := context.WithCancel(ctx)
go r.c.mainloop(ctx, params)
err := r.c.query(params)
if err != nil {
cancel()
return err
}
// If previous probe was ok, it should be fine now. In case of an error later on,
// the entries' queue is closed.
go func() {
if err := r.c.periodicQuery(ctx, params); err != nil {
cancel()
}
}()
return nil
}
// Lookup a specific service by its name and type in a given domain.
func (r *Resolver) Lookup(ctx context.Context, instance, service, domain string, entries chan<- *ServiceEntry) error {
params := defaultParams(service)
params.Instance = instance
if domain != "" {
params.Domain = domain
}
params.Entries = entries
ctx, cancel := context.WithCancel(ctx)
go r.c.mainloop(ctx, params)
err := r.c.query(params)
if err != nil {
// cancel mainloop
cancel()
return err
}
// If previous probe was ok, it should be fine now. In case of an error later on,
// the entries' queue is closed.
go func() {
if err := r.c.periodicQuery(ctx, params); err != nil {
cancel()
}
}()
return nil
}
// defaultParams returns a default set of QueryParams.
func defaultParams(service string) *LookupParams {
return NewLookupParams("", service, "local", make(chan *ServiceEntry))
}
// Client structure encapsulates both IPv4/IPv6 UDP connections.
type client struct {
ipv4conn *ipv4.PacketConn
ipv6conn *ipv6.PacketConn
ifaces []net.Interface
}
// Client structure constructor
func newClient(opts clientOpts) (*client, error) {
ifaces := opts.ifaces
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
// IPv4 interfaces
var ipv4conn *ipv4.PacketConn
if (opts.listenOn & IPv4) > 0 {
var err error
ipv4conn, err = joinUdp4Multicast(ifaces)
if err != nil {
return nil, err
}
}
// IPv6 interfaces
var ipv6conn *ipv6.PacketConn
if (opts.listenOn & IPv6) > 0 {
var err error
ipv6conn, err = joinUdp6Multicast(ifaces)
if err != nil {
return nil, err
}
}
return &client{
ipv4conn: ipv4conn,
ipv6conn: ipv6conn,
ifaces: ifaces,
}, nil
}
// Start listeners and waits for the shutdown signal from exit channel
func (c *client) mainloop(ctx context.Context, params *LookupParams) {
// start listening for responses
msgCh := make(chan *dns.Msg, 32)
if c.ipv4conn != nil {
go c.recv(ctx, c.ipv4conn, msgCh)
}
if c.ipv6conn != nil {
go c.recv(ctx, c.ipv6conn, msgCh)
}
// Iterate through channels from listeners goroutines
var entries, sentEntries map[string]*ServiceEntry
sentEntries = make(map[string]*ServiceEntry)
for {
select {
case <-ctx.Done():
// Context expired. Notify subscriber that we are done here.
params.done()
c.shutdown()
return
case msg := <-msgCh:
entries = make(map[string]*ServiceEntry)
sections := append(msg.Answer, msg.Ns...)
sections = append(sections, msg.Extra...)
for _, answer := range sections {
switch rr := answer.(type) {
case *dns.PTR:
if params.ServiceName() != rr.Hdr.Name {
continue
}
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Ptr {
continue
}
if _, ok := entries[rr.Ptr]; !ok {
entries[rr.Ptr] = NewServiceEntry(
trimDot(strings.Replace(rr.Ptr, rr.Hdr.Name, "", -1)),
params.Service,
params.Domain)
}
entries[rr.Ptr].TTL = rr.Hdr.Ttl
case *dns.SRV:
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name {
continue
} else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) {
continue
}
if _, ok := entries[rr.Hdr.Name]; !ok {
entries[rr.Hdr.Name] = NewServiceEntry(
trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)),
params.Service,
params.Domain)
}
entries[rr.Hdr.Name].HostName = rr.Target
entries[rr.Hdr.Name].Port = int(rr.Port)
entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl
case *dns.TXT:
if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name {
continue
} else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) {
continue
}
if _, ok := entries[rr.Hdr.Name]; !ok {
entries[rr.Hdr.Name] = NewServiceEntry(
trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)),
params.Service,
params.Domain)
}
entries[rr.Hdr.Name].Text = rr.Txt
entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl
}
}
// Associate IPs in a second round as other fields should be filled by now.
for _, answer := range sections {
switch rr := answer.(type) {
case *dns.A:
for k, e := range entries {
if e.HostName == rr.Hdr.Name {
entries[k].AddrIPv4 = append(entries[k].AddrIPv4, rr.A)
}
}
case *dns.AAAA:
for k, e := range entries {
if e.HostName == rr.Hdr.Name {
entries[k].AddrIPv6 = append(entries[k].AddrIPv6, rr.AAAA)
}
}
}
}
}
if len(entries) > 0 {
for k, e := range entries {
if e.TTL == 0 {
delete(entries, k)
delete(sentEntries, k)
continue
}
if _, ok := sentEntries[k]; ok {
continue
}
// If this is an DNS-SD query do not throw PTR away.
// It is expected to have only PTR for enumeration
if params.ServiceRecord.ServiceTypeName() != params.ServiceRecord.ServiceName() {
// Require at least one resolved IP address for ServiceEntry
// TODO: wait some more time as chances are high both will arrive.
if len(e.AddrIPv4) == 0 && len(e.AddrIPv6) == 0 {
continue
}
}
// Submit entry to subscriber and cache it.
// This is also a point to possibly stop probing actively for a
// service entry.
params.Entries <- e
sentEntries[k] = e
params.disableProbing()
}
// reset entries
entries = make(map[string]*ServiceEntry)
}
}
}
// Shutdown client will close currently open connections and channel implicitly.
func (c *client) shutdown() {
if c.ipv4conn != nil {
c.ipv4conn.Close()
}
if c.ipv6conn != nil {
c.ipv6conn.Close()
}
}
// Data receiving routine reads from connection, unpacks packets into dns.Msg
// structures and sends them to a given msgCh channel
func (c *client) recv(ctx context.Context, l interface{}, msgCh chan *dns.Msg) {
var readFrom func([]byte) (n int, src net.Addr, err error)
switch pConn := l.(type) {
case *ipv6.PacketConn:
readFrom = func(b []byte) (n int, src net.Addr, err error) {
n, _, src, err = pConn.ReadFrom(b)
return
}
case *ipv4.PacketConn:
readFrom = func(b []byte) (n int, src net.Addr, err error) {
n, _, src, err = pConn.ReadFrom(b)
return
}
default:
return
}
buf := make([]byte, 65536)
var fatalErr error
for {
// Handles the following cases:
// - ReadFrom aborts with error due to closed UDP connection -> causes ctx cancel
// - ReadFrom aborts otherwise.
// TODO: the context check can be removed. Verify!
if ctx.Err() != nil || fatalErr != nil {
return
}
n, _, err := readFrom(buf)
if err != nil {
fatalErr = err
continue
}
msg := new(dns.Msg)
if err := msg.Unpack(buf[:n]); err != nil {
// log.Printf("[WARN] mdns: Failed to unpack packet: %v", err)
continue
}
select {
case msgCh <- msg:
// Submit decoded DNS message and continue.
case <-ctx.Done():
// Abort.
return
}
}
}
// periodicQuery sens multiple probes until a valid response is received by
// the main processing loop or some timeout/cancel fires.
// TODO: move error reporting to shutdown function as periodicQuery is called from
// go routine context.
func (c *client) periodicQuery(ctx context.Context, params *LookupParams) error {
if params.stopProbing == nil {
return nil
}
bo := backoff.NewExponentialBackOff()
bo.InitialInterval = 4 * time.Second
bo.MaxInterval = 60 * time.Second
bo.Reset()
for {
// Do periodic query.
if err := c.query(params); err != nil {
return err
}
// Backoff and cancel logic.
wait := bo.NextBackOff()
if wait == backoff.Stop {
return fmt.Errorf("periodicQuery: abort due to timeout")
}
select {
case <-time.After(wait):
// Wait for next iteration.
case <-params.stopProbing:
// Chan is closed (or happened in the past).
// Done here. Received a matching mDNS entry.
return nil
case <-ctx.Done():
return ctx.Err()
}
}
}
// Performs the actual query by service name (browse) or service instance name (lookup),
// start response listeners goroutines and loops over the entries channel.
func (c *client) query(params *LookupParams) error {
var serviceName, serviceInstanceName string
serviceName = fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain))
if params.Instance != "" {
serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName)
}
// send the query
m := new(dns.Msg)
if serviceInstanceName != "" {
m.Question = []dns.Question{
dns.Question{serviceInstanceName, dns.TypeSRV, dns.ClassINET},
dns.Question{serviceInstanceName, dns.TypeTXT, dns.ClassINET},
}
m.RecursionDesired = false
} else {
m.SetQuestion(serviceName, dns.TypePTR)
m.RecursionDesired = false
}
if err := c.sendQuery(m); err != nil {
return err
}
return nil
}
// Pack the dns.Msg and write to available connections (multicast)
func (c *client) sendQuery(msg *dns.Msg) error {
buf, err := msg.Pack()
if err != nil {
return err
}
if c.ipv4conn != nil {
var wcm ipv4.ControlMessage
for ifi := range c.ifaces {
wcm.IfIndex = c.ifaces[ifi].Index
c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
}
}
if c.ipv6conn != nil {
var wcm ipv6.ControlMessage
for ifi := range c.ifaces {
wcm.IfIndex = c.ifaces[ifi].Index
c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
}
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package zeroconf
import (
"fmt"
"net"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
var (
// Multicast groups used by mDNS
mdnsGroupIPv4 = net.IPv4(224, 0, 0, 251)
mdnsGroupIPv6 = net.ParseIP("ff02::fb")
// mDNS wildcard addresses
mdnsWildcardAddrIPv4 = &net.UDPAddr{
IP: net.ParseIP("224.0.0.0"),
Port: 5353,
}
mdnsWildcardAddrIPv6 = &net.UDPAddr{
IP: net.ParseIP("ff02::"),
// IP: net.ParseIP("fd00::12d3:26e7:48db:e7d"),
Port: 5353,
}
// mDNS endpoint addresses
ipv4Addr = &net.UDPAddr{
IP: mdnsGroupIPv4,
Port: 5353,
}
ipv6Addr = &net.UDPAddr{
IP: mdnsGroupIPv6,
Port: 5353,
}
)
func joinUdp6Multicast(interfaces []net.Interface) (*ipv6.PacketConn, error) {
udpConn, err := net.ListenUDP("udp6", mdnsWildcardAddrIPv6)
if err != nil {
return nil, err
}
// Join multicast groups to receive announcements
pkConn := ipv6.NewPacketConn(udpConn)
pkConn.SetControlMessage(ipv6.FlagInterface, true)
if len(interfaces) == 0 {
interfaces = listMulticastInterfaces()
}
// log.Println("Using multicast interfaces: ", interfaces)
var failedJoins int
for _, iface := range interfaces {
if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
// log.Println("Udp6 JoinGroup failed for iface ", iface)
failedJoins++
}
}
if failedJoins == len(interfaces) {
pkConn.Close()
return nil, fmt.Errorf("udp6: failed to join any of these interfaces: %v", interfaces)
}
return pkConn, nil
}
func joinUdp4Multicast(interfaces []net.Interface) (*ipv4.PacketConn, error) {
udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4)
if err != nil {
// log.Printf("[ERR] bonjour: Failed to bind to udp4 mutlicast: %v", err)
return nil, err
}
// Join multicast groups to receive announcements
pkConn := ipv4.NewPacketConn(udpConn)
pkConn.SetControlMessage(ipv4.FlagInterface, true)
if len(interfaces) == 0 {
interfaces = listMulticastInterfaces()
}
// log.Println("Using multicast interfaces: ", interfaces)
var failedJoins int
for _, iface := range interfaces {
if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
// log.Println("Udp4 JoinGroup failed for iface ", iface)
failedJoins++
}
}
if failedJoins == len(interfaces) {
pkConn.Close()
return nil, fmt.Errorf("udp4: failed to join any of these interfaces: %v", interfaces)
}
return pkConn, nil
}
func listMulticastInterfaces() []net.Interface {
var interfaces []net.Interface
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for _, ifi := range ifaces {
if (ifi.Flags & net.FlagUp) == 0 {
continue
}
if (ifi.Flags & net.FlagMulticast) > 0 {
interfaces = append(interfaces, ifi)
}
}
return interfaces
}
+14
View File
@@ -0,0 +1,14 @@
// Package zeroconf is a pure Golang library that employs Multicast DNS-SD for
// browsing and resolving services in your network and registering own services
// in the local network.
//
// It basically implements aspects of the standards
// RFC 6762 (mDNS) and
// RFC 6763 (DNS-SD).
// Though it does not support all requirements yet, the aim is to provide a
// complient solution in the long-term with the community.
//
// By now, it should be compatible to [Avahi](http://avahi.org/) (tested) and
// Apple's Bonjour (untested). Should work in the most office, home and private
// environments.
package zeroconf
+14
View File
@@ -0,0 +1,14 @@
module github.com/grandcat/zeroconf
go 1.26.0
require (
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/miekg/dns v1.1.73
golang.org/x/net v0.60.0
)
require (
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
)
+10
View File
@@ -0,0 +1,10 @@
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+42
View File
@@ -0,0 +1,42 @@
package zeroconf
import (
"net"
"testing"
"github.com/miekg/dns"
)
func TestHandleQuestionAnswersHostnameA(t *testing.T) {
svc := NewServiceEntry("alias", "_kportal._tcp", "local.")
svc.HostName = "alias.local."
svc.AddrIPv4 = []net.IP{net.IPv4(127, 0, 0, 1)}
s := &Server{service: svc, ttl: 3200}
q := dns.Question{Name: "alias.local.", Qtype: dns.TypeA, Qclass: dns.ClassINET}
resp := new(dns.Msg)
if err := s.handleQuestion(q, resp, new(dns.Msg), 0); err != nil {
t.Fatal(err)
}
if len(resp.Answer) != 1 {
t.Fatalf("want 1 answer, got %d", len(resp.Answer))
}
a, ok := resp.Answer[0].(*dns.A)
if !ok || !a.A.Equal(net.IPv4(127, 0, 0, 1)) || a.Hdr.Name != "alias.local." {
t.Fatalf("unexpected answer: %v", resp.Answer[0])
}
}
func TestHandleQuestionIgnoresOtherTypesForHostname(t *testing.T) {
svc := NewServiceEntry("alias", "_kportal._tcp", "local.")
svc.HostName = "alias.local."
svc.AddrIPv4 = []net.IP{net.IPv4(127, 0, 0, 1)}
s := &Server{service: svc, ttl: 3200}
q := dns.Question{Name: "alias.local.", Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
resp := new(dns.Msg)
_ = s.handleQuestion(q, resp, new(dns.Msg), 0)
if len(resp.Answer) != 0 {
t.Fatalf("want no answer, got %d", len(resp.Answer))
}
}
+750
View File
@@ -0,0 +1,750 @@
package zeroconf
import (
"fmt"
"log"
"math/rand"
"net"
"os"
"strings"
"sync"
"time"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"errors"
"github.com/miekg/dns"
)
const (
// Number of Multicast responses sent for a query message (default: 1 < x < 9)
multicastRepetitions = 2
)
// Register a service by given arguments. This call will take the system's hostname
// and lookup IP by that hostname.
func Register(instance, service, domain string, port int, text []string, ifaces []net.Interface) (*Server, error) {
entry := NewServiceEntry(instance, service, domain)
entry.Port = port
entry.Text = text
if entry.Instance == "" {
return nil, fmt.Errorf("Missing service instance name")
}
if entry.Service == "" {
return nil, fmt.Errorf("Missing service name")
}
if entry.Domain == "" {
entry.Domain = "local."
}
if entry.Port == 0 {
return nil, fmt.Errorf("Missing port")
}
var err error
if entry.HostName == "" {
entry.HostName, err = os.Hostname()
if err != nil {
return nil, fmt.Errorf("Could not determine host")
}
}
if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) {
entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain))
}
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
for _, iface := range ifaces {
v4, v6 := addrsForInterface(&iface)
entry.AddrIPv4 = append(entry.AddrIPv4, v4...)
entry.AddrIPv6 = append(entry.AddrIPv6, v6...)
}
if entry.AddrIPv4 == nil && entry.AddrIPv6 == nil {
return nil, fmt.Errorf("Could not determine host IP addresses")
}
s, err := newServer(ifaces)
if err != nil {
return nil, err
}
s.service = entry
go s.mainloop()
go s.probe()
return s, nil
}
// RegisterProxy registers a service proxy. This call will skip the hostname/IP lookup and
// will use the provided values.
func RegisterProxy(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface) (*Server, error) {
entry := NewServiceEntry(instance, service, domain)
entry.Port = port
entry.Text = text
entry.HostName = host
if entry.Instance == "" {
return nil, fmt.Errorf("Missing service instance name")
}
if entry.Service == "" {
return nil, fmt.Errorf("Missing service name")
}
if entry.HostName == "" {
return nil, fmt.Errorf("Missing host name")
}
if entry.Domain == "" {
entry.Domain = "local"
}
if entry.Port == 0 {
return nil, fmt.Errorf("Missing port")
}
if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) {
entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain))
}
for _, ip := range ips {
ipAddr := net.ParseIP(ip)
if ipAddr == nil {
return nil, fmt.Errorf("Failed to parse given IP: %v", ip)
} else if ipv4 := ipAddr.To4(); ipv4 != nil {
entry.AddrIPv4 = append(entry.AddrIPv4, ipAddr)
} else if ipv6 := ipAddr.To16(); ipv6 != nil {
entry.AddrIPv6 = append(entry.AddrIPv6, ipAddr)
} else {
return nil, fmt.Errorf("The IP is neither IPv4 nor IPv6: %#v", ipAddr)
}
}
if len(ifaces) == 0 {
ifaces = listMulticastInterfaces()
}
s, err := newServer(ifaces)
if err != nil {
return nil, err
}
s.service = entry
go s.mainloop()
go s.probe()
return s, nil
}
const (
qClassCacheFlush uint16 = 1 << 15
)
// Server structure encapsulates both IPv4/IPv6 UDP connections
type Server struct {
service *ServiceEntry
ipv4conn *ipv4.PacketConn
ipv6conn *ipv6.PacketConn
ifaces []net.Interface
shouldShutdown chan struct{}
shutdownLock sync.Mutex
shutdownEnd sync.WaitGroup
isShutdown bool
ttl uint32
}
// Constructs server structure
func newServer(ifaces []net.Interface) (*Server, error) {
ipv4conn, err4 := joinUdp4Multicast(ifaces)
if err4 != nil {
log.Printf("[zeroconf] no suitable IPv4 interface: %s", err4.Error())
}
ipv6conn, err6 := joinUdp6Multicast(ifaces)
if err6 != nil {
log.Printf("[zeroconf] no suitable IPv6 interface: %s", err6.Error())
}
if err4 != nil && err6 != nil {
// No supported interface left.
return nil, fmt.Errorf("No supported interface")
}
s := &Server{
ipv4conn: ipv4conn,
ipv6conn: ipv6conn,
ifaces: ifaces,
ttl: 3200,
shouldShutdown: make(chan struct{}),
}
return s, nil
}
// Start listeners and waits for the shutdown signal from exit channel
func (s *Server) mainloop() {
if s.ipv4conn != nil {
go s.recv4(s.ipv4conn)
}
if s.ipv6conn != nil {
go s.recv6(s.ipv6conn)
}
}
// Shutdown closes all udp connections and unregisters the service
func (s *Server) Shutdown() {
s.shutdown()
}
// SetText updates and announces the TXT records
func (s *Server) SetText(text []string) {
s.service.Text = text
s.announceText()
}
// TTL sets the TTL for DNS replies
func (s *Server) TTL(ttl uint32) {
s.ttl = ttl
}
// Shutdown server will close currently open connections & channel
func (s *Server) shutdown() error {
s.shutdownLock.Lock()
defer s.shutdownLock.Unlock()
if s.isShutdown {
return errors.New("Server is already shutdown")
}
err := s.unregister()
if err != nil {
return err
}
close(s.shouldShutdown)
if s.ipv4conn != nil {
s.ipv4conn.Close()
}
if s.ipv6conn != nil {
s.ipv6conn.Close()
}
// Wait for connection and routines to be closed
s.shutdownEnd.Wait()
s.isShutdown = true
return nil
}
// recv is a long running routine to receive packets from an interface
func (s *Server) recv4(c *ipv4.PacketConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
s.shutdownEnd.Add(1)
defer s.shutdownEnd.Done()
for {
select {
case <-s.shouldShutdown:
return
default:
var ifIndex int
n, cm, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if cm != nil {
ifIndex = cm.IfIndex
}
if err := s.parsePacket(buf[:n], ifIndex, from); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle query v4: %v", err)
}
}
}
}
// recv is a long running routine to receive packets from an interface
func (s *Server) recv6(c *ipv6.PacketConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
s.shutdownEnd.Add(1)
defer s.shutdownEnd.Done()
for {
select {
case <-s.shouldShutdown:
return
default:
var ifIndex int
n, cm, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if cm != nil {
ifIndex = cm.IfIndex
}
if err := s.parsePacket(buf[:n], ifIndex, from); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle query v6: %v", err)
}
}
}
}
// parsePacket is used to parse an incoming packet
func (s *Server) parsePacket(packet []byte, ifIndex int, from net.Addr) error {
var msg dns.Msg
if err := msg.Unpack(packet); err != nil {
// log.Printf("[ERR] zeroconf: Failed to unpack packet: %v", err)
return err
}
return s.handleQuery(&msg, ifIndex, from)
}
// handleQuery is used to handle an incoming query
func (s *Server) handleQuery(query *dns.Msg, ifIndex int, from net.Addr) error {
// Ignore questions with authoritative section for now
if len(query.Ns) > 0 {
return nil
}
// Handle each question
var err error
for _, q := range query.Question {
resp := dns.Msg{}
resp.SetReply(query)
resp.Compress = true
resp.RecursionDesired = false
resp.Authoritative = true
resp.Question = nil // RFC6762 section 6 "responses MUST NOT contain any questions"
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
if err = s.handleQuestion(q, &resp, query, ifIndex); err != nil {
// log.Printf("[ERR] zeroconf: failed to handle question %v: %v", q, err)
continue
}
// Check if there is an answer
if len(resp.Answer) == 0 {
continue
}
if isUnicastQuestion(q) {
// Send unicast
if e := s.unicastResponse(&resp, ifIndex, from); e != nil {
err = e
}
} else {
// Send mulicast
if e := s.multicastResponse(&resp, ifIndex); e != nil {
err = e
}
}
}
return err
}
// RFC6762 7.1. Known-Answer Suppression
func isKnownAnswer(resp *dns.Msg, query *dns.Msg) bool {
if len(resp.Answer) == 0 || len(query.Answer) == 0 {
return false
}
if resp.Answer[0].Header().Rrtype != dns.TypePTR {
return false
}
answer := resp.Answer[0].(*dns.PTR)
for _, known := range query.Answer {
hdr := known.Header()
if hdr.Rrtype != answer.Hdr.Rrtype {
continue
}
ptr := known.(*dns.PTR)
if ptr.Ptr == answer.Ptr && hdr.Ttl >= answer.Hdr.Ttl/2 {
// log.Printf("skipping known answer: %v", ptr)
return true
}
}
return false
}
// handleQuestion is used to handle an incoming question
func (s *Server) handleQuestion(q dns.Question, resp *dns.Msg, query *dns.Msg, ifIndex int) error {
if s.service == nil {
return nil
}
switch q.Name {
case s.service.ServiceTypeName():
s.serviceTypeName(resp, s.ttl)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
case s.service.ServiceName():
s.composeBrowsingAnswers(resp, ifIndex)
if isKnownAnswer(resp, query) {
resp.Answer = nil
}
case s.service.ServiceInstanceName():
s.composeLookupAnswers(resp, s.ttl, ifIndex, false)
// kportal patch: answer direct A/AAAA lookups for the registered hostname
// so the address record can be refreshed after its TTL expires.
case s.service.HostName:
if q.Qtype == dns.TypeA || q.Qtype == dns.TypeAAAA || q.Qtype == dns.TypeANY {
resp.Answer = s.appendAddrs(resp.Answer, s.ttl, ifIndex, false)
}
}
return nil
}
func (s *Server) composeBrowsingAnswers(resp *dns.Msg, ifIndex int) {
ptr := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Ptr: s.service.ServiceInstanceName(),
}
resp.Answer = append(resp.Answer, ptr)
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
resp.Extra = append(resp.Extra, srv, txt)
resp.Extra = s.appendAddrs(resp.Extra, s.ttl, ifIndex, false)
}
func (s *Server) composeLookupAnswers(resp *dns.Msg, ttl uint32, ifIndex int, flushCache bool) {
// From RFC6762
// The most significant bit of the rrclass for a record in the Answer
// Section of a response message is the Multicast DNS cache-flush bit
// and is discussed in more detail below in Section 10.2, "Announcements
// to Flush Outdated Cache Entries".
ptr := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceInstanceName(),
}
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: ttl,
},
Txt: s.service.Text,
}
dnssd := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceTypeName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceName(),
}
resp.Answer = append(resp.Answer, srv, txt, ptr, dnssd)
resp.Answer = s.appendAddrs(resp.Answer, ttl, ifIndex, flushCache)
}
func (s *Server) serviceTypeName(resp *dns.Msg, ttl uint32) {
// From RFC6762
// 9. Service Type Enumeration
//
// For this purpose, a special meta-query is defined. A DNS query for
// PTR records with the name "_services._dns-sd._udp.<Domain>" yields a
// set of PTR records, where the rdata of each PTR record is the two-
// label <Service> name, plus the same domain, e.g.,
// "_http._tcp.<Domain>".
dnssd := &dns.PTR{
Hdr: dns.RR_Header{
Name: s.service.ServiceTypeName(),
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: ttl,
},
Ptr: s.service.ServiceName(),
}
resp.Answer = append(resp.Answer, dnssd)
}
// Perform probing & announcement
//TODO: implement a proper probing & conflict resolution
func (s *Server) probe() {
q := new(dns.Msg)
q.SetQuestion(s.service.ServiceInstanceName(), dns.TypePTR)
q.RecursionDesired = false
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Priority: 0,
Weight: 0,
Port: uint16(s.service.Port),
Target: s.service.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
q.Ns = []dns.RR{srv, txt}
randomizer := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < multicastRepetitions; i++ {
if err := s.multicastResponse(q, 0); err != nil {
log.Println("[ERR] zeroconf: failed to send probe:", err.Error())
}
time.Sleep(time.Duration(randomizer.Intn(250)) * time.Millisecond)
}
// From RFC6762
// The Multicast DNS responder MUST send at least two unsolicited
// responses, one second apart. To provide increased robustness against
// packet loss, a responder MAY send up to eight unsolicited responses,
// provided that the interval between unsolicited responses increases by
// at least a factor of two with every response sent.
timeout := 1 * time.Second
for i := 0; i < multicastRepetitions; i++ {
for _, intf := range s.ifaces {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
// TODO: make response authoritative if we are the publisher
resp.Compress = true
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
s.composeLookupAnswers(resp, s.ttl, intf.Index, true)
if err := s.multicastResponse(resp, intf.Index); err != nil {
log.Println("[ERR] zeroconf: failed to send announcement:", err.Error())
}
}
time.Sleep(timeout)
timeout *= 2
}
}
// announceText sends a Text announcement with cache flush enabled
func (s *Server) announceText() {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: s.service.ServiceInstanceName(),
Rrtype: dns.TypeTXT,
Class: dns.ClassINET | qClassCacheFlush,
Ttl: s.ttl,
},
Txt: s.service.Text,
}
resp.Answer = []dns.RR{txt}
s.multicastResponse(resp, 0)
}
func (s *Server) unregister() error {
resp := new(dns.Msg)
resp.MsgHdr.Response = true
resp.Answer = []dns.RR{}
resp.Extra = []dns.RR{}
s.composeLookupAnswers(resp, 0, 0, true)
return s.multicastResponse(resp, 0)
}
func (s *Server) appendAddrs(list []dns.RR, ttl uint32, ifIndex int, flushCache bool) []dns.RR {
v4 := s.service.AddrIPv4
v6 := s.service.AddrIPv6
if len(v4) == 0 && len(v6) == 0 {
iface, _ := net.InterfaceByIndex(ifIndex)
if iface != nil {
a4, a6 := addrsForInterface(iface)
v4 = append(v4, a4...)
v6 = append(v6, a6...)
}
}
if ttl > 0 {
// RFC6762 Section 10 says A/AAAA records SHOULD
// use TTL of 120s, to account for network interface
// and IP address changes.
ttl = 120
}
var cacheFlushBit uint16
if flushCache {
cacheFlushBit = qClassCacheFlush
}
for _, ipv4 := range v4 {
a := &dns.A{
Hdr: dns.RR_Header{
Name: s.service.HostName,
Rrtype: dns.TypeA,
Class: dns.ClassINET | cacheFlushBit,
Ttl: ttl,
},
A: ipv4,
}
list = append(list, a)
}
for _, ipv6 := range v6 {
aaaa := &dns.AAAA{
Hdr: dns.RR_Header{
Name: s.service.HostName,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET | cacheFlushBit,
Ttl: ttl,
},
AAAA: ipv6,
}
list = append(list, aaaa)
}
return list
}
func addrsForInterface(iface *net.Interface) ([]net.IP, []net.IP) {
var v4, v6, v6local []net.IP
addrs, _ := iface.Addrs()
for _, address := range addrs {
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
v4 = append(v4, ipnet.IP)
} else {
switch ip := ipnet.IP.To16(); ip != nil {
case ip.IsGlobalUnicast():
v6 = append(v6, ipnet.IP)
case ip.IsLinkLocalUnicast():
v6local = append(v6local, ipnet.IP)
}
}
}
}
if len(v6) == 0 {
v6 = v6local
}
return v4, v6
}
// unicastResponse is used to send a unicast response packet
func (s *Server) unicastResponse(resp *dns.Msg, ifIndex int, from net.Addr) error {
buf, err := resp.Pack()
if err != nil {
return err
}
addr := from.(*net.UDPAddr)
if addr.IP.To4() != nil {
if ifIndex != 0 {
var wcm ipv4.ControlMessage
wcm.IfIndex = ifIndex
_, err = s.ipv4conn.WriteTo(buf, &wcm, addr)
} else {
_, err = s.ipv4conn.WriteTo(buf, nil, addr)
}
return err
} else {
if ifIndex != 0 {
var wcm ipv6.ControlMessage
wcm.IfIndex = ifIndex
_, err = s.ipv6conn.WriteTo(buf, &wcm, addr)
} else {
_, err = s.ipv6conn.WriteTo(buf, nil, addr)
}
return err
}
}
// multicastResponse us used to send a multicast response packet
func (s *Server) multicastResponse(msg *dns.Msg, ifIndex int) error {
buf, err := msg.Pack()
if err != nil {
return err
}
if s.ipv4conn != nil {
var wcm ipv4.ControlMessage
if ifIndex != 0 {
wcm.IfIndex = ifIndex
s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
} else {
for _, intf := range s.ifaces {
wcm.IfIndex = intf.Index
s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr)
}
}
}
if s.ipv6conn != nil {
var wcm ipv6.ControlMessage
if ifIndex != 0 {
wcm.IfIndex = ifIndex
s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
} else {
for _, intf := range s.ifaces {
wcm.IfIndex = intf.Index
s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr)
}
}
}
return nil
}
func isUnicastQuestion(q dns.Question) bool {
// From RFC6762
// 18.12. Repurposing of Top Bit of qclass in Question Section
//
// In the Question Section of a Multicast DNS query, the top bit of the
// qclass field is used to indicate that unicast responses are preferred
// for this particular question. (See Section 5.4.)
return q.Qclass&qClassCacheFlush != 0
}
+109
View File
@@ -0,0 +1,109 @@
package zeroconf
import (
"fmt"
"net"
"sync"
)
// ServiceRecord contains the basic description of a service, which contains instance name, service type & domain
type ServiceRecord struct {
Instance string `json:"name"` // Instance name (e.g. "My web page")
Service string `json:"type"` // Service name (e.g. _http._tcp.)
Domain string `json:"domain"` // If blank, assumes "local"
// private variable populated on ServiceRecord creation
serviceName string
serviceInstanceName string
serviceTypeName string
}
// ServiceName returns a complete service name (e.g. _foobar._tcp.local.), which is composed
// of a service name (also referred as service type) and a domain.
func (s *ServiceRecord) ServiceName() string {
return s.serviceName
}
// ServiceInstanceName returns a complete service instance name (e.g. MyDemo\ Service._foobar._tcp.local.),
// which is composed from service instance name, service name and a domain.
func (s *ServiceRecord) ServiceInstanceName() string {
return s.serviceInstanceName
}
// ServiceTypeName returns the complete identifier for a DNS-SD query.
func (s *ServiceRecord) ServiceTypeName() string {
return s.serviceTypeName
}
// NewServiceRecord constructs a ServiceRecord.
func NewServiceRecord(instance, service, domain string) *ServiceRecord {
s := &ServiceRecord{
Instance: instance,
Service: service,
Domain: domain,
serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)),
}
// Cache service instance name
if instance != "" {
s.serviceInstanceName = fmt.Sprintf("%s.%s", trimDot(s.Instance), s.ServiceName())
}
// Cache service type name domain
typeNameDomain := "local"
if len(s.Domain) > 0 {
typeNameDomain = trimDot(s.Domain)
}
s.serviceTypeName = fmt.Sprintf("_services._dns-sd._udp.%s.", typeNameDomain)
return s
}
// LookupParams contains configurable properties to create a service discovery request
type LookupParams struct {
ServiceRecord
Entries chan<- *ServiceEntry // Entries Channel
stopProbing chan struct{}
once sync.Once
}
// NewLookupParams constructs a LookupParams.
func NewLookupParams(instance, service, domain string, entries chan<- *ServiceEntry) *LookupParams {
return &LookupParams{
ServiceRecord: *NewServiceRecord(instance, service, domain),
Entries: entries,
stopProbing: make(chan struct{}),
}
}
// Notify subscriber that no more entries will arrive. Mostly caused
// by an expired context.
func (l *LookupParams) done() {
close(l.Entries)
}
func (l *LookupParams) disableProbing() {
l.once.Do(func() { close(l.stopProbing) })
}
// ServiceEntry represents a browse/lookup result for client API.
// It is also used to configure service registration (server API), which is
// used to answer multicast queries.
type ServiceEntry struct {
ServiceRecord
HostName string `json:"hostname"` // Host machine DNS name
Port int `json:"port"` // Service Port
Text []string `json:"text"` // Service info served as a TXT record
TTL uint32 `json:"ttl"` // TTL of the service record
AddrIPv4 []net.IP `json:"-"` // Host machine IPv4 address
AddrIPv6 []net.IP `json:"-"` // Host machine IPv6 address
}
// NewServiceEntry constructs a ServiceEntry.
func NewServiceEntry(instance, service, domain string) *ServiceEntry {
return &ServiceEntry{
ServiceRecord: *NewServiceRecord(instance, service, domain),
}
}
+8
View File
@@ -0,0 +1,8 @@
package zeroconf
import "strings"
// trimDot is used to trim the dots from the start or end of a string
func trimDot(s string) string {
return strings.Trim(s, ".")
}