mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-28 05:26:27 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1167847fd4 |
@@ -9,34 +9,25 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Modern Kubernetes port-forward manager with interactive terminal UI</strong>
|
<strong>Kubernetes port-forward manager with interactive terminal UI</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
kportal simplifies managing multiple Kubernetes port-forwards with an elegant, interactive terminal interface. Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), it provides real-time status updates, automatic reconnection, and hot-reload configuration support.
|
kportal manages multiple Kubernetes port-forwards with an interactive terminal interface. It provides real-time status updates, automatic reconnection, hot-reload configuration, and mDNS hostname publishing.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🎯 **Interactive TUI** - Beautiful terminal interface with keyboard navigation (↑↓/jk, Space to toggle, q to quit)
|
- **Interactive TUI** - Terminal interface with keyboard navigation
|
||||||
- ➕ **Live Add** - Add new port-forwards on-the-fly without editing config files or restarting
|
- **Live management** - Add, edit, and delete port-forwards without restarting
|
||||||
- ✏️ **Live Edit** - Modify existing port-forwards (ports, resources, aliases) in real-time
|
- **Auto-reconnect** - Exponential backoff retry on connection failures
|
||||||
- 🗑️ **Live Delete** - Remove port-forwards instantly from the running session
|
- **Hot-reload** - Configuration changes applied automatically
|
||||||
- 🔄 **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s)
|
- **Health monitoring** - Multiple check methods with stale connection detection
|
||||||
- ⚡ **Hot-Reload** - Update configuration without restarting - changes applied automatically
|
- **Multi-context** - Support for multiple Kubernetes contexts and namespaces
|
||||||
- 🏥 **Advanced Health Checks** - Multiple check methods (tcp-dial, data-transfer) with stale connection detection
|
- **Pod restart handling** - Automatic reconnection when pods restart
|
||||||
- 🛡️ **Goroutine Watchdog** - Detects and recovers from completely hung workers
|
- **Label selectors** - Dynamic pod targeting using label selectors
|
||||||
- 🎨 **Multi-Context** - Support for multiple Kubernetes contexts and namespaces
|
- **Port conflict detection** - Validates port availability with PID information
|
||||||
- 📦 **Batch Management** - Manage all port-forwards from a single configuration file
|
- **mDNS hostnames** - Access forwards via `.local` hostnames
|
||||||
- 🔌 **Toggle Forwards** - Enable/disable individual port-forwards on the fly with Space key
|
|
||||||
- 🚀 **Grace Period** - Smart 10-second grace period to avoid false "Error" status on startup
|
|
||||||
- 📊 **Status Display** - Clear visual indicators: Active (●), Starting (○), Reconnecting (◐), Error (✗)
|
|
||||||
- 🔍 **Error Reporting** - Detailed error messages displayed below the table
|
|
||||||
- 🔄 **Pod Restart Handling** - Detects and reconnects to pods when they restart
|
|
||||||
- 🏷️ **Label Selector Support** - Dynamically target pods using label selectors
|
|
||||||
- 📋 **Prefix Matching** - Automatically find and reconnect to pods with name prefixes
|
|
||||||
- 🚫 **Port Conflict Detection** - Validates port availability before starting with detailed PID info
|
|
||||||
- 🎭 **Alias Support** - Cleaner, more readable display names for your forwards
|
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
@@ -46,7 +37,7 @@ kportal simplifies managing multiple Kubernetes port-forwards with an elegant, i
|
|||||||
brew install lukaszraczylo/brew-taps/kportal
|
brew install lukaszraczylo/brew-taps/kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
### Quick Install Script
|
### Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
||||||
@@ -54,24 +45,19 @@ curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.
|
|||||||
|
|
||||||
### Manual Download
|
### Manual Download
|
||||||
|
|
||||||
Download the latest binary for your platform from the [releases page](https://github.com/lukaszraczylo/kportal/releases):
|
Download binaries from the [releases page](https://github.com/lukaszraczylo/kportal/releases).
|
||||||
|
|
||||||
- **macOS**: `kportal-{version}-darwin-{amd64|arm64}.tar.gz`
|
|
||||||
- **Linux**: `kportal-{version}-linux-{amd64|arm64}.tar.gz`
|
|
||||||
- **Windows**: `kportal-{version}-windows-{amd64|arm64}.zip`
|
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/lukaszraczylo/kportal.git
|
git clone https://github.com/lukaszraczylo/kportal.git
|
||||||
cd kportal
|
cd kportal
|
||||||
make build
|
make build && make install
|
||||||
make install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
1. **Create a configuration file** (`.kportal.yaml`):
|
Create `.kportal.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
contexts:
|
contexts:
|
||||||
@@ -85,32 +71,32 @@ contexts:
|
|||||||
localPort: 5432
|
localPort: 5432
|
||||||
alias: prod-db
|
alias: prod-db
|
||||||
|
|
||||||
- name: frontend
|
|
||||||
forwards:
|
|
||||||
- resource: service/redis
|
- resource: service/redis
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
port: 6379
|
port: 6379
|
||||||
localPort: 6380
|
localPort: 6379
|
||||||
alias: prod-redis
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Run kportal**:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal
|
kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Navigate the interface**:
|
### Keyboard Controls
|
||||||
- `↑↓` or `j/k` - Navigate through forwards
|
|
||||||
- `Space` or `Enter` - Toggle forward on/off
|
| Key | Action |
|
||||||
- `a` - Add new port-forward interactively
|
|-----|--------|
|
||||||
- `e` - Edit selected port-forward
|
| `↑↓` / `j/k` | Navigate |
|
||||||
- `d` - Delete selected port-forward
|
| `Space` / `Enter` | Toggle forward |
|
||||||
- `q` - Quit application
|
| `a` | Add forward |
|
||||||
|
| `e` | Edit forward |
|
||||||
|
| `d` | Delete forward |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
## 📖 Configuration
|
## 📖 Configuration
|
||||||
|
|
||||||
### Simple Configuration
|
### Basic Structure
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
contexts:
|
contexts:
|
||||||
@@ -118,592 +104,207 @@ contexts:
|
|||||||
namespaces:
|
namespaces:
|
||||||
- name: <namespace-name>
|
- name: <namespace-name>
|
||||||
forwards:
|
forwards:
|
||||||
- resource: <resource-type>/<resource-name>
|
- resource: <type>/<name>
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
port: <remote-port>
|
port: <remote-port>
|
||||||
localPort: <local-port>
|
localPort: <local-port>
|
||||||
alias: <friendly-name> # Optional
|
alias: <display-name> # optional
|
||||||
|
selector: <label-selector> # optional
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Configuration
|
### Forward Options
|
||||||
|
|
||||||
```yaml
|
| Field | Required | Description |
|
||||||
contexts:
|
|-------|----------|-------------|
|
||||||
# Production cluster
|
| `resource` | Yes | Resource type and name (e.g., `service/postgres`, `pod/my-app`) |
|
||||||
- name: prod-us-west
|
| `protocol` | Yes | Protocol (`tcp`) |
|
||||||
namespaces:
|
| `port` | Yes | Remote port |
|
||||||
- name: databases
|
| `localPort` | Yes | Local port |
|
||||||
forwards:
|
| `alias` | No | Display name and mDNS hostname |
|
||||||
# Direct pod connection with prefix matching
|
| `selector` | No | Label selector for pod resolution |
|
||||||
- resource: pod/postgres-primary
|
|
||||||
protocol: tcp
|
|
||||||
port: 5432
|
|
||||||
localPort: 5432
|
|
||||||
alias: prod-postgres
|
|
||||||
|
|
||||||
# Service connection
|
|
||||||
- resource: service/redis-master
|
|
||||||
protocol: tcp
|
|
||||||
port: 6379
|
|
||||||
localPort: 6379
|
|
||||||
alias: prod-redis
|
|
||||||
|
|
||||||
# Pod with label selector
|
|
||||||
- resource: pod
|
|
||||||
selector: app=mongodb
|
|
||||||
protocol: tcp
|
|
||||||
port: 27017
|
|
||||||
localPort: 27017
|
|
||||||
alias: mongo
|
|
||||||
|
|
||||||
- name: applications
|
|
||||||
forwards:
|
|
||||||
- resource: deployment/api-server
|
|
||||||
protocol: tcp
|
|
||||||
port: 8080
|
|
||||||
localPort: 8080
|
|
||||||
alias: api
|
|
||||||
|
|
||||||
# Development cluster
|
|
||||||
- name: dev-local
|
|
||||||
namespaces:
|
|
||||||
- name: default
|
|
||||||
forwards:
|
|
||||||
- resource: service/grafana
|
|
||||||
protocol: tcp
|
|
||||||
port: 3000
|
|
||||||
localPort: 3000
|
|
||||||
alias: grafana-dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Options
|
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
|
||||||
|-------|------|----------|-------------|
|
|
||||||
| `resource` | string | Yes | Kubernetes resource with type prefix (e.g., `service/name`, `pod/name`) |
|
|
||||||
| `protocol` | string | Yes | Connection protocol (typically `tcp`) |
|
|
||||||
| `port` | int | Yes | Remote port on the Kubernetes resource |
|
|
||||||
| `localPort` | int | Yes | Local port to forward to |
|
|
||||||
| `alias` | string | No | Friendly name for display (defaults to resource name) |
|
|
||||||
| `selector` | string | No | Label selector for dynamic pod selection (e.g., `app=nginx,env=prod`) |
|
|
||||||
|
|
||||||
### Resource Formats
|
### Resource Formats
|
||||||
|
|
||||||
- **Pod by name**: `pod/pod-name` or just `pod-name`
|
| Format | Description |
|
||||||
- **Pod by prefix**: `pod/my-app` (matches `my-app-xyz789`, `my-app-abc123`, etc.)
|
|--------|-------------|
|
||||||
- **Pod by selector**: Set `resource: pod` and use `selector: app=nginx`
|
| `service/name` | Service forwarding |
|
||||||
- **Service**: `service/service-name` or `svc/service-name`
|
| `pod/name` | Direct pod by name |
|
||||||
- **Deployment**: `deployment/deployment-name` or `deploy/deployment-name`
|
| `pod/prefix` | Pod by prefix (matches `prefix-*`) |
|
||||||
|
| `pod` + `selector` | Pod by label selector |
|
||||||
|
| `deployment/name` | Deployment |
|
||||||
|
|
||||||
### Health Check & Reliability (Advanced)
|
### Health Check Configuration
|
||||||
|
|
||||||
kportal includes advanced health checking to prevent stale connections during long-running operations like database dumps:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
healthCheck:
|
healthCheck:
|
||||||
interval: "3s" # Health check frequency (default: 3s)
|
interval: "3s" # Check frequency
|
||||||
timeout: "2s" # Health check timeout (default: 2s)
|
timeout: "2s" # Check timeout
|
||||||
method: "data-transfer" # Check method: "tcp-dial" or "data-transfer" (default: data-transfer)
|
method: "data-transfer" # tcp-dial or data-transfer
|
||||||
maxConnectionAge: "25m" # Proactive reconnect before k8s timeout (default: 25m)
|
maxConnectionAge: "25m" # Reconnect before k8s timeout
|
||||||
maxIdleTime: "10m" # Detect hung connections (default: 10m)
|
maxIdleTime: "10m" # Detect idle connections
|
||||||
|
|
||||||
reliability:
|
|
||||||
tcpKeepalive: "30s" # TCP keepalive interval (default: 30s)
|
|
||||||
dialTimeout: "30s" # Connection dial timeout (default: 30s)
|
|
||||||
retryOnStale: true # Auto-reconnect stale connections (default: true)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Health Check Methods:**
|
|
||||||
- **`tcp-dial`**: Fast TCP connection test - verifies local port is listening
|
|
||||||
- **`data-transfer`**: More reliable - attempts to read data to verify tunnel is functional
|
|
||||||
|
|
||||||
**Stale Detection:**
|
|
||||||
- **Max Connection Age**: Kubernetes API typically has 30-minute timeout. kportal reconnects at 25 minutes by default to avoid hitting this limit. **Important**: Age-based reconnection only occurs when the connection is ALSO idle - active transfers (like database dumps) are never interrupted.
|
|
||||||
- **Max Idle Time**: Detects connections with no data transfer, common when intermediate firewalls drop idle TCP connections
|
|
||||||
|
|
||||||
**Use Case Example - Database Dumps:**
|
|
||||||
```yaml
|
|
||||||
# Optimized for long-running pg_dump
|
|
||||||
healthCheck:
|
|
||||||
method: "data-transfer"
|
|
||||||
maxConnectionAge: "20m" # Only applies when idle - won't interrupt active dumps
|
|
||||||
maxIdleTime: "5m" # Detects truly stale connections
|
|
||||||
|
|
||||||
reliability:
|
reliability:
|
||||||
tcpKeepalive: "30s"
|
tcpKeepalive: "30s"
|
||||||
|
dialTimeout: "30s"
|
||||||
retryOnStale: true
|
retryOnStale: true
|
||||||
```
|
```
|
||||||
|
|
||||||
This configuration ensures multi-hour database dumps complete without interruption. The `maxConnectionAge` will only trigger reconnection if the connection has been idle for more than `maxIdleTime`, preventing interruption of active data transfers.
|
Health check methods:
|
||||||
|
- `tcp-dial` - Fast TCP connection test
|
||||||
|
- `data-transfer` - Verifies tunnel functionality by attempting data read
|
||||||
|
|
||||||
## 🎮 Usage
|
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps.
|
||||||
|
|
||||||
### Interactive Mode (Default)
|
### mDNS Hostnames
|
||||||
|
|
||||||
|
Enable mDNS to access forwards via `.local` hostnames:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mdns:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
contexts:
|
||||||
|
- name: production
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/postgres
|
||||||
|
port: 5432
|
||||||
|
localPort: 5432
|
||||||
|
alias: prod-db # Accessible via prod-db.local:5432
|
||||||
|
```
|
||||||
|
|
||||||
|
- Explicit `alias` becomes `<alias>.local`
|
||||||
|
- Without alias, hostname is generated from resource name (`service/redis` → `redis.local`)
|
||||||
|
- Works on macOS (Bonjour) and Linux (avahi-daemon)
|
||||||
|
|
||||||
|
Verify registration:
|
||||||
|
```bash
|
||||||
|
dns-sd -B _kportal._tcp local # macOS
|
||||||
|
avahi-browse -t _kportal._tcp # Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Interactive Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal
|
kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
Starts the interactive TUI where you can:
|
|
||||||
- View all configured port-forwards in a table
|
|
||||||
- See real-time status updates (Active, Starting, Reconnecting, Error)
|
|
||||||
- Toggle forwards on/off with Space key
|
|
||||||
- View detailed error messages at the bottom of the screen
|
|
||||||
|
|
||||||
### Verbose Mode
|
### Verbose Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal -v
|
kportal -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs in verbose mode with:
|
|
||||||
- Detailed logging to stdout
|
|
||||||
- Periodic status table updates every 2 seconds
|
|
||||||
- Full error traces
|
|
||||||
- No interactive controls (for automation/debugging)
|
|
||||||
|
|
||||||
### Validate Configuration
|
### Validate Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal --check
|
kportal --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Validates your configuration file without starting any forwards:
|
### Custom Config File
|
||||||
- Checks YAML syntax
|
|
||||||
- Validates all required fields
|
|
||||||
- Detects duplicate local ports
|
|
||||||
- Shows validation errors with line numbers
|
|
||||||
|
|
||||||
### Custom Configuration File
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal -c /path/to/config.yaml
|
kportal -c /path/to/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Version Information
|
## Status Indicators
|
||||||
|
|
||||||
|
| Indicator | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `● Active` | Connection healthy |
|
||||||
|
| `○ Starting` | Initial connection (10s grace period) |
|
||||||
|
| `◐ Reconnecting` | Reconnecting after failure |
|
||||||
|
| `✗ Error` | Connection failed |
|
||||||
|
| `○ Disabled` | Manually disabled |
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Hot-Reload
|
||||||
|
|
||||||
|
Configuration changes are applied automatically. Manual reload:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal --version
|
kill -HUP $(pgrep kportal)
|
||||||
# Output: kportal version 0.1.5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 kftray Migration
|
### Port Conflict Detection
|
||||||
|
|
||||||
Migrate from kftray JSON configuration:
|
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports.
|
||||||
|
|
||||||
|
### Retry Strategy
|
||||||
|
|
||||||
|
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds.
|
||||||
|
|
||||||
|
## Migration from kftray
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal --convert configs.json --convert-output .kportal.yaml
|
kportal --convert configs.json --convert-output .kportal.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example conversion:**
|
## Signal Handling
|
||||||
|
|
||||||
kftray JSON:
|
- `Ctrl+C` / `SIGTERM` - Graceful shutdown
|
||||||
```json
|
- `SIGHUP` - Reload configuration
|
||||||
[
|
|
||||||
{
|
|
||||||
"service": "postgres",
|
|
||||||
"namespace": "default",
|
|
||||||
"local_port": 5432,
|
|
||||||
"remote_port": 5432,
|
|
||||||
"context": "production",
|
|
||||||
"workload_type": "service",
|
|
||||||
"protocol": "tcp",
|
|
||||||
"alias": "prod-db"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Converts to kportal YAML:
|
|
||||||
```yaml
|
|
||||||
contexts:
|
|
||||||
- name: production
|
|
||||||
namespaces:
|
|
||||||
- name: default
|
|
||||||
forwards:
|
|
||||||
- resource: service/postgres
|
|
||||||
protocol: tcp
|
|
||||||
port: 5432
|
|
||||||
localPort: 5432
|
|
||||||
alias: prod-db
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Status Indicators
|
|
||||||
|
|
||||||
| Indicator | Status | Description |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| `● Active` | 🟢 Green | Port-forward is active and healthy |
|
|
||||||
| `○ Starting` | 🟡 Yellow | Initial connection in progress (10s grace period) |
|
|
||||||
| `◐ Reconnecting` | 🟡 Yellow | Attempting to reconnect after failure |
|
|
||||||
| `✗ Error` | 🔴 Red | Connection failed - see error details below table |
|
|
||||||
| `○ Disabled` | ⚪ Gray | Port-forward manually disabled via Space key |
|
|
||||||
|
|
||||||
## 🛠️ Advanced Features
|
|
||||||
|
|
||||||
### Hot-Reload
|
|
||||||
|
|
||||||
kportal automatically watches for configuration file changes and reloads:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Edit your config while kportal is running
|
|
||||||
vim .kportal.yaml
|
|
||||||
|
|
||||||
# Changes are applied automatically within seconds:
|
|
||||||
# - New forwards are started
|
|
||||||
# - Removed forwards are stopped
|
|
||||||
# - Existing forwards continue running unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
Supports manual reload via `SIGHUP`:
|
|
||||||
```bash
|
|
||||||
kill -HUP $(pgrep kportal)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
|
|
||||||
Built-in health monitoring system:
|
|
||||||
- **Check interval**: Every 5 seconds
|
|
||||||
- **Timeout**: 2 seconds per check
|
|
||||||
- **Grace period**: 10 seconds for new connections
|
|
||||||
- **Automatic updates**: Real-time status changes in UI
|
|
||||||
- **Error tracking**: Detailed error messages for failed connections
|
|
||||||
|
|
||||||
### Error Display
|
|
||||||
|
|
||||||
When connections fail, errors are displayed below the table:
|
|
||||||
|
|
||||||
```
|
|
||||||
Errors:
|
|
||||||
• prod-postgres: dial tcp 127.0.0.1:5432: connect: connection refused
|
|
||||||
• prod-redis: i/o timeout after 2.0s
|
|
||||||
```
|
|
||||||
|
|
||||||
Errors automatically clear when:
|
|
||||||
- Connection becomes healthy
|
|
||||||
- Forward is disabled
|
|
||||||
- Forward is removed
|
|
||||||
|
|
||||||
### Port Conflict Detection
|
|
||||||
|
|
||||||
kportal checks for port conflicts at multiple stages:
|
|
||||||
|
|
||||||
**At startup:**
|
|
||||||
```
|
|
||||||
Port conflicts detected:
|
|
||||||
Port 8080:
|
|
||||||
• Requested by: api-server (context: prod, namespace: default)
|
|
||||||
• Currently used by: PID 1234 (chrome)
|
|
||||||
```
|
|
||||||
|
|
||||||
**During hot-reload:**
|
|
||||||
- Only validates new ports being added
|
|
||||||
- Skips currently managed ports
|
|
||||||
- Rejects configuration if conflicts found
|
|
||||||
|
|
||||||
### Pod Restart Handling
|
|
||||||
|
|
||||||
When a pod restarts:
|
|
||||||
1. Port-forward connection breaks
|
|
||||||
2. kportal immediately re-resolves the resource:
|
|
||||||
- For prefix matches: Finds newest pod with matching prefix
|
|
||||||
- For selectors: Re-queries pods with matching labels
|
|
||||||
3. Reconnects to new pod
|
|
||||||
4. Logs the switch: `Switched to new pod: old-pod-abc → new-pod-xyz`
|
|
||||||
|
|
||||||
### Retry Strategy
|
|
||||||
|
|
||||||
Exponential backoff with maximum interval:
|
|
||||||
- **Intervals**: 1s → 2s → 4s → 8s → 10s (max)
|
|
||||||
- **Infinite retries**: Continues until connection succeeds
|
|
||||||
- **Independent**: Each forward has its own retry logic
|
|
||||||
- **Grace period**: First 10 seconds show "Starting" instead of "Error"
|
|
||||||
|
|
||||||
## 🔧 Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Go 1.23 or higher
|
|
||||||
- Access to a Kubernetes cluster
|
|
||||||
- kubectl configured with contexts
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build binary
|
|
||||||
make build
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
make test
|
|
||||||
|
|
||||||
# Run all checks (fmt, vet, staticcheck, test)
|
|
||||||
make all
|
|
||||||
|
|
||||||
# Check current version
|
|
||||||
make version
|
|
||||||
|
|
||||||
# Install locally
|
|
||||||
make install
|
|
||||||
|
|
||||||
# Install system-wide
|
|
||||||
sudo make install-system
|
|
||||||
|
|
||||||
# Clean build artifacts
|
|
||||||
make clean
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
kportal/
|
|
||||||
├── cmd/kportal/ # Main application entry point
|
|
||||||
├── internal/
|
|
||||||
│ ├── config/ # Configuration parsing and validation
|
|
||||||
│ ├── forward/ # Port-forward manager and workers
|
|
||||||
│ │ ├── manager.go # Orchestrates all forwards
|
|
||||||
│ │ ├── worker.go # Individual forward worker
|
|
||||||
│ │ └── port_checker.go # Port conflict detection
|
|
||||||
│ ├── healthcheck/ # Health monitoring system
|
|
||||||
│ │ └── checker.go # Port health checking
|
|
||||||
│ ├── k8s/ # Kubernetes client wrapper
|
|
||||||
│ │ ├── client.go # K8s client management
|
|
||||||
│ │ ├── port_forward.go # Port-forward implementation
|
|
||||||
│ │ └── resolver.go # Resource resolution
|
|
||||||
│ ├── retry/ # Retry logic with backoff
|
|
||||||
│ │ └── backoff.go # Exponential backoff
|
|
||||||
│ ├── ui/ # Terminal UI implementations
|
|
||||||
│ │ ├── bubbletea_ui.go # Interactive TUI (Bubble Tea)
|
|
||||||
│ │ └── table_ui.go # Simple table for verbose mode
|
|
||||||
│ └── converter/ # kftray JSON converter
|
|
||||||
├── Formula/ # Homebrew formula
|
|
||||||
├── .github/workflows/ # CI/CD pipelines
|
|
||||||
│ └── release.yml # Release automation
|
|
||||||
├── install.sh # Installation script
|
|
||||||
├── semver.yaml # Semantic version config
|
|
||||||
├── Makefile # Build automation
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Examples
|
|
||||||
|
|
||||||
### Database Access
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
contexts:
|
|
||||||
production:
|
|
||||||
namespaces:
|
|
||||||
databases:
|
|
||||||
- resource: postgres-primary
|
|
||||||
port: 5432
|
|
||||||
local_port: 5432
|
|
||||||
alias: prod-db
|
|
||||||
```
|
|
||||||
|
|
||||||
Connect with:
|
|
||||||
```bash
|
|
||||||
kportal # Start in another terminal
|
|
||||||
psql -h localhost -p 5432 -U postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple Services
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
contexts:
|
|
||||||
dev:
|
|
||||||
namespaces:
|
|
||||||
default:
|
|
||||||
- resource: api
|
|
||||||
port: 8080
|
|
||||||
local_port: 8080
|
|
||||||
- resource: frontend
|
|
||||||
port: 3000
|
|
||||||
local_port: 3000
|
|
||||||
- resource: redis
|
|
||||||
port: 6379
|
|
||||||
local_port: 6379
|
|
||||||
```
|
|
||||||
|
|
||||||
Access:
|
|
||||||
- API: `http://localhost:8080`
|
|
||||||
- Frontend: `http://localhost:3000`
|
|
||||||
- Redis: `redis-cli -p 6379`
|
|
||||||
|
|
||||||
### Cross-Context Setup
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
contexts:
|
|
||||||
prod-us:
|
|
||||||
namespaces:
|
|
||||||
backend:
|
|
||||||
- resource: api
|
|
||||||
port: 8080
|
|
||||||
local_port: 8080
|
|
||||||
alias: prod-us-api
|
|
||||||
|
|
||||||
prod-eu:
|
|
||||||
namespaces:
|
|
||||||
backend:
|
|
||||||
- resource: api
|
|
||||||
port: 8080
|
|
||||||
local_port: 8081 # Different local port
|
|
||||||
alias: prod-eu-api
|
|
||||||
```
|
|
||||||
|
|
||||||
Compare APIs across regions simultaneously.
|
|
||||||
|
|
||||||
### Debug Multiple Pod Versions
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
contexts:
|
|
||||||
production:
|
|
||||||
namespaces:
|
|
||||||
default:
|
|
||||||
# Version 1
|
|
||||||
- resource: pod
|
|
||||||
selector: app=myapp,version=v1
|
|
||||||
port: 8080
|
|
||||||
local_port: 8080
|
|
||||||
alias: app-v1
|
|
||||||
|
|
||||||
# Version 2
|
|
||||||
- resource: pod
|
|
||||||
selector: app=myapp,version=v2
|
|
||||||
port: 8080
|
|
||||||
local_port: 8081
|
|
||||||
alias: app-v2
|
|
||||||
|
|
||||||
# Debug port for v2
|
|
||||||
- resource: pod
|
|
||||||
selector: app=myapp,version=v2
|
|
||||||
port: 6060
|
|
||||||
local_port: 6060
|
|
||||||
alias: app-v2-pprof
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Port Already in Use
|
### Port Already in Use
|
||||||
|
|
||||||
**Problem**: `Port 8080: already in use by PID 1234 (chrome)`
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
```bash
|
```bash
|
||||||
# Find the process
|
lsof -i :<port>
|
||||||
lsof -i :8080
|
kill <pid>
|
||||||
|
|
||||||
# Kill the process
|
|
||||||
kill 1234
|
|
||||||
|
|
||||||
# Or use a different local port in config
|
|
||||||
local_port: 8081
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|
||||||
**Problem**: `dial tcp 127.0.0.1:8080: connect: connection refused`
|
1. Verify pod is running: `kubectl get pods -n <namespace>`
|
||||||
|
2. Verify port is correct: `kubectl describe pod <pod>`
|
||||||
**Common causes**:
|
3. Check service endpoints: `kubectl get endpoints <service>`
|
||||||
1. **Pod not ready yet** - Wait for status to change from "Starting" → "Active" (10s grace period)
|
|
||||||
2. **Wrong port number** - Verify the pod/service actually exposes that port
|
|
||||||
3. **Service not exposed** - Check with `kubectl get svc` and `kubectl describe svc <name>`
|
|
||||||
|
|
||||||
**Debug**:
|
|
||||||
```bash
|
|
||||||
# Check pod status
|
|
||||||
kubectl get pods -n <namespace>
|
|
||||||
|
|
||||||
# Check if port is exposed
|
|
||||||
kubectl describe pod <pod-name> -n <namespace>
|
|
||||||
|
|
||||||
# Check service endpoints
|
|
||||||
kubectl get endpoints <service-name> -n <namespace>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Not Found
|
### Context Not Found
|
||||||
|
|
||||||
**Problem**: `context "prod" not found in kubeconfig`
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
```bash
|
||||||
# List available contexts
|
|
||||||
kubectl config get-contexts
|
kubectl config get-contexts
|
||||||
|
|
||||||
# Verify context name matches
|
|
||||||
kubectl config current-context
|
|
||||||
|
|
||||||
# Update your config to use the correct context name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health Check Errors During Startup
|
## 🔧 Development
|
||||||
|
|
||||||
**Problem**: Seeing "Error" status immediately after starting
|
### Prerequisites
|
||||||
|
|
||||||
**This is normal!** kportal has a 10-second grace period. If the connection is still failing after 10 seconds, check:
|
- Go 1.23+
|
||||||
- Pod is running: `kubectl get pods`
|
- Kubernetes cluster access
|
||||||
- Port is correct in config
|
- kubectl configured
|
||||||
- Network connectivity to cluster
|
|
||||||
|
|
||||||
### Logs Covering UI
|
### Build
|
||||||
|
|
||||||
**Problem**: Kubernetes client logs appearing over the interactive UI
|
|
||||||
|
|
||||||
**This is fixed in v0.1.5+**. The interactive mode now completely suppresses all logs including:
|
|
||||||
- Standard Go `log` package
|
|
||||||
- Kubernetes `klog` output
|
|
||||||
- Any stderr/stdout leakage
|
|
||||||
|
|
||||||
If you still see logs, please file an issue!
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Make your changes and add tests
|
|
||||||
4. Run checks: `make all`
|
|
||||||
5. Commit your changes (follow [semantic commit messages](#semantic-versioning))
|
|
||||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
7. Open a Pull Request
|
|
||||||
|
|
||||||
### Semantic Versioning
|
|
||||||
|
|
||||||
This project uses [semver-gen](https://github.com/lukaszraczylo/semver-generator) for automatic semantic version generation based on git commit messages.
|
|
||||||
|
|
||||||
**Version Keywords:**
|
|
||||||
- **Patch** (0.0.X): `fix`, `bugfix`, `hotfix`, `patch`, `docs`, `test`, `refactor`
|
|
||||||
- **Minor** (0.X.0): `feat`, `feature`, `add`, `enhance`, `update`, `improve`
|
|
||||||
- **Major** (X.0.0): `breaking`, `major`, `BREAKING CHANGE`
|
|
||||||
|
|
||||||
Example commits:
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat: add health check grace period" # Bumps minor version
|
make build # Build binary
|
||||||
git commit -m "fix: resolve port conflict detection" # Bumps patch version
|
make test # Run tests
|
||||||
git commit -m "breaking: change config file format" # Bumps major version
|
make all # fmt, vet, staticcheck, test
|
||||||
|
make install # Install locally
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📄 License
|
## Contributing
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
## License
|
||||||
|
|
||||||
- Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) by Charm - An awesome framework for building terminal UIs
|
MIT License - see [LICENSE](LICENSE).
|
||||||
- Styled with [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling library
|
|
||||||
- Inspired by [kftray](https://github.com/hcavarsan/kftray) - Original GUI port-forward manager
|
|
||||||
- Uses [client-go](https://github.com/kubernetes/client-go) for Kubernetes integration
|
|
||||||
- Version management by [semver-gen](https://github.com/lukaszraczylo/semver-generator)
|
|
||||||
|
|
||||||
## 📚 Documentation
|
## Acknowledgments
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
- [Website](https://lukaszraczylo.github.io/kportal)
|
- [Website](https://lukaszraczylo.github.io/kportal)
|
||||||
- [Issue Tracker](https://github.com/lukaszraczylo/kportal/issues)
|
- [Issues](https://github.com/lukaszraczylo/kportal/issues)
|
||||||
- [Releases](https://github.com/lukaszraczylo/kportal/releases)
|
- [Releases](https://github.com/lukaszraczylo/kportal/releases)
|
||||||
- [Changelog](CHANGELOG.md)
|
- [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
## Signal Handling
|
|
||||||
|
|
||||||
- `Ctrl+C` / `SIGTERM`: Graceful shutdown (closes all forwards)
|
|
||||||
- `SIGHUP`: Reload configuration file
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Made with ❤️ by [Lukasz Raczylo](https://github.com/lukaszraczylo)
|
|
||||||
|
|||||||
+57
-251
@@ -1,320 +1,126 @@
|
|||||||
# Release Infrastructure Setup Summary
|
# Release Infrastructure
|
||||||
|
|
||||||
This document summarizes all the release infrastructure that has been set up for kportal.
|
Documentation for kportal's release automation and distribution.
|
||||||
|
|
||||||
## ✅ Completed Setup
|
## 🔄 CI/CD Pipeline
|
||||||
|
|
||||||
### 1. GitHub Actions CI/CD Pipeline
|
|
||||||
|
|
||||||
**File**: `.github/workflows/release.yml`
|
**File**: `.github/workflows/release.yml`
|
||||||
|
|
||||||
**Features**:
|
The pipeline builds multi-platform binaries, creates GitHub releases, and updates Homebrew on version tags.
|
||||||
- Multi-platform binary builds (Linux, macOS, Windows - amd64 & arm64)
|
|
||||||
- Automatic release creation on version tags
|
### Trigger a Release
|
||||||
- Binary archiving (tar.gz for Unix, zip for Windows)
|
|
||||||
- SHA256 checksum generation
|
|
||||||
- Automated Homebrew formula updates
|
|
||||||
- Release notes generation
|
|
||||||
|
|
||||||
**How to trigger**:
|
|
||||||
```bash
|
```bash
|
||||||
# Commit with semantic versioning keywords
|
|
||||||
git commit -m "feat: add new feature"
|
|
||||||
|
|
||||||
# Tag the release
|
|
||||||
git tag -a v0.2.0 -m "Release v0.2.0"
|
git tag -a v0.2.0 -m "Release v0.2.0"
|
||||||
|
|
||||||
# Push tags
|
|
||||||
git push origin v0.2.0
|
git push origin v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
The pipeline will automatically:
|
The pipeline will:
|
||||||
1. Build binaries for all platforms
|
1. Build binaries for all platforms
|
||||||
2. Create GitHub release with binaries
|
2. Create GitHub release with binaries and checksums
|
||||||
3. Update Homebrew tap formula
|
3. Update Homebrew tap formula
|
||||||
4. Generate release notes
|
|
||||||
|
|
||||||
### 2. Installation Methods
|
## 📦 Installation Methods
|
||||||
|
|
||||||
#### A. Homebrew Formula
|
### Homebrew
|
||||||
|
|
||||||
**File**: `Formula/kportal.rb`
|
**File**: `Formula/kportal.rb`
|
||||||
|
|
||||||
**Installation command**:
|
|
||||||
```bash
|
```bash
|
||||||
brew install lukaszraczylo/tap/kportal
|
brew install lukaszraczylo/tap/kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: Formula is automatically updated by CI/CD pipeline. You'll need to create a separate tap repository:
|
Formula is automatically updated by CI/CD. Requires:
|
||||||
1. Create repo: `https://github.com/lukaszraczylo/brew-taps`
|
- Tap repository: `https://github.com/lukaszraczylo/brew-taps`
|
||||||
2. Add Formula/kportal.rb to that repo
|
- Secret: `HOMEBREW_TAP_TOKEN` with `repo` scope
|
||||||
3. Set `HOMEBREW_TAP_TOKEN` secret in GitHub repository settings
|
|
||||||
|
|
||||||
#### B. Quick Install Script
|
### Install Script
|
||||||
|
|
||||||
**File**: `install.sh`
|
**File**: `install.sh`
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Auto-detects OS and architecture
|
|
||||||
- Downloads appropriate binary
|
|
||||||
- Extracts and installs to /usr/local/bin
|
|
||||||
- Verifies installation
|
|
||||||
- Colorful output with emoji indicators
|
|
||||||
|
|
||||||
**Installation command**:
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
#### C. Manual Download
|
Auto-detects OS/architecture and installs to `/usr/local/bin`.
|
||||||
|
|
||||||
Users can download binaries directly from GitHub releases:
|
### Manual Download
|
||||||
```
|
|
||||||
https://github.com/lukaszraczylo/kportal/releases
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Documentation
|
Download from [releases page](https://github.com/lukaszraczylo/kportal/releases).
|
||||||
|
|
||||||
#### A. Comprehensive README.md
|
## Platform Support
|
||||||
|
|
||||||
**File**: `README.md`
|
| OS | Architecture | Format |
|
||||||
|
|----|--------------|--------|
|
||||||
|
| Linux | amd64, arm64 | tar.gz |
|
||||||
|
| macOS | amd64, arm64 | tar.gz |
|
||||||
|
| Windows | amd64, arm64 | zip |
|
||||||
|
|
||||||
**Contents**:
|
## 🚀 Release Process
|
||||||
- Feature showcase with emojis
|
|
||||||
- Multiple installation methods
|
|
||||||
- Quick start guide
|
|
||||||
- Configuration examples
|
|
||||||
- Usage instructions
|
|
||||||
- Advanced features documentation
|
|
||||||
- Troubleshooting guide
|
|
||||||
- Contributing guidelines
|
|
||||||
|
|
||||||
#### B. GitHub Pages Website
|
1. **Make changes and test**
|
||||||
|
|
||||||
**File**: `docs/index.html`
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Modern, responsive design with TailwindCSS
|
|
||||||
- Hero section with clear CTA
|
|
||||||
- Feature showcase cards
|
|
||||||
- Installation guide
|
|
||||||
- Configuration examples with syntax highlighting
|
|
||||||
- Documentation links
|
|
||||||
- Mobile-friendly
|
|
||||||
|
|
||||||
**URL** (once enabled): `https://lukaszraczylo.github.io/kportal`
|
|
||||||
|
|
||||||
**To enable**:
|
|
||||||
1. Go to GitHub repository settings
|
|
||||||
2. Pages section
|
|
||||||
3. Source: Deploy from a branch
|
|
||||||
4. Branch: main
|
|
||||||
5. Folder: /docs
|
|
||||||
|
|
||||||
### 4. Supporting Files
|
|
||||||
|
|
||||||
#### CHANGELOG.md
|
|
||||||
**File**: `CHANGELOG.md`
|
|
||||||
|
|
||||||
Tracks all changes following Keep a Changelog format. Update this file with each release.
|
|
||||||
|
|
||||||
#### CONTRIBUTING.md
|
|
||||||
**File**: `CONTRIBUTING.md`
|
|
||||||
|
|
||||||
Guidelines for:
|
|
||||||
- Bug reporting
|
|
||||||
- Feature requests
|
|
||||||
- Pull request process
|
|
||||||
- Commit message format
|
|
||||||
- Development setup
|
|
||||||
- Testing guidelines
|
|
||||||
|
|
||||||
## 🚀 Release Workflow
|
|
||||||
|
|
||||||
### Standard Release Process
|
|
||||||
|
|
||||||
1. **Develop features**
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b feature/my-feature
|
make test && make all
|
||||||
# Make changes
|
|
||||||
make test
|
|
||||||
make all
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Commit with semantic messages**
|
2. **Update CHANGELOG.md**
|
||||||
```bash
|
|
||||||
git commit -m "feat: add amazing feature"
|
|
||||||
git commit -m "fix: resolve bug in health check"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update CHANGELOG.md**
|
3. **Tag and push**
|
||||||
```markdown
|
|
||||||
## [0.2.0] - 2025-11-24
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Amazing new feature
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Bug in health check
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Tag the release**
|
|
||||||
```bash
|
```bash
|
||||||
git tag -a v0.2.0 -m "Release v0.2.0"
|
git tag -a v0.2.0 -m "Release v0.2.0"
|
||||||
git push origin main
|
git push origin main
|
||||||
git push origin v0.2.0
|
git push origin v0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **CI/CD automatically**:
|
## Version Bumping
|
||||||
- Builds all binaries
|
|
||||||
- Creates GitHub release
|
|
||||||
- Updates Homebrew formula
|
|
||||||
- Attaches binaries and checksums
|
|
||||||
|
|
||||||
### Version Bumping (Semantic Versioning)
|
Version determined by commit message keywords:
|
||||||
|
|
||||||
Version is automatically determined by semver-gen from commit messages:
|
| Bump | Keywords |
|
||||||
|
|------|----------|
|
||||||
|
| Patch (0.0.X) | `fix`, `bugfix`, `docs`, `test`, `refactor` |
|
||||||
|
| Minor (0.X.0) | `feat`, `feature`, `add`, `enhance`, `update` |
|
||||||
|
| Major (X.0.0) | `breaking`, `major`, `BREAKING CHANGE` |
|
||||||
|
|
||||||
- **Patch** (0.0.X): `fix`, `bugfix`, `hotfix`, `patch`, `docs`, `test`, `refactor`
|
## Required Secrets
|
||||||
- **Minor** (0.X.0): `feat`, `feature`, `add`, `enhance`, `update`, `improve`
|
|
||||||
- **Major** (X.0.0): `breaking`, `major`, `BREAKING CHANGE`
|
|
||||||
|
|
||||||
## 📦 Platform Support
|
| Secret | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `GITHUB_TOKEN` | Provided by GitHub Actions |
|
||||||
|
| `HOMEBREW_TAP_TOKEN` | Personal access token with `repo` scope |
|
||||||
|
|
||||||
### Supported Platforms
|
## ⚙️ Initial Setup
|
||||||
|
|
||||||
| OS | Architecture | Archive Format |
|
|
||||||
|---------|-------------|----------------|
|
|
||||||
| Linux | amd64 | tar.gz |
|
|
||||||
| Linux | arm64 | tar.gz |
|
|
||||||
| macOS | amd64 | tar.gz |
|
|
||||||
| macOS | arm64 | tar.gz |
|
|
||||||
| Windows | amd64 | zip |
|
|
||||||
| Windows | arm64 | zip |
|
|
||||||
|
|
||||||
## 🔒 Required GitHub Secrets
|
|
||||||
|
|
||||||
For full automation, set these secrets in your GitHub repository:
|
|
||||||
|
|
||||||
1. **GITHUB_TOKEN** - Automatically provided by GitHub Actions
|
|
||||||
2. **HOMEBREW_TAP_TOKEN** - Personal access token for updating Homebrew tap
|
|
||||||
- Create at: https://github.com/settings/tokens
|
|
||||||
- Permissions needed: `repo` scope
|
|
||||||
- Add to repository secrets
|
|
||||||
|
|
||||||
## 📝 Next Steps
|
|
||||||
|
|
||||||
### 1. Enable GitHub Pages
|
### 1. Enable GitHub Pages
|
||||||
- Repository Settings → Pages → Source: main branch, /docs folder
|
|
||||||
|
|
||||||
### 2. Create Homebrew Tap Repository
|
Repository Settings → Pages → Source: main branch, /docs folder
|
||||||
|
|
||||||
|
### 2. Create Homebrew Tap
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create new repository
|
|
||||||
gh repo create lukaszraczylo/brew-taps --public
|
gh repo create lukaszraczylo/brew-taps --public
|
||||||
|
|
||||||
# Clone and set up
|
|
||||||
git clone https://github.com/lukaszraczylo/brew-taps
|
|
||||||
cd brew-taps
|
cd brew-taps
|
||||||
cp ../kportal/Formula/kportal.rb ./Formula/
|
mkdir Formula
|
||||||
git add Formula/kportal.rb
|
# Formula will be auto-updated by CI
|
||||||
git commit -m "Initial formula for kportal"
|
|
||||||
git push origin main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Add GitHub Token to Secrets
|
### 3. Add Token Secret
|
||||||
- Repository Settings → Secrets and variables → Actions
|
|
||||||
- New repository secret
|
Repository Settings → Secrets → Actions → New secret:
|
||||||
- Name: `HOMEBREW_TAP_TOKEN`
|
- Name: `HOMEBREW_TAP_TOKEN`
|
||||||
- Value: Your personal access token
|
- Value: Personal access token with `repo` scope
|
||||||
|
|
||||||
### 4. Create First Release
|
|
||||||
```bash
|
|
||||||
cd kportal
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: initial release setup"
|
|
||||||
git push origin main
|
|
||||||
git tag -a v0.1.5 -m "Release v0.1.5"
|
|
||||||
git push origin v0.1.5
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Test Installation Methods
|
|
||||||
|
|
||||||
After first release, test:
|
|
||||||
```bash
|
|
||||||
# Homebrew (once tap is set up)
|
|
||||||
brew install lukaszraczylo/tap/kportal
|
|
||||||
|
|
||||||
# Quick install script
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
|
||||||
|
|
||||||
# Manual download
|
|
||||||
# Visit releases page and download binary
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Customization
|
|
||||||
|
|
||||||
### Update Website Colors
|
|
||||||
|
|
||||||
Edit `docs/index.html`:
|
|
||||||
```javascript
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: '#3b82f6', // Blue
|
|
||||||
secondary: '#8b5cf6', // Purple
|
|
||||||
dark: '#0f172a', // Dark slate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Release Notes Template
|
|
||||||
|
|
||||||
Edit `.github/workflows/release.yml` in the "Generate release notes" step.
|
|
||||||
|
|
||||||
## 📊 Monitoring
|
|
||||||
|
|
||||||
After releases, monitor:
|
|
||||||
- GitHub Actions workflow runs
|
|
||||||
- GitHub Releases page
|
|
||||||
- Homebrew tap repository commits
|
|
||||||
- Download statistics on releases page
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Release workflow fails
|
### Release workflow fails
|
||||||
- Check GitHub Actions logs
|
- Check GitHub Actions logs
|
||||||
- Verify all required secrets are set
|
- Verify secrets are configured
|
||||||
- Ensure tag follows v\d+.\d+.\d+ format
|
- Ensure tag follows `v\d+.\d+.\d+` format
|
||||||
|
|
||||||
### Homebrew formula not updating
|
### Homebrew not updating
|
||||||
- Verify HOMEBREW_TAP_TOKEN is valid
|
- Verify `HOMEBREW_TAP_TOKEN` is valid
|
||||||
- Check tap repository permissions
|
- Check tap repository permissions
|
||||||
- Review release workflow logs
|
|
||||||
|
|
||||||
### Install script fails
|
### Install script fails
|
||||||
- Test locally with different OS/arch combinations
|
- Verify release binaries are attached
|
||||||
- Check release binary naming matches script expectations
|
- Check binary naming matches script expectations
|
||||||
- Verify binaries are attached to release
|
|
||||||
|
|
||||||
## ✅ Checklist for First Release
|
|
||||||
|
|
||||||
- [ ] All code committed and pushed
|
|
||||||
- [ ] GitHub Pages enabled
|
|
||||||
- [ ] Homebrew tap repository created
|
|
||||||
- [ ] HOMEBREW_TAP_TOKEN secret set
|
|
||||||
- [ ] CHANGELOG.md updated
|
|
||||||
- [ ] Version tag created and pushed
|
|
||||||
- [ ] Release workflow completed successfully
|
|
||||||
- [ ] Binaries attached to release
|
|
||||||
- [ ] Homebrew formula updated
|
|
||||||
- [ ] Install script tested
|
|
||||||
- [ ] Documentation website live
|
|
||||||
- [ ] README.md installation links work
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Documentation last updated**: 2025-11-23
|
|
||||||
**Setup completed for**: kportal v0.1.5
|
|
||||||
|
|||||||
+69
-136
@@ -1,171 +1,104 @@
|
|||||||
# Interactive Add/Remove Wizards
|
# Interactive Wizards
|
||||||
|
|
||||||
kportal now includes interactive wizards for adding and removing port forwards directly from the running UI!
|
kportal includes wizards for adding and removing port forwards from the running UI.
|
||||||
|
|
||||||
## Quick Start
|
## ⌨️ Quick Reference
|
||||||
|
|
||||||
Run kportal normally:
|
| Key | Action |
|
||||||
```bash
|
|-----|--------|
|
||||||
./kportal
|
| `a` | Add new forward |
|
||||||
```
|
| `d` | Delete forwards |
|
||||||
|
|
||||||
From the main view:
|
## ➕ Add Forward Wizard
|
||||||
- Press **`n`** to add a new port forward
|
|
||||||
- Press **`d`** to delete existing port forwards
|
|
||||||
|
|
||||||
## Add Forward Wizard (`n` key)
|
Press `a` from the main view to start the wizard.
|
||||||
|
|
||||||
The wizard guides you through 7 steps to add a new forward:
|
### Steps
|
||||||
|
|
||||||
### Step 1: Select Context
|
1. **Context** - Select Kubernetes context
|
||||||
Choose from available Kubernetes contexts in your kubeconfig.
|
2. **Namespace** - Select namespace
|
||||||
|
3. **Resource Type** - Choose pod (prefix), pod (selector), or service
|
||||||
|
4. **Resource** - Enter prefix, selector, or select service
|
||||||
|
5. **Remote Port** - Enter port on the resource
|
||||||
|
6. **Local Port** - Enter local port (validates availability)
|
||||||
|
7. **Confirm** - Review and optionally add an alias
|
||||||
|
|
||||||
### Step 2: Select Namespace
|
### Navigation
|
||||||
Pick the namespace where your resource lives.
|
|
||||||
|
|
||||||
### Step 3: Select Resource Type
|
| Key | Action |
|
||||||
Three options:
|
|-----|--------|
|
||||||
- **Pod (by name prefix)** - Forward to a specific pod by prefix matching
|
| `↑↓` / `j/k` | Navigate options |
|
||||||
- **Pod (by label selector)** - Forward to pods matching labels (survives restarts)
|
| `Enter` | Confirm and proceed |
|
||||||
- **Service** - Most stable, load-balanced option
|
| `Esc` | Go back / Cancel |
|
||||||
|
| `Ctrl+C` | Cancel immediately |
|
||||||
|
|
||||||
### Step 4: Enter Resource
|
## 🗑️ Delete Forward Wizard
|
||||||
- **Pod prefix**: Type a prefix like `nginx-` to match pods
|
|
||||||
- **Label selector**: Enter labels like `app=nginx,env=prod`
|
|
||||||
- **Service**: Select from a list of services
|
|
||||||
|
|
||||||
The wizard shows real-time validation and matching resources!
|
Press `d` from the main view.
|
||||||
|
|
||||||
### Step 5: Remote Port
|
### Navigation
|
||||||
Enter the port number on the remote resource. The wizard displays detected ports from running containers.
|
|
||||||
|
|
||||||
### Step 6: Local Port
|
| Key | Action |
|
||||||
Enter the local port to bind to. The wizard checks availability in real-time.
|
|-----|--------|
|
||||||
|
| `↑↓` / `j/k` | Navigate |
|
||||||
|
| `Space` | Toggle selection |
|
||||||
|
| `a` | Select all |
|
||||||
|
| `n` | Deselect all |
|
||||||
|
| `Enter` | Confirm deletion |
|
||||||
|
| `Esc` | Cancel |
|
||||||
|
|
||||||
### Step 7: Confirmation
|
## 🎯 Resource Selection
|
||||||
Review your configuration and optionally add an alias (friendly name). Confirm to save!
|
|
||||||
|
|
||||||
### Navigation Keys
|
### Pod by Prefix
|
||||||
|
|
||||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate options
|
Enter app name prefix to match pods:
|
||||||
- **`Enter`** - Confirm and proceed to next step
|
|
||||||
- **`Esc`** - Go back one step (or cancel on first step)
|
|
||||||
- **`Ctrl+C`** - Hard cancel and return to main view
|
|
||||||
- **`Backspace`** - Delete characters in text fields
|
|
||||||
|
|
||||||
## Remove Forward Wizard (`d` key)
|
|
||||||
|
|
||||||
Multi-select interface for removing forwards:
|
|
||||||
|
|
||||||
1. **Select forwards**: Use arrow keys to navigate, `Space` to toggle selection
|
|
||||||
2. **Confirm removal**: Press `Enter` and confirm your choice
|
|
||||||
|
|
||||||
### Navigation Keys
|
|
||||||
|
|
||||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate forwards
|
|
||||||
- **`Space`** - Toggle selection of current forward
|
|
||||||
- **`a`** - Select all forwards
|
|
||||||
- **`n`** - Deselect all forwards
|
|
||||||
- **`Enter`** - Proceed to confirmation
|
|
||||||
- **`Esc`** - Cancel and return to main view
|
|
||||||
- **`Ctrl+C`** - Hard cancel
|
|
||||||
|
|
||||||
## Auto Hot-Reload
|
|
||||||
|
|
||||||
When you save a forward via the wizard:
|
|
||||||
1. The wizard writes to `.kportal.yaml` atomically
|
|
||||||
2. The file watcher detects the change (~100ms)
|
|
||||||
3. The manager reloads and starts the new forward
|
|
||||||
4. The UI updates automatically
|
|
||||||
|
|
||||||
No restart needed!
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The wizards handle errors gracefully:
|
|
||||||
|
|
||||||
- **Cluster unreachable**: Shows error but allows manual entry
|
|
||||||
- **Port conflicts**: Displays which process is using the port
|
|
||||||
- **Invalid selectors**: Shows validation errors in real-time
|
|
||||||
- **Duplicate ports**: Prevents adding forwards with conflicting ports
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
### Pod Prefix Matching
|
|
||||||
When using pod prefix, you can type just the app name:
|
|
||||||
- `nginx` matches `nginx-deployment-abc123`
|
- `nginx` matches `nginx-deployment-abc123`
|
||||||
- `postgres` matches `postgres-statefulset-0`
|
- `postgres` matches `postgres-statefulset-0`
|
||||||
|
|
||||||
### Label Selectors
|
### Pod by Selector
|
||||||
Use standard Kubernetes label syntax:
|
|
||||||
- `app=nginx` - Single label
|
|
||||||
- `app=nginx,env=prod` - Multiple labels (comma-separated)
|
|
||||||
- Real-time validation shows matching pods as you type!
|
|
||||||
|
|
||||||
### Aliases
|
Use Kubernetes label syntax:
|
||||||
Use aliases for cleaner UI display:
|
- `app=nginx`
|
||||||
- Instead of: `production/default/pod/nginx-deployment-abc123:80→8080`
|
- `app=nginx,env=prod`
|
||||||
- Shows as: `my-nginx:80→8080`
|
|
||||||
|
|
||||||
### Quick Selection
|
Matching pods are shown in real-time.
|
||||||
In list views, you can use `j`/`k` (Vim-style) or arrow keys for navigation.
|
|
||||||
|
|
||||||
## Example Workflow
|
### Service
|
||||||
|
|
||||||
Adding a forward for a PostgreSQL database:
|
Select from discovered services in the namespace.
|
||||||
|
|
||||||
1. Press `n` in main view
|
## 🔄 Auto Hot-Reload
|
||||||
2. Select context: `production` (arrow keys + Enter)
|
|
||||||
3. Select namespace: `default` (arrow keys + Enter)
|
|
||||||
4. Select type: `Service` (arrow keys + Enter)
|
|
||||||
5. Select service: `postgres` (arrow keys + Enter)
|
|
||||||
6. Enter remote port: `5432` (type + Enter)
|
|
||||||
7. Enter local port: `5432` (type + Enter)
|
|
||||||
8. Add alias: `prod-db` (optional, type + Enter)
|
|
||||||
9. Confirm: Select "Add to .kportal.yaml" (Enter)
|
|
||||||
|
|
||||||
Done! The forward starts automatically within seconds.
|
Changes are applied automatically:
|
||||||
|
1. Wizard writes to `.kportal.yaml` atomically
|
||||||
|
2. File watcher detects change (~100ms)
|
||||||
|
3. Manager reloads and starts forward
|
||||||
|
4. UI updates
|
||||||
|
|
||||||
## Architecture
|
## Error Handling
|
||||||
|
|
||||||
The wizards use:
|
The wizards handle:
|
||||||
- **Config Mutator**: Safe, atomic YAML writes (temp file + rename)
|
- Cluster unreachable - allows manual entry
|
||||||
- **K8s Discovery**: Lists contexts, namespaces, pods, services
|
- Port conflicts - shows which process is using the port
|
||||||
- **Modal Overlays**: Wizards appear centered over the main view
|
- Invalid selectors - real-time validation
|
||||||
- **Async Validation**: Port checks and selector validation run in background
|
- Duplicate ports - prevents conflicts
|
||||||
- **Hot-Reload Integration**: File watcher picks up changes automatically
|
|
||||||
|
|
||||||
## Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Wizards not appearing?
|
### Wizard not appearing
|
||||||
Check that kportal can connect to your Kubernetes cluster:
|
|
||||||
|
Verify cluster connectivity:
|
||||||
```bash
|
```bash
|
||||||
kubectl cluster-info
|
kubectl cluster-info
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port check showing wrong status?
|
### Port validation delayed
|
||||||
The port check happens asynchronously. Wait a moment after typing for validation.
|
|
||||||
|
|
||||||
### Changes not appearing?
|
Port checks run asynchronously. Wait briefly after typing.
|
||||||
The file watcher triggers within 100ms. If changes aren't visible, check:
|
|
||||||
|
### Changes not visible
|
||||||
|
|
||||||
|
Check:
|
||||||
1. `.kportal.yaml` was written correctly
|
1. `.kportal.yaml` was written correctly
|
||||||
2. No validation errors in the file
|
2. No validation errors in file
|
||||||
3. kportal process is still running
|
3. kportal process is running
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Navigation Summary**
|
|
||||||
|
|
||||||
Main View:
|
|
||||||
- `n` - New forward wizard
|
|
||||||
- `d` - Delete forward wizard
|
|
||||||
- `Space` - Toggle forward on/off
|
|
||||||
- `↑↓/jk` - Navigate forwards
|
|
||||||
- `q` - Quit
|
|
||||||
|
|
||||||
Wizards:
|
|
||||||
- `Enter` - Next step / Confirm
|
|
||||||
- `Esc` - Previous step / Cancel
|
|
||||||
- `Ctrl+C` - Hard cancel
|
|
||||||
- `↑↓/jk` - Navigate
|
|
||||||
- `Space` - Toggle (in delete wizard)
|
|
||||||
|
|||||||
+26
-1
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/nvm/kportal/internal/forward"
|
"github.com/nvm/kportal/internal/forward"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
|
"github.com/nvm/kportal/internal/mdns"
|
||||||
"github.com/nvm/kportal/internal/ui"
|
"github.com/nvm/kportal/internal/ui"
|
||||||
"github.com/nvm/kportal/internal/version"
|
"github.com/nvm/kportal/internal/version"
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
@@ -209,6 +210,14 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create mDNS publisher if enabled in config
|
||||||
|
mdnsPublisher := mdns.NewPublisher(cfg.IsMDNSEnabled())
|
||||||
|
manager.SetMDNSPublisher(mdnsPublisher)
|
||||||
|
|
||||||
|
if cfg.IsMDNSEnabled() && *verbose {
|
||||||
|
log.Printf("mDNS hostname publishing enabled - aliases will be accessible via <alias>.local")
|
||||||
|
}
|
||||||
|
|
||||||
// Create UI (bubbletea for interactive, simple table for verbose)
|
// Create UI (bubbletea for interactive, simple table for verbose)
|
||||||
var bubbleTeaUI *ui.BubbleTeaUI
|
var bubbleTeaUI *ui.BubbleTeaUI
|
||||||
var tableUI *ui.TableUI
|
var tableUI *ui.TableUI
|
||||||
@@ -318,7 +327,23 @@ func main() {
|
|||||||
|
|
||||||
case os.Interrupt, syscall.SIGTERM:
|
case os.Interrupt, syscall.SIGTERM:
|
||||||
log.Printf("Received shutdown signal, stopping...")
|
log.Printf("Received shutdown signal, stopping...")
|
||||||
manager.Stop()
|
|
||||||
|
// Graceful shutdown with timeout - force exit if it takes too long
|
||||||
|
shutdownDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
manager.Stop()
|
||||||
|
close(shutdownDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-shutdownDone:
|
||||||
|
log.Printf("Graceful shutdown complete")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
log.Printf("Shutdown timed out, forcing exit...")
|
||||||
|
case sig := <-sigChan:
|
||||||
|
// Second signal received - force exit immediately
|
||||||
|
log.Printf("Received second signal (%v), forcing exit...", sig)
|
||||||
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+426
-672
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||||
@@ -48,11 +49,13 @@ require (
|
|||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||||
|
github.com/grandcat/zeroconf v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/miekg/dns v1.1.27 // indirect
|
||||||
github.com/moby/spdystream v0.5.0 // indirect
|
github.com/moby/spdystream v0.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
@@ -68,6 +71,7 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/crypto v0.44.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
|||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
||||||
@@ -84,6 +86,8 @@ 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/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 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
@@ -100,6 +104,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
|||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM=
|
||||||
|
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -122,6 +128,7 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM
|
|||||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
@@ -149,12 +156,17 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
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/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||||
|
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
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/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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
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-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.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
@@ -166,6 +178,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
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-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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -181,6 +194,7 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
|||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -31,6 +32,13 @@ type Config struct {
|
|||||||
Contexts []Context `yaml:"contexts"`
|
Contexts []Context `yaml:"contexts"`
|
||||||
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
||||||
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
|
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
|
||||||
|
MDNS *MDNSSpec `yaml:"mdns,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MDNSSpec configures mDNS (multicast DNS) hostname publishing
|
||||||
|
// When enabled, forwards with aliases can be accessed via <alias>.local hostnames
|
||||||
|
type MDNSSpec struct {
|
||||||
|
Enabled bool `yaml:"enabled"` // Enable mDNS hostname publishing
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheckSpec configures health check behavior
|
// HealthCheckSpec configures health check behavior
|
||||||
@@ -133,6 +141,11 @@ func (c *Config) GetDialTimeout() time.Duration {
|
|||||||
return parseDurationOrDefault(c.Reliability.DialTimeout, DefaultDialTimeout)
|
return parseDurationOrDefault(c.Reliability.DialTimeout, DefaultDialTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsMDNSEnabled returns whether mDNS hostname publishing is enabled
|
||||||
|
func (c *Config) IsMDNSEnabled() bool {
|
||||||
|
return c.MDNS != nil && c.MDNS.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
// Context represents a Kubernetes context with its namespaces
|
// Context represents a Kubernetes context with its namespaces
|
||||||
type Context struct {
|
type Context struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
@@ -199,6 +212,25 @@ func (f *Forward) GetNamespace() string {
|
|||||||
return f.namespaceName
|
return f.namespaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMDNSAlias returns the alias to use for mDNS hostname registration.
|
||||||
|
// If an explicit alias is set, it returns that.
|
||||||
|
// Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto").
|
||||||
|
func (f *Forward) GetMDNSAlias() string {
|
||||||
|
if f.Alias != "" {
|
||||||
|
return f.Alias
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate alias from resource name
|
||||||
|
// Format is "type/name" (e.g., "service/logto", "pod/my-app")
|
||||||
|
parts := strings.SplitN(f.Resource, "/", 2)
|
||||||
|
if len(parts) == 2 && parts[1] != "" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: can't generate a valid alias (e.g., "pod" with selector)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// LoadConfig loads and parses the configuration file from the given path.
|
// LoadConfig loads and parses the configuration file from the given path.
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
// Validate file size before reading
|
// Validate file size before reading
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
|||||||
// Check for duplicate local ports
|
// Check for duplicate local ports
|
||||||
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
||||||
|
|
||||||
|
// Validate mDNS configuration
|
||||||
|
if cfg.IsMDNSEnabled() {
|
||||||
|
errs = append(errs, v.validateMDNS(cfg)...)
|
||||||
|
}
|
||||||
|
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,3 +275,85 @@ func FormatValidationErrors(errs []ValidationError) string {
|
|||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateMDNS validates mDNS configuration when enabled.
|
||||||
|
// It checks that aliases used for mDNS hostnames are valid and unique.
|
||||||
|
// This includes both explicit aliases and auto-generated ones from resource names.
|
||||||
|
func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
||||||
|
var errs []ValidationError
|
||||||
|
|
||||||
|
aliasMap := make(map[string][]string) // alias -> list of forward IDs using it
|
||||||
|
|
||||||
|
for _, ctx := range cfg.Contexts {
|
||||||
|
for _, ns := range ctx.Namespaces {
|
||||||
|
for _, fwd := range ns.Forwards {
|
||||||
|
// Get the mDNS alias (explicit or generated from resource name)
|
||||||
|
mdnsAlias := fwd.GetMDNSAlias()
|
||||||
|
if mdnsAlias == "" {
|
||||||
|
// No alias available (e.g., "pod" with selector only)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate alias is a valid hostname (RFC 1123)
|
||||||
|
if !isValidHostname(mdnsAlias) {
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
Field: "alias",
|
||||||
|
Message: fmt.Sprintf("Forward %s has invalid mDNS hostname '%s' (must be a valid RFC 1123 hostname)", fwd.ID(), mdnsAlias),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasMap[mdnsAlias] = append(aliasMap[mdnsAlias], fwd.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate aliases (would cause mDNS conflicts)
|
||||||
|
for alias, forwards := range aliasMap {
|
||||||
|
if len(forwards) > 1 {
|
||||||
|
errs = append(errs, ValidationError{
|
||||||
|
Field: "alias",
|
||||||
|
Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", alias),
|
||||||
|
Context: map[string]string{
|
||||||
|
"alias": alias,
|
||||||
|
"forwards": strings.Join(forwards, ", "),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidHostname checks if a string is a valid RFC 1123 hostname.
|
||||||
|
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
|
||||||
|
// and be 1-63 characters long.
|
||||||
|
func isValidHostname(name string) bool {
|
||||||
|
if len(name) == 0 || len(name) > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must start with alphanumeric
|
||||||
|
if !isAlphanumeric(name[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must end with alphanumeric
|
||||||
|
if !isAlphanumeric(name[len(name)-1]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all characters
|
||||||
|
for i := 0; i < len(name); i++ {
|
||||||
|
c := name[i]
|
||||||
|
if !isAlphanumeric(c) && c != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAlphanumeric returns true if the character is a letter or digit.
|
||||||
|
func isAlphanumeric(c byte) bool {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||||
|
}
|
||||||
|
|||||||
@@ -701,3 +701,274 @@ func TestValidator_ValidateStructure(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidator_ValidateMDNS(t *testing.T) {
|
||||||
|
validator := NewValidator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expectErrors bool
|
||||||
|
errorContains []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "mDNS disabled - no validation",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "invalid_alias", contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - valid aliases",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "my-app", contextName: "dev", namespaceName: "default"},
|
||||||
|
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "my-service", contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - no alias (allowed)",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - invalid alias with underscore",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "my_app", contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: true,
|
||||||
|
errorContains: []string{"invalid mDNS hostname", "RFC 1123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - alias starts with hyphen",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "-myapp", contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: true,
|
||||||
|
errorContains: []string{"invalid mDNS hostname"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - alias ends with hyphen",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "myapp-", contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: true,
|
||||||
|
errorContains: []string{"invalid mDNS hostname"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - duplicate aliases",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "myapp", contextName: "dev", namespaceName: "default"},
|
||||||
|
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "myapp", contextName: "dev", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: true,
|
||||||
|
errorContains: []string{"Duplicate mDNS hostname", "conflict"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mDNS enabled - duplicate aliases across contexts",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "cluster1",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "shared-name", contextName: "cluster1", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cluster2",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "shared-name", contextName: "cluster2", namespaceName: "default"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErrors: true,
|
||||||
|
errorContains: []string{"Duplicate mDNS hostname", "shared-name"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
errs := validator.ValidateConfig(tt.config)
|
||||||
|
|
||||||
|
if tt.expectErrors {
|
||||||
|
assert.NotEmpty(t, errs, "expected validation errors")
|
||||||
|
|
||||||
|
// Check that expected error messages are present
|
||||||
|
for _, expectedMsg := range tt.errorContains {
|
||||||
|
found := false
|
||||||
|
for _, err := range errs {
|
||||||
|
if strings.Contains(err.Message, expectedMsg) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "expected error message '%s' not found in errors: %v", expectedMsg, errs)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidHostname(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"valid simple", "myservice", true},
|
||||||
|
{"valid with hyphen", "my-service", true},
|
||||||
|
{"valid with numbers", "service123", true},
|
||||||
|
{"valid mixed", "my-service-123", true},
|
||||||
|
{"valid uppercase", "MyService", true},
|
||||||
|
{"valid single char", "a", true},
|
||||||
|
{"valid single digit", "1", true},
|
||||||
|
{"valid max length (63)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
||||||
|
{"invalid empty", "", false},
|
||||||
|
{"invalid too long (64)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
|
||||||
|
{"invalid starts with hyphen", "-myservice", false},
|
||||||
|
{"invalid ends with hyphen", "myservice-", false},
|
||||||
|
{"invalid underscore", "my_service", false},
|
||||||
|
{"invalid dot", "my.service", false},
|
||||||
|
{"invalid space", "my service", false},
|
||||||
|
{"invalid special char", "my@service", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isValidHostname(tt.hostname)
|
||||||
|
assert.Equal(t, tt.valid, result, "isValidHostname(%q) = %v, want %v", tt.hostname, result, tt.valid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAlphanumeric(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
char byte
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{'a', true},
|
||||||
|
{'z', true},
|
||||||
|
{'A', true},
|
||||||
|
{'Z', true},
|
||||||
|
{'0', true},
|
||||||
|
{'9', true},
|
||||||
|
{'-', false},
|
||||||
|
{'_', false},
|
||||||
|
{'.', false},
|
||||||
|
{' ', false},
|
||||||
|
{'@', false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.char), func(t *testing.T) {
|
||||||
|
result := isAlphanumeric(tt.char)
|
||||||
|
assert.Equal(t, tt.valid, result, "isAlphanumeric(%q) = %v, want %v", tt.char, result, tt.valid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/nvm/kportal/internal/healthcheck"
|
"github.com/nvm/kportal/internal/healthcheck"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
|
"github.com/nvm/kportal/internal/mdns"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusUpdater is an interface for updating forward status
|
// StatusUpdater is an interface for updating forward status
|
||||||
@@ -30,6 +31,7 @@ type Manager struct {
|
|||||||
portChecker *PortChecker
|
portChecker *PortChecker
|
||||||
healthChecker *healthcheck.Checker
|
healthChecker *healthcheck.Checker
|
||||||
watchdog *Watchdog
|
watchdog *Watchdog
|
||||||
|
mdnsPublisher *mdns.Publisher
|
||||||
verbose bool
|
verbose bool
|
||||||
currentConfig *config.Config
|
currentConfig *config.Config
|
||||||
statusUI StatusUpdater
|
statusUI StatusUpdater
|
||||||
@@ -117,6 +119,11 @@ func (m *Manager) SetStatusUI(ui StatusUpdater) {
|
|||||||
m.statusUI = ui
|
m.statusUI = ui
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMDNSPublisher sets the mDNS publisher for the manager
|
||||||
|
func (m *Manager) SetMDNSPublisher(publisher *mdns.Publisher) {
|
||||||
|
m.mdnsPublisher = publisher
|
||||||
|
}
|
||||||
|
|
||||||
// Start initializes and starts all port-forwards from the configuration.
|
// Start initializes and starts all port-forwards from the configuration.
|
||||||
func (m *Manager) Start(cfg *config.Config) error {
|
func (m *Manager) Start(cfg *config.Config) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
@@ -186,6 +193,11 @@ func (m *Manager) Stop() {
|
|||||||
m.healthChecker.Stop()
|
m.healthChecker.Stop()
|
||||||
m.watchdog.Stop()
|
m.watchdog.Stop()
|
||||||
|
|
||||||
|
// Stop mDNS publisher
|
||||||
|
if m.mdnsPublisher != nil {
|
||||||
|
m.mdnsPublisher.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
m.workersMu.Lock()
|
m.workersMu.Lock()
|
||||||
workers := make([]*ForwardWorker, 0, len(m.workers))
|
workers := make([]*ForwardWorker, 0, len(m.workers))
|
||||||
for _, worker := range m.workers {
|
for _, worker := range m.workers {
|
||||||
@@ -391,6 +403,22 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
|||||||
// Store worker
|
// Store worker
|
||||||
m.workers[fwd.ID()] = worker
|
m.workers[fwd.ID()] = worker
|
||||||
|
|
||||||
|
// Register mDNS hostname if enabled
|
||||||
|
// Uses explicit alias if set, otherwise generates from resource name
|
||||||
|
if m.mdnsPublisher != nil {
|
||||||
|
mdnsAlias := fwd.GetMDNSAlias()
|
||||||
|
if mdnsAlias != "" {
|
||||||
|
if err := m.mdnsPublisher.Register(fwd.ID(), mdnsAlias, fwd.LocalPort); err != nil {
|
||||||
|
logger.Warn("Failed to register mDNS hostname", map[string]interface{}{
|
||||||
|
"forward_id": fwd.ID(),
|
||||||
|
"alias": mdnsAlias,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// Don't fail the forward start - mDNS is optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +442,11 @@ func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
|
|||||||
m.healthChecker.Unregister(id)
|
m.healthChecker.Unregister(id)
|
||||||
m.watchdog.UnregisterWorker(id)
|
m.watchdog.UnregisterWorker(id)
|
||||||
|
|
||||||
|
// Unregister mDNS hostname
|
||||||
|
if m.mdnsPublisher != nil {
|
||||||
|
m.mdnsPublisher.Unregister(id)
|
||||||
|
}
|
||||||
|
|
||||||
// Notify UI - either remove or update to disabled status
|
// Notify UI - either remove or update to disabled status
|
||||||
if m.statusUI != nil {
|
if m.statusUI != nil {
|
||||||
if removeFromUI {
|
if removeFromUI {
|
||||||
|
|||||||
@@ -85,7 +85,16 @@ func (w *ForwardWorker) Start() {
|
|||||||
func (w *ForwardWorker) Stop() {
|
func (w *ForwardWorker) Stop() {
|
||||||
w.cancel()
|
w.cancel()
|
||||||
close(w.stopChan)
|
close(w.stopChan)
|
||||||
<-w.doneChan // Wait for worker to finish
|
|
||||||
|
// Wait for worker to finish with timeout to prevent blocking forever
|
||||||
|
select {
|
||||||
|
case <-w.doneChan:
|
||||||
|
// Worker finished gracefully
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
// Worker didn't finish in time, but we've cancelled its context
|
||||||
|
// so it will clean up eventually
|
||||||
|
log.Printf("[%s] Worker stop timed out, continuing...", w.forward.ID())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// run is the main worker loop that handles retries.
|
// run is the main worker loop that handles retries.
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package mdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grandcat/zeroconf"
|
||||||
|
"github.com/nvm/kportal/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// shutdownTimeout is the maximum time to wait for mDNS server shutdown
|
||||||
|
shutdownTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
// mdnsDomain is the standard mDNS domain (RFC 6762)
|
||||||
|
// This is always ".local" for multicast DNS - it's not configurable
|
||||||
|
// and is different from your network's DNS search domain
|
||||||
|
mdnsDomain = "local"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Publisher manages mDNS hostname registrations for port forwards.
|
||||||
|
// It allows forwards with aliases to be accessible via <alias>.local hostnames.
|
||||||
|
type Publisher struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
servers map[string]*zeroconf.Server // forwardID -> server
|
||||||
|
aliases map[string]string // forwardID -> alias (for logging)
|
||||||
|
enabled bool
|
||||||
|
localIPs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPublisher creates a new mDNS Publisher.
|
||||||
|
// If enabled is false, all registration calls will be no-ops.
|
||||||
|
func NewPublisher(enabled bool) *Publisher {
|
||||||
|
p := &Publisher{
|
||||||
|
servers: make(map[string]*zeroconf.Server),
|
||||||
|
aliases: make(map[string]string),
|
||||||
|
enabled: enabled,
|
||||||
|
localIPs: getLocalIPs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
logger.Info("mDNS publisher initialized", map[string]interface{}{
|
||||||
|
"domain": mdnsDomain,
|
||||||
|
"local_ips": p.localIPs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register publishes an mDNS hostname for a forward.
|
||||||
|
// The hostname will be <alias>.local and will resolve to 127.0.0.1.
|
||||||
|
// If the forward has no alias or mDNS is disabled, this is a no-op.
|
||||||
|
func (p *Publisher) Register(forwardID, alias string, localPort int) error {
|
||||||
|
if !p.enabled || alias == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Check if already registered
|
||||||
|
if _, exists := p.servers[forwardID]; exists {
|
||||||
|
logger.Debug("mDNS hostname already registered", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
"alias": alias,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the mDNS service
|
||||||
|
// We use a generic service type and rely on the hostname registration
|
||||||
|
server, err := zeroconf.RegisterProxy(
|
||||||
|
alias, // Instance name (shown in service discovery)
|
||||||
|
"_kportal._tcp", // Service type (custom for kportal)
|
||||||
|
"local.", // Domain
|
||||||
|
localPort, // Port
|
||||||
|
alias, // Hostname (will be <alias>.local)
|
||||||
|
[]string{"127.0.0.1"}, // IPs to resolve to
|
||||||
|
[]string{fmt.Sprintf("forward=%s", forwardID)}, // TXT records
|
||||||
|
nil, // interfaces (nil = all)
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register mDNS for %s: %w", alias, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.servers[forwardID] = server
|
||||||
|
p.aliases[forwardID] = alias
|
||||||
|
|
||||||
|
logger.Info("mDNS hostname registered", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
"hostname": GetHostname(alias),
|
||||||
|
"port": localPort,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister removes the mDNS hostname for a forward.
|
||||||
|
func (p *Publisher) Unregister(forwardID string) {
|
||||||
|
if !p.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
server, exists := p.servers[forwardID]
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alias := p.aliases[forwardID]
|
||||||
|
shutdownWithTimeout(server, forwardID)
|
||||||
|
delete(p.servers, forwardID)
|
||||||
|
delete(p.aliases, forwardID)
|
||||||
|
|
||||||
|
logger.Info("mDNS hostname unregistered", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
"hostname": GetHostname(alias),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down all mDNS registrations.
|
||||||
|
func (p *Publisher) Stop() {
|
||||||
|
if !p.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Shutdown all servers concurrently with timeout
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for forwardID, server := range p.servers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id string, srv *zeroconf.Server) {
|
||||||
|
defer wg.Done()
|
||||||
|
shutdownWithTimeout(srv, id)
|
||||||
|
}(forwardID, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all shutdowns to complete (or timeout)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
p.servers = make(map[string]*zeroconf.Server)
|
||||||
|
p.aliases = make(map[string]string)
|
||||||
|
|
||||||
|
logger.Info("mDNS publisher stopped", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdownWithTimeout attempts to shutdown a zeroconf server with a timeout.
|
||||||
|
// If shutdown hangs, it logs a warning and returns anyway.
|
||||||
|
func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
server.Shutdown()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Shutdown completed successfully
|
||||||
|
case <-time.After(shutdownTimeout):
|
||||||
|
logger.Warn("mDNS shutdown timed out, continuing anyway", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
"timeout": shutdownTimeout.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns whether mDNS publishing is enabled.
|
||||||
|
func (p *Publisher) IsEnabled() bool {
|
||||||
|
return p.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDomain returns the mDNS domain being used (always "local" per RFC 6762).
|
||||||
|
func (p *Publisher) GetDomain() string {
|
||||||
|
return mdnsDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHostname returns the full mDNS hostname for an alias.
|
||||||
|
// Example: GetHostname("myapp") returns "myapp.local"
|
||||||
|
func GetHostname(alias string) string {
|
||||||
|
return alias + "." + mdnsDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegisteredCount returns the number of currently registered hostnames.
|
||||||
|
func (p *Publisher) GetRegisteredCount() int {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return len(p.servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLocalIPs returns the local IP addresses for logging purposes.
|
||||||
|
func getLocalIPs() []string {
|
||||||
|
var ips []string
|
||||||
|
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return []string{"127.0.0.1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||||
|
if ipnet.IP.To4() != nil {
|
||||||
|
ips = append(ips, ipnet.IP.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ips) == 0 {
|
||||||
|
return []string{"127.0.0.1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package mdns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: Tests that actually register mDNS services require network I/O
|
||||||
|
// and can be slow or hang in CI environments. We test the logic paths
|
||||||
|
// without actually calling zeroconf for most tests.
|
||||||
|
|
||||||
|
func TestNewPublisher_Disabled(t *testing.T) {
|
||||||
|
p := NewPublisher(false)
|
||||||
|
|
||||||
|
assert.False(t, p.IsEnabled())
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPublisher_Enabled(t *testing.T) {
|
||||||
|
p := NewPublisher(true)
|
||||||
|
|
||||||
|
assert.True(t, p.IsEnabled())
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_WhenDisabled_NoOp(t *testing.T) {
|
||||||
|
p := NewPublisher(false)
|
||||||
|
|
||||||
|
err := p.Register("forward-1", "test-alias", 8080)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_EmptyAlias_NoOp(t *testing.T) {
|
||||||
|
p := NewPublisher(true)
|
||||||
|
|
||||||
|
err := p.Register("forward-1", "", 8080)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
|
||||||
|
p := NewPublisher(false)
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
p.Unregister("forward-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnregister_NotRegistered_NoOp(t *testing.T) {
|
||||||
|
p := NewPublisher(true)
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
p.Unregister("non-existent")
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop_WhenDisabled_NoOp(t *testing.T) {
|
||||||
|
p := NewPublisher(false)
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
p.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStop_WhenNoRegistrations(t *testing.T) {
|
||||||
|
p := NewPublisher(true)
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
p.Stop()
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLocalIPs(t *testing.T) {
|
||||||
|
ips := getLocalIPs()
|
||||||
|
|
||||||
|
// Should return at least one IP
|
||||||
|
assert.NotEmpty(t, ips, "getLocalIPs should return at least one IP")
|
||||||
|
|
||||||
|
// All IPs should be non-empty strings
|
||||||
|
for _, ip := range ips {
|
||||||
|
assert.NotEmpty(t, ip, "IP address should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integration tests - only run when explicitly requested
|
||||||
|
// These tests actually register mDNS services and require network access
|
||||||
|
|
||||||
|
func TestRegister_Integration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping mDNS integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewPublisher(true)
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
err := p.Register("forward-1", "test-service", 8080)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping mDNS integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewPublisher(true)
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
// First registration
|
||||||
|
err := p.Register("forward-1", "test-service", 8080)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||||
|
|
||||||
|
// Second registration with same ID should be idempotent
|
||||||
|
err = p.Register("forward-1", "test-service", 8080)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_MultipleForwards_Integration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping mDNS integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewPublisher(true)
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
err1 := p.Register("forward-1", "service-a", 8080)
|
||||||
|
err2 := p.Register("forward-2", "service-b", 8081)
|
||||||
|
err3 := p.Register("forward-3", "service-c", 8082)
|
||||||
|
|
||||||
|
assert.NoError(t, err1)
|
||||||
|
assert.NoError(t, err2)
|
||||||
|
assert.NoError(t, err3)
|
||||||
|
assert.Equal(t, 3, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnregister_Success_Integration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping mDNS integration test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := NewPublisher(true)
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
p.Register("forward-1", "test-service", 8080)
|
||||||
|
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||||
|
|
||||||
|
p.Unregister("forward-1")
|
||||||
|
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user