mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
968b85dbf0 | ||
|
|
935962f6da | ||
|
|
789cc7626f | ||
|
|
8bd14cce3b | ||
|
|
7e3c112738 | ||
|
|
f7287b3ef6 | ||
|
|
985220b6e6 | ||
|
|
b940fa7f7a | ||
|
|
63c905dead | ||
|
|
1c419ab1c2 | ||
|
|
260a909e3d | ||
|
|
554f1e5ae0 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## ✨ 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
File diff suppressed because it is too large
Load Diff
@@ -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.61.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.47.0 // indirect
|
||||
golang.org/x/text v0.43.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
|
||||
|
||||
@@ -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.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
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.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// Color palette for wizards
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+11
@@ -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
@@ -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.
|
||||
Vendored
+27
@@ -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.
|
||||
|
||||
Vendored
+456
@@ -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
|
||||
}
|
||||
Vendored
+115
@@ -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
|
||||
}
|
||||
Vendored
+14
@@ -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
|
||||
Vendored
+14
@@ -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.61.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
)
|
||||
Vendored
+10
@@ -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.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
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
@@ -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))
|
||||
}
|
||||
}
|
||||
Vendored
+750
@@ -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
|
||||
}
|
||||
Vendored
+109
@@ -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),
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -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, ".")
|
||||
}
|
||||
Reference in New Issue
Block a user