Compare commits
3 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
547cad7a21 | ||
|
f3aa92cd87 | ||
|
6654db26b7 |
@@ -20,8 +20,15 @@ class TailscaleFunnelPlugin(octoprint.plugin.StartupPlugin,
|
|||||||
##~~ StartupPlugin mixin
|
##~~ StartupPlugin mixin
|
||||||
|
|
||||||
def on_after_startup(self):
|
def on_after_startup(self):
|
||||||
self._logger = self._plugin_manager.get_logger("octoprint_tailscale_funnel")
|
# Ensure a valid logger; older OctoPrints don't provide PluginManager.get_logger
|
||||||
self._logger.info("Tailscale Funnel Plugin started")
|
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.tailscale_interface = TailscaleInterface(self._logger)
|
||||||
self.status_monitor = StatusMonitor(self)
|
self.status_monitor = StatusMonitor(self)
|
||||||
self.status_monitor.start()
|
self.status_monitor.start()
|
||||||
|
@@ -11,6 +11,13 @@ $(function() {
|
|||||||
self.tailscaleInstalled = ko.observable(true);
|
self.tailscaleInstalled = ko.observable(true);
|
||||||
self.tailscaleRunning = 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
|
// Button states
|
||||||
self.refreshInProgress = ko.observable(false);
|
self.refreshInProgress = ko.observable(false);
|
||||||
self.toggleInProgress = ko.observable(false);
|
self.toggleInProgress = ko.observable(false);
|
||||||
@@ -126,38 +133,39 @@ $(function() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy URL to clipboard
|
// Copy URL to clipboard (robust, event-safe)
|
||||||
self.copyUrlToClipboard = function() {
|
self.copyUrlToClipboard = function(_, event) {
|
||||||
if (self.publicUrl() && self.publicUrl() !== "Not available") {
|
if (event && event.stopPropagation) event.stopPropagation();
|
||||||
navigator.clipboard.writeText(self.publicUrl()).then(function() {
|
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({
|
new PNotify({
|
||||||
title: "Copied to Clipboard",
|
title: "Copied to Clipboard",
|
||||||
text: "Public URL copied to clipboard",
|
text: "Public URL copied to clipboard",
|
||||||
type: "success"
|
type: "success"
|
||||||
});
|
});
|
||||||
}, function() {
|
} catch (err) {
|
||||||
// Fallback for older browsers
|
new PNotify({
|
||||||
var textArea = document.createElement("textarea");
|
title: "Copy Failed",
|
||||||
textArea.value = self.publicUrl();
|
text: "Failed to copy URL to clipboard",
|
||||||
document.body.appendChild(textArea);
|
type: "error"
|
||||||
textArea.select();
|
});
|
||||||
try {
|
}
|
||||||
document.execCommand("copy");
|
document.body.removeChild(textArea);
|
||||||
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
|
// Handle messages from the backend
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -74,14 +75,30 @@ class TailscaleInterface:
|
|||||||
"""
|
"""
|
||||||
Check if tailscale is installed
|
Check if tailscale is installed
|
||||||
"""
|
"""
|
||||||
result = self._run_command("which tailscale")
|
# Try PATH lookup, absolute path, or a sudo check that doesn't prompt
|
||||||
return result["success"] and result["output"] != ""
|
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):
|
def is_tailscale_running(self):
|
||||||
"""
|
"""
|
||||||
Check if tailscale is running
|
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"]
|
return result["success"]
|
||||||
|
|
||||||
def get_tailscale_status(self):
|
def get_tailscale_status(self):
|
||||||
@@ -109,7 +126,12 @@ class TailscaleInterface:
|
|||||||
if not self.is_tailscale_installed():
|
if not self.is_tailscale_installed():
|
||||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||||
|
|
||||||
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"]:
|
if result["success"]:
|
||||||
try:
|
try:
|
||||||
status = json.loads(result["output"]) if result["output"] else {}
|
status = json.loads(result["output"]) if result["output"] else {}
|
||||||
@@ -180,22 +202,49 @@ class TailscaleInterface:
|
|||||||
if not self.is_tailscale_installed():
|
if not self.is_tailscale_installed():
|
||||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||||
|
|
||||||
# Try new CLI first, then legacy
|
# Execute multiple disable attempts tolerantly (idempotent)
|
||||||
cmds = [
|
commands = [
|
||||||
# Newer: turn off serve mapping and remove funnel config
|
# Try to disable funnel first (new + legacy)
|
||||||
"tailscale serve --http={p} off".format(p=port),
|
|
||||||
"tailscale funnel reset",
|
"tailscale funnel reset",
|
||||||
# Legacy
|
|
||||||
"tailscale funnel {p} off".format(p=port),
|
"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),
|
||||||
]
|
]
|
||||||
result = self._run_first_success(cmds)
|
|
||||||
if result["success"]:
|
any_succeeded = False
|
||||||
# Double-check disabled
|
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:
|
try:
|
||||||
return not self.is_funnel_enabled()
|
if not self.is_funnel_enabled():
|
||||||
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return True
|
# If status check fails, assume disabled if we attempted commands
|
||||||
return False
|
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):
|
def get_public_url(self):
|
||||||
"""
|
"""
|
||||||
@@ -204,7 +253,12 @@ class TailscaleInterface:
|
|||||||
if not self.is_tailscale_installed():
|
if not self.is_tailscale_installed():
|
||||||
raise TailscaleNotInstalledError("Tailscale is not installed")
|
raise TailscaleNotInstalledError("Tailscale is not installed")
|
||||||
|
|
||||||
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"]:
|
if result["success"]:
|
||||||
try:
|
try:
|
||||||
status = json.loads(result["output"]) if result["output"] else {}
|
status = json.loads(result["output"]) if result["output"] else {}
|
||||||
|
@@ -34,9 +34,9 @@
|
|||||||
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl">
|
<span id="tailscale_funnel_url" class="input-xlarge uneditable-input" data-bind="text: publicUrl">
|
||||||
Not available
|
Not available
|
||||||
</span>
|
</span>
|
||||||
<button id="tailscale_funnel_copy_url_btn" class="btn" type="button" data-bind="click: copyUrlToClipboard, enable: publicUrl() && publicUrl() !== 'Not available'">
|
<a id="tailscale_funnel_open_url_btn" class="btn" target="_blank" rel="noopener" data-bind="attr: { href: currentUrl }, visible: currentUrl()">
|
||||||
<i class="fas fa-copy"></i> Copy
|
<i class="fas fa-external-link-alt"></i> Open
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="help-block">Public URL for accessing your OctoPrint instance</span>
|
<span class="help-block">Public URL for accessing your OctoPrint instance</span>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -14,7 +14,7 @@ plugin_package = "octoprint_tailscale_funnel"
|
|||||||
plugin_name = "OctoPrint-Tailscale-Funnel"
|
plugin_name = "OctoPrint-Tailscale-Funnel"
|
||||||
|
|
||||||
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
|
# The plugin's version. Can be overwritten within OctoPrint's internal data via __plugin_version__ in the plugin module
|
||||||
plugin_version = "0.1.2"
|
plugin_version = "0.1.6.1"
|
||||||
|
|
||||||
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
|
# The plugin's description. Can be overwritten within OctoPrint's internal data via __plugin_description__ in the plugin
|
||||||
# module
|
# module
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "0.1.2"
|
"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