Initial commit: Tailscale Funnel plugin for OctoPrint with build documentation
This commit is contained in:
171
.gitignore
vendored
Normal file
171
.gitignore
vendored
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# OctoPrint plugin specific
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
.pytest_cache/
|
||||||
|
.pytest_cache/**
|
||||||
|
.coverage
|
||||||
|
.coverage/**
|
||||||
|
htmlcov/
|
||||||
|
htmlcov/**
|
||||||
|
.cache
|
||||||
|
.cache/**
|
||||||
|
.nox
|
||||||
|
.nox/**
|
||||||
|
.tox
|
||||||
|
.tox/**
|
||||||
|
egg-info/
|
||||||
|
**/*.egg-info
|
||||||
|
**/eggs/**
|
||||||
|
build/
|
||||||
|
**/build/**
|
||||||
|
dist/
|
||||||
|
**/dist/**
|
||||||
|
*.egg
|
||||||
|
*.egg-info/
|
||||||
|
**/*.egg
|
||||||
|
**/*.egg-info/
|
||||||
|
|
||||||
|
# IDE specific
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# OS specific
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
OctoPrint_Tailscale_Funnel.egg-info/
|
||||||
|
**/OctoPrint_Tailscale_Funnel.egg-info/
|
213
.qoder/quests/octoprint-tailscale-plugin.md
Normal file
213
.qoder/quests/octoprint-tailscale-plugin.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# OctoPrint Tailscale Funnel Plugin Design Document
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The OctoPrint Tailscale Funnel Plugin is designed to make the OctoPrint web interface publicly accessible via Tailscale Funnel. This plugin will enable users to securely access their OctoPrint instance from anywhere on the internet without needing to configure port forwarding, dynamic DNS, or deal with complex firewall settings.
|
||||||
|
|
||||||
|
### 1.1 Purpose
|
||||||
|
|
||||||
|
The primary purpose of this plugin is to provide a simple, secure way for OctoPrint users to access their 3D printer's web interface remotely using Tailscale's Funnel feature, which creates encrypted tunnels from the public internet to local services.
|
||||||
|
|
||||||
|
### 1.2 Scope
|
||||||
|
|
||||||
|
This plugin will:
|
||||||
|
- Integrate with the existing Tailscale installation on the system
|
||||||
|
- Provide a user interface within OctoPrint to enable/disable Funnel access
|
||||||
|
- Configure and manage Tailscale Funnel for the OctoPrint web service
|
||||||
|
- Display the public URL for accessing OctoPrint remotely
|
||||||
|
- Handle authentication and security considerations
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 System Components
|
||||||
|
|
||||||
|
The plugin consists of the following components:
|
||||||
|
|
||||||
|
1. **Plugin Core**: The main plugin implementation that integrates with OctoPrint's plugin system
|
||||||
|
2. **Tailscale Interface**: Component that communicates with the Tailscale daemon via CLI
|
||||||
|
3. **Web UI**: Frontend elements that allow users to configure and monitor Funnel status
|
||||||
|
4. **Settings Management**: Component that stores and retrieves plugin configuration
|
||||||
|
5. **Status Monitor**: Background service that monitors the Funnel connection status
|
||||||
|
|
||||||
|
### 2.2 Component Interaction Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[OctoPrint Web Interface] --> B[Plugin Core]
|
||||||
|
B --> C[Tailscale Interface]
|
||||||
|
B --> D[Web UI]
|
||||||
|
B --> E[Settings Management]
|
||||||
|
B --> F[Status Monitor]
|
||||||
|
C --> G[Tailscale Daemon]
|
||||||
|
D --> A
|
||||||
|
E --> B
|
||||||
|
F --> C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Plugin Integration Points
|
||||||
|
|
||||||
|
The plugin will integrate with OctoPrint through several mixin types:
|
||||||
|
- StartupPlugin: Initialize Tailscale integration on startup
|
||||||
|
- SettingsPlugin: Manage plugin configuration
|
||||||
|
- AssetPlugin: Provide custom JavaScript and CSS assets
|
||||||
|
- TemplatePlugin: Add UI elements to OctoPrint's interface
|
||||||
|
- BlueprintPlugin: Create custom API endpoints for plugin functionality
|
||||||
|
|
||||||
|
## 3. Plugin Features
|
||||||
|
|
||||||
|
### 3.1 Core Functionality
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Funnel Enable/Disable | Toggle Tailscale Funnel for OctoPrint web interface |
|
||||||
|
| Status Monitoring | Display current Funnel connection status |
|
||||||
|
| Public URL Display | Show the public URL for accessing OctoPrint |
|
||||||
|
| Configuration Management | Store and retrieve plugin settings |
|
||||||
|
|
||||||
|
### 3.2 User Interface
|
||||||
|
|
||||||
|
The plugin will add a new section to OctoPrint's settings panel with the following elements:
|
||||||
|
|
||||||
|
1. **Status Indicator**: Shows whether Funnel is currently enabled or disabled
|
||||||
|
2. **Enable/Disable Button**: Toggle to turn Funnel on or off
|
||||||
|
3. **Public URL Display**: Shows the public URL when Funnel is enabled
|
||||||
|
4. **Status Messages**: Displays current status and any error messages
|
||||||
|
|
||||||
|
### 3.3 Security Considerations
|
||||||
|
|
||||||
|
1. The plugin will require explicit user consent before enabling Funnel
|
||||||
|
2. All communication with Tailscale will be done through the local CLI
|
||||||
|
3. The plugin will not store any authentication credentials
|
||||||
|
4. Users will be warned about the security implications of making their printer accessible from the internet
|
||||||
|
|
||||||
|
## 4. Implementation Details
|
||||||
|
|
||||||
|
### 4.1 Plugin Structure
|
||||||
|
|
||||||
|
The plugin will follow a standard OctoPrint plugin structure with separate directories for Python code, static assets (JavaScript and CSS), templates, and documentation files.
|
||||||
|
|
||||||
|
### 4.2 Main Plugin Implementation
|
||||||
|
|
||||||
|
The main plugin class will implement several OctoPrint mixins:
|
||||||
|
|
||||||
|
- StartupPlugin: Initialize Tailscale integration on startup
|
||||||
|
- SettingsPlugin: Manage plugin configuration
|
||||||
|
- AssetPlugin: Provide custom JavaScript and CSS assets
|
||||||
|
- TemplatePlugin: Add UI elements to OctoPrint's interface
|
||||||
|
- BlueprintPlugin: Create custom API endpoints for plugin functionality
|
||||||
|
|
||||||
|
### 4.3 Tailscale Integration
|
||||||
|
|
||||||
|
The plugin will interact with Tailscale through subprocess calls to the Tailscale command-line tool to check status, enable/disable Funnel, and retrieve connection information.
|
||||||
|
|
||||||
|
### 4.4 Configuration Options
|
||||||
|
|
||||||
|
The plugin will store the following settings:
|
||||||
|
|
||||||
|
| Setting | Description | Default |
|
||||||
|
|---------|-------------|---------|
|
||||||
|
| enabled | Whether Funnel is enabled | False |
|
||||||
|
| port | Port to expose via Funnel | 80 |
|
||||||
|
| confirm_enable | Require confirmation before enabling | True |
|
||||||
|
|
||||||
|
## 5. API Endpoints
|
||||||
|
|
||||||
|
### 5.1 Custom API Routes
|
||||||
|
|
||||||
|
The plugin will expose the following API endpoints:
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| /api/plugin/tailscale_funnel/status | GET | Get current Funnel status |
|
||||||
|
| /api/plugin/tailscale_funnel/enable | POST | Enable Tailscale Funnel |
|
||||||
|
| /api/plugin/tailscale_funnel/disable | POST | Disable Tailscale Funnel |
|
||||||
|
|
||||||
|
### 5.2 API Response Format
|
||||||
|
|
||||||
|
All API responses will follow a standardized format that includes a status indicator, descriptive message, and data payload.
|
||||||
|
|
||||||
|
## 6. Frontend Implementation
|
||||||
|
|
||||||
|
### 6.1 JavaScript Components
|
||||||
|
|
||||||
|
The frontend will include a view model that:
|
||||||
|
- Fetches current Funnel status from the backend
|
||||||
|
- Provides methods to enable/disable Funnel
|
||||||
|
- Updates the UI with status information
|
||||||
|
- Handles user interactions
|
||||||
|
|
||||||
|
### 6.2 UI Elements
|
||||||
|
|
||||||
|
The settings panel will include:
|
||||||
|
- Toggle switch for enabling/disabling Funnel
|
||||||
|
- Read-only field displaying the public URL
|
||||||
|
- Status indicator showing connection state
|
||||||
|
- Action buttons for enabling/disabling
|
||||||
|
- Warning messages about security implications
|
||||||
|
|
||||||
|
## 7. Error Handling
|
||||||
|
|
||||||
|
### 7.1 Common Error Conditions
|
||||||
|
|
||||||
|
| Error | Cause | Handling |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| Tailscale not installed | Missing tailscale command | Show installation instructions |
|
||||||
|
| Not connected to tailnet | Tailscale not logged in | Prompt user to login |
|
||||||
|
| Insufficient permissions | User can't run tailscale | Show permission error |
|
||||||
|
| Funnel not enabled | Admin setting | Show configuration instructions |
|
||||||
|
|
||||||
|
### 7.2 User Feedback
|
||||||
|
|
||||||
|
The plugin will provide clear feedback for all operations:
|
||||||
|
- Success messages when operations complete
|
||||||
|
- Error messages with troubleshooting suggestions
|
||||||
|
- Progress indicators during operations
|
||||||
|
- Status updates in real-time
|
||||||
|
|
||||||
|
## 8. Testing Strategy
|
||||||
|
|
||||||
|
### 8.1 Unit Tests
|
||||||
|
|
||||||
|
Unit tests will cover:
|
||||||
|
- Tailscale command execution
|
||||||
|
- Status parsing logic
|
||||||
|
- Configuration management
|
||||||
|
- API endpoint responses
|
||||||
|
|
||||||
|
### 8.2 Integration Tests
|
||||||
|
|
||||||
|
Integration tests will verify:
|
||||||
|
- Proper communication with Tailscale daemon
|
||||||
|
- Correct UI updates based on status changes
|
||||||
|
- Settings persistence
|
||||||
|
- Error handling scenarios
|
||||||
|
|
||||||
|
### 8.3 Manual Testing
|
||||||
|
|
||||||
|
Manual testing will validate:
|
||||||
|
- UI appearance and usability
|
||||||
|
- End-to-end enable/disable workflow
|
||||||
|
- Error message clarity
|
||||||
|
- Cross-platform compatibility
|
||||||
|
|
||||||
|
## 9. Deployment
|
||||||
|
|
||||||
|
### 9.1 Installation Requirements
|
||||||
|
|
||||||
|
- OctoPrint 1.3.0 or higher
|
||||||
|
- Tailscale installed and configured on the system
|
||||||
|
- Python 3.7 or higher
|
||||||
|
|
||||||
|
### 9.2 Installation Process
|
||||||
|
|
||||||
|
1. Install Tailscale on the system
|
||||||
|
2. Install the plugin through OctoPrint's plugin manager
|
||||||
|
3. Configure plugin settings in OctoPrint's settings panel
|
||||||
|
4. Enable Funnel through the plugin interface
|
||||||
|
|
||||||
|
### 9.3 Dependencies
|
||||||
|
|
||||||
|
The plugin will have minimal dependencies:
|
||||||
|
- Standard Python libraries for subprocess management
|
||||||
|
- OctoPrint's built-in plugin framework
|
||||||
|
- Tailscale CLI (installed separately by user)
|
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