summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.agents/context.md131
-rw-r--r--.gitignore1
-rwxr-xr-xincus-fwd105
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__
diff --git a/incus-fwd b/incus-fwd
index 5023c08..110e984 100755
--- a/incus-fwd
+++ b/incus-fwd
@@ -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)