Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

::: rpcclient.core.client.CoreClient

## WebDAV mounting

::: rpcclient.core.subsystems.webdav.WebDav

::: rpcclient.core.subsystems.webdav.WebDavServer

## Symbols

::: rpcclient.core.symbol
Expand Down
96 changes: 96 additions & 0 deletions docs/guides/mounting-webdav.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Mounting a remote path in Finder (WebDAV)

`rpcclient` can expose any path on the remote target as a local **WebDAV** volume, so macOS
Finder (or any WebDAV client) can browse and edit the remote filesystem read-write as if it were
mounted.

A small async WebDAV server runs **inside the `rpcclient` process**; every WebDAV request is
translated into `client.fs` calls against the target. Nothing runs on the target and there are no
server-side changes, so it works the same for iOS, macOS, and Linux targets.

WebDAV is used rather than FTP because macOS Finder mounts FTP **read-only** — it only allows
writes to servers that implement WebDAV locking, which this server does.

## Quick start

Serve the target's root and mount it in your file manager in one step:

```shell
rpcdav HOSTNAME --mount
```

`--mount` uses the host's native WebDAV mechanism — `mount_webdav` on macOS, `net use` on
Windows, `gio mount` on Linux — and reveals the volume in the file manager. If none is
available, it falls back to printing the URL to open manually.

Serve a specific path instead of `/`:

```shell
rpcdav HOSTNAME /var/mobile/Containers --mount
```

The same is available as a subcommand of `rpcclient`:

```shell
rpcclient HOSTNAME webdav [PATH] [--mount]
```

Press `Ctrl-C` to unmount and stop the server.

## Mounting manually

Without `--mount`, the server prints its local URL:

```
WebDAV server serving '/' at http://127.0.0.1:52314/
In Finder: Go → Connect to Server → http://127.0.0.1:52314/
```

In Finder, choose **Go → Connect to Server** (`⌘K`) and enter that URL (on Linux/Windows, open
it in your file manager's "connect to server" equivalent, or use any WebDAV client).

## Usage

```none
Usage: rpcdav [OPTIONS] HOSTNAME [PATH]

Serve a remote HOSTNAME's PATH (default: /) over WebDAV for local mounting.

Options:
-p, --port INTEGER TCP port to connect to
-r, --rebind-symbols reload all symbols upon connection
-l, --load-all-libraries load all libraries
--mount mount the served path locally and reveal it in your file manager
--host TEXT local interface to bind
--bind-port INTEGER local TCP port (0 picks a free port)
--readonly expose the path read-only
```

## From a script

The `webdav` subsystem is available on every client:

```python
server = await client.webdav.serve("/var/mobile", host="127.0.0.1", port=0)
print(server.url) # http://127.0.0.1:<port>/
# ... use the mount ...
await server.stop()
```

## Notes and limitations

- **Freshness / stale views.** A mounted WebDAV volume is a *client-cached network filesystem*.
Changes you make through the mount are immediate, but changes made on the target out-of-band may
appear stale until the client revalidates. WebDAV has no live-reload mechanism, and macOS's
WebDAV client caches directory listings and attributes at the kernel level regardless of server
hints. To force a refresh, navigate out of the folder and back (or reopen the window).
- **Read-write** browse, read, create, edit, `mkdir`, rename/move, and delete all propagate to the
target.
- **Finder metadata** (`.DS_Store`, AppleDouble `._*`, …) writes are swallowed — reported as
success but never written to the target, so they don't clutter it or fail on read-only roots.
- **`chmod` / `chown` do not propagate.** WebDAV has no representation for POSIX permissions or
ownership, and macOS's WebDAV client never sends them — such changes on the mount are local
no-ops. Use `client.fs.chmod(...)` / `client.fs.chown(...)` for real remote permission changes.
- **New files and directories** are created by the process the `rpcserver` runs as, so they are
owned by that user (e.g. `root` when the server runs as root), with the server's default
permissions — not your local user.
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ plugins:
Guides:
- guides/quickstart.md
- guides/calling-native-functions.md
- guides/mounting-webdav.md
API reference:
- api/index.md
- api/core.md
Expand Down Expand Up @@ -95,6 +96,7 @@ nav:
- Guides:
- Quick start: guides/quickstart.md
- Calling native functions: guides/calling-native-functions.md
- Mounting in Finder (WebDAV): guides/mounting-webdav.md
- API Reference:
- Overview: api/index.md
- Core client: api/core.md
Expand Down
6 changes: 5 additions & 1 deletion src/rpcclient/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ dependencies = [
"protobuf",
"inquirer3",
"ipython-smart-await>=0.3.0",
"asgiwebdav>=2.0.1",
"backports.zstd; python_version < '3.14'",
"uvicorn",
]

[project.optional-dependencies]
test = ["pytest", "pytest-repeat", "pytest-asyncio"]
test = ["pytest", "pytest-repeat", "pytest-asyncio", "httpx"]
docs = ["mkdocs-material>=9.5", "mkdocstrings[python]>=0.26", "mkdocs-llmstxt>=0.5"]
dev = ["rpcclient[test,docs]"]

Expand All @@ -70,6 +73,7 @@ dev = ["rpcclient[test,docs]"]
[project.scripts]
rpcclient = "rpcclient.__main__:rpcclient"
rpclocal = "rpcclient.__main__:rpclocal"
rpcdav = "rpcclient.__main__:rpcdav"

[tool.setuptools.packages.find]
exclude = ["docs*", "tests*"]
Expand Down
171 changes: 159 additions & 12 deletions src/rpcclient/rpcclient/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging

import click
Expand All @@ -6,6 +7,12 @@
from rpcclient.client_manager import ClientManager
from rpcclient.clients.darwin.client import DarwinClient
from rpcclient.console.console import Console, disable_loggers
from rpcclient.core.webdav_mount import (
mount_webdav_volume,
reveal_in_file_manager,
unmount_webdav_volume,
webdav_mount_supported,
)
from rpcclient.transports import DEFAULT_PORT
from rpcclient.utils import run_in_loop

Expand Down Expand Up @@ -38,37 +45,177 @@ async def _connect() -> int:
Console(manager).interactive(switch_cid=cid, startup_files=startup_files)


@click.command()
async def _connect_client(
manager: ClientManager, hostname: str, port: int, rebind_symbols: bool, load_all_libraries: bool
):
client = await manager.create(hostname=hostname, port=port)
if isinstance(client, DarwinClient):
if rebind_symbols:
await client.rebind_symbols()
if load_all_libraries:
await client.load_all_libraries()
return client


@click.group(invoke_without_command=True)
@click.argument("hostname", required=False)
@click.option("-p", "--port", type=click.INT, default=DEFAULT_PORT, help="TCP port to connect to")
@click.option("-r", "--rebind-symbols", is_flag=True, help="reload all symbols upon connection")
@click.option("-l", "--load-all-libraries", is_flag=True, help="load all libraries")
@startup_files_option
@click.pass_context
def rpcclient(
hostname: str | None, port: int, rebind_symbols: bool, load_all_libraries: bool, startup_files: tuple[str]
ctx: click.Context,
hostname: str | None,
port: int,
rebind_symbols: bool,
load_all_libraries: bool,
startup_files: tuple[str],
):
"""
Start the console.
If HOSTNAME is provided, connect immediately.
Otherwise, start without a connection.
You can connect later from the console.
Provide a subcommand (e.g. `webdav`) to run it against HOSTNAME instead of the console.
"""
manager = ClientManager()
ctx.obj = {
"manager": manager,
"hostname": hostname,
"port": port,
"rebind_symbols": rebind_symbols,
"load_all_libraries": load_all_libraries,
}

if ctx.invoked_subcommand is not None:
return

cid = None
if hostname:
client = run_in_loop(_connect_client(manager, hostname, port, rebind_symbols, load_all_libraries))
cid = client.id

async def _connect() -> int:
client = await manager.create(hostname=hostname, port=port)
if isinstance(client, DarwinClient):
if rebind_symbols:
await client.rebind_symbols()
if load_all_libraries:
await client.load_all_libraries()
return client.id
Console(manager).interactive(switch_cid=cid, startup_files=startup_files)

cid = run_in_loop(_connect())

Console(manager).interactive(switch_cid=cid, startup_files=startup_files)
def _require_mount_tool(mount: bool) -> None:
"""Fail early if ``--mount`` was requested but no WebDAV mount tool is available on this host."""
if mount and not webdav_mount_supported():
raise click.UsageError(
"--mount is unavailable: no supported WebDAV mount tool found "
"(mount_webdav on macOS, net on Windows, gio on Linux)"
)


def _serve_webdav(
manager: ClientManager,
hostname: str,
port: int,
rebind_symbols: bool,
load_all_libraries: bool,
path: str,
mount: bool,
bind_host: str,
bind_port: int,
readonly: bool,
) -> None:
"""Connect to HOSTNAME, serve PATH over WebDAV, optionally mount, and block until interrupted."""
_require_mount_tool(mount)

async def _setup():
client = await _connect_client(manager, hostname, port, rebind_symbols, load_all_libraries)
server = await client.webdav.serve(path, host=bind_host, port=bind_port, readonly=readonly)
mounted = await mount_webdav_volume(server.url, label=f"rpc-{hostname}-{path}") if mount else None
return client, server, mounted

client, server, mounted = run_in_loop(_setup())
click.echo(f"WebDAV server serving {path!r} at {server.url}")
if mounted is not None:
run_in_loop(reveal_in_file_manager(mounted.reveal_target))
click.echo(f"Mounted; revealed {mounted.reveal_target}")
else:
if mount:
click.echo("Automatic mount is unavailable on this host; open the URL below in your file manager.")
click.echo(f"Open this WebDAV URL in your file manager: {server.url}")
click.echo("Press Ctrl-C to stop.")

try:
run_in_loop(asyncio.Event().wait())
except KeyboardInterrupt:
pass
finally:

async def _teardown() -> None:
if mounted is not None:
await unmount_webdav_volume(mounted)
await server.stop()
await client.close()

run_in_loop(_teardown())
click.echo("stopped.")


@rpcclient.command()
@click.argument("path", required=False, default="/")
@click.option("--mount", is_flag=True, help="mount the served path locally and reveal it in your file manager")
@click.option("--host", "bind_host", default="127.0.0.1", help="local interface to bind")
@click.option("--port", "bind_port", type=click.INT, default=0, help="local TCP port (0 picks a free port)")
@click.option("--readonly", is_flag=True, help="expose the path read-only")
@click.pass_context
def webdav(ctx: click.Context, path: str, mount: bool, bind_host: str, bind_port: int, readonly: bool) -> None:
"""Serve a remote PATH (default: /) over WebDAV for local mounting."""
obj = ctx.obj
if not obj["hostname"]:
raise click.UsageError("HOSTNAME is required: rpcclient HOSTNAME webdav [--mount]")
_serve_webdav(
obj["manager"],
obj["hostname"],
obj["port"],
obj["rebind_symbols"],
obj["load_all_libraries"],
path,
mount,
bind_host,
bind_port,
readonly,
)


@click.command()
@click.argument("hostname")
@click.argument("path", required=False, default="/")
@click.option("-p", "--port", type=click.INT, default=DEFAULT_PORT, help="TCP port to connect to")
@click.option("-r", "--rebind-symbols", is_flag=True, help="reload all symbols upon connection")
@click.option("-l", "--load-all-libraries", is_flag=True, help="load all libraries")
@click.option("--mount", is_flag=True, help="mount the served path locally and reveal it in your file manager")
@click.option("--host", "bind_host", default="127.0.0.1", help="local interface to bind")
@click.option("--bind-port", "bind_port", type=click.INT, default=0, help="local TCP port (0 picks a free port)")
@click.option("--readonly", is_flag=True, help="expose the path read-only")
def rpcdav(
hostname: str,
path: str,
port: int,
rebind_symbols: bool,
load_all_libraries: bool,
mount: bool,
bind_host: str,
bind_port: int,
readonly: bool,
) -> None:
"""Serve a remote HOSTNAME's PATH (default: /) over WebDAV for local mounting."""
_serve_webdav(
ClientManager(),
hostname,
port,
rebind_symbols,
load_all_libraries,
path,
mount,
bind_host,
bind_port,
readonly,
)


if __name__ == "__main__":
Expand Down
5 changes: 5 additions & 0 deletions src/rpcclient/rpcclient/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
from rpcclient.core.subsystems.network import Network
from rpcclient.core.subsystems.processes import Processes
from rpcclient.core.subsystems.sysctl import Sysctl
from rpcclient.core.subsystems.webdav import WebDav
from rpcclient.core.symbol import Symbol
from rpcclient.core.symbols_jar import (
LazySymbol,
Expand Down Expand Up @@ -226,6 +227,10 @@ def lief(self) -> Lief[Self]:
def sysctl(self) -> Sysctl[Self]:
return Sysctl(self)

@subsystem
def webdav(self) -> WebDav[Self]:
return WebDav(self)

async def info(self) -> None:
"""print information about the current target"""
uname = await self.get_uname()
Expand Down
Loading
Loading