feat: Tailscale serve/funnel (new CLI) support; UI bindings; Gitea update.json; docs sudo; bump 0.1.2

This commit is contained in:
Markus F.J. Busche
2025-09-20 15:59:11 +02:00
parent 62ccfc4a00
commit 667ecc5e72
9 changed files with 172 additions and 35 deletions

View File

@@ -35,6 +35,42 @@ The plugin adds a new section to OctoPrint's settings panel with the following o
* **Port**: The port to expose via Funnel (default: 80) * **Port**: The port to expose via Funnel (default: 80)
* **Confirm Enable**: Require confirmation before enabling Funnel (default: True) * **Confirm Enable**: Require confirmation before enabling Funnel (default: True)
## Runtime Permissions (sudo)
Some Tailscale operations (serve/funnel) may require elevated privileges depending on your setup. The plugin executes `tailscale` from the OctoPrint process user. If enabling/disabling Funnel fails with permission errors or HTTP 500, configure passwordless sudo for the OctoPrint user to run `tailscale`:
1. Determine the OctoPrint service user (common: `octoprint` or `pi`):
```bash
systemctl show -p User octoprint | sed 's/User=//'
```
2. Allow passwordless sudo for `tailscale` for that user (replace <USER>):
```bash
echo '<USER> ALL=(root) NOPASSWD: /usr/bin/tailscale *' | sudo tee /etc/sudoers.d/octoprint-tailscale
sudo chmod 440 /etc/sudoers.d/octoprint-tailscale
sudo visudo -cf /etc/sudoers.d/octoprint-tailscale
```
3. Test (should not prompt for a password):
```bash
sudo -n tailscale status --json >/dev/null && echo OK || echo FAIL
```
Security note: Restricting the sudo rule to `/usr/bin/tailscale *` limits elevated access to the Tailscale CLI.
## Initial Tailscale Serve/Funnel setup (optional)
If you prefer preconfiguring Tailscale manually (instead of letting the plugin set it up), these commands map OctoPrint on port 80 to the root path and enable Funnel using current Tailscale CLI syntax:
```bash
# Tailnet-only mapping at /
sudo tailscale serve --bg --http 80 http://127.0.0.1:80
# Public internet via Funnel (HTTPS will be available on 443)
sudo tailscale funnel --bg 80
```
Disable/reset if needed:
```bash
sudo tailscale serve --http=80 off
sudo tailscale funnel reset
```
## Security Considerations ## Security Considerations
Enabling Funnel makes your OctoPrint instance accessible from the public internet. Only enable it when needed and disable it when finished. The plugin will show a confirmation dialog before enabling Funnel if the "Confirm Enable" option is checked. Enabling Funnel makes your OctoPrint instance accessible from the public internet. Only enable it when needed and disable it when finished. The plugin will show a confirmation dialog before enabling Funnel if the "Confirm Enable" option is checked.
@@ -47,6 +83,21 @@ The plugin exposes the following API endpoints:
* `POST /api/plugin/tailscale_funnel/enable` - Enable Tailscale Funnel * `POST /api/plugin/tailscale_funnel/enable` - Enable Tailscale Funnel
* `POST /api/plugin/tailscale_funnel/disable` - Disable Tailscale Funnel * `POST /api/plugin/tailscale_funnel/disable` - Disable Tailscale Funnel
## Updates via Gitea (update.json)
This plugin can announce new versions via a JSON file hosted in your Gitea repo. The plugin is configured to read:
`https://gitea.elpatron.me/elpatron/octo-funnel/raw/branch/main/update.json`
Workflow for a new release (example to bump 0.1.1 → 0.1.2):
1. Update the version in `setup.py` (`plugin_version = "0.1.2"`).
2. Build artifacts (sdist/wheel/ZIP).
3. Upload the ZIP to Gitea Releases or ensure the archive URL resolves for the tag.
4. Commit and push `update.json` with the new version:
```json
{ "version": "0.1.2" }
```
5. In OctoPrint: open Software Update and trigger a re-check (or restart).
## License ## License
AGPLv3 AGPLv3

View File

@@ -203,14 +203,13 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
displayName="Tailscale Funnel Plugin", displayName="Tailscale Funnel Plugin",
displayVersion=self._plugin_version, displayVersion=self._plugin_version,
# version check: github repository # version check: gitea via generic JSON endpoint
type="github_release", type="jsondata",
user="markus", jsondata="https://gitea.elpatron.me/elpatron/octo-funnel/raw/branch/main/octoprint_tailscale_funnel/update.json",
repo="OctoPrint-Tailscale-Funnel",
current=self._plugin_version, current=self._plugin_version,
# update method: pip # update method: pip (direct zip from Gitea release tag)
pip="https://github.com/markus/OctoPrint-Tailscale-Funnel/archive/{target_version}.zip" pip="https://gitea.elpatron.me/elpatron/octo-funnel/archive/v{target_version}.zip"
) )
) )

View File

@@ -49,6 +49,27 @@ class TailscaleInterface:
self._logger.error("Error running command '{}': {}".format(command, str(e))) self._logger.error("Error running command '{}': {}".format(command, str(e)))
return {"success": False, "output": None, "error": str(e)} return {"success": False, "output": None, "error": str(e)}
def _run_first_success(self, commands):
"""
Try commands in order and return the first successful result.
"""
last_result = None
for command in commands:
result = self._run_command(command)
if self._logger:
self._logger.debug("tailscale cmd: '{}' -> rc_ok={}".format(command, result["success"]))
if result["success"]:
return result
# Try again with sudo -n if available/allowed, to avoid interactive prompt
sudo_command = "sudo -n " + command
sudo_result = self._run_command(sudo_command)
if self._logger:
self._logger.debug("tailscale cmd (sudo): '{}' -> rc_ok={}".format(sudo_command, sudo_result["success"]))
if sudo_result["success"]:
return sudo_result
last_result = result
return last_result if last_result is not None else {"success": False, "output": None, "error": "No commands executed"}
def is_tailscale_installed(self): def is_tailscale_installed(self):
""" """
Check if tailscale is installed Check if tailscale is installed
@@ -91,11 +112,23 @@ class TailscaleInterface:
result = self._run_command("tailscale funnel status --json") result = self._run_command("tailscale funnel status --json")
if result["success"]: if result["success"]:
try: try:
status = json.loads(result["output"]) status = json.loads(result["output"]) if result["output"] else {}
# Check if any funnel is active
for _, ports in status.get("AllowFunnel", {}).items(): # New CLI JSON structure (since ~1.44+): has keys like "TCP" and "Web"
if any(ports.values()): tcp_cfg = status.get("TCP", {})
if isinstance(tcp_cfg, dict) and tcp_cfg:
for _port, cfg in tcp_cfg.items():
# cfg may contain flags like {"HTTP": true} or {"HTTPS": true}
if isinstance(cfg, dict) and any(bool(v) for v in cfg.values()):
return True return True
# Old CLI JSON structure: {"AllowFunnel": {"https://<domain>": {"80": true}}}
allow = status.get("AllowFunnel", {})
if isinstance(allow, dict) and allow:
for _key, ports in allow.items():
if isinstance(ports, dict) and any(bool(v) for v in ports.values()):
return True
return False return False
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
if self._logger: if self._logger:
@@ -115,9 +148,30 @@ class TailscaleInterface:
if not self.is_tailscale_installed(): if not self.is_tailscale_installed():
raise TailscaleNotInstalledError("Tailscale is not installed") raise TailscaleNotInstalledError("Tailscale is not installed")
command = "tailscale funnel {} on".format(port) # If already enabled, consider success
result = self._run_command(command) try:
return result["success"] if self.is_funnel_enabled():
return True
except Exception:
# ignore and continue to try enabling
pass
# Since newer Tailscale versions changed the CLI, try new syntax first,
# then fall back to the legacy commands.
# 1) Ensure a serve mapping exists
serve_cmd_new = "tailscale serve --bg --http {p} --set-path {p} http://127.0.0.1:{p}".format(p=port)
serve_cmd_old = "tailscale serve --bg https http://127.0.0.1:{p}".format(p=port)
self._run_first_success([serve_cmd_new, serve_cmd_old])
# 2) Enable funnel
funnel_cmd_new = "tailscale funnel --bg {p}".format(p=port)
funnel_cmd_old = "tailscale funnel {p} on".format(p=port)
result = self._run_first_success([funnel_cmd_new, funnel_cmd_old])
# Validate by querying status
if result["success"] and self.is_funnel_enabled():
return True
return False
def disable_funnel(self, port): def disable_funnel(self, port):
""" """
@@ -126,9 +180,22 @@ class TailscaleInterface:
if not self.is_tailscale_installed(): if not self.is_tailscale_installed():
raise TailscaleNotInstalledError("Tailscale is not installed") raise TailscaleNotInstalledError("Tailscale is not installed")
command = "tailscale funnel {} off".format(port) # Try new CLI first, then legacy
result = self._run_command(command) cmds = [
return result["success"] # Newer: turn off serve mapping and remove funnel config
"tailscale serve --http={p} off".format(p=port),
"tailscale funnel reset",
# Legacy
"tailscale funnel {p} off".format(p=port),
]
result = self._run_first_success(cmds)
if result["success"]:
# Double-check disabled
try:
return not self.is_funnel_enabled()
except Exception:
return True
return False
def get_public_url(self): def get_public_url(self):
""" """
@@ -140,18 +207,34 @@ class TailscaleInterface:
result = self._run_command("tailscale funnel status --json") result = self._run_command("tailscale funnel status --json")
if result["success"]: if result["success"]:
try: try:
status = json.loads(result["output"]) status = json.loads(result["output"]) if result["output"] else {}
# Extract the public URL
# This is a simplified approach - in practice, you might need to parse more details # New JSON shape
funnel_status = status.get("AllowFunnel", {}) web = status.get("Web", {})
if funnel_status: if isinstance(web, dict) and web:
# Get the first available funnel URL for hostport, mapping in web.items():
for key, ports in funnel_status.items(): # hostport like "octopi.warbler-bearded.ts.net:80"
domain = hostport.split(":")[0]
# If handler paths exist, pick the first path
path = "/"
handlers = mapping.get("Handlers", {}) if isinstance(mapping, dict) else {}
if handlers:
first_path = next(iter(handlers.keys()))
if isinstance(first_path, str):
path = first_path if first_path.startswith("/") else "/" + first_path
scheme = "https" if hostport.endswith(":443") else "http"
return "{}://{}{}".format(scheme, domain, path)
# Legacy JSON shape
allow = status.get("AllowFunnel", {})
if isinstance(allow, dict) and allow:
for key, ports in allow.items():
if any(ports.values()): if any(ports.values()):
# Extract the domain from the key match = re.search(r'https?://([a-zA-Z0-9\-\.]+)', key)
match = re.search(r'https://([a-zA-Z0-9\-\.]+)', key)
if match: if match:
return "https://{}".format(match.group(1)) # Assume https if legacy provided https, else http
scheme = "https" if key.startswith("https://") else "http"
return "{}://{}".format(scheme, match.group(1))
return None return None
except (json.JSONDecodeError, KeyError) as e: except (json.JSONDecodeError, KeyError) as e:
if self._logger: if self._logger:

View File

@@ -4,10 +4,10 @@
<label class="control-label">Funnel Status</label> <label class="control-label">Funnel Status</label>
<div class="controls"> <div class="controls">
<div class="input-append"> <div class="input-append">
<span id="tailscale_funnel_status" class="input-xlarge uneditable-input"> <span id="tailscale_funnel_status" class="input-xlarge uneditable-input" data-bind="text: funnelStatus">
Checking... Checking...
</span> </span>
<button id="tailscale_funnel_refresh_btn" class="btn" type="button"> <button id="tailscale_funnel_refresh_btn" class="btn" type="button" data-bind="click: refreshStatus, enable: !refreshInProgress()">
<i class="fas fa-sync"></i> Refresh <i class="fas fa-sync"></i> Refresh
</button> </button>
</div> </div>
@@ -19,7 +19,7 @@
<label class="control-label">Enable Funnel</label> <label class="control-label">Enable Funnel</label>
<div class="controls"> <div class="controls">
<div class="btn-group" data-toggle="buttons-checkbox"> <div class="btn-group" data-toggle="buttons-checkbox">
<button id="tailscale_funnel_toggle_btn" class="btn btn-success"> <button id="tailscale_funnel_toggle_btn" class="btn btn-success" data-bind="click: toggleFunnel, text: funnelEnabled() ? 'Disable' : 'Enable', css: { 'btn-success': !funnelEnabled(), 'btn-danger': funnelEnabled() }">
<i class="fas fa-toggle-on"></i> Enable <i class="fas fa-toggle-on"></i> Enable
</button> </button>
</div> </div>
@@ -31,10 +31,10 @@
<label class="control-label">Public URL</label> <label class="control-label">Public URL</label>
<div class="controls"> <div class="controls">
<div class="input-append"> <div class="input-append">
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input"> <span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl">
Not available Not available
</span> </span>
<button id="tailscale_funnel_copy_url_btn" class="btn" type="button"> <button id="tailscale_funnel_copy_url_btn" class="btn" type="button" data-bind="click: copyUrlToClipboard, enable: publicUrl() && publicUrl() !== 'Not available'">
<i class="fas fa-copy"></i> Copy <i class="fas fa-copy"></i> Copy
</button> </button>
</div> </div>

View File

@@ -14,7 +14,7 @@ plugin_package = "octoprint_tailscale_funnel"
plugin_name = "OctoPrint-Tailscale-Funnel" plugin_name = "OctoPrint-Tailscale-Funnel"
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module # The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
plugin_version = "0.1.1" plugin_version = "0.1.2"
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin # The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
# module # module

View File

@@ -0,0 +1,4 @@
{
"version": "0.1.2"
}