Compare commits

...

1 Commits

Author SHA1 Message Date
lukaszraczylo 1167847fd4 bugfixes nov2025 pt4 (#7)
* Add mDNS resolution.
* Update the website and documentation
2025-11-25 11:14:33 +00:00
14 changed files with 1550 additions and 1607 deletions
+147 -546
View File
@@ -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.
![kportal Screenshot](docs/kportal-screenshot.png) ![kportal Screenshot](docs/kportal-screenshot.png)
## ✨ 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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
+14
View File
@@ -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=
+32
View File
@@ -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
+87
View File
@@ -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')
}
+271
View File
@@ -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)
})
}
}
+33
View File
@@ -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 {
+10 -1
View File
@@ -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.
+220
View File
@@ -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
}
+154
View File
@@ -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())
}