diff options
| author | Louis Burda <dev@sinitax.com> | 2026-01-13 16:50:31 +0100 |
|---|---|---|
| committer | Louis Burda <dev@sinitax.com> | 2026-01-13 16:50:31 +0100 |
| commit | 014aabbb90f2b23b31377f15ef3cbcadba15f608 (patch) | |
| tree | 8050bbe04c6d424fc6e5206489e34b21161fc5e1 | |
| parent | 6a2e3c5098b0631b6ed23396989ea67245b0d45e (diff) | |
| download | incus-fwd-main.tar.gz incus-fwd-main.zip | |
| -rw-r--r-- | .agents/context.md | 131 | ||||
| -rw-r--r-- | .gitignore | 1 | ||||
| -rwxr-xr-x | incus-fwd | 105 |
3 files changed, 212 insertions, 25 deletions
diff --git a/.agents/context.md b/.agents/context.md new file mode 100644 index 0000000..054e794 --- /dev/null +++ b/.agents/context.md @@ -0,0 +1,131 @@ +# incus-fwd - Context Summary for LLMs + +## Project Overview + +`incus-fwd` is a Python CLI tool that simplifies port forwarding configuration for Incus containers/VMs. It wraps the `incus config device` commands to provide an intuitive interface for managing proxy devices. + +## Key Files + +- `incus-fwd` - Main Python script (executable) + +## Architecture + +### Core Data Structure + +```python +@dataclass(frozen=True) +class PortMapping: + host_start: int # Starting host port + host_end: int # Ending host port (same as start for single port) + guest_start: int # Starting guest port + guest_end: int # Ending guest port (same as start for single port) + reverse: bool # If True, guest listens and connects to host +``` + +### Proxy Device Modes + +1. **Normal (forward) proxy** (`-m`/`--map`): + - Host listens on specified port(s) + - Connections forwarded to guest + - Incus device: `listen=tcp:0.0.0.0:HOST_PORT`, `connect=tcp:127.0.0.1:GUEST_PORT` + - Default bind mode (host) + +2. **Reverse proxy** (`-r`/`--reverse-map`): + - Guest listens on specified port(s) + - Connections forwarded to host + - Incus device: `listen=tcp:0.0.0.0:GUEST_PORT`, `connect=tcp:127.0.0.1:HOST_PORT`, `bind=instance` + +### Key Functions + +| Function | Purpose | +|----------|---------| +| `parse_port_range(spec)` | Parse "PORT" or "START-END" into (start, end) tuple | +| `parse_port_mapping(spec, reverse)` | Parse "HOST:GUEST" or "PORT" into PortMapping | +| `parse_unmap_spec(spec)` | Parse unmap spec, returns (side, start, end) | +| `load_existing_mappings(instance)` | Query incus for current proxy devices | +| `parse_device_config(listen, connect, bind_mode)` | Convert incus device config to PortMapping | +| `remove_mapping_overlap(mappings, side, start, end)` | Remove/truncate overlapping mappings | +| `apply_changes(instance, existing, desired, device_map, dry_run)` | Compute diff and apply minimal changes | + +### Command-Line Interface + +``` +incus-fwd [-m HOST:GUEST] [-r HOST:GUEST] [-u RANGE] [-G] [-H] [-l] [-n] instance + +Options: + -m, --map HOST:GUEST Forward: host listens, forwards to guest + -r, --reverse-map HOST:GUEST Reverse: guest listens, forwards to host + -u, --unmap RANGE Remove mappings (RANGE: for host, :RANGE for guest) + -G, --guest-all Map all ports 1:1 (forward) + -H, --host-all Remove all port forwarding + -l, --list List mappings after changes + -n, --dryrun Preview changes without applying +``` + +### Port Specification Formats + +- Single port: `80` +- Port range: `8000-8100` +- Mapping: `HOST:GUEST` (e.g., `8080:80`) +- Shorthand: Just `PORT` maps `PORT:PORT` + +### Unmap Specification + +- `8080` or `8080:` - Remove by host port +- `:80` - Remove by guest port +- Ranges supported: `8000-8100`, `:80-90` + +## Implementation Notes + +1. **Incremental Updates**: The tool computes the diff between existing and desired mappings, only adding/removing what's necessary. + +2. **Device Naming**: Proxy devices are named `proxy0`, `proxy1`, etc. New devices use the first available index. + +3. **YAML Parsing**: Existing devices are parsed from `incus config device show` YAML output using regex (not a YAML library). + +4. **Reverse Detection**: Reverse mappings are detected by the presence of `bind: instance` in the device config. + +5. **Semantic Port Fields**: The `host_*` and `guest_*` fields in `PortMapping` always refer to the semantic host/guest ports, regardless of the `reverse` flag. The `reverse` flag only affects how these map to incus `listen`/`connect` addresses. This ensures `--unmap` works correctly for both forward and reverse mappings. + +6. **Preserving Reverse Flag**: When `remove_mapping_overlap()` creates truncated mappings (e.g., unmapping a range from the middle of a larger range), it must preserve the `reverse` flag from the original mapping. + +## Common Modification Patterns + +### Adding a New Flag + +1. Add argument in `parser.add_argument()` section +2. Add operation type in operations list building +3. Add validation in the try/except block +4. Add handling in the operations processing loop +5. Update epilog examples + +### Changing Device Configuration + +Modify `PortMapping.to_device_config()` to change how devices are created. + +### Changing Mapping Detection + +Modify `parse_device_config()` to change how existing devices are parsed. + +## Dependencies + +- Python 3 (standard library only) +- `incus` CLI tool available in PATH + +## Testing + +No automated tests. Manual testing against an Incus instance: + +```bash +# Test forward mapping +./incus-fwd -m 8080:80 -l myinstance + +# Test reverse mapping +./incus-fwd -r 3000 -l myinstance + +# Test dry run +./incus-fwd -n -m 8080:80 myinstance + +# List current mappings +./incus-fwd -l myinstance +``` diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ @@ -10,11 +10,12 @@ from typing import List, Set, Dict, Tuple, Optional @dataclass(frozen=True) class PortMapping: - """Represents a port mapping from host to guest.""" + """Represents a port mapping from host to guest (or reverse).""" host_start: int host_end: int guest_start: int guest_end: int + reverse: bool = False # If True, guest listens and connects to host def __str__(self): if self.host_start == self.host_end: @@ -27,10 +28,17 @@ class PortMapping: else: guest_spec = f"{self.guest_start}-{self.guest_end}" + if self.reverse: + return f"{host_spec}:{guest_spec} (reverse)" return f"{host_spec}:{guest_spec}" def to_device_config(self): - """Convert to incus device listen/connect format.""" + """Convert to incus device listen/connect format. + + Returns (listen, connect, bind) tuple. + For normal mappings: host listens, connects to guest, bind=host (None) + For reverse mappings: guest listens, connects to host, bind=instance + """ if self.host_start == self.host_end: host_spec = str(self.host_start) else: @@ -41,10 +49,20 @@ class PortMapping: else: guest_spec = f"{self.guest_start}-{self.guest_end}" - return ( - f"tcp:0.0.0.0:{host_spec}", - f"tcp:127.0.0.1:{guest_spec}" - ) + if self.reverse: + # Reverse: guest listens, connects to host + return ( + f"tcp:0.0.0.0:{guest_spec}", + f"tcp:127.0.0.1:{host_spec}", + "instance" + ) + else: + # Normal: host listens, connects to guest + return ( + f"tcp:0.0.0.0:{host_spec}", + f"tcp:127.0.0.1:{guest_spec}", + None + ) def parse_port_range(spec: str) -> Tuple[int, int]: @@ -65,7 +83,7 @@ def parse_port_range(spec: str) -> Tuple[int, int]: return (port, port) -def parse_port_mapping(spec: str) -> PortMapping: +def parse_port_mapping(spec: str, reverse: bool = False) -> PortMapping: """Parse a port mapping specification (HOST:GUEST or PORT).""" if ':' in spec: host_spec, guest_spec = spec.split(':', 1) @@ -77,10 +95,10 @@ def parse_port_mapping(spec: str) -> PortMapping: if host_size != guest_size: raise ValueError(f"Range size mismatch in mapping {spec}") - return PortMapping(host_start, host_end, guest_start, guest_end) + return PortMapping(host_start, host_end, guest_start, guest_end, reverse) else: start, end = parse_port_range(spec) - return PortMapping(start, end, start, end) + return PortMapping(start, end, start, end, reverse) def parse_unmap_spec(spec: str) -> Tuple[str, int, int]: @@ -116,13 +134,14 @@ def load_existing_mappings(instance: str) -> Tuple[Set[PortMapping], Dict[str, P listen_addr = None connect_addr = None device_type = None + bind_mode = None for line in output.split('\n'): device_match = re.match(r'^([a-zA-Z0-9_-]+):$', line) if device_match: # Process previous device if current_device and device_type == 'proxy' and listen_addr and connect_addr: - mapping = parse_device_config(listen_addr, connect_addr) + mapping = parse_device_config(listen_addr, connect_addr, bind_mode) if mapping: mappings.add(mapping) device_to_mapping[current_device] = mapping @@ -131,6 +150,7 @@ def load_existing_mappings(instance: str) -> Tuple[Set[PortMapping], Dict[str, P listen_addr = None connect_addr = None device_type = None + bind_mode = None continue listen_match = re.match(r'^\s+listen:\s*(.+)$', line) @@ -148,9 +168,14 @@ def load_existing_mappings(instance: str) -> Tuple[Set[PortMapping], Dict[str, P device_type = type_match.group(1) continue + bind_match = re.match(r'^\s+bind:\s*(.+)$', line) + if bind_match: + bind_mode = bind_match.group(1) + continue + # Process last device if current_device and device_type == 'proxy' and listen_addr and connect_addr: - mapping = parse_device_config(listen_addr, connect_addr) + mapping = parse_device_config(listen_addr, connect_addr, bind_mode) if mapping: mappings.add(mapping) device_to_mapping[current_device] = mapping @@ -158,7 +183,7 @@ def load_existing_mappings(instance: str) -> Tuple[Set[PortMapping], Dict[str, P return (mappings, device_to_mapping) -def parse_device_config(listen: str, connect: str) -> Optional[PortMapping]: +def parse_device_config(listen: str, connect: str, bind_mode: Optional[str] = None) -> Optional[PortMapping]: """Parse incus device listen/connect config into PortMapping.""" listen_match = re.match(r'tcp:[^:]+:(.+)$', listen) connect_match = re.match(r'tcp:[^:]+:(.+)$', connect) @@ -167,9 +192,19 @@ def parse_device_config(listen: str, connect: str) -> Optional[PortMapping]: return None try: - host_start, host_end = parse_port_range(listen_match.group(1)) - guest_start, guest_end = parse_port_range(connect_match.group(1)) - return PortMapping(host_start, host_end, guest_start, guest_end) + listen_port_start, listen_port_end = parse_port_range(listen_match.group(1)) + connect_port_start, connect_port_end = parse_port_range(connect_match.group(1)) + + is_reverse = (bind_mode == 'instance') + + if is_reverse: + # Reverse: listen is guest port, connect is host port + return PortMapping(connect_port_start, connect_port_end, + listen_port_start, listen_port_end, reverse=True) + else: + # Normal: listen is host port, connect is guest port + return PortMapping(listen_port_start, listen_port_end, + connect_port_start, connect_port_end, reverse=False) except ValueError: return None @@ -188,13 +223,15 @@ def remove_mapping_overlap(mappings: List[PortMapping], side: str, offset = unmap_start - 1 - mapping.host_start result.append(PortMapping( mapping.host_start, unmap_start - 1, - mapping.guest_start, mapping.guest_start + offset + mapping.guest_start, mapping.guest_start + offset, + mapping.reverse )) if mapping.host_end > unmap_end: offset = unmap_end + 1 - mapping.host_start result.append(PortMapping( unmap_end + 1, mapping.host_end, - mapping.guest_start + offset, mapping.guest_end + mapping.guest_start + offset, mapping.guest_end, + mapping.reverse )) else: if mapping.guest_end < unmap_start or mapping.guest_start > unmap_end: @@ -204,13 +241,15 @@ def remove_mapping_overlap(mappings: List[PortMapping], side: str, offset = unmap_start - 1 - mapping.guest_start result.append(PortMapping( mapping.host_start, mapping.host_start + offset, - mapping.guest_start, unmap_start - 1 + mapping.guest_start, unmap_start - 1, + mapping.reverse )) if mapping.guest_end > unmap_end: offset = unmap_end + 1 - mapping.guest_start result.append(PortMapping( mapping.host_start + offset, mapping.host_end, - unmap_end + 1, mapping.guest_end + unmap_end + 1, mapping.guest_end, + mapping.reverse )) return result @@ -247,12 +286,12 @@ def apply_changes(instance: str, existing_mappings: Set[PortMapping], while f'proxy{device_index}' in existing_devices: device_index += 1 - listen, connect = mapping.to_device_config() - subprocess.run( - ['incus', 'config', 'device', 'add', instance, f'proxy{device_index}', - 'proxy', f'listen={listen}', f'connect={connect}'], - stderr=subprocess.DEVNULL - ) + listen, connect, bind = mapping.to_device_config() + cmd = ['incus', 'config', 'device', 'add', instance, f'proxy{device_index}', + 'proxy', f'listen={listen}', f'connect={connect}'] + if bind: + cmd.append(f'bind={bind}') + subprocess.run(cmd, stderr=subprocess.DEVNULL) existing_devices.append(f'proxy{device_index}') @@ -271,6 +310,12 @@ examples: # Map host port 8080 to guest port 80 %(prog)s -m 8080:80 myinstance + # Reverse map: guest listens on 3000, forwards to host port 3000 + %(prog)s -r 3000 myinstance + + # Reverse map: guest listens on 8080, forwards to host port 80 + %(prog)s -r 80:8080 myinstance + # Remove mappings by host port range %(prog)s -u 8080 myinstance %(prog)s -u 8000-8100 myinstance @@ -283,6 +328,8 @@ examples: parser.add_argument('instance', help='Instance name') parser.add_argument('-m', '--map', action='append', dest='mappings', metavar='HOST:GUEST', help='Map host port(s) to guest port(s). Shorthand: -m PORT (maps PORT:PORT)') + parser.add_argument('-r', '--reverse-map', action='append', dest='reverse_mappings', metavar='HOST:GUEST', + help='Reverse map: guest listens and forwards to host. Shorthand: -r PORT (maps PORT:PORT)') parser.add_argument('-u', '--unmap', action='append', dest='unmappings', metavar='RANGE[:RANGE]', help='Remove port mappings (RANGE: for host, :RANGE for guest)') parser.add_argument('-G', '--guest-all', action='store_true', @@ -304,6 +351,9 @@ examples: if args.mappings: for spec in args.mappings: operations.append(('map', spec)) + if args.reverse_mappings: + for spec in args.reverse_mappings: + operations.append(('reverse-map', spec)) if args.unmappings: for spec in args.unmappings: operations.append(('unmap', spec)) @@ -312,6 +362,8 @@ examples: for op_type, spec in operations: if op_type == 'map': parse_port_mapping(spec) + elif op_type == 'reverse-map': + parse_port_mapping(spec, reverse=True) elif op_type == 'unmap': parse_unmap_spec(spec) except ValueError as e: @@ -348,6 +400,9 @@ examples: elif op_type == 'map': mapping = parse_port_mapping(spec) mappings.append(mapping) + elif op_type == 'reverse-map': + mapping = parse_port_mapping(spec, reverse=True) + mappings.append(mapping) elif op_type == 'unmap': side, start, end = parse_unmap_spec(spec) mappings = remove_mapping_overlap(mappings, side, start, end) |
