Update README
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -168,4 +168,5 @@ Thumbs.db
|
||||
|
||||
# Project specific
|
||||
OctoPrint_Tailscale_Funnel.egg-info/
|
||||
**/OctoPrint_Tailscale_Funnel.egg-info/
|
||||
**/OctoPrint_Tailscale_Funnel.egg-info/
|
||||
dist/
|
52
README.md
52
README.md
@@ -1,52 +0,0 @@
|
||||
# OctoPrint Tailscale Funnel Plugin
|
||||
|
||||
This plugin makes your OctoPrint instance accessible from anywhere via Tailscale Funnel, without needing to configure port forwarding, dynamic DNS, or complex firewall settings.
|
||||
|
||||
## Features
|
||||
|
||||
* Enable/disable Tailscale Funnel access directly from OctoPrint's settings
|
||||
* Monitor the current Funnel connection status
|
||||
* Display the public URL for accessing OctoPrint remotely
|
||||
* Configure the port to expose via Funnel
|
||||
|
||||
## Requirements
|
||||
|
||||
* OctoPrint 1.3.0 or higher
|
||||
* Tailscale installed and configured on the system
|
||||
* Python 3.7 or higher
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install Tailscale on your system and ensure it's running (see https://tailscale.com/download/linux or run `curl -fsSL https://tailscale.com/install.sh | sh`)
|
||||
2. Start Tailscale on your system (run `sudo tailscale up`)
|
||||
3. Authenticate using the Tailscale URL (e.g. https://login.tailscale.com/a/<some random characters>)
|
||||
4. Install the plugin through OctoPrint's plugin manager (go to Settings -> Plugins -> Install and search for "Tailscale Funnel")
|
||||
5. Configure the plugin settings in OctoPrint's settings panel
|
||||
6. Enable Funnel through the plugin interface
|
||||
|
||||
## Building from Source
|
||||
|
||||
If you want to build the plugin from source, please refer to the [BUILDING.md](octoprint_tailscale_funnel/BUILDING.md) file for detailed instructions.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin adds a new section to OctoPrint's settings panel with the following options:
|
||||
|
||||
* **Port**: The port to expose via Funnel (default: 80)
|
||||
* **Confirm Enable**: Require confirmation before enabling Funnel (default: True)
|
||||
|
||||
## 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.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The plugin exposes the following API endpoints:
|
||||
|
||||
* `GET /api/plugin/tailscale_funnel/status` - Get current Funnel status
|
||||
* `POST /api/plugin/tailscale_funnel/enable` - Enable Tailscale Funnel
|
||||
* `POST /api/plugin/tailscale_funnel/disable` - Disable Tailscale Funnel
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3
|
@@ -55,7 +55,7 @@ This will create distribution files in the `dist/` directory:
|
||||
|
||||
First, install the zip utility if not already installed:
|
||||
```bash
|
||||
sudo apt install zip # On Ubuntu/Debian
|
||||
sudo apt install zip # On Ubuntu/Debian/Raspbian
|
||||
```
|
||||
|
||||
Then create a proper zip file:
|
||||
|
@@ -1,230 +0,0 @@
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import octoprint.plugin
|
||||
from .tailscale_interface import TailscaleInterface, TailscaleError, TailscaleNotInstalledError, TailscaleNotRunningError
|
||||
from .status_monitor import StatusMonitor
|
||||
|
||||
|
||||
class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
octoprint.plugin.SettingsPlugin,
|
||||
octoprint.plugin.AssetPlugin,
|
||||
octoprint.plugin.TemplatePlugin,
|
||||
octoprint.plugin.BlueprintPlugin):
|
||||
|
||||
def __init__(self):
|
||||
self._logger = None
|
||||
self.tailscale_interface = None
|
||||
self.status_monitor = None
|
||||
|
||||
##~~ StartupPlugin mixin
|
||||
|
||||
def on_after_startup(self):
|
||||
self._logger = self._plugin_manager.get_logger("octoprint_tailscale_funnel")
|
||||
self._logger.info("Tailscale Funnel Plugin started")
|
||||
self.tailscale_interface = TailscaleInterface(self._logger)
|
||||
self.status_monitor = StatusMonitor(self)
|
||||
self.status_monitor.start()
|
||||
|
||||
def on_shutdown(self):
|
||||
if self.status_monitor:
|
||||
self.status_monitor.stop()
|
||||
|
||||
##~~ SettingsPlugin mixin
|
||||
|
||||
def get_settings_defaults(self):
|
||||
return dict(
|
||||
enabled=False,
|
||||
port=80,
|
||||
confirm_enable=True
|
||||
)
|
||||
|
||||
def get_settings_version(self):
|
||||
return 1
|
||||
|
||||
def on_settings_migrate(self, target, current):
|
||||
if current is None:
|
||||
# Migrate from settings version 0 to 1
|
||||
if self._settings.has(["funnel_enabled"]):
|
||||
self._settings.set_boolean(["enabled"], self._settings.get_boolean(["funnel_enabled"]))
|
||||
self._settings.remove(["funnel_enabled"])
|
||||
|
||||
def on_settings_save(self, data):
|
||||
# Save the settings
|
||||
octoprint.plugin.SettingsPlugin.on_settings_save(self, data)
|
||||
|
||||
# If enabled setting changed, update funnel status
|
||||
if "enabled" in data:
|
||||
enabled = self._settings.get_boolean(["enabled"])
|
||||
port = self._settings.get_int(["port"])
|
||||
|
||||
try:
|
||||
if enabled:
|
||||
# Enable funnel
|
||||
if self.tailscale_interface:
|
||||
success = self.tailscale_interface.enable_funnel(port)
|
||||
if success:
|
||||
self._logger.info("Tailscale Funnel enabled on port {}".format(port))
|
||||
else:
|
||||
self._logger.error("Failed to enable Tailscale Funnel on port {}".format(port))
|
||||
else:
|
||||
# Disable funnel
|
||||
if self.tailscale_interface:
|
||||
success = self.tailscale_interface.disable_funnel(port)
|
||||
if success:
|
||||
self._logger.info("Tailscale Funnel disabled on port {}".format(port))
|
||||
else:
|
||||
self._logger.error("Failed to disable Tailscale Funnel on port {}".format(port))
|
||||
except TailscaleError as e:
|
||||
self._logger.error("Tailscale error: {}".format(str(e)))
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error: {}".format(str(e)))
|
||||
|
||||
##~~ AssetPlugin mixin
|
||||
|
||||
def get_assets(self):
|
||||
return dict(
|
||||
js=["js/tailscale_funnel.js"],
|
||||
css=["css/tailscale_funnel.css"],
|
||||
less=["less/tailscale_funnel.less"]
|
||||
)
|
||||
|
||||
##~~ TemplatePlugin mixin
|
||||
|
||||
def get_template_configs(self):
|
||||
return [
|
||||
dict(type="settings", custom_bindings=False)
|
||||
]
|
||||
|
||||
##~~ BlueprintPlugin mixin
|
||||
|
||||
@octoprint.plugin.BlueprintPlugin.route("/status", methods=["GET"])
|
||||
def get_status(self):
|
||||
if not self.tailscale_interface:
|
||||
return {"status": "error", "message": "Tailscale interface not initialized"}, 500
|
||||
|
||||
try:
|
||||
# Check if tailscale is installed
|
||||
if not self.tailscale_interface.is_tailscale_installed():
|
||||
return {"status": "error", "message": "Tailscale is not installed"}, 500
|
||||
|
||||
# Check if tailscale is running
|
||||
if not self.tailscale_interface.is_tailscale_running():
|
||||
return {"status": "error", "message": "Tailscale is not running"}, 500
|
||||
|
||||
# Get funnel status
|
||||
funnel_enabled = self.tailscale_interface.is_funnel_enabled()
|
||||
public_url = self.tailscale_interface.get_public_url() if funnel_enabled else None
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"funnel_enabled": funnel_enabled,
|
||||
"public_url": public_url,
|
||||
"tailscale_installed": True,
|
||||
"tailscale_running": True
|
||||
}
|
||||
}
|
||||
except TailscaleNotInstalledError as e:
|
||||
return {"status": "error", "message": "Tailscale is not installed: {}".format(str(e))}, 500
|
||||
except TailscaleNotRunningError as e:
|
||||
return {"status": "error", "message": "Tailscale is not running: {}".format(str(e))}, 500
|
||||
except TailscaleError as e:
|
||||
return {"status": "error", "message": "Tailscale error: {}".format(str(e))}, 500
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error in get_status: {}".format(str(e)))
|
||||
return {"status": "error", "message": "Unexpected error: {}".format(str(e))}, 500
|
||||
|
||||
@octoprint.plugin.BlueprintPlugin.route("/enable", methods=["POST"])
|
||||
def enable_funnel(self):
|
||||
if not self.tailscale_interface:
|
||||
return {"status": "error", "message": "Tailscale interface not initialized"}, 500
|
||||
|
||||
try:
|
||||
port = self._settings.get_int(["port"])
|
||||
success = self.tailscale_interface.enable_funnel(port)
|
||||
|
||||
if success:
|
||||
# Update settings
|
||||
self._settings.set_boolean(["enabled"], True)
|
||||
self._settings.save()
|
||||
|
||||
public_url = self.tailscale_interface.get_public_url()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Tailscale Funnel enabled successfully",
|
||||
"data": {
|
||||
"public_url": public_url
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": "Failed to enable Tailscale Funnel"}, 500
|
||||
except TailscaleNotInstalledError as e:
|
||||
return {"status": "error", "message": "Tailscale is not installed: {}".format(str(e))}, 500
|
||||
except TailscaleError as e:
|
||||
return {"status": "error", "message": "Tailscale error: {}".format(str(e))}, 500
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error in enable_funnel: {}".format(str(e)))
|
||||
return {"status": "error", "message": "Unexpected error: {}".format(str(e))}, 500
|
||||
|
||||
@octoprint.plugin.BlueprintPlugin.route("/disable", methods=["POST"])
|
||||
def disable_funnel(self):
|
||||
if not self.tailscale_interface:
|
||||
return {"status": "error", "message": "Tailscale interface not initialized"}, 500
|
||||
|
||||
try:
|
||||
port = self._settings.get_int(["port"])
|
||||
success = self.tailscale_interface.disable_funnel(port)
|
||||
|
||||
if success:
|
||||
# Update settings
|
||||
self._settings.set_boolean(["enabled"], False)
|
||||
self._settings.save()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Tailscale Funnel disabled successfully"
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": "Failed to disable Tailscale Funnel"}, 500
|
||||
except TailscaleNotInstalledError as e:
|
||||
return {"status": "error", "message": "Tailscale is not installed: {}".format(str(e))}, 500
|
||||
except TailscaleError as e:
|
||||
return {"status": "error", "message": "Tailscale error: {}".format(str(e))}, 500
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error in disable_funnel: {}".format(str(e)))
|
||||
return {"status": "error", "message": "Unexpected error: {}".format(str(e))}, 500
|
||||
|
||||
##~~ Softwareupdate hook
|
||||
|
||||
def get_update_information(self):
|
||||
return dict(
|
||||
tailscale_funnel=dict(
|
||||
displayName="Tailscale Funnel Plugin",
|
||||
displayVersion=self._plugin_version,
|
||||
|
||||
# version check: github repository
|
||||
type="github_release",
|
||||
user="markus",
|
||||
repo="OctoPrint-Tailscale-Funnel",
|
||||
current=self._plugin_version,
|
||||
|
||||
# update method: pip
|
||||
pip="https://github.com/markus/OctoPrint-Tailscale-Funnel/archive/{target_version}.zip"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# If you want your plugin to be registered within OctoPrint under its own name,
|
||||
# define it here.
|
||||
__plugin_name__ = "Tailscale Funnel Plugin"
|
||||
__plugin_pythoncompat__ = ">=3.7,<4" # python 3.7+
|
||||
|
||||
def __plugin_load__():
|
||||
global __plugin_implementation__
|
||||
__plugin_implementation__ = TailscaleFunnelPlugin()
|
||||
|
||||
global __plugin_hooks__
|
||||
__plugin_hooks__ = {
|
||||
"octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information
|
||||
}
|
@@ -1,74 +0,0 @@
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class StatusMonitor:
|
||||
def __init__(self, plugin, interval=30):
|
||||
self.plugin = plugin
|
||||
self.interval = interval
|
||||
self._logger = plugin._logger
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the status monitoring thread
|
||||
"""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._logger.warning("Status monitor is already running")
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._monitor_loop)
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
self._logger.info("Status monitor started")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop the status monitoring thread
|
||||
"""
|
||||
if self._thread is None:
|
||||
return
|
||||
|
||||
self._stop_event.set()
|
||||
self._thread.join()
|
||||
self._thread = None
|
||||
self._logger.info("Status monitor stopped")
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""
|
||||
Main monitoring loop
|
||||
"""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Check status if tailscale interface is available
|
||||
if self.plugin.tailscale_interface:
|
||||
# Get current funnel status
|
||||
funnel_enabled = self.plugin.tailscale_interface.is_funnel_enabled()
|
||||
|
||||
# Update settings if needed
|
||||
current_setting = self.plugin._settings.get_boolean(["enabled"])
|
||||
if funnel_enabled != current_setting:
|
||||
self.plugin._settings.set_boolean(["enabled"], funnel_enabled)
|
||||
self.plugin._settings.save()
|
||||
self._logger.info("Funnel status updated in settings: {}".format(funnel_enabled))
|
||||
|
||||
# Send a notification to the frontend
|
||||
self.plugin._plugin_manager.send_plugin_message(
|
||||
self.plugin._identifier,
|
||||
{
|
||||
"type": "funnel_status_change",
|
||||
"enabled": funnel_enabled
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error("Error in status monitoring loop: {}".format(str(e)))
|
||||
|
||||
# Wait for the next interval or until stopped
|
||||
if self._stop_event.wait(self.interval):
|
||||
break
|
@@ -1,161 +0,0 @@
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
class TailscaleError(Exception):
|
||||
"""Base exception for Tailscale interface errors"""
|
||||
pass
|
||||
|
||||
|
||||
class TailscaleNotInstalledError(TailscaleError):
|
||||
"""Exception raised when Tailscale is not installed"""
|
||||
pass
|
||||
|
||||
|
||||
class TailscaleNotRunningError(TailscaleError):
|
||||
"""Exception raised when Tailscale is not running"""
|
||||
pass
|
||||
|
||||
|
||||
class FunnelNotEnabledError(TailscaleError):
|
||||
"""Exception raised when Funnel is not enabled"""
|
||||
pass
|
||||
|
||||
|
||||
class TailscaleInterface:
|
||||
def __init__(self, logger=None):
|
||||
self._logger = logger
|
||||
|
||||
def _run_command(self, command):
|
||||
"""
|
||||
Run a command and return the result
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return {"success": True, "output": result.stdout.strip(), "error": None}
|
||||
else:
|
||||
return {"success": False, "output": None, "error": result.stderr.strip()}
|
||||
except subprocess.TimeoutExpired:
|
||||
if self._logger:
|
||||
self._logger.error("Command timed out: '{}'".format(command))
|
||||
return {"success": False, "output": None, "error": "Command timed out"}
|
||||
except Exception as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error running command '{}': {}".format(command, str(e)))
|
||||
return {"success": False, "output": None, "error": str(e)}
|
||||
|
||||
def is_tailscale_installed(self):
|
||||
"""
|
||||
Check if tailscale is installed
|
||||
"""
|
||||
result = self._run_command("which tailscale")
|
||||
return result["success"] and result["output"] != ""
|
||||
|
||||
def is_tailscale_running(self):
|
||||
"""
|
||||
Check if tailscale is running
|
||||
"""
|
||||
result = self._run_command("tailscale status --json")
|
||||
return result["success"]
|
||||
|
||||
def get_tailscale_status(self):
|
||||
"""
|
||||
Get tailscale status as JSON
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale status --json")
|
||||
if result["success"]:
|
||||
try:
|
||||
return json.loads(result["output"])
|
||||
except json.JSONDecodeError as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error parsing tailscale status JSON: {}".format(str(e)))
|
||||
raise TailscaleError("Error parsing tailscale status: {}".format(str(e)))
|
||||
else:
|
||||
raise TailscaleNotRunningError("Tailscale is not running: {}".format(result["error"]))
|
||||
|
||||
def is_funnel_enabled(self):
|
||||
"""
|
||||
Check if tailscale funnel is enabled
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale funnel status --json")
|
||||
if result["success"]:
|
||||
try:
|
||||
status = json.loads(result["output"])
|
||||
# Check if any funnel is active
|
||||
for _, ports in status.get("AllowFunnel", {}).items():
|
||||
if any(ports.values()):
|
||||
return True
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error parsing funnel status JSON: {}".format(str(e)))
|
||||
raise TailscaleError("Error parsing funnel status: {}".format(str(e)))
|
||||
else:
|
||||
# If the command fails, it might be because Funnel is not enabled
|
||||
# Check if it's a specific error we can handle
|
||||
if "funnel" in result["error"].lower() and "not enabled" in result["error"].lower():
|
||||
return False
|
||||
raise TailscaleError("Error checking funnel status: {}".format(result["error"]))
|
||||
|
||||
def enable_funnel(self, port):
|
||||
"""
|
||||
Enable tailscale funnel for the specified port
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
command = "tailscale funnel {} on".format(port)
|
||||
result = self._run_command(command)
|
||||
return result["success"]
|
||||
|
||||
def disable_funnel(self, port):
|
||||
"""
|
||||
Disable tailscale funnel for the specified port
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
command = "tailscale funnel {} off".format(port)
|
||||
result = self._run_command(command)
|
||||
return result["success"]
|
||||
|
||||
def get_public_url(self):
|
||||
"""
|
||||
Get the public URL for accessing the service via funnel
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale funnel status --json")
|
||||
if result["success"]:
|
||||
try:
|
||||
status = json.loads(result["output"])
|
||||
# Extract the public URL
|
||||
# This is a simplified approach - in practice, you might need to parse more details
|
||||
funnel_status = status.get("AllowFunnel", {})
|
||||
if funnel_status:
|
||||
# Get the first available funnel URL
|
||||
for key, ports in funnel_status.items():
|
||||
if any(ports.values()):
|
||||
# Extract the domain from the key
|
||||
match = re.search(r'https://([a-zA-Z0-9\-\.]+)', key)
|
||||
if match:
|
||||
return "https://{}".format(match.group(1))
|
||||
return None
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error parsing funnel status for URL: {}".format(str(e)))
|
||||
raise TailscaleError("Error parsing funnel status for URL: {}".format(str(e)))
|
||||
else:
|
||||
raise TailscaleError("Error getting funnel status: {}".format(result["error"]))
|
Reference in New Issue
Block a user