Compare commits
5 Commits
d379c8d2cd
...
v0.1.6.1
Author | SHA1 | Date | |
---|---|---|---|
|
547cad7a21 | ||
|
f3aa92cd87 | ||
|
6654db26b7 | ||
|
667ecc5e72 | ||
|
62ccfc4a00 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -168,4 +168,5 @@ Thumbs.db
|
||||
|
||||
# Project specific
|
||||
OctoPrint_Tailscale_Funnel.egg-info/
|
||||
**/OctoPrint_Tailscale_Funnel.egg-info/
|
||||
**/OctoPrint_Tailscale_Funnel.egg-info/
|
||||
dist/
|
52
README.md
52
README.md
@@ -1,52 +0,0 @@
|
||||
# OctoPrint Tailscale Funnel Plugin
|
||||
|
||||
This plugin makes your OctoPrint instance accessible from anywhere via Tailscale Funnel, without needing to configure port forwarding, dynamic DNS, or complex firewall settings.
|
||||
|
||||
## Features
|
||||
|
||||
* Enable/disable Tailscale Funnel access directly from OctoPrint's settings
|
||||
* Monitor the current Funnel connection status
|
||||
* Display the public URL for accessing OctoPrint remotely
|
||||
* Configure the port to expose via Funnel
|
||||
|
||||
## Requirements
|
||||
|
||||
* OctoPrint 1.3.0 or higher
|
||||
* Tailscale installed and configured on the system
|
||||
* Python 3.7 or higher
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install Tailscale on your system and ensure it's running (see https://tailscale.com/download/linux or run `curl -fsSL https://tailscale.com/install.sh | sh`)
|
||||
2. Start Tailscale on your system (run `sudo tailscale up`)
|
||||
3. Authenticate using the Tailscale URL (e.g. https://login.tailscale.com/a/<some random characters>)
|
||||
4. Install the plugin through OctoPrint's plugin manager (go to Settings -> Plugins -> Install and search for "Tailscale Funnel")
|
||||
5. Configure the plugin settings in OctoPrint's settings panel
|
||||
6. Enable Funnel through the plugin interface
|
||||
|
||||
## Building from Source
|
||||
|
||||
If you want to build the plugin from source, please refer to the [BUILDING.md](octoprint_tailscale_funnel/BUILDING.md) file for detailed instructions.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin adds a new section to OctoPrint's settings panel with the following options:
|
||||
|
||||
* **Port**: The port to expose via Funnel (default: 80)
|
||||
* **Confirm Enable**: Require confirmation before enabling Funnel (default: True)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Enabling Funnel makes your OctoPrint instance accessible from the public internet. Only enable it when needed and disable it when finished. The plugin will show a confirmation dialog before enabling Funnel if the "Confirm Enable" option is checked.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The plugin exposes the following API endpoints:
|
||||
|
||||
* `GET /api/plugin/tailscale_funnel/status` - Get current Funnel status
|
||||
* `POST /api/plugin/tailscale_funnel/enable` - Enable Tailscale Funnel
|
||||
* `POST /api/plugin/tailscale_funnel/disable` - Disable Tailscale Funnel
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3
|
@@ -55,7 +55,7 @@ This will create distribution files in the `dist/` directory:
|
||||
|
||||
First, install the zip utility if not already installed:
|
||||
```bash
|
||||
sudo apt install zip # On Ubuntu/Debian
|
||||
sudo apt install zip # On Ubuntu/Debian/Raspbian
|
||||
```
|
||||
|
||||
Then create a proper zip file:
|
||||
|
@@ -35,6 +35,42 @@ The plugin adds a new section to OctoPrint's settings panel with the following o
|
||||
* **Port**: The port to expose via Funnel (default: 80)
|
||||
* **Confirm Enable**: Require confirmation before enabling Funnel (default: True)
|
||||
|
||||
## Runtime Permissions (sudo)
|
||||
|
||||
Some Tailscale operations (serve/funnel) may require elevated privileges depending on your setup. The plugin executes `tailscale` from the OctoPrint process user. If enabling/disabling Funnel fails with permission errors or HTTP 500, configure passwordless sudo for the OctoPrint user to run `tailscale`:
|
||||
|
||||
1. Determine the OctoPrint service user (common: `octoprint` or `pi`):
|
||||
```bash
|
||||
systemctl show -p User octoprint | sed 's/User=//'
|
||||
```
|
||||
2. Allow passwordless sudo for `tailscale` for that user (replace <USER>):
|
||||
```bash
|
||||
echo '<USER> ALL=(root) NOPASSWD: /usr/bin/tailscale *' | sudo tee /etc/sudoers.d/octoprint-tailscale
|
||||
sudo chmod 440 /etc/sudoers.d/octoprint-tailscale
|
||||
sudo visudo -cf /etc/sudoers.d/octoprint-tailscale
|
||||
```
|
||||
3. Test (should not prompt for a password):
|
||||
```bash
|
||||
sudo -n tailscale status --json >/dev/null && echo OK || echo FAIL
|
||||
```
|
||||
|
||||
Security note: Restricting the sudo rule to `/usr/bin/tailscale *` limits elevated access to the Tailscale CLI.
|
||||
|
||||
## Initial Tailscale Serve/Funnel setup (optional)
|
||||
|
||||
If you prefer preconfiguring Tailscale manually (instead of letting the plugin set it up), these commands map OctoPrint on port 80 to the root path and enable Funnel using current Tailscale CLI syntax:
|
||||
```bash
|
||||
# Tailnet-only mapping at /
|
||||
sudo tailscale serve --bg --http 80 http://127.0.0.1:80
|
||||
# Public internet via Funnel (HTTPS will be available on 443)
|
||||
sudo tailscale funnel --bg 80
|
||||
```
|
||||
Disable/reset if needed:
|
||||
```bash
|
||||
sudo tailscale serve --http=80 off
|
||||
sudo tailscale funnel reset
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Enabling Funnel makes your OctoPrint instance accessible from the public internet. Only enable it when needed and disable it when finished. The plugin will show a confirmation dialog before enabling Funnel if the "Confirm Enable" option is checked.
|
||||
@@ -47,6 +83,21 @@ The plugin exposes the following API endpoints:
|
||||
* `POST /api/plugin/tailscale_funnel/enable` - Enable Tailscale Funnel
|
||||
* `POST /api/plugin/tailscale_funnel/disable` - Disable Tailscale Funnel
|
||||
|
||||
## Updates via Gitea (update.json)
|
||||
|
||||
This plugin can announce new versions via a JSON file hosted in your Gitea repo. The plugin is configured to read:
|
||||
`https://gitea.elpatron.me/elpatron/octo-funnel/raw/branch/main/update.json`
|
||||
|
||||
Workflow for a new release (example to bump 0.1.1 → 0.1.2):
|
||||
1. Update the version in `setup.py` (`plugin_version = "0.1.2"`).
|
||||
2. Build artifacts (sdist/wheel/ZIP).
|
||||
3. Upload the ZIP to Gitea Releases or ensure the archive URL resolves for the tag.
|
||||
4. Commit and push `update.json` with the new version:
|
||||
```json
|
||||
{ "version": "0.1.2" }
|
||||
```
|
||||
5. In OctoPrint: open Software Update and trigger a re-check (or restart).
|
||||
|
||||
## License
|
||||
|
||||
AGPLv3
|
@@ -1,230 +0,0 @@
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import octoprint.plugin
|
||||
from .tailscale_interface import TailscaleInterface, TailscaleError, TailscaleNotInstalledError, TailscaleNotRunningError
|
||||
from .status_monitor import StatusMonitor
|
||||
|
||||
|
||||
class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
octoprint.plugin.SettingsPlugin,
|
||||
octoprint.plugin.AssetPlugin,
|
||||
octoprint.plugin.TemplatePlugin,
|
||||
octoprint.plugin.BlueprintPlugin):
|
||||
|
||||
def __init__(self):
|
||||
self._logger = None
|
||||
self.tailscale_interface = None
|
||||
self.status_monitor = None
|
||||
|
||||
##~~ StartupPlugin mixin
|
||||
|
||||
def on_after_startup(self):
|
||||
self._logger = self._plugin_manager.get_logger("octoprint_tailscale_funnel")
|
||||
self._logger.info("Tailscale Funnel Plugin started")
|
||||
self.tailscale_interface = TailscaleInterface(self._logger)
|
||||
self.status_monitor = StatusMonitor(self)
|
||||
self.status_monitor.start()
|
||||
|
||||
def on_shutdown(self):
|
||||
if self.status_monitor:
|
||||
self.status_monitor.stop()
|
||||
|
||||
##~~ SettingsPlugin mixin
|
||||
|
||||
def get_settings_defaults(self):
|
||||
return dict(
|
||||
enabled=False,
|
||||
port=80,
|
||||
confirm_enable=True
|
||||
)
|
||||
|
||||
def get_settings_version(self):
|
||||
return 1
|
||||
|
||||
def on_settings_migrate(self, target, current):
|
||||
if current is None:
|
||||
# Migrate from settings version 0 to 1
|
||||
if self._settings.has(["funnel_enabled"]):
|
||||
self._settings.set_boolean(["enabled"], self._settings.get_boolean(["funnel_enabled"]))
|
||||
self._settings.remove(["funnel_enabled"])
|
||||
|
||||
def on_settings_save(self, data):
|
||||
# Save the settings
|
||||
octoprint.plugin.SettingsPlugin.on_settings_save(self, data)
|
||||
|
||||
# If enabled setting changed, update funnel status
|
||||
if "enabled" in data:
|
||||
enabled = self._settings.get_boolean(["enabled"])
|
||||
port = self._settings.get_int(["port"])
|
||||
|
||||
try:
|
||||
if enabled:
|
||||
# Enable funnel
|
||||
if self.tailscale_interface:
|
||||
success = self.tailscale_interface.enable_funnel(port)
|
||||
if success:
|
||||
self._logger.info("Tailscale Funnel enabled on port {}".format(port))
|
||||
else:
|
||||
self._logger.error("Failed to enable Tailscale Funnel on port {}".format(port))
|
||||
else:
|
||||
# Disable funnel
|
||||
if self.tailscale_interface:
|
||||
success = self.tailscale_interface.disable_funnel(port)
|
||||
if success:
|
||||
self._logger.info("Tailscale Funnel disabled on port {}".format(port))
|
||||
else:
|
||||
self._logger.error("Failed to disable Tailscale Funnel on port {}".format(port))
|
||||
except TailscaleError as e:
|
||||
self._logger.error("Tailscale error: {}".format(str(e)))
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error: {}".format(str(e)))
|
||||
|
||||
##~~ AssetPlugin mixin
|
||||
|
||||
def get_assets(self):
|
||||
return dict(
|
||||
js=["js/tailscale_funnel.js"],
|
||||
css=["css/tailscale_funnel.css"],
|
||||
less=["less/tailscale_funnel.less"]
|
||||
)
|
||||
|
||||
##~~ TemplatePlugin mixin
|
||||
|
||||
def get_template_configs(self):
|
||||
return [
|
||||
dict(type="settings", custom_bindings=False)
|
||||
]
|
||||
|
||||
##~~ BlueprintPlugin mixin
|
||||
|
||||
@octoprint.plugin.BlueprintPlugin.route("/status", methods=["GET"])
|
||||
def get_status(self):
|
||||
if not self.tailscale_interface:
|
||||
return {"status": "error", "message": "Tailscale interface not initialized"}, 500
|
||||
|
||||
try:
|
||||
# Check if tailscale is installed
|
||||
if not self.tailscale_interface.is_tailscale_installed():
|
||||
return {"status": "error", "message": "Tailscale is not installed"}, 500
|
||||
|
||||
# Check if tailscale is running
|
||||
if not self.tailscale_interface.is_tailscale_running():
|
||||
return {"status": "error", "message": "Tailscale is not running"}, 500
|
||||
|
||||
# Get funnel status
|
||||
funnel_enabled = self.tailscale_interface.is_funnel_enabled()
|
||||
public_url = self.tailscale_interface.get_public_url() if funnel_enabled else None
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"funnel_enabled": funnel_enabled,
|
||||
"public_url": public_url,
|
||||
"tailscale_installed": True,
|
||||
"tailscale_running": True
|
||||
}
|
||||
}
|
||||
except TailscaleNotInstalledError as e:
|
||||
return {"status": "error", "message": "Tailscale is not installed: {}".format(str(e))}, 500
|
||||
except TailscaleNotRunningError as e:
|
||||
return {"status": "error", "message": "Tailscale is not running: {}".format(str(e))}, 500
|
||||
except TailscaleError as e:
|
||||
return {"status": "error", "message": "Tailscale error: {}".format(str(e))}, 500
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error in get_status: {}".format(str(e)))
|
||||
return {"status": "error", "message": "Unexpected error: {}".format(str(e))}, 500
|
||||
|
||||
@octoprint.plugin.BlueprintPlugin.route("/enable", methods=["POST"])
|
||||
def enable_funnel(self):
|
||||
if not self.tailscale_interface:
|
||||
return {"status": "error", "message": "Tailscale interface not initialized"}, 500
|
||||
|
||||
try:
|
||||
port = self._settings.get_int(["port"])
|
||||
success = self.tailscale_interface.enable_funnel(port)
|
||||
|
||||
if success:
|
||||
# Update settings
|
||||
self._settings.set_boolean(["enabled"], True)
|
||||
self._settings.save()
|
||||
|
||||
public_url = self.tailscale_interface.get_public_url()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Tailscale Funnel enabled successfully",
|
||||
"data": {
|
||||
"public_url": public_url
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": "Failed to enable Tailscale Funnel"}, 500
|
||||
except TailscaleNotInstalledError as e:
|
||||
return {"status": "error", "message": "Tailscale is not installed: {}".format(str(e))}, 500
|
||||
except TailscaleError as e:
|
||||
return {"status": "error", "message": "Tailscale error: {}".format(str(e))}, 500
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error in enable_funnel: {}".format(str(e)))
|
||||
return {"status": "error", "message": "Unexpected error: {}".format(str(e))}, 500
|
||||
|
||||
@octoprint.plugin.BlueprintPlugin.route("/disable", methods=["POST"])
|
||||
def disable_funnel(self):
|
||||
if not self.tailscale_interface:
|
||||
return {"status": "error", "message": "Tailscale interface not initialized"}, 500
|
||||
|
||||
try:
|
||||
port = self._settings.get_int(["port"])
|
||||
success = self.tailscale_interface.disable_funnel(port)
|
||||
|
||||
if success:
|
||||
# Update settings
|
||||
self._settings.set_boolean(["enabled"], False)
|
||||
self._settings.save()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Tailscale Funnel disabled successfully"
|
||||
}
|
||||
else:
|
||||
return {"status": "error", "message": "Failed to disable Tailscale Funnel"}, 500
|
||||
except TailscaleNotInstalledError as e:
|
||||
return {"status": "error", "message": "Tailscale is not installed: {}".format(str(e))}, 500
|
||||
except TailscaleError as e:
|
||||
return {"status": "error", "message": "Tailscale error: {}".format(str(e))}, 500
|
||||
except Exception as e:
|
||||
self._logger.error("Unexpected error in disable_funnel: {}".format(str(e)))
|
||||
return {"status": "error", "message": "Unexpected error: {}".format(str(e))}, 500
|
||||
|
||||
##~~ Softwareupdate hook
|
||||
|
||||
def get_update_information(self):
|
||||
return dict(
|
||||
tailscale_funnel=dict(
|
||||
displayName="Tailscale Funnel Plugin",
|
||||
displayVersion=self._plugin_version,
|
||||
|
||||
# version check: github repository
|
||||
type="github_release",
|
||||
user="markus",
|
||||
repo="OctoPrint-Tailscale-Funnel",
|
||||
current=self._plugin_version,
|
||||
|
||||
# update method: pip
|
||||
pip="https://github.com/markus/OctoPrint-Tailscale-Funnel/archive/{target_version}.zip"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# If you want your plugin to be registered within OctoPrint under its own name,
|
||||
# define it here.
|
||||
__plugin_name__ = "Tailscale Funnel Plugin"
|
||||
__plugin_pythoncompat__ = ">=3.7,<4" # python 3.7+
|
||||
|
||||
def __plugin_load__():
|
||||
global __plugin_implementation__
|
||||
__plugin_implementation__ = TailscaleFunnelPlugin()
|
||||
|
||||
global __plugin_hooks__
|
||||
__plugin_hooks__ = {
|
||||
"octoprint.plugin.softwareupdate.check_config": __plugin_implementation__.get_update_information
|
||||
}
|
@@ -20,8 +20,15 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
##~~ StartupPlugin mixin
|
||||
|
||||
def on_after_startup(self):
|
||||
self._logger = self._plugin_manager.get_logger("octoprint_tailscale_funnel")
|
||||
self._logger.info("Tailscale Funnel Plugin started")
|
||||
# Ensure a valid logger; older OctoPrints don't provide PluginManager.get_logger
|
||||
try:
|
||||
import logging
|
||||
if getattr(self, "_logger", None) is None:
|
||||
self._logger = logging.getLogger("octoprint.plugins.tailscale_funnel")
|
||||
except Exception:
|
||||
pass
|
||||
if self._logger:
|
||||
self._logger.info("Tailscale Funnel Plugin started")
|
||||
self.tailscale_interface = TailscaleInterface(self._logger)
|
||||
self.status_monitor = StatusMonitor(self)
|
||||
self.status_monitor.start()
|
||||
@@ -203,14 +210,13 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
||||
displayName="Tailscale Funnel Plugin",
|
||||
displayVersion=self._plugin_version,
|
||||
|
||||
# version check: github repository
|
||||
type="github_release",
|
||||
user="markus",
|
||||
repo="OctoPrint-Tailscale-Funnel",
|
||||
# version check: gitea via generic JSON endpoint
|
||||
type="jsondata",
|
||||
jsondata="https://gitea.elpatron.me/elpatron/octo-funnel/raw/branch/main/octoprint_tailscale_funnel/update.json",
|
||||
current=self._plugin_version,
|
||||
|
||||
# update method: pip
|
||||
pip="https://github.com/markus/OctoPrint-Tailscale-Funnel/archive/{target_version}.zip"
|
||||
# update method: pip (direct zip from Gitea release tag)
|
||||
pip="https://gitea.elpatron.me/elpatron/octo-funnel/archive/v{target_version}.zip"
|
||||
)
|
||||
)
|
||||
|
||||
|
@@ -11,6 +11,13 @@ $(function() {
|
||||
self.tailscaleInstalled = ko.observable(true);
|
||||
self.tailscaleRunning = ko.observable(true);
|
||||
|
||||
// Computed URL used across UI and copy handler
|
||||
self.currentUrl = ko.pureComputed(function() {
|
||||
var url = (typeof self.publicUrl === 'function') ? self.publicUrl() : self.publicUrl;
|
||||
if (!url || url === "Not available") return "";
|
||||
return ("" + url).trim();
|
||||
});
|
||||
|
||||
// Button states
|
||||
self.refreshInProgress = ko.observable(false);
|
||||
self.toggleInProgress = ko.observable(false);
|
||||
@@ -126,38 +133,39 @@ $(function() {
|
||||
});
|
||||
};
|
||||
|
||||
// Copy URL to clipboard
|
||||
self.copyUrlToClipboard = function() {
|
||||
if (self.publicUrl() && self.publicUrl() !== "Not available") {
|
||||
navigator.clipboard.writeText(self.publicUrl()).then(function() {
|
||||
// Copy URL to clipboard (robust, event-safe)
|
||||
self.copyUrlToClipboard = function(_, event) {
|
||||
if (event && event.stopPropagation) event.stopPropagation();
|
||||
var url = self.currentUrl();
|
||||
if (!url) return;
|
||||
(navigator.clipboard && navigator.clipboard.writeText ? navigator.clipboard.writeText(url) : Promise.reject()).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 = url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
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);
|
||||
});
|
||||
}
|
||||
} 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
|
@@ -2,6 +2,7 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import json
|
||||
import re
|
||||
|
||||
@@ -49,18 +50,55 @@ class TailscaleInterface:
|
||||
self._logger.error("Error running command '{}': {}".format(command, str(e)))
|
||||
return {"success": False, "output": None, "error": str(e)}
|
||||
|
||||
def _run_first_success(self, commands):
|
||||
"""
|
||||
Try commands in order and return the first successful result.
|
||||
"""
|
||||
last_result = None
|
||||
for command in commands:
|
||||
result = self._run_command(command)
|
||||
if self._logger:
|
||||
self._logger.debug("tailscale cmd: '{}' -> rc_ok={}".format(command, result["success"]))
|
||||
if result["success"]:
|
||||
return result
|
||||
# Try again with sudo -n if available/allowed, to avoid interactive prompt
|
||||
sudo_command = "sudo -n " + command
|
||||
sudo_result = self._run_command(sudo_command)
|
||||
if self._logger:
|
||||
self._logger.debug("tailscale cmd (sudo): '{}' -> rc_ok={}".format(sudo_command, sudo_result["success"]))
|
||||
if sudo_result["success"]:
|
||||
return sudo_result
|
||||
last_result = result
|
||||
return last_result if last_result is not None else {"success": False, "output": None, "error": "No commands executed"}
|
||||
|
||||
def is_tailscale_installed(self):
|
||||
"""
|
||||
Check if tailscale is installed
|
||||
"""
|
||||
result = self._run_command("which tailscale")
|
||||
return result["success"] and result["output"] != ""
|
||||
# Try PATH lookup, absolute path, or a sudo check that doesn't prompt
|
||||
candidates = [
|
||||
"command -v tailscale",
|
||||
"test -x /usr/bin/tailscale && echo /usr/bin/tailscale",
|
||||
"test -x /usr/local/bin/tailscale && echo /usr/local/bin/tailscale",
|
||||
]
|
||||
result = self._run_first_success(candidates)
|
||||
if result and result["success"] and result.get("output"):
|
||||
return True
|
||||
# Fallback: ask tailscale for version via sudo -n (non-interactive)
|
||||
version_check = self._run_command("sudo -n tailscale version")
|
||||
return version_check["success"]
|
||||
|
||||
def is_tailscale_running(self):
|
||||
"""
|
||||
Check if tailscale is running
|
||||
"""
|
||||
result = self._run_command("tailscale status --json")
|
||||
# Prefer non-interactive sudo to avoid PATH/permission issues
|
||||
result = self._run_first_success([
|
||||
"sudo -n tailscale status --json",
|
||||
"tailscale status --json",
|
||||
"/usr/bin/tailscale status --json",
|
||||
"/usr/local/bin/tailscale status --json",
|
||||
])
|
||||
return result["success"]
|
||||
|
||||
def get_tailscale_status(self):
|
||||
@@ -88,14 +126,31 @@ class TailscaleInterface:
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale funnel status --json")
|
||||
result = self._run_first_success([
|
||||
"sudo -n tailscale funnel status --json",
|
||||
"tailscale funnel status --json",
|
||||
"/usr/bin/tailscale funnel status --json",
|
||||
"/usr/local/bin/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
|
||||
status = json.loads(result["output"]) if result["output"] else {}
|
||||
|
||||
# New CLI JSON structure (since ~1.44+): has keys like "TCP" and "Web"
|
||||
tcp_cfg = status.get("TCP", {})
|
||||
if isinstance(tcp_cfg, dict) and tcp_cfg:
|
||||
for _port, cfg in tcp_cfg.items():
|
||||
# cfg may contain flags like {"HTTP": true} or {"HTTPS": true}
|
||||
if isinstance(cfg, dict) and any(bool(v) for v in cfg.values()):
|
||||
return True
|
||||
|
||||
# Old CLI JSON structure: {"AllowFunnel": {"https://<domain>": {"80": true}}}
|
||||
allow = status.get("AllowFunnel", {})
|
||||
if isinstance(allow, dict) and allow:
|
||||
for _key, ports in allow.items():
|
||||
if isinstance(ports, dict) and any(bool(v) for v in ports.values()):
|
||||
return True
|
||||
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
if self._logger:
|
||||
@@ -114,10 +169,31 @@ class TailscaleInterface:
|
||||
"""
|
||||
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"]
|
||||
|
||||
# If already enabled, consider success
|
||||
try:
|
||||
if self.is_funnel_enabled():
|
||||
return True
|
||||
except Exception:
|
||||
# ignore and continue to try enabling
|
||||
pass
|
||||
|
||||
# Since newer Tailscale versions changed the CLI, try new syntax first,
|
||||
# then fall back to the legacy commands.
|
||||
# 1) Ensure a serve mapping exists
|
||||
serve_cmd_new = "tailscale serve --bg --http {p} --set-path {p} http://127.0.0.1:{p}".format(p=port)
|
||||
serve_cmd_old = "tailscale serve --bg https http://127.0.0.1:{p}".format(p=port)
|
||||
self._run_first_success([serve_cmd_new, serve_cmd_old])
|
||||
|
||||
# 2) Enable funnel
|
||||
funnel_cmd_new = "tailscale funnel --bg {p}".format(p=port)
|
||||
funnel_cmd_old = "tailscale funnel {p} on".format(p=port)
|
||||
result = self._run_first_success([funnel_cmd_new, funnel_cmd_old])
|
||||
|
||||
# Validate by querying status
|
||||
if result["success"] and self.is_funnel_enabled():
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_funnel(self, port):
|
||||
"""
|
||||
@@ -125,10 +201,50 @@ class TailscaleInterface:
|
||||
"""
|
||||
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"]
|
||||
|
||||
# Execute multiple disable attempts tolerantly (idempotent)
|
||||
commands = [
|
||||
# Try to disable funnel first (new + legacy)
|
||||
"tailscale funnel reset",
|
||||
"tailscale funnel {p} off".format(p=port),
|
||||
# Then disable serve mappings (newer variants)
|
||||
"tailscale serve --http={p} off".format(p=port),
|
||||
"tailscale serve --https={p} off".format(p=port),
|
||||
]
|
||||
|
||||
any_succeeded = False
|
||||
for cmd in commands:
|
||||
res = self._run_first_success([cmd])
|
||||
if res and res.get("success"):
|
||||
any_succeeded = True
|
||||
else:
|
||||
# Treat "not enabled"/"no such" errors as harmless for idempotency
|
||||
err = (res or {}).get("error") or ""
|
||||
if isinstance(err, str) and (
|
||||
"not enabled" in err.lower()
|
||||
or "no handlers" in err.lower()
|
||||
or "no such" in err.lower()
|
||||
or "not found" in err.lower()
|
||||
or "already off" in err.lower()
|
||||
):
|
||||
any_succeeded = True
|
||||
|
||||
# Poll for a short time until status reflects disabled
|
||||
for _ in range(6): # ~3.0s total
|
||||
try:
|
||||
if not self.is_funnel_enabled():
|
||||
return True
|
||||
except Exception:
|
||||
# If status check fails, assume disabled if we attempted commands
|
||||
if any_succeeded:
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
|
||||
# Final status check
|
||||
try:
|
||||
return not self.is_funnel_enabled()
|
||||
except Exception:
|
||||
return any_succeeded
|
||||
|
||||
def get_public_url(self):
|
||||
"""
|
||||
@@ -137,21 +253,42 @@ class TailscaleInterface:
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale funnel status --json")
|
||||
result = self._run_first_success([
|
||||
"sudo -n tailscale funnel status --json",
|
||||
"tailscale funnel status --json",
|
||||
"/usr/bin/tailscale funnel status --json",
|
||||
"/usr/local/bin/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():
|
||||
status = json.loads(result["output"]) if result["output"] else {}
|
||||
|
||||
# New JSON shape
|
||||
web = status.get("Web", {})
|
||||
if isinstance(web, dict) and web:
|
||||
for hostport, mapping in web.items():
|
||||
# hostport like "octopi.warbler-bearded.ts.net:80"
|
||||
domain = hostport.split(":")[0]
|
||||
# If handler paths exist, pick the first path
|
||||
path = "/"
|
||||
handlers = mapping.get("Handlers", {}) if isinstance(mapping, dict) else {}
|
||||
if handlers:
|
||||
first_path = next(iter(handlers.keys()))
|
||||
if isinstance(first_path, str):
|
||||
path = first_path if first_path.startswith("/") else "/" + first_path
|
||||
scheme = "https" if hostport.endswith(":443") else "http"
|
||||
return "{}://{}{}".format(scheme, domain, path)
|
||||
|
||||
# Legacy JSON shape
|
||||
allow = status.get("AllowFunnel", {})
|
||||
if isinstance(allow, dict) and allow:
|
||||
for key, ports in allow.items():
|
||||
if any(ports.values()):
|
||||
# Extract the domain from the key
|
||||
match = re.search(r'https://([a-zA-Z0-9\-\.]+)', key)
|
||||
match = re.search(r'https?://([a-zA-Z0-9\-\.]+)', key)
|
||||
if match:
|
||||
return "https://{}".format(match.group(1))
|
||||
# Assume https if legacy provided https, else http
|
||||
scheme = "https" if key.startswith("https://") else "http"
|
||||
return "{}://{}".format(scheme, match.group(1))
|
||||
return None
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
if self._logger:
|
||||
|
@@ -4,10 +4,10 @@
|
||||
<label class="control-label">Funnel Status</label>
|
||||
<div class="controls">
|
||||
<div class="input-append">
|
||||
<span id="tailscale_funnel_status" class="input-xlarge uneditable-input">
|
||||
<span id="tailscale_funnel_status" class="input-xlarge uneditable-input" data-bind="text: funnelStatus">
|
||||
Checking...
|
||||
</span>
|
||||
<button id="tailscale_funnel_refresh_btn" class="btn" type="button">
|
||||
<button id="tailscale_funnel_refresh_btn" class="btn" type="button" data-bind="click: refreshStatus, enable: !refreshInProgress()">
|
||||
<i class="fas fa-sync"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -19,7 +19,7 @@
|
||||
<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">
|
||||
<button id="tailscale_funnel_toggle_btn" class="btn btn-success" data-bind="click: toggleFunnel, text: funnelEnabled() ? 'Disable' : 'Enable', css: { 'btn-success': !funnelEnabled(), 'btn-danger': funnelEnabled() }">
|
||||
<i class="fas fa-toggle-on"></i> Enable
|
||||
</button>
|
||||
</div>
|
||||
@@ -31,12 +31,12 @@
|
||||
<label class="control-label">Public URL</label>
|
||||
<div class="controls">
|
||||
<div class="input-append">
|
||||
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input">
|
||||
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl">
|
||||
Not available
|
||||
</span>
|
||||
<button id="tailscale_funnel_copy_url_btn" class="btn" type="button">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
<a id="tailscale_funnel_open_url_btn" class="btn" target="_blank" rel="noopener" data-bind="attr: { href: currentUrl }, visible: currentUrl()">
|
||||
<i class="fas fa-external-link-alt"></i> Open
|
||||
</a>
|
||||
</div>
|
||||
<span class="help-block">Public URL for accessing your OctoPrint instance</span>
|
||||
</div>
|
@@ -14,7 +14,7 @@ plugin_package = "octoprint_tailscale_funnel"
|
||||
plugin_name = "OctoPrint-Tailscale-Funnel"
|
||||
|
||||
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
|
||||
plugin_version = "0.1.1"
|
||||
plugin_version = "0.1.6.1"
|
||||
|
||||
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
|
||||
# module
|
||||
|
@@ -1,74 +0,0 @@
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class StatusMonitor:
|
||||
def __init__(self, plugin, interval=30):
|
||||
self.plugin = plugin
|
||||
self.interval = interval
|
||||
self._logger = plugin._logger
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Start the status monitoring thread
|
||||
"""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._logger.warning("Status monitor is already running")
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._monitor_loop)
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
self._logger.info("Status monitor started")
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop the status monitoring thread
|
||||
"""
|
||||
if self._thread is None:
|
||||
return
|
||||
|
||||
self._stop_event.set()
|
||||
self._thread.join()
|
||||
self._thread = None
|
||||
self._logger.info("Status monitor stopped")
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""
|
||||
Main monitoring loop
|
||||
"""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Check status if tailscale interface is available
|
||||
if self.plugin.tailscale_interface:
|
||||
# Get current funnel status
|
||||
funnel_enabled = self.plugin.tailscale_interface.is_funnel_enabled()
|
||||
|
||||
# Update settings if needed
|
||||
current_setting = self.plugin._settings.get_boolean(["enabled"])
|
||||
if funnel_enabled != current_setting:
|
||||
self.plugin._settings.set_boolean(["enabled"], funnel_enabled)
|
||||
self.plugin._settings.save()
|
||||
self._logger.info("Funnel status updated in settings: {}".format(funnel_enabled))
|
||||
|
||||
# Send a notification to the frontend
|
||||
self.plugin._plugin_manager.send_plugin_message(
|
||||
self.plugin._identifier,
|
||||
{
|
||||
"type": "funnel_status_change",
|
||||
"enabled": funnel_enabled
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error("Error in status monitoring loop: {}".format(str(e)))
|
||||
|
||||
# Wait for the next interval or until stopped
|
||||
if self._stop_event.wait(self.interval):
|
||||
break
|
@@ -1,161 +0,0 @@
|
||||
# coding=utf-8
|
||||
from __future__ import absolute_import
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
class TailscaleError(Exception):
|
||||
"""Base exception for Tailscale interface errors"""
|
||||
pass
|
||||
|
||||
|
||||
class TailscaleNotInstalledError(TailscaleError):
|
||||
"""Exception raised when Tailscale is not installed"""
|
||||
pass
|
||||
|
||||
|
||||
class TailscaleNotRunningError(TailscaleError):
|
||||
"""Exception raised when Tailscale is not running"""
|
||||
pass
|
||||
|
||||
|
||||
class FunnelNotEnabledError(TailscaleError):
|
||||
"""Exception raised when Funnel is not enabled"""
|
||||
pass
|
||||
|
||||
|
||||
class TailscaleInterface:
|
||||
def __init__(self, logger=None):
|
||||
self._logger = logger
|
||||
|
||||
def _run_command(self, command):
|
||||
"""
|
||||
Run a command and return the result
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode == 0:
|
||||
return {"success": True, "output": result.stdout.strip(), "error": None}
|
||||
else:
|
||||
return {"success": False, "output": None, "error": result.stderr.strip()}
|
||||
except subprocess.TimeoutExpired:
|
||||
if self._logger:
|
||||
self._logger.error("Command timed out: '{}'".format(command))
|
||||
return {"success": False, "output": None, "error": "Command timed out"}
|
||||
except Exception as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error running command '{}': {}".format(command, str(e)))
|
||||
return {"success": False, "output": None, "error": str(e)}
|
||||
|
||||
def is_tailscale_installed(self):
|
||||
"""
|
||||
Check if tailscale is installed
|
||||
"""
|
||||
result = self._run_command("which tailscale")
|
||||
return result["success"] and result["output"] != ""
|
||||
|
||||
def is_tailscale_running(self):
|
||||
"""
|
||||
Check if tailscale is running
|
||||
"""
|
||||
result = self._run_command("tailscale status --json")
|
||||
return result["success"]
|
||||
|
||||
def get_tailscale_status(self):
|
||||
"""
|
||||
Get tailscale status as JSON
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale status --json")
|
||||
if result["success"]:
|
||||
try:
|
||||
return json.loads(result["output"])
|
||||
except json.JSONDecodeError as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error parsing tailscale status JSON: {}".format(str(e)))
|
||||
raise TailscaleError("Error parsing tailscale status: {}".format(str(e)))
|
||||
else:
|
||||
raise TailscaleNotRunningError("Tailscale is not running: {}".format(result["error"]))
|
||||
|
||||
def is_funnel_enabled(self):
|
||||
"""
|
||||
Check if tailscale funnel is enabled
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale funnel status --json")
|
||||
if result["success"]:
|
||||
try:
|
||||
status = json.loads(result["output"])
|
||||
# Check if any funnel is active
|
||||
for _, ports in status.get("AllowFunnel", {}).items():
|
||||
if any(ports.values()):
|
||||
return True
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error parsing funnel status JSON: {}".format(str(e)))
|
||||
raise TailscaleError("Error parsing funnel status: {}".format(str(e)))
|
||||
else:
|
||||
# If the command fails, it might be because Funnel is not enabled
|
||||
# Check if it's a specific error we can handle
|
||||
if "funnel" in result["error"].lower() and "not enabled" in result["error"].lower():
|
||||
return False
|
||||
raise TailscaleError("Error checking funnel status: {}".format(result["error"]))
|
||||
|
||||
def enable_funnel(self, port):
|
||||
"""
|
||||
Enable tailscale funnel for the specified port
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
command = "tailscale funnel {} on".format(port)
|
||||
result = self._run_command(command)
|
||||
return result["success"]
|
||||
|
||||
def disable_funnel(self, port):
|
||||
"""
|
||||
Disable tailscale funnel for the specified port
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
command = "tailscale funnel {} off".format(port)
|
||||
result = self._run_command(command)
|
||||
return result["success"]
|
||||
|
||||
def get_public_url(self):
|
||||
"""
|
||||
Get the public URL for accessing the service via funnel
|
||||
"""
|
||||
if not self.is_tailscale_installed():
|
||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||
|
||||
result = self._run_command("tailscale funnel status --json")
|
||||
if result["success"]:
|
||||
try:
|
||||
status = json.loads(result["output"])
|
||||
# Extract the public URL
|
||||
# This is a simplified approach - in practice, you might need to parse more details
|
||||
funnel_status = status.get("AllowFunnel", {})
|
||||
if funnel_status:
|
||||
# Get the first available funnel URL
|
||||
for key, ports in funnel_status.items():
|
||||
if any(ports.values()):
|
||||
# Extract the domain from the key
|
||||
match = re.search(r'https://([a-zA-Z0-9\-\.]+)', key)
|
||||
if match:
|
||||
return "https://{}".format(match.group(1))
|
||||
return None
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
if self._logger:
|
||||
self._logger.error("Error parsing funnel status for URL: {}".format(str(e)))
|
||||
raise TailscaleError("Error parsing funnel status for URL: {}".format(str(e)))
|
||||
else:
|
||||
raise TailscaleError("Error getting funnel status: {}".format(result["error"]))
|
4
octoprint_tailscale_funnel/update.json
Normal file
4
octoprint_tailscale_funnel/update.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "0.1.6.1"
|
||||
}
|
||||
|
136
scripts/release_gitea.sh
Normal file
136
scripts/release_gitea.sh
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Load .env if present
|
||||
if [ -f "$(dirname "$0")/../.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "$(dirname "$0")/../.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
API_URL=${GITEA_API_URL:-"https://gitea.elpatron.me/api/v1"}
|
||||
OWNER=${GITEA_OWNER:-"elpatron"}
|
||||
REPO=${GITEA_REPO:-"octo-funnel"}
|
||||
TOKEN=${GITEA_API_TOKEN:-""}
|
||||
|
||||
TAG=""
|
||||
NAME=""
|
||||
ASSET_PATH=""
|
||||
BODY_FILE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 -t <tag> -a <asset-zip> [-n <name>] [-b <body-file>]" >&2
|
||||
}
|
||||
|
||||
while getopts ":t:a:n:b:" opt; do
|
||||
case $opt in
|
||||
t) TAG="$OPTARG" ;;
|
||||
a) ASSET_PATH="$OPTARG" ;;
|
||||
n) NAME="$OPTARG" ;;
|
||||
b) BODY_FILE="$OPTARG" ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$TAG" ] || [ -z "$ASSET_PATH" ]; then
|
||||
usage; exit 2
|
||||
fi
|
||||
if [ -z "$NAME" ]; then NAME="$TAG"; fi
|
||||
if [ ! -f "$ASSET_PATH" ]; then
|
||||
echo "Asset not found: $ASSET_PATH" >&2; exit 1
|
||||
fi
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "GITEA_API_TOKEN not set (in .env)." >&2; exit 1
|
||||
fi
|
||||
|
||||
BODY="Tailscale Funnel Plugin ${TAG}\n\nAutomated release."
|
||||
if [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ]; then
|
||||
BODY=$(cat "$BODY_FILE")
|
||||
fi
|
||||
|
||||
# Build JSON payload via python (robust quoting)
|
||||
create_payload=$(REL_TAG="$TAG" REL_NAME="$NAME" REL_BODY_TXT="$BODY" python3 - <<'PY'
|
||||
import json, os
|
||||
payload = {
|
||||
"tag_name": os.environ["REL_TAG"],
|
||||
"name": os.environ["REL_NAME"],
|
||||
"body": os.environ["REL_BODY_TXT"],
|
||||
"draft": False,
|
||||
"prerelease": False
|
||||
}
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)
|
||||
|
||||
create_resp=$(curl -sS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-d "$create_payload" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases" || true)
|
||||
|
||||
# Extract id; if missing, fetch by tag
|
||||
rel_id=$(python3 - <<'PY'
|
||||
import sys, json
|
||||
data=sys.stdin.read().strip()
|
||||
if not data:
|
||||
print("")
|
||||
else:
|
||||
try:
|
||||
obj=json.loads(data)
|
||||
print(obj.get('id',''))
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
<<<"$create_resp")
|
||||
|
||||
if [ -z "$rel_id" ]; then
|
||||
get_resp=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}")
|
||||
rel_id=$(python3 - <<'PY'
|
||||
import sys, json
|
||||
obj=json.loads(sys.stdin.read())
|
||||
print(obj.get('id',''))
|
||||
PY
|
||||
<<<"$get_resp")
|
||||
fi
|
||||
|
||||
if [ -z "$rel_id" ]; then
|
||||
echo "Failed to get create/fetch release id" >&2
|
||||
echo "$create_resp" | head -c 400 >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Delete existing asset with same name (if any)
|
||||
assets_json=$(curl -sS -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets")
|
||||
asset_id=$(python3 - <<'PY'
|
||||
import sys, json, os
|
||||
name=os.environ['ASSET_NAME']
|
||||
try:
|
||||
arr=json.loads(sys.stdin.read())
|
||||
for a in arr:
|
||||
if a.get('name')==name:
|
||||
print(a.get('id',''))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
ASSET_NAME="$(basename "$ASSET_PATH")" <<<"$assets_json")
|
||||
|
||||
if [ -n "$asset_id" ]; then
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets/${asset_id}" >/dev/null || true
|
||||
fi
|
||||
|
||||
# Upload asset
|
||||
upload_resp=$(curl -sS -H "Authorization: token ${TOKEN}" -F attachment=@"${ASSET_PATH}" "${API_URL}/repos/${OWNER}/${REPO}/releases/${rel_id}/assets?name=$(basename "$ASSET_PATH")")
|
||||
|
||||
html_url=$(python3 - <<'PY'
|
||||
import sys, json
|
||||
try:
|
||||
print(json.loads(sys.stdin.read()).get('browser_download_url',''))
|
||||
except Exception:
|
||||
print("")
|
||||
PY
|
||||
<<<"$upload_resp")
|
||||
|
||||
echo "Release ${TAG} created (id=${rel_id}). Asset uploaded: ${html_url}"
|
||||
|
Reference in New Issue
Block a user