Initial commit: Tailscale Funnel plugin for OctoPrint with build documentation
This commit is contained in:
125
octoprint_tailscale_funnel/BUILDING.md
Normal file
125
octoprint_tailscale_funnel/BUILDING.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Building the Tailscale Funnel Plugin
|
||||
|
||||
This document describes how to build the Tailscale Funnel plugin for OctoPrint from source.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before building the plugin, ensure you have the following installed on your system:
|
||||
|
||||
* Python 3.7 or higher
|
||||
* pip (Python package installer)
|
||||
* git (optional, for version control)
|
||||
|
||||
## Build Process
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
If you haven't already, clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.elpatron.me/elpatron/octo-funnel.git
|
||||
cd octo-funnel
|
||||
```
|
||||
|
||||
### 2. Create a Virtual Environment
|
||||
|
||||
It's recommended to use a virtual environment to isolate the build dependencies:
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
### 3. Install Build Dependencies
|
||||
|
||||
Install the required Python packages:
|
||||
|
||||
```bash
|
||||
pip install setuptools wheel octoprint
|
||||
```
|
||||
|
||||
### 4. Build the Plugin
|
||||
|
||||
Navigate to the plugin directory and run the setup script:
|
||||
|
||||
```bash
|
||||
cd octoprint_tailscale_funnel
|
||||
python setup.py sdist bdist_wheel
|
||||
```
|
||||
|
||||
This will create distribution files in the `dist/` directory:
|
||||
- A source distribution (tar.gz)
|
||||
- A wheel distribution (whl)
|
||||
|
||||
### 5. Create OctoPrint-Compatible Package
|
||||
|
||||
OctoPrint's plugin manager expects a zip file with a specific naming convention. Create it by copying and renaming the tar.gz file:
|
||||
|
||||
```bash
|
||||
cp dist/octoprint_tailscale_funnel-*.tar.gz dist/OctoPrint-Tailscale-Funnel-*.zip
|
||||
```
|
||||
|
||||
The resulting file `OctoPrint-Tailscale-Funnel-*.zip` can be uploaded to OctoPrint through the plugin manager.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
After a successful build, the project directory will contain:
|
||||
|
||||
```
|
||||
octoprint_tailscale_funnel/
|
||||
├── dist/
|
||||
│ ├── octoprint_tailscale_funnel-<version>.tar.gz
|
||||
│ ├── octoprint_tailscale_funnel-<version>-py3-none-any.whl
|
||||
│ └── OctoPrint-Tailscale-Funnel-<version>.zip
|
||||
├── build/
|
||||
│ └── ... (temporary build files)
|
||||
├── OctoPrint_Tailscale_Funnel.egg-info/
|
||||
│ └── ... (package metadata)
|
||||
└── ... (source files)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### ImportError: No module named 'octoprint_setuptools'
|
||||
|
||||
If you encounter this error, make sure you've installed OctoPrint in your virtual environment:
|
||||
|
||||
```bash
|
||||
pip install octoprint
|
||||
```
|
||||
|
||||
### Permission Errors
|
||||
|
||||
If you encounter permission errors during installation, you may need to use the `--user` flag:
|
||||
|
||||
```bash
|
||||
pip install --user setuptools wheel octoprint
|
||||
```
|
||||
|
||||
### Package Directory Issues
|
||||
|
||||
If you see warnings about package directories, ensure the setup.py file is correctly configured with the appropriate package structure.
|
||||
|
||||
## Cleaning Up
|
||||
|
||||
To clean up build artifacts:
|
||||
|
||||
```bash
|
||||
rm -rf build/ dist/ *.egg-info/
|
||||
```
|
||||
|
||||
This will remove all generated files and allow you to rebuild from scratch.
|
||||
|
||||
## Versioning
|
||||
|
||||
The plugin version is defined in `setup.py`. To release a new version:
|
||||
|
||||
1. Update the `plugin_version` variable in `setup.py`
|
||||
2. Rebuild the plugin following the steps above
|
||||
3. The new version will be reflected in the generated filenames
|
||||
|
||||
## Additional Resources
|
||||
|
||||
* [OctoPrint Plugin Development Documentation](https://docs.octoprint.org/en/master/plugins/getting-started.html)
|
||||
* [Tailscale Documentation](https://tailscale.com/kb/)
|
||||
* [Python Packaging Documentation](https://packaging.python.org/)
|
6
octoprint_tailscale_funnel/MANIFEST.in
Normal file
6
octoprint_tailscale_funnel/MANIFEST.in
Normal file
@@ -0,0 +1,6 @@
|
||||
include README.md
|
||||
include requirements.txt
|
||||
include setup.py
|
||||
recursive-include octoprint_tailscale_funnel/templates *
|
||||
recursive-include octoprint_tailscale_funnel/static *
|
||||
recursive-include octoprint_tailscale_funnel/tests *
|
50
octoprint_tailscale_funnel/README.md
Normal file
50
octoprint_tailscale_funnel/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 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
|
||||
2. Install the plugin through OctoPrint's plugin manager
|
||||
3. Configure the plugin settings in OctoPrint's settings panel
|
||||
4. Enable Funnel through the plugin interface
|
||||
|
||||
## Building from Source
|
||||
|
||||
If you want to build the plugin from source, please refer to the [BUILDING.md](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
|
230
octoprint_tailscale_funnel/__init__.py
Normal file
230
octoprint_tailscale_funnel/__init__.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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
octoprint_tailscale_funnel/requirements.txt
Normal file
1
octoprint_tailscale_funnel/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
OctoPrint>=1.3.0
|
94
octoprint_tailscale_funnel/setup.py
Normal file
94
octoprint_tailscale_funnel/setup.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# coding=utf-8
|
||||
|
||||
########################################################################################################################
|
||||
### Do not forget to adjust the following variables to your own plugin.
|
||||
|
||||
# The plugin's identifier, has to be unique
|
||||
plugin_identifier = "tailscale_funnel"
|
||||
|
||||
# The plugin's python package, should be "octoprint_<plugin identifier>", has to be unique
|
||||
plugin_package = "."
|
||||
|
||||
# The plugin's human readable name. Can be overwritten within OctoPrint's internal data via __plugin_name__ in the
|
||||
# plugin module
|
||||
plugin_name = "OctoPrint-Tailscale-Funnel"
|
||||
|
||||
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
|
||||
plugin_version = "0.1.0"
|
||||
|
||||
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
|
||||
# module
|
||||
plugin_description = """Plugin to make OctoPrint accessible via Tailscale Funnel"""
|
||||
|
||||
# The plugin's author. Can be overwritten within OctoPrint's internal data via __plugin_author__ in the plugin module
|
||||
plugin_author = "Markus F.J. Busche"
|
||||
|
||||
# The plugin's author's mail address.
|
||||
plugin_author_email = "elpatron@mailbox.org"
|
||||
|
||||
# The plugin's homepage URL. Can be overwritten within OctoPrint's internal data via __plugin_url__ in the plugin module
|
||||
plugin_url = "https://gitea.elpatron.me/elpatron/octo-funnel"
|
||||
|
||||
# The plugin's license. Can be overwritten within OctoPrint's internal data via __plugin_license__ in the plugin module
|
||||
plugin_license = "AGPLv3"
|
||||
|
||||
# Any additional requirements besides OctoPrint should be listed here
|
||||
plugin_requires = []
|
||||
|
||||
### --------------------------------------------------------------------------------------------------------------------
|
||||
### More advanced options that you usually shouldn't have to touch follow after this point
|
||||
### --------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
# Additional package data to install for this plugin. The subfolders "templates", "static" and "translations" will
|
||||
# already be installed automatically if they exist. Note that if you add something here you'll also need to update
|
||||
# MANIFEST.in to match to ensure that python setup.py sdist produces a source distribution that contains all your
|
||||
# files. This is sadly due to how python's setup.py works, see also http://stackoverflow.com/a/14159430/2028598
|
||||
plugin_additional_data = []
|
||||
|
||||
# Any additional python packages you need to install with your plugin that are not contained in <plugin_package>.*
|
||||
plugin_additional_packages = []
|
||||
|
||||
# Any python packages within <plugin_package>.* you do NOT want to install with your plugin
|
||||
plugin_ignored_packages = []
|
||||
|
||||
# Additional parameters for the call to setuptools.setup. If your plugin wants to register additional entry points,
|
||||
# define dependency links or other things like that, this is the place to go. Will be merged recursively with the
|
||||
# default setup parameters as provided by octoprint_setuptools.create_plugin_setup_parameters using
|
||||
# octoprint.util.dict_merge.
|
||||
#
|
||||
# Example:
|
||||
# plugin_requires = ["someDependency==dev"]
|
||||
# additional_setup_parameters = {"dependency_links": ["https://github.com/someUser/someRepo/archive/master.zip#egg=someDependency-dev"]}
|
||||
additional_setup_parameters = {"python_requires": ">=3.7,<4"}
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
try:
|
||||
import octoprint_setuptools
|
||||
except:
|
||||
print("Could not import OctoPrint's setuptools, are you sure you have setuptools installed?")
|
||||
raise
|
||||
|
||||
setup_parameters = octoprint_setuptools.create_plugin_setup_parameters(
|
||||
identifier=plugin_identifier,
|
||||
package=plugin_package,
|
||||
name=plugin_name,
|
||||
version=plugin_version,
|
||||
description=plugin_description,
|
||||
author=plugin_author,
|
||||
mail=plugin_author_email,
|
||||
url=plugin_url,
|
||||
license=plugin_license,
|
||||
requires=plugin_requires,
|
||||
additional_packages=plugin_additional_packages,
|
||||
ignored_packages=plugin_ignored_packages,
|
||||
additional_data=plugin_additional_data
|
||||
)
|
||||
|
||||
if len(additional_setup_parameters):
|
||||
from octoprint.util import dict_merge
|
||||
setup_parameters = dict_merge(setup_parameters, additional_setup_parameters)
|
||||
|
||||
setup(**setup_parameters)
|
41
octoprint_tailscale_funnel/static/css/tailscale_funnel.css
Normal file
41
octoprint_tailscale_funnel/static/css/tailscale_funnel.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Tailscale Funnel Plugin Styles */
|
||||
|
||||
#tailscale_funnel_status {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#tailscale_funnel_status.enabled {
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
#tailscale_funnel_status.disabled {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
#tailscale_funnel_status.error {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
#tailscale_funnel_toggle_btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
#tailscale_funnel_toggle_btn.enabled {
|
||||
background-color: #5cb85c;
|
||||
border-color: #4cae4c;
|
||||
}
|
||||
|
||||
#tailscale_funnel_toggle_btn.disabled {
|
||||
background-color: #777;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
#tailscale_funnel_url {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tailscale-funnel-warning {
|
||||
background-color: #fcf8e3;
|
||||
border-color: #faebcc;
|
||||
color: #8a6d3b;
|
||||
}
|
189
octoprint_tailscale_funnel/static/js/tailscale_funnel.js
Normal file
189
octoprint_tailscale_funnel/static/js/tailscale_funnel.js
Normal file
@@ -0,0 +1,189 @@
|
||||
$(function() {
|
||||
function TailscaleFunnelViewModel(parameters) {
|
||||
var self = this;
|
||||
|
||||
self.settings = parameters[0];
|
||||
|
||||
// Status observables
|
||||
self.funnelStatus = ko.observable("Checking...");
|
||||
self.funnelEnabled = ko.observable(false);
|
||||
self.publicUrl = ko.observable("Not available");
|
||||
self.tailscaleInstalled = ko.observable(true);
|
||||
self.tailscaleRunning = ko.observable(true);
|
||||
|
||||
// Button states
|
||||
self.refreshInProgress = ko.observable(false);
|
||||
self.toggleInProgress = ko.observable(false);
|
||||
|
||||
// Initialize
|
||||
self.onStartup = function() {
|
||||
self.refreshStatus();
|
||||
};
|
||||
|
||||
// Refresh the status
|
||||
self.refreshStatus = function() {
|
||||
if (self.refreshInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.refreshInProgress(true);
|
||||
self.funnelStatus("Checking...");
|
||||
|
||||
$.ajax({
|
||||
url: PLUGIN_BASEURL + "tailscale_funnel/status",
|
||||
type: "GET",
|
||||
dataType: "json",
|
||||
success: function(response) {
|
||||
if (response.status === "success") {
|
||||
self.tailscaleInstalled(response.data.tailscale_installed);
|
||||
self.tailscaleRunning(response.data.tailscale_running);
|
||||
|
||||
if (response.data.tailscale_installed && response.data.tailscale_running) {
|
||||
self.funnelEnabled(response.data.funnel_enabled);
|
||||
self.funnelStatus(response.data.funnel_enabled ? "Enabled" : "Disabled");
|
||||
self.publicUrl(response.data.public_url || "Not available");
|
||||
} else if (!response.data.tailscale_installed) {
|
||||
self.funnelStatus("Tailscale not installed");
|
||||
} else if (!response.data.tailscale_running) {
|
||||
self.funnelStatus("Tailscale not running");
|
||||
}
|
||||
} else {
|
||||
self.funnelStatus("Error: " + response.message);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
self.funnelStatus("Error: " + error);
|
||||
},
|
||||
complete: function() {
|
||||
self.refreshInProgress(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Toggle funnel on/off
|
||||
self.toggleFunnel = function() {
|
||||
if (self.toggleInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var enable = !self.funnelEnabled();
|
||||
|
||||
// Show confirmation if required
|
||||
if (self.settings.settings.plugins.tailscale_funnel.confirm_enable() && enable) {
|
||||
if (!confirm("Enabling Funnel will make your OctoPrint instance accessible from the public internet. Do you want to continue?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.toggleInProgress(true);
|
||||
self.funnelStatus(enable ? "Enabling..." : "Disabling...");
|
||||
|
||||
var action = enable ? "enable" : "disable";
|
||||
|
||||
$.ajax({
|
||||
url: PLUGIN_BASEURL + "tailscale_funnel/" + action,
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
success: function(response) {
|
||||
if (response.status === "success") {
|
||||
self.funnelEnabled(enable);
|
||||
self.funnelStatus(enable ? "Enabled" : "Disabled");
|
||||
|
||||
if (enable && response.data && response.data.public_url) {
|
||||
self.publicUrl(response.data.public_url);
|
||||
} else if (!enable) {
|
||||
self.publicUrl("Not available");
|
||||
}
|
||||
|
||||
// Show success message
|
||||
new PNotify({
|
||||
title: "Tailscale Funnel",
|
||||
text: response.message,
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
self.funnelStatus("Error");
|
||||
// Show error message
|
||||
new PNotify({
|
||||
title: "Tailscale Funnel Error",
|
||||
text: response.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
self.funnelStatus("Error");
|
||||
// Show error message
|
||||
new PNotify({
|
||||
title: "Tailscale Funnel Error",
|
||||
text: "Failed to " + action + " Funnel: " + error,
|
||||
type: "error"
|
||||
});
|
||||
},
|
||||
complete: function() {
|
||||
self.toggleInProgress(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Copy URL to clipboard
|
||||
self.copyUrlToClipboard = function() {
|
||||
if (self.publicUrl() && self.publicUrl() !== "Not available") {
|
||||
navigator.clipboard.writeText(self.publicUrl()).then(function() {
|
||||
new PNotify({
|
||||
title: "Copied to Clipboard",
|
||||
text: "Public URL copied to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
}, function() {
|
||||
// Fallback for older browsers
|
||||
var textArea = document.createElement("textarea");
|
||||
textArea.value = self.publicUrl();
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
new PNotify({
|
||||
title: "Copied to Clipboard",
|
||||
text: "Public URL copied to clipboard",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
new PNotify({
|
||||
title: "Copy Failed",
|
||||
text: "Failed to copy URL to clipboard",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle messages from the backend
|
||||
self.onDataUpdaterPluginMessage = function(plugin, data) {
|
||||
if (plugin !== "tailscale_funnel") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "funnel_status_change") {
|
||||
self.funnelEnabled(data.enabled);
|
||||
self.funnelStatus(data.enabled ? "Enabled" : "Disabled");
|
||||
|
||||
if (data.enabled) {
|
||||
// Refresh to get the public URL
|
||||
self.refreshStatus();
|
||||
} else {
|
||||
self.publicUrl("Not available");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Register the ViewModel
|
||||
OCTOPRINT_VIEWMODELS.push({
|
||||
construct: TailscaleFunnelViewModel,
|
||||
dependencies: ["settingsViewModel"],
|
||||
elements: []
|
||||
});
|
||||
});
|
@@ -0,0 +1,3 @@
|
||||
// Tailscale Funnel Plugin Styles
|
||||
|
||||
@import "../css/tailscale_funnel.css";
|
74
octoprint_tailscale_funnel/status_monitor.py
Normal file
74
octoprint_tailscale_funnel/status_monitor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# 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
|
161
octoprint_tailscale_funnel/tailscale_interface.py
Normal file
161
octoprint_tailscale_funnel/tailscale_interface.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# 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"]))
|
@@ -0,0 +1,70 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="control-group">
|
||||
<label class="control-label">Funnel Status</label>
|
||||
<div class="controls">
|
||||
<div class="input-append">
|
||||
<span id="tailscale_funnel_status" class="input-xlarge uneditable-input">
|
||||
Checking...
|
||||
</span>
|
||||
<button id="tailscale_funnel_refresh_btn" class="btn" type="button">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<span class="help-block">Current status of Tailscale Funnel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Enable Funnel</label>
|
||||
<div class="controls">
|
||||
<div class="btn-group" data-toggle="buttons-checkbox">
|
||||
<button id="tailscale_funnel_toggle_btn" class="btn btn-success">
|
||||
<i class="fas fa-toggle-on"></i> Enable
|
||||
</button>
|
||||
</div>
|
||||
<span class="help-block">Toggle Tailscale Funnel on/off</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Public URL</label>
|
||||
<div class="controls">
|
||||
<div class="input-append">
|
||||
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input">
|
||||
Not available
|
||||
</span>
|
||||
<button id="tailscale_funnel_copy_url_btn" class="btn" type="button">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<span class="help-block">Public URL for accessing your OctoPrint instance</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Port</label>
|
||||
<div class="controls">
|
||||
<input type="number" id="tailscale_funnel_port" class="input-small"
|
||||
data-bind="value: settings.plugins.tailscale_funnel.port">
|
||||
<span class="help-block">Port to expose via Funnel (default: 80)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label class="control-label">Confirm Enable</label>
|
||||
<div class="controls">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox"
|
||||
data-bind="checked: settings.plugins.tailscale_funnel.confirm_enable">
|
||||
Require confirmation before enabling Funnel
|
||||
</label>
|
||||
<span class="help-block">Show confirmation dialog before enabling Funnel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h4><i class="fas fa-info-circle"></i> About Tailscale Funnel</h4>
|
||||
<p>Tailscale Funnel allows you to securely access your OctoPrint instance from anywhere on the internet without port forwarding or dynamic DNS.</p>
|
||||
<p><strong>Security Note:</strong> Enabling Funnel makes your printer accessible from the public internet. Only enable it when needed and disable it when finished.</p>
|
||||
</div>
|
||||
</div>
|
0
octoprint_tailscale_funnel/tests/__init__.py
Normal file
0
octoprint_tailscale_funnel/tests/__init__.py
Normal file
69
octoprint_tailscale_funnel/tests/test_plugin.py
Normal file
69
octoprint_tailscale_funnel/tests/test_plugin.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import unittest
|
||||
import mock
|
||||
|
||||
from octoprint_tailscale_funnel import TailscaleFunnelPlugin
|
||||
|
||||
|
||||
class TestTailscaleFunnelPlugin(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.plugin = TailscaleFunnelPlugin()
|
||||
|
||||
def test_plugin_name(self):
|
||||
# Test that the plugin has the correct name
|
||||
self.assertEqual(self.plugin._plugin_name, "Tailscale Funnel Plugin")
|
||||
|
||||
def test_get_settings_defaults(self):
|
||||
# Test that the default settings are correct
|
||||
defaults = self.plugin.get_settings_defaults()
|
||||
self.assertIn("enabled", defaults)
|
||||
self.assertIn("port", defaults)
|
||||
self.assertIn("confirm_enable", defaults)
|
||||
self.assertEqual(defaults["enabled"], False)
|
||||
self.assertEqual(defaults["port"], 80)
|
||||
self.assertEqual(defaults["confirm_enable"], True)
|
||||
|
||||
def test_get_assets(self):
|
||||
# Test that the assets are correctly defined
|
||||
assets = self.plugin.get_assets()
|
||||
self.assertIn("js", assets)
|
||||
self.assertIn("css", assets)
|
||||
self.assertIn("less", assets)
|
||||
self.assertIn("js/tailscale_funnel.js", assets["js"])
|
||||
self.assertIn("css/tailscale_funnel.css", assets["css"])
|
||||
self.assertIn("less/tailscale_funnel.less", assets["less"])
|
||||
|
||||
def test_get_template_configs(self):
|
||||
# Test that the template configs are correctly defined
|
||||
configs = self.plugin.get_template_configs()
|
||||
self.assertIsInstance(configs, list)
|
||||
self.assertEqual(len(configs), 1)
|
||||
self.assertIn("type", configs[0])
|
||||
self.assertIn("custom_bindings", configs[0])
|
||||
self.assertEqual(configs[0]["type"], "settings")
|
||||
self.assertEqual(configs[0]["custom_bindings"], False)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.TailscaleFunnelPlugin._run_command')
|
||||
def test_get_update_information(self, mock_run_command):
|
||||
# Setup
|
||||
self.plugin._plugin_version = "1.0.0"
|
||||
|
||||
# Execute
|
||||
update_info = self.plugin.get_update_information()
|
||||
|
||||
# Assert
|
||||
self.assertIn("tailscale_funnel", update_info)
|
||||
plugin_info = update_info["tailscale_funnel"]
|
||||
self.assertEqual(plugin_info["displayName"], "Tailscale Funnel Plugin")
|
||||
self.assertEqual(plugin_info["displayVersion"], "1.0.0")
|
||||
self.assertEqual(plugin_info["type"], "github_release")
|
||||
self.assertEqual(plugin_info["user"], "markus")
|
||||
self.assertEqual(plugin_info["repo"], "OctoPrint-Tailscale-Funnel")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
74
octoprint_tailscale_funnel/tests/test_status_monitor.py
Normal file
74
octoprint_tailscale_funnel/tests/test_status_monitor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import unittest
|
||||
import mock
|
||||
import threading
|
||||
import time
|
||||
|
||||
from octoprint_tailscale_funnel.status_monitor import StatusMonitor
|
||||
|
||||
|
||||
class TestStatusMonitor(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create a mock plugin
|
||||
self.mock_plugin = mock.Mock()
|
||||
self.mock_plugin._logger = mock.Mock()
|
||||
self.mock_plugin.tailscale_interface = mock.Mock()
|
||||
self.mock_plugin._settings = mock.Mock()
|
||||
self.mock_plugin._plugin_manager = mock.Mock()
|
||||
|
||||
self.status_monitor = StatusMonitor(self.mock_plugin, interval=0.1) # Short interval for testing
|
||||
|
||||
def test_init(self):
|
||||
# Test that the status monitor is initialized correctly
|
||||
self.assertEqual(self.status_monitor.plugin, self.mock_plugin)
|
||||
self.assertEqual(self.status_monitor.interval, 0.1)
|
||||
self.assertIsNone(self.status_monitor._thread)
|
||||
|
||||
def test_start(self):
|
||||
# Test that start creates and starts a thread
|
||||
self.status_monitor.start()
|
||||
self.assertIsNotNone(self.status_monitor._thread)
|
||||
self.assertTrue(self.status_monitor._thread.is_alive())
|
||||
|
||||
# Clean up
|
||||
self.status_monitor.stop()
|
||||
|
||||
def test_stop(self):
|
||||
# Test that stop stops the thread
|
||||
self.status_monitor.start()
|
||||
self.assertTrue(self.status_monitor._thread.is_alive())
|
||||
|
||||
self.status_monitor.stop()
|
||||
self.assertIsNone(self.status_monitor._thread)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.status_monitor.time')
|
||||
def test_monitor_loop(self, mock_time):
|
||||
# Setup mocks
|
||||
mock_time.sleep = mock.Mock() # Don't actually sleep during testing
|
||||
|
||||
# Mock the tailscale interface
|
||||
self.mock_plugin.tailscale_interface.is_funnel_enabled.return_value = True
|
||||
self.mock_plugin._settings.get_boolean.return_value = False # Different from funnel status
|
||||
|
||||
# Start the monitor
|
||||
self.status_monitor.start()
|
||||
|
||||
# Give it a moment to run
|
||||
time.sleep(0.2)
|
||||
|
||||
# Stop the monitor
|
||||
self.status_monitor.stop()
|
||||
|
||||
# Assert that the methods were called
|
||||
self.mock_plugin.tailscale_interface.is_funnel_enabled.assert_called()
|
||||
self.mock_plugin._settings.set_boolean.assert_called_with(["enabled"], True)
|
||||
self.mock_plugin._settings.save.assert_called()
|
||||
self.mock_plugin._plugin_manager.send_plugin_message.assert_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
153
octoprint_tailscale_funnel/tests/test_tailscale_interface.py
Normal file
153
octoprint_tailscale_funnel/tests/test_tailscale_interface.py
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import unittest
|
||||
import mock
|
||||
|
||||
from octoprint_tailscale_funnel.tailscale_interface import TailscaleInterface, TailscaleError
|
||||
|
||||
|
||||
class TestTailscaleInterface(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.tailscale_interface = TailscaleInterface()
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_run_command_success(self, mock_run):
|
||||
# Setup mock
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "command output"
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface._run_command("test command")
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(result["output"], "command output")
|
||||
self.assertIsNone(result["error"])
|
||||
mock_run.assert_called_once_with("test command", shell=True, capture_output=True, text=True, timeout=30)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_run_command_failure(self, mock_run):
|
||||
# Setup mock
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "error message"
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface._run_command("test command")
|
||||
|
||||
# Assert
|
||||
self.assertFalse(result["success"])
|
||||
self.assertIsNone(result["output"])
|
||||
self.assertEqual(result["error"], "error message")
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_is_tailscale_installed_true(self, mock_run):
|
||||
# Setup mock
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "/usr/bin/tailscale"
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface.is_tailscale_installed()
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_is_tailscale_installed_false(self, mock_run):
|
||||
# Setup mock
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "which: no tailscale in (/usr/bin:/bin)"
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface.is_tailscale_installed()
|
||||
|
||||
# Assert
|
||||
self.assertFalse(result)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_is_tailscale_running_true(self, mock_run, mock_installed):
|
||||
# Setup mocks
|
||||
mock_installed.return_value = True
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = '{"Status": "running"}'
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface.is_tailscale_running()
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_is_tailscale_running_false(self, mock_run, mock_installed):
|
||||
# Setup mocks
|
||||
mock_installed.return_value = True
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 1
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = "not running"
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface.is_tailscale_running()
|
||||
|
||||
# Assert
|
||||
self.assertFalse(result)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_enable_funnel_success(self, mock_run, mock_installed):
|
||||
# Setup mocks
|
||||
mock_installed.return_value = True
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface.enable_funnel(80)
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result)
|
||||
mock_run.assert_called_once_with("tailscale funnel 80 on", shell=True, capture_output=True, text=True, timeout=30)
|
||||
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
|
||||
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
|
||||
def test_disable_funnel_success(self, mock_run, mock_installed):
|
||||
# Setup mocks
|
||||
mock_installed.return_value = True
|
||||
mock_result = mock.Mock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
mock_run.return_value = mock_result
|
||||
|
||||
# Execute
|
||||
result = self.tailscale_interface.disable_funnel(80)
|
||||
|
||||
# Assert
|
||||
self.assertTrue(result)
|
||||
mock_run.assert_called_once_with("tailscale funnel 80 off", shell=True, capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
Reference in New Issue
Block a user