$params
+*/
+function includePage(string $filename, array $params = array()): string
+{
+ extract($params);
+
+ if (is_file($filename)) {
+ ob_start();
+ try {
+ include $filename;
+ return ob_get_clean() ?: "";
+ } catch (\Throwable $e) {
+ ob_end_clean();
+ throw $e;
+ }
+ }
+ return "";
+}
diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/disabled.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/disabled.php
new file mode 100644
index 0000000..3470a45
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/disabled.php
@@ -0,0 +1,32 @@
+.
+*/
+
+namespace Tailscale;
+
+use EDACerton\PluginUtils\Translator;
+
+if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+}
+
+$tr = $tr ?? new Translator(PLUGIN_ROOT);
+?>
+= $tr->tr("tailscale_lock"); ?>
+
+= $tr->tr("lock.disabled"); ?>
\ No newline at end of file
diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/locked.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/locked.php
new file mode 100644
index 0000000..61aeef8
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/locked.php
@@ -0,0 +1,45 @@
+.
+*/
+
+namespace Tailscale;
+
+use EDACerton\PluginUtils\Translator;
+
+if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+}
+
+$tr = $tr ?? new Translator(PLUGIN_ROOT);
+?>
+= $tr->tr("tailscale_lock"); ?>
+
+
+= $tr->tr('lock.unsigned'); ?>.
+
+
+= $tr->tr('lock.unsigned_instructions'); ?>
+
+
+
+= $tailscaleInfo->getTailscaleLockNodekey(); ?>
\ No newline at end of file
diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signed.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signed.php
new file mode 100644
index 0000000..3fc998b
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signed.php
@@ -0,0 +1,47 @@
+.
+*/
+
+namespace Tailscale;
+
+use EDACerton\PluginUtils\Translator;
+
+if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+}
+
+$tr = $tr ?? new Translator(PLUGIN_ROOT);
+?>
+= $tr->tr("tailscale_lock"); ?>
+
+
+ = $tr->tr('lock.signed_node'); ?>
+
+
+
+= $tr->tr('lock.make_signing'); ?>
+
+
+
+
+= $tailscaleInfo->getTailscaleLockPubkey(); ?>
diff --git a/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signing.php b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signing.php
new file mode 100644
index 0000000..f0e38c3
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/include/tailscale-lock/signing.php
@@ -0,0 +1,80 @@
+.
+*/
+
+namespace Tailscale;
+
+use EDACerton\PluginUtils\Translator;
+
+if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+}
+
+$tr = $tr ?? new Translator(PLUGIN_ROOT);
+?>
+= $tr->tr("tailscale_lock"); ?>
+= $tr->tr("lock.sign"); ?>
+
+ = $tr->tr("lock.signing_node"); ?>
+
+
+= $tr->tr("lock.signing_instructions"); ?>
+
+
+
+
+
+
diff --git a/src/usr/local/emhttp/plugins/tailscale/lib/ipaddr.min.js b/src/usr/local/emhttp/plugins/tailscale/lib/ipaddr.min.js
new file mode 100644
index 0000000..bd03676
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/lib/ipaddr.min.js
@@ -0,0 +1 @@
+!function(t){!function(t){"use strict";const r="(0?\\d+|0x[a-f0-9]+)",e={fourOctet:new RegExp(`^${r}\\.${r}\\.${r}\\.${r}$`,"i"),threeOctet:new RegExp(`^${r}\\.${r}\\.${r}$`,"i"),twoOctet:new RegExp(`^${r}\\.${r}$`,"i"),longValue:new RegExp(`^${r}$`,"i")},n=new RegExp("^0[0-7]+$","i"),i=new RegExp("^0x[a-f0-9]+$","i"),o="(?:[0-9a-f]+::?)+",s={zoneIndex:new RegExp("%[0-9a-z]{1,}","i"),native:new RegExp(`^(::)?(${o})?([0-9a-f]+)?(::)?(%[0-9a-z]{1,})?$`,"i"),deprecatedTransitional:new RegExp(`^(?:::)(${r}\\.${r}\\.${r}\\.${r}(%[0-9a-z]{1,})?)$`,"i"),transitional:new RegExp(`^((?:${o})|(?:::)(?:${o})?)${r}\\.${r}\\.${r}\\.${r}(%[0-9a-z]{1,})?$`,"i")};function a(t,r){if(t.indexOf("::")!==t.lastIndexOf("::"))return null;let e,n,i=0,o=-1,a=(t.match(s.zoneIndex)||[])[0];for(a&&(a=a.substring(1),t=t.replace(/%.+$/,""));(o=t.indexOf(":",o+1))>=0;)i++;if("::"===t.substr(0,2)&&i--,"::"===t.substr(-2,2)&&i--,i>r)return null;for(n=r-i,e=":";n--;)e+="0:";return":"===(t=t.replace("::",e))[0]&&(t=t.slice(1)),":"===t[t.length-1]&&(t=t.slice(0,-1)),{parts:r=function(){const r=t.split(":"),e=[];for(let t=0;t0;){if((i=e-n)<0&&(i=0),t[o]>>i!=r[o]>>i)return!1;n-=e,o+=1}return!0}function u(t){if(i.test(t))return parseInt(t,16);if("0"===t[0]&&!isNaN(parseInt(t[1],10))){if(n.test(t))return parseInt(t,8);throw new Error(`ipaddr: cannot parse ${t} as octal`)}return parseInt(t,10)}function d(t,r){for(;t.length=0;n-=1){if(!((i=this.octets[n])in e))return null;if(o=e[i],r&&0!==o)return null;8!==o&&(r=!0),t+=o}return 32-t},t.prototype.range=function(){return h.subnetMatch(this,this.SpecialRanges)},t.prototype.toByteArray=function(){return this.octets.slice(0)},t.prototype.toIPv4MappedAddress=function(){return h.IPv6.parse(`::ffff:${this.toString()}`)},t.prototype.toNormalizedString=function(){return this.toString()},t.prototype.toString=function(){return this.octets.join(".")},t}(),h.IPv4.broadcastAddressFromCIDR=function(t){try{const r=this.parseCIDR(t),e=r[0].toByteArray(),n=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[];let o=0;for(;o<4;)i.push(parseInt(e[o],10)|255^parseInt(n[o],10)),o++;return new this(i)}catch(t){throw new Error("ipaddr: the address does not have IPv4 CIDR format")}},h.IPv4.isIPv4=function(t){return null!==this.parser(t)},h.IPv4.isValid=function(t){try{return new this(this.parser(t)),!0}catch(t){return!1}},h.IPv4.isValidCIDR=function(t){try{return this.parseCIDR(t),!0}catch(t){return!1}},h.IPv4.isValidFourPartDecimal=function(t){return!(!h.IPv4.isValid(t)||!t.match(/^(0|[1-9]\d*)(\.(0|[1-9]\d*)){3}$/))},h.IPv4.networkAddressFromCIDR=function(t){let r,e,n,i,o;try{for(n=(r=this.parseCIDR(t))[0].toByteArray(),o=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[],e=0;e<4;)i.push(parseInt(n[e],10)&parseInt(o[e],10)),e++;return new this(i)}catch(t){throw new Error("ipaddr: the address does not have IPv4 CIDR format")}},h.IPv4.parse=function(t){const r=this.parser(t);if(null===r)throw new Error("ipaddr: string is not formatted like an IPv4 Address");return new this(r)},h.IPv4.parseCIDR=function(t){let r;if(r=t.match(/^(.+)\/(\d+)$/)){const t=parseInt(r[2]);if(t>=0&&t<=32){const e=[this.parse(r[1]),t];return Object.defineProperty(e,"toString",{value:function(){return this.join("/")}}),e}}throw new Error("ipaddr: string is not formatted like an IPv4 CIDR range")},h.IPv4.parser=function(t){let r,n,i;if(r=t.match(e.fourOctet))return function(){const t=r.slice(1,6),e=[];for(let r=0;r4294967295||i<0)throw new Error("ipaddr: address outside defined range");return function(){const t=[];let r;for(r=0;r<=24;r+=8)t.push(i>>r&255);return t}().reverse()}return(r=t.match(e.twoOctet))?function(){const t=r.slice(1,4),e=[];if((i=u(t[1]))>16777215||i<0)throw new Error("ipaddr: address outside defined range");return e.push(u(t[0])),e.push(i>>16&255),e.push(i>>8&255),e.push(255&i),e}():(r=t.match(e.threeOctet))?function(){const t=r.slice(1,5),e=[];if((i=u(t[2]))>65535||i<0)throw new Error("ipaddr: address outside defined range");return e.push(u(t[0])),e.push(u(t[1])),e.push(i>>8&255),e.push(255&i),e}():null},h.IPv4.subnetMaskFromPrefixLength=function(t){if((t=parseInt(t))<0||t>32)throw new Error("ipaddr: invalid IPv4 prefix length");const r=[0,0,0,0];let e=0;const n=Math.floor(t/8);for(;e=0;o-=1){if(!((n=this.parts[o])in e))return null;if(i=e[n],r&&0!==i)return null;16!==i&&(r=!0),t+=i}return 128-t},t.prototype.range=function(){return h.subnetMatch(this,this.SpecialRanges)},t.prototype.toByteArray=function(){let t;const r=[],e=this.parts;for(let n=0;n>8),r.push(255&t);return r},t.prototype.toFixedLengthString=function(){const t=function(){const t=[];for(let r=0;r>8,255&r,e>>8,255&e])},t.prototype.toNormalizedString=function(){const t=function(){const t=[];for(let r=0;ri&&(n=e.index,i=e[0].length);return i<0?r:`${r.substring(0,n)}::${r.substring(n+i)}`},t.prototype.toString=function(){return this.toRFC5952String()},t}(),h.IPv6.broadcastAddressFromCIDR=function(t){try{const r=this.parseCIDR(t),e=r[0].toByteArray(),n=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[];let o=0;for(;o<16;)i.push(parseInt(e[o],10)|255^parseInt(n[o],10)),o++;return new this(i)}catch(t){throw new Error(`ipaddr: the address does not have IPv6 CIDR format (${t})`)}},h.IPv6.isIPv6=function(t){return null!==this.parser(t)},h.IPv6.isValid=function(t){if("string"==typeof t&&-1===t.indexOf(":"))return!1;try{const r=this.parser(t);return new this(r.parts,r.zoneId),!0}catch(t){return!1}},h.IPv6.isValidCIDR=function(t){if("string"==typeof t&&-1===t.indexOf(":"))return!1;try{return this.parseCIDR(t),!0}catch(t){return!1}},h.IPv6.networkAddressFromCIDR=function(t){let r,e,n,i,o;try{for(n=(r=this.parseCIDR(t))[0].toByteArray(),o=this.subnetMaskFromPrefixLength(r[1]).toByteArray(),i=[],e=0;e<16;)i.push(parseInt(n[e],10)&parseInt(o[e],10)),e++;return new this(i)}catch(t){throw new Error(`ipaddr: the address does not have IPv6 CIDR format (${t})`)}},h.IPv6.parse=function(t){const r=this.parser(t);if(null===r.parts)throw new Error("ipaddr: string is not formatted like an IPv6 Address");return new this(r.parts,r.zoneId)},h.IPv6.parseCIDR=function(t){let r,e,n;if((e=t.match(/^(.+)\/(\d+)$/))&&(r=parseInt(e[2]))>=0&&r<=128)return n=[this.parse(e[1]),r],Object.defineProperty(n,"toString",{value:function(){return this.join("/")}}),n;throw new Error("ipaddr: string is not formatted like an IPv6 CIDR range")},h.IPv6.parser=function(t){let r,e,n,i,o,p;if(n=t.match(s.deprecatedTransitional))return this.parser(`::ffff:${n[1]}`);if(s.native.test(t))return a(t,8);if((n=t.match(s.transitional))&&(p=n[6]||"",r=n[1],n[1].endsWith("::")||(r=r.slice(0,-1)),(r=a(r+p,6)).parts)){for(o=[parseInt(n[2]),parseInt(n[3]),parseInt(n[4]),parseInt(n[5])],e=0;e128)throw new Error("ipaddr: invalid IPv6 prefix length");const r=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0];let e=0;const n=Math.floor(t/8);for(;e.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb}
diff --git a/src/usr/local/emhttp/plugins/tailscale/lib/select2/select2.min.js b/src/usr/local/emhttp/plugins/tailscale/lib/select2/select2.min.js
new file mode 100644
index 0000000..e421426
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/lib/select2/select2.min.js
@@ -0,0 +1,2 @@
+/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */
+!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(u){var e=function(){if(u&&u.fn&&u.fn.select2&&u.fn.select2.amd)var e=u.fn.select2.amd;var t,n,r,h,o,s,f,g,m,v,y,_,i,a,b;function w(e,t){return i.call(e,t)}function l(e,t){var n,r,i,o,s,a,l,c,u,d,p,h=t&&t.split("/"),f=y.map,g=f&&f["*"]||{};if(e){for(s=(e=e.split("/")).length-1,y.nodeIdCompat&&b.test(e[s])&&(e[s]=e[s].replace(b,"")),"."===e[0].charAt(0)&&h&&(e=h.slice(0,h.length-1).concat(e)),u=0;u":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},i.appendMany=function(e,t){if("1.7"===o.fn.jquery.substr(0,3)){var n=o();o.map(t,function(e){n=n.add(e)}),t=n}e.append(t)},i.__cache={};var n=0;return i.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null==t&&(e.id?(t=e.id,e.setAttribute("data-select2-id",t)):(e.setAttribute("data-select2-id",++n),t=n.toString())),t},i.StoreData=function(e,t,n){var r=i.GetUniqueElementId(e);i.__cache[r]||(i.__cache[r]={}),i.__cache[r][t]=n},i.GetData=function(e,t){var n=i.GetUniqueElementId(e);return t?i.__cache[n]&&null!=i.__cache[n][t]?i.__cache[n][t]:o(e).data(t):i.__cache[n]},i.RemoveData=function(e){var t=i.GetUniqueElementId(e);null!=i.__cache[t]&&delete i.__cache[t],e.removeAttribute("data-select2-id")},i}),e.define("select2/results",["jquery","./utils"],function(h,f){function r(e,t,n){this.$element=e,this.data=n,this.options=t,r.__super__.constructor.call(this)}return f.Extend(r,f.Observable),r.prototype.render=function(){var e=h('');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},r.prototype.clear=function(){this.$results.empty()},r.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=h(' '),r=this.options.get("translations").get(e.message);n.append(t(r(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},r.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},r.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested"});p.append(l),s.append(a),s.append(p)}else this.template(e,t);return f.StoreData(t,"data",e),t},r.prototype.bind=function(t,e){var l=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){l.clear(),l.append(e.data),t.isOpen()&&(l.setClasses(),l.highlightFirstItem())}),t.on("results:append",function(e){l.append(e.data),t.isOpen()&&l.setClasses()}),t.on("query",function(e){l.hideMessages(),l.showLoading(e)}),t.on("select",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(l.setClasses(),l.options.get("scrollAfterSelect")&&l.highlightFirstItem())}),t.on("open",function(){l.$results.attr("aria-expanded","true"),l.$results.attr("aria-hidden","false"),l.setClasses(),l.ensureHighlightVisible()}),t.on("close",function(){l.$results.attr("aria-expanded","false"),l.$results.attr("aria-hidden","true"),l.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=l.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e=l.getHighlightedResults();if(0!==e.length){var t=f.GetData(e[0],"data");"true"==e.attr("aria-selected")?l.trigger("close",{}):l.trigger("select",{data:t})}}),t.on("results:previous",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e);if(!(n<=0)){var r=n-1;0===e.length&&(r=0);var i=t.eq(r);i.trigger("mouseenter");var o=l.$results.offset().top,s=i.offset().top,a=l.$results.scrollTop()+(s-o);0===r?l.$results.scrollTop(0):s-o<0&&l.$results.scrollTop(a)}}),t.on("results:next",function(){var e=l.getHighlightedResults(),t=l.$results.find("[aria-selected]"),n=t.index(e)+1;if(!(n>=t.length)){var r=t.eq(n);r.trigger("mouseenter");var i=l.$results.offset().top+l.$results.outerHeight(!1),o=r.offset().top+r.outerHeight(!1),s=l.$results.scrollTop()+o-i;0===n?l.$results.scrollTop(0):ithis.$results.outerHeight()||o<0)&&this.$results.scrollTop(i)}},r.prototype.template=function(e,t){var n=this.options.get("templateResult"),r=this.options.get("escapeMarkup"),i=n(e,t);null==i?t.style.display="none":"string"==typeof i?t.innerHTML=r(i):h(t).append(i)},r}),e.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),e.define("select2/selection/base",["jquery","../utils","../keys"],function(n,r,i){function o(e,t){this.$element=e,this.options=t,o.__super__.constructor.call(this)}return r.Extend(o,r.Observable),o.prototype.render=function(){var e=n(' ');return this._tabindex=0,null!=r.GetData(this.$element[0],"old-tabindex")?this._tabindex=r.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},o.prototype.bind=function(e,t){var n=this,r=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",r),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},o.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},o.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&r.GetData(this,"element").select2("close")})})},o.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},o.prototype.position=function(e,t){t.find(".selection").append(e)},o.prototype.destroy=function(){this._detachCloseHandler(this.container)},o.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},o.prototype.isEnabled=function(){return!this.isDisabled()},o.prototype.isDisabled=function(){return this.options.get("disabled")},o}),e.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,r){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e.addClass("select2-selection--single"),e.html(' '),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var r=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",r).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",r),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e(" ")},i.prototype.update=function(e){if(0!==e.length){var t=e[0],n=this.$selection.find(".select2-selection__rendered"),r=this.display(t,n);n.empty().append(r);var i=t.title||t.text;i?n.attr("title",i):n.removeAttr("title")}else this.clear()},i}),e.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,l){function n(e,t){n.__super__.constructor.apply(this,arguments)}return l.Extend(n,e),n.prototype.render=function(){var e=n.__super__.render.call(this);return e.addClass("select2-selection--multiple"),e.html(''),e},n.prototype.bind=function(e,t){var r=this;n.__super__.bind.apply(this,arguments),this.$selection.on("click",function(e){r.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){if(!r.isDisabled()){var t=i(this).parent(),n=l.GetData(t[0],"data");r.trigger("unselect",{originalEvent:e,data:n})}})},n.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},n.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},n.prototype.selectionContainer=function(){return i('× ')},n.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=0;n×');a.StoreData(r[0],"data",t),this.$selection.find(".select2-selection__rendered").prepend(r)}},e}),e.define("select2/selection/search",["jquery","../utils","../keys"],function(r,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=r(' ');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),t.on("open",function(){r.$search.attr("aria-controls",i),r.$search.trigger("focus")}),t.on("close",function(){r.$search.val(""),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.trigger("focus")}),t.on("enable",function(){r.$search.prop("disabled",!1),r._transferTabIndex()}),t.on("disable",function(){r.$search.prop("disabled",!0)}),t.on("focus",function(e){r.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){r.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){r._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===r.$search.val()){var t=r.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("select",function(){r._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var r=this;this._checkIfMaximumSelected(function(){e.call(r,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var r=this;this.current(function(e){var t=null!=e?e.length:0;0=r.maximumSelectionLength?r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t(' ');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o(' ');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){r.handleSearch(e)}),t.on("open",function(){r.$search.attr("tabindex",0),r.$search.attr("aria-controls",i),r.$search.trigger("focus"),window.setTimeout(function(){r.$search.trigger("focus")},0)}),t.on("close",function(){r.$search.attr("tabindex",-1),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.val(""),r.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||r.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(r.showSearch(e)?r.$searchContainer.removeClass("select2-search--hide"):r.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;0<=r;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("query",function(e){r.lastParams=e,r.loading=!0}),t.on("query:append",function(e){r.lastParams=e,r.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n(' '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),r._bindContainerResultHandlers(t)}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(" "),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,r="scroll.select2."+t.id,i="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(r,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(r+" "+i+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,r="resize.select2."+t.id,i="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+r+" "+i)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),r=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=i.top,o.bottom=i.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+s,d={left:i.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(r="below"),u||!c||t?!c&&u&&t&&(r="below"):r="above",("above"==r||t&&"below"!==r)&&(d.top=o.top-h.top-s),null!=r&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+r),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+r)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,r){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,r)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,r=0;r ');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("jquery-mousewheel",["jquery"],function(e){return e}),e.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,o,t,s){if(null==i.fn.select2){var a=["open","close","destroy"];i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new o(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,r=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=s.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,r)}),-1/dev/null
diff --git a/src/usr/local/emhttp/plugins/tailscale/settings.json b/src/usr/local/emhttp/plugins/tailscale/settings.json
new file mode 100644
index 0000000..9ada3ee
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/settings.json
@@ -0,0 +1,34 @@
+{
+ "INCLUDE_INTERFACE": {
+ "default": "1",
+ "description": "Adds tailscale interface to Unraid services"
+ },
+ "USAGE": {
+ "default": "1",
+ "description": "Allow collection of usage data"
+ },
+ "ACCEPT_DNS": {
+ "default": "0",
+ "description": "Use MagicDNS"
+ },
+ "ACCEPT_ROUTES": {
+ "default": "0",
+ "description": "Use routes from the tailnet"
+ },
+ "SYSCTL_IP_FORWARD": {
+ "default": "1",
+ "description": "Enable IP forwarding in sysctl"
+ },
+ "ENABLE_TAILSCALE": {
+ "default": "1",
+ "description": "Tailscale enabled"
+ },
+ "WG_PORT": {
+ "default": "0",
+ "description": "Port for Wireguard connections"
+ },
+ "TAILDROP_DIR": {
+ "default": "",
+ "description": "Destination for Taildrop files"
+ }
+}
diff --git a/src/usr/local/emhttp/plugins/tailscale/style.css b/src/usr/local/emhttp/plugins/tailscale/style.css
new file mode 100644
index 0000000..d4bdbb8
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/style.css
@@ -0,0 +1,19 @@
+.tablesorter .filtered {
+ display: none;
+}
+.tablesorter-filter.disabled {
+ display: none;
+}
+.fileTree {
+ width: 300px;
+ max-height: 150px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ position: absolute;
+ z-index: 100;
+ display: none
+}
+
+li.select2-results__option {
+ color: black;
+}
\ No newline at end of file
diff --git a/src/usr/local/emhttp/plugins/tailscale/tailscale-watcher.php b/src/usr/local/emhttp/plugins/tailscale/tailscale-watcher.php
new file mode 100755
index 0000000..f89a647
--- /dev/null
+++ b/src/usr/local/emhttp/plugins/tailscale/tailscale-watcher.php
@@ -0,0 +1,10 @@
+#!/usr/bin/php -q
+run();
diff --git a/src/usr/local/emhttp/plugins/tailscale/tailscale.png b/src/usr/local/emhttp/plugins/tailscale/tailscale.png
new file mode 100644
index 0000000..8408ec6
Binary files /dev/null and b/src/usr/local/emhttp/plugins/tailscale/tailscale.png differ
diff --git a/src/usr/local/etc/rc.d/rc.tailscale b/src/usr/local/etc/rc.d/rc.tailscale
new file mode 100755
index 0000000..723eb1b
--- /dev/null
+++ b/src/usr/local/etc/rc.d/rc.tailscale
@@ -0,0 +1,53 @@
+#!/bin/sh
+# /etc/rc.d/rc.tailscaled - start/stop the tailscaled daemon
+
+. /usr/local/php/unraid-tailscale-utils/log.sh
+
+start_tailscaled() {
+ if ! /usr/bin/pgrep --ns $$ --euid root -f "^/usr/local/sbin/tailscaled" 1> /dev/null 2> /dev/null ; then
+
+ /usr/local/php/unraid-tailscale-utils/pre-startup.php
+ if [ $? -ne 0 ]; then
+ log "Tailscale is disabled in settings, not starting tailscaled."
+ return
+ fi
+
+ if [ -f /usr/local/emhttp/plugins/tailscale/custom-params.sh ]; then
+ . /usr/local/emhttp/plugins/tailscale/custom-params.sh
+ else
+ TAILSCALE_CUSTOM_PARAMS=""
+ fi
+
+ TAILSCALE_START_CMD="/usr/local/sbin/tailscaled -statedir /boot/config/plugins/tailscale/state -tun tailscale1 $TAILSCALE_CUSTOM_PARAMS"
+ log "Starting tailscaled: $TAILSCALE_START_CMD"
+ mkdir -p /boot/config/plugins/tailscale/state
+ $TAILSCALE_START_CMD 2>&1 | grep -vF "monitor: [unexpected]" >> /var/log/tailscale.log &
+ nohup /usr/local/emhttp/plugins/tailscale/tailscale-watcher.php 1>/dev/null 2>&1 &
+ fi
+}
+
+stop_tailscaled() {
+ log "Stopping tailscaled."
+ killall --ns $$ --wait tailscale-watcher.php 2> /dev/null
+ killall --ns $$ --wait tailscaled 2> /dev/null
+}
+
+restart_tailscaled() {
+ stop_tailscaled
+ sleep 1
+ start_tailscaled
+}
+
+case "$1" in
+'start')
+ start_tailscaled
+ ;;
+'stop')
+ stop_tailscaled
+ ;;
+'restart')
+ restart_tailscaled
+ ;;
+*)
+ echo "usage $0 start|stop|restart"
+esac
diff --git a/src/usr/local/php/unraid-tailscale-utils/composer.json b/src/usr/local/php/unraid-tailscale-utils/composer.json
new file mode 100644
index 0000000..3fba013
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/composer.json
@@ -0,0 +1,35 @@
+{
+ "name": "dkaser/unraid-tailscale-utils",
+ "description": "Tailscale configuration",
+ "type": "library",
+ "license": "GPL-3.0-or-later",
+ "autoload": {
+ "psr-4": {
+ "Tailscale\\": "unraid-tailscale-utils/"
+ }
+ },
+ "authors": [
+ {
+ "name": "Derek Kaser"
+ }
+ ],
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.64",
+ "phpstan/phpstan": "^2.1"
+ },
+ "config": {
+ "sort-packages": true,
+ "bin-dir": "../../../../../vendor/bin/"
+ },
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/dkaser/unraid-utils.git"
+ }
+ ],
+ "require": {
+ "edacerton/plugin-utils": "^1.0",
+ "rlanvin/php-ip": "^3.0",
+ "php": ">=8.3"
+ }
+}
\ No newline at end of file
diff --git a/src/usr/local/php/unraid-tailscale-utils/composer.lock b/src/usr/local/php/unraid-tailscale-utils/composer.lock
new file mode 100644
index 0000000..e56d24b
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/composer.lock
@@ -0,0 +1,2828 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "5e629f49773cc054ee1b10817d8c5bb2",
+ "packages": [
+ {
+ "name": "edacerton/plugin-utils",
+ "version": "1.6.0-stable",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dkaser/unraid-utils.git",
+ "reference": "b5c574fbbae82f4fc80818e2e446db2993035406"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dkaser/unraid-utils/zipball/b5c574fbbae82f4fc80818e2e446db2993035406",
+ "reference": "b5c574fbbae82f4fc80818e2e446db2993035406",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.64",
+ "phpstan/phpstan": "^2.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "EDACerton\\PluginUtils\\": "src/"
+ }
+ },
+ "license": [
+ "GPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Derek Kaser"
+ }
+ ],
+ "description": "Utility classes",
+ "support": {
+ "source": "https://github.com/dkaser/unraid-utils/tree/1.6.0-stable",
+ "issues": "https://github.com/dkaser/unraid-utils/issues"
+ },
+ "time": "2026-01-04T15:19:06+00:00"
+ },
+ {
+ "name": "rlanvin/php-ip",
+ "version": "v3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/rlanvin/php-ip.git",
+ "reference": "7811f12256a5a610ddcb31ed9840179f4dd3784d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/rlanvin/php-ip/zipball/7811f12256a5a610ddcb31ed9840179f4dd3784d",
+ "reference": "7811f12256a5a610ddcb31ed9840179f4dd3784d",
+ "shasum": ""
+ },
+ "require": {
+ "ext-gmp": "*",
+ "php": ">=7.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^6.5|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpIP\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "IPv4/IPv6 manipulation library for PHP",
+ "homepage": "https://github.com/rlanvin/php-ip",
+ "keywords": [
+ "IP",
+ "ipv4",
+ "ipv6"
+ ],
+ "support": {
+ "issues": "https://github.com/rlanvin/php-ip/issues",
+ "source": "https://github.com/rlanvin/php-ip/tree/v3.0.0"
+ },
+ "time": "2022-03-02T08:51:37+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "clue/ndjson-react",
+ "version": "v1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/clue/reactphp-ndjson.git",
+ "reference": "392dc165fce93b5bb5c637b67e59619223c931b0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0",
+ "reference": "392dc165fce93b5bb5c637b67e59619223c931b0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3",
+ "react/stream": "^1.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
+ "react/event-loop": "^1.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Clue\\React\\NDJson\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering"
+ }
+ ],
+ "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.",
+ "homepage": "https://github.com/clue/reactphp-ndjson",
+ "keywords": [
+ "NDJSON",
+ "json",
+ "jsonlines",
+ "newline",
+ "reactphp",
+ "streaming"
+ ],
+ "support": {
+ "issues": "https://github.com/clue/reactphp-ndjson/issues",
+ "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://clue.engineering/support",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/clue",
+ "type": "github"
+ }
+ ],
+ "time": "2022-12-23T10:58:28+00:00"
+ },
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
+ {
+ "name": "composer/semver",
+ "version": "3.4.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/semver.git",
+ "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^5.3.2 || ^7.0 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.11",
+ "symfony/phpunit-bridge": "^3 || ^7"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Semver\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nils Adermann",
+ "email": "naderman@naderman.de",
+ "homepage": "http://www.naderman.de"
+ },
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ },
+ {
+ "name": "Rob Bast",
+ "email": "rob.bast@gmail.com",
+ "homepage": "http://robbast.nl"
+ }
+ ],
+ "description": "Semver library that offers utilities, version constraint parsing and validation.",
+ "keywords": [
+ "semantic",
+ "semver",
+ "validation",
+ "versioning"
+ ],
+ "support": {
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/semver/issues",
+ "source": "https://github.com/composer/semver/tree/3.4.4"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-20T19:15:30+00:00"
+ },
+ {
+ "name": "composer/xdebug-handler",
+ "version": "3.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/xdebug-handler.git",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1 || ^2 || ^3",
+ "php": "^7.2.5 || ^8.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.1",
+ "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Composer\\XdebugHandler\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "John Stevenson",
+ "email": "john-stevenson@blueyonder.co.uk"
+ }
+ ],
+ "description": "Restarts a process without Xdebug.",
+ "keywords": [
+ "Xdebug",
+ "performance"
+ ],
+ "support": {
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/xdebug-handler/issues",
+ "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-06T16:37:16+00:00"
+ },
+ {
+ "name": "evenement/evenement",
+ "version": "v3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/igorw/evenement.git",
+ "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
+ "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9 || ^6"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Evenement\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Igor Wiedler",
+ "email": "igor@wiedler.ch"
+ }
+ ],
+ "description": "Événement is a very simple event dispatching library for PHP",
+ "keywords": [
+ "event-dispatcher",
+ "event-emitter"
+ ],
+ "support": {
+ "issues": "https://github.com/igorw/evenement/issues",
+ "source": "https://github.com/igorw/evenement/tree/v3.0.2"
+ },
+ "time": "2023-08-08T05:53:35+00:00"
+ },
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-14T07:29:31+00:00"
+ },
+ {
+ "name": "friendsofphp/php-cs-fixer",
+ "version": "v3.92.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
+ "reference": "260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58",
+ "reference": "260cc8c4a1d2f6d2f22cd4f9c70aa72e55ebac58",
+ "shasum": ""
+ },
+ "require": {
+ "clue/ndjson-react": "^1.3",
+ "composer/semver": "^3.4",
+ "composer/xdebug-handler": "^3.0.5",
+ "ext-filter": "*",
+ "ext-hash": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "fidry/cpu-core-counter": "^1.3",
+ "php": "^7.4 || ^8.0",
+ "react/child-process": "^0.6.6",
+ "react/event-loop": "^1.5",
+ "react/socket": "^1.16",
+ "react/stream": "^1.4",
+ "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0",
+ "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0",
+ "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+ "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+ "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+ "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+ "symfony/polyfill-mbstring": "^1.33",
+ "symfony/polyfill-php80": "^1.33",
+ "symfony/polyfill-php81": "^1.33",
+ "symfony/polyfill-php84": "^1.33",
+ "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0",
+ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0"
+ },
+ "require-dev": {
+ "facile-it/paraunit": "^1.3.1 || ^2.7",
+ "infection/infection": "^0.31",
+ "justinrainbow/json-schema": "^6.6",
+ "keradus/cli-executor": "^2.3",
+ "mikey179/vfsstream": "^1.6.12",
+ "php-coveralls/php-coveralls": "^2.9",
+ "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6",
+ "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6",
+ "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.46",
+ "symfony/polyfill-php85": "^1.33",
+ "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0",
+ "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0"
+ },
+ "suggest": {
+ "ext-dom": "For handling output formats in XML",
+ "ext-mbstring": "For handling non-UTF8 characters."
+ },
+ "bin": [
+ "php-cs-fixer"
+ ],
+ "type": "application",
+ "autoload": {
+ "psr-4": {
+ "PhpCsFixer\\": "src/"
+ },
+ "exclude-from-classmap": [
+ "src/**/Internal/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Dariusz Rumiński",
+ "email": "dariusz.ruminski@gmail.com"
+ }
+ ],
+ "description": "A tool to automatically fix PHP code style",
+ "keywords": [
+ "Static code analysis",
+ "fixer",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
+ "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.92.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/keradus",
+ "type": "github"
+ }
+ ],
+ "time": "2026-01-08T21:57:37+00:00"
+ },
+ {
+ "name": "phpstan/phpstan",
+ "version": "2.1.33",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f",
+ "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-05T10:24:31+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/event-dispatcher",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/event-dispatcher.git",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Standard interfaces for event handling.",
+ "keywords": [
+ "events",
+ "psr",
+ "psr-14"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
+ "time": "2019-01-08T18:20:26+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "react/cache",
+ "version": "v1.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/cache.git",
+ "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
+ "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0",
+ "react/promise": "^3.0 || ^2.0 || ^1.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "React\\Cache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "Async, Promise-based cache interface for ReactPHP",
+ "keywords": [
+ "cache",
+ "caching",
+ "promise",
+ "reactphp"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/cache/issues",
+ "source": "https://github.com/reactphp/cache/tree/v1.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2022-11-30T15:59:55+00:00"
+ },
+ {
+ "name": "react/child-process",
+ "version": "v0.6.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/child-process.git",
+ "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
+ "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
+ "shasum": ""
+ },
+ "require": {
+ "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+ "php": ">=5.3.0",
+ "react/event-loop": "^1.2",
+ "react/stream": "^1.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+ "react/socket": "^1.16",
+ "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "React\\ChildProcess\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "Event-driven library for executing child processes with ReactPHP.",
+ "keywords": [
+ "event-driven",
+ "process",
+ "reactphp"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/child-process/issues",
+ "source": "https://github.com/reactphp/child-process/tree/v0.6.7"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2025-12-23T15:25:20+00:00"
+ },
+ {
+ "name": "react/dns",
+ "version": "v1.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/dns.git",
+ "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
+ "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0",
+ "react/cache": "^1.0 || ^0.6 || ^0.5",
+ "react/event-loop": "^1.2",
+ "react/promise": "^3.2 || ^2.7 || ^1.2.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+ "react/async": "^4.3 || ^3 || ^2",
+ "react/promise-timer": "^1.11"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "React\\Dns\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "Async DNS resolver for ReactPHP",
+ "keywords": [
+ "async",
+ "dns",
+ "dns-resolver",
+ "reactphp"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/dns/issues",
+ "source": "https://github.com/reactphp/dns/tree/v1.14.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2025-11-18T19:34:28+00:00"
+ },
+ {
+ "name": "react/event-loop",
+ "version": "v1.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/event-loop.git",
+ "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+ "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+ },
+ "suggest": {
+ "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "React\\EventLoop\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
+ "keywords": [
+ "asynchronous",
+ "event-loop"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/event-loop/issues",
+ "source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2025-11-17T20:46:25+00:00"
+ },
+ {
+ "name": "react/promise",
+ "version": "v3.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/promise.git",
+ "reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
+ "reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "1.12.28 || 1.4.10",
+ "phpunit/phpunit": "^9.6 || ^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "React\\Promise\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "A lightweight implementation of CommonJS Promises/A for PHP",
+ "keywords": [
+ "promise",
+ "promises"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/promise/issues",
+ "source": "https://github.com/reactphp/promise/tree/v3.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2025-08-19T18:57:03+00:00"
+ },
+ {
+ "name": "react/socket",
+ "version": "v1.17.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/socket.git",
+ "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
+ "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
+ "shasum": ""
+ },
+ "require": {
+ "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+ "php": ">=5.3.0",
+ "react/dns": "^1.13",
+ "react/event-loop": "^1.2",
+ "react/promise": "^3.2 || ^2.6 || ^1.2.1",
+ "react/stream": "^1.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+ "react/async": "^4.3 || ^3.3 || ^2",
+ "react/promise-stream": "^1.4",
+ "react/promise-timer": "^1.11"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "React\\Socket\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
+ "keywords": [
+ "Connection",
+ "Socket",
+ "async",
+ "reactphp",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/socket/issues",
+ "source": "https://github.com/reactphp/socket/tree/v1.17.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2025-11-19T20:47:34+00:00"
+ },
+ {
+ "name": "react/stream",
+ "version": "v1.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/reactphp/stream.git",
+ "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+ "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+ "shasum": ""
+ },
+ "require": {
+ "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+ "php": ">=5.3.8",
+ "react/event-loop": "^1.2"
+ },
+ "require-dev": {
+ "clue/stream-filter": "~1.2",
+ "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "React\\Stream\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering",
+ "homepage": "https://clue.engineering/"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "reactphp@ceesjankiewiet.nl",
+ "homepage": "https://wyrihaximus.net/"
+ },
+ {
+ "name": "Jan Sorgalla",
+ "email": "jsorgalla@gmail.com",
+ "homepage": "https://sorgalla.com/"
+ },
+ {
+ "name": "Chris Boden",
+ "email": "cboden@gmail.com",
+ "homepage": "https://cboden.dev/"
+ }
+ ],
+ "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
+ "keywords": [
+ "event-driven",
+ "io",
+ "non-blocking",
+ "pipe",
+ "reactphp",
+ "readable",
+ "stream",
+ "writable"
+ ],
+ "support": {
+ "issues": "https://github.com/reactphp/stream/issues",
+ "source": "https://github.com/reactphp/stream/tree/v1.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/reactphp",
+ "type": "open_collective"
+ }
+ ],
+ "time": "2024-06-11T12:45:25+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^12.0",
+ "symfony/process": "^7.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-07T04:55:46+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v7.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
+ "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.2|^8.0"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/dotenv": "<6.4",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/process": "<6.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v7.4.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-23T14:50:43+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher",
+ "version": "v7.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher.git",
+ "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d",
+ "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/event-dispatcher-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/service-contracts": "<2.5"
+ },
+ "provide": {
+ "psr/event-dispatcher-implementation": "1.0",
+ "symfony/event-dispatcher-implementation": "2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/error-handler": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/framework-bundle": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\EventDispatcher\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-10-28T09:38:46+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher-contracts",
+ "version": "v3.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+ "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
+ "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/event-dispatcher": "^1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\EventDispatcher\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to dispatching event",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:21:43+00:00"
+ },
+ {
+ "name": "symfony/filesystem",
+ "version": "v7.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "d551b38811096d0be9c4691d406991b47c0c630a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a",
+ "reference": "d551b38811096d0be9c4691d406991b47c0c630a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
+ },
+ "require-dev": {
+ "symfony/process": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides basic utilities for the filesystem",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v7.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-11-27T13:27:24+00:00"
+ },
+ {
+ "name": "symfony/finder",
+ "version": "v7.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/finder.git",
+ "reference": "fffe05569336549b20a1be64250b40516d6e8d06"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06",
+ "reference": "fffe05569336549b20a1be64250b40516d6e8d06",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "symfony/filesystem": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Finder\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Finds files and directories via an intuitive fluent interface",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/finder/tree/v7.4.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-23T14:50:43+00:00"
+ },
+ {
+ "name": "symfony/options-resolver",
+ "version": "v7.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/options-resolver.git",
+ "reference": "b38026df55197f9e39a44f3215788edf83187b80"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80",
+ "reference": "b38026df55197f9e39a44f3215788edf83187b80",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\OptionsResolver\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an improved replacement for the array_replace PHP function",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "config",
+ "configuration",
+ "options"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/options-resolver/tree/v7.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-11-12T15:39:26+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-27T09:58:17+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+ "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-12-23T08:48:59+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php80",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php80.git",
+ "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php80\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ion Bazan",
+ "email": "ion.bazan@gmail.com"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-01-02T08:10:11+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php81",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php81.git",
+ "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php81\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php84",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php84.git",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php84\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-24T13:30:11+00:00"
+ },
+ {
+ "name": "symfony/process",
+ "version": "v7.4.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/process.git",
+ "reference": "608476f4604102976d687c483ac63a79ba18cc97"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
+ "reference": "608476f4604102976d687c483ac63a79ba18cc97",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Process\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Executes commands in sub-processes",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v7.4.5"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-26T15:07:59+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.6.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-07-15T11:30:57+00:00"
+ },
+ {
+ "name": "symfony/stopwatch",
+ "version": "v7.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/stopwatch.git",
+ "reference": "8a24af0a2e8a872fb745047180649b8418303084"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084",
+ "reference": "8a24af0a2e8a872fb745047180649b8418303084",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Stopwatch\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides a way to profile code",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/stopwatch/tree/v7.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-04T07:05:15+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v7.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003",
+ "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.33",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v7.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-11-27T13:27:24+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.3"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.6.0"
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/daily.php b/src/usr/local/php/unraid-tailscale-utils/daily.php
new file mode 100755
index 0000000..14a95d3
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/daily.php
@@ -0,0 +1,34 @@
+#!/usr/bin/php -q
+.
+*/
+
+namespace Tailscale;
+
+$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
+
+require_once "{$docroot}/plugins/tailscale/include/common.php";
+if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+}
+$utils = new Utils(PLUGIN_NAME);
+
+$tailscaleConfig = $tailscaleConfig ?? new Config();
+
+$utils->run_task('Tailscale\System::notifyOnKeyExpiration');
+$utils->run_task('Tailscale\System::refreshWebGuiCert');
diff --git a/src/usr/local/php/unraid-tailscale-utils/daily.sh b/src/usr/local/php/unraid-tailscale-utils/daily.sh
new file mode 100755
index 0000000..fa9ae97
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/daily.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+/usr/local/php/unraid-tailscale-utils/daily.php 1>/dev/null 2>&1
diff --git a/src/usr/local/php/unraid-tailscale-utils/log.sh b/src/usr/local/php/unraid-tailscale-utils/log.sh
new file mode 100644
index 0000000..da3609d
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/log.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+log() {
+ LOG_TIME=`date '+%Y/%m/%d %H:%M:%S'`
+ CALLER=`basename "$0"`
+ echo "$LOG_TIME $CALLER: $1" >> /var/log/tailscale-utils.log
+}
\ No newline at end of file
diff --git a/src/usr/local/php/unraid-tailscale-utils/pre-startup.php b/src/usr/local/php/unraid-tailscale-utils/pre-startup.php
new file mode 100755
index 0000000..8073ff4
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/pre-startup.php
@@ -0,0 +1,43 @@
+#!/usr/bin/php -q
+.
+*/
+
+namespace Tailscale;
+
+$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
+require_once "{$docroot}/plugins/tailscale/include/common.php";
+
+if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+}
+$utils = new Utils(PLUGIN_NAME);
+
+$tailscaleConfig = $tailscaleConfig ?? new Config();
+
+$utils->run_task('Tailscale\System::createTailscaledParamsFile', array($tailscaleConfig));
+$utils->run_task('Tailscale\System::applyGRO');
+$utils->run_task('Tailscale\System::setExtraInterface', array($tailscaleConfig));
+$utils->run_task('Tailscale\System::enableIPForwarding', array($tailscaleConfig));
+$utils->run_task('Tailscale\System::createTaildropLink', array($tailscaleConfig));
+
+if ($tailscaleConfig->Enable) {
+ exit(0);
+} else {
+ exit(1);
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Config.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Config.php
new file mode 100644
index 0000000..0bf993b
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Config.php
@@ -0,0 +1,66 @@
+.
+*/
+
+namespace Tailscale;
+
+class Config
+{
+ public bool $IncludeInterface;
+ public bool $Usage;
+ public bool $IPForward;
+ public bool $Enable;
+ public bool $SSH;
+ public bool $AllowDNS;
+ public bool $AllowRoutes;
+ public bool $AllowFunnel;
+ public bool $AddPeersToHosts;
+ public bool $NoLogsNoSupport;
+ public bool $UseTPM;
+
+ public int $WgPort;
+ public string $TaildropDir;
+
+ public function __construct()
+ {
+ $config_file = '/boot/config/plugins/tailscale/tailscale.cfg';
+
+ // Load configuration file
+ if (file_exists($config_file)) {
+ $saved_config = parse_ini_file($config_file) ?: array();
+ } else {
+ $saved_config = array();
+ }
+
+ $this->IncludeInterface = boolval($saved_config["INCLUDE_INTERFACE"] ?? "1");
+ $this->Usage = boolval($saved_config["USAGE"] ?? "1");
+ $this->IPForward = boolval($saved_config["SYSCTL_IP_FORWARD"] ?? "1");
+ $this->Enable = boolval($saved_config["ENABLE_TAILSCALE"] ?? "1");
+ $this->SSH = boolval($saved_config["SSH"] ?? "0");
+ $this->AllowDNS = boolval($saved_config["ACCEPT_DNS"] ?? "0");
+ $this->AllowRoutes = boolval($saved_config["ACCEPT_ROUTES"] ?? "0");
+ $this->AllowFunnel = boolval($saved_config["ALLOW_FUNNEL"] ?? "0");
+ $this->AddPeersToHosts = boolval($saved_config["ADD_PEERS_TO_HOSTS"] ?? "0");
+ $this->NoLogsNoSupport = boolval($saved_config["NO_LOGS_NO_SUPPORT"] ?? "0");
+ $this->UseTPM = boolval($saved_config["USE_TPM"] ?? "0");
+
+ $this->WgPort = intval($saved_config["WG_PORT"] ?? "0");
+
+ $this->TaildropDir = $saved_config["TAILDROP_DIR"] ?? "";
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ConnectionInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ConnectionInfo.php
new file mode 100644
index 0000000..b34f79b
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ConnectionInfo.php
@@ -0,0 +1,36 @@
+.
+*/
+
+namespace Tailscale;
+
+class ConnectionInfo
+{
+ public string $HostName = "";
+ public string $DNSName = "";
+ public string $TailscaleIPs = "";
+ public string $MagicDNSSuffix = "";
+ public string $AdvertisedRoutes = "";
+ public string $AcceptRoutes = "";
+ public string $AcceptDNS = "";
+ public string $RunSSH = "";
+ public string $ExitNodeLocal = "";
+ public string $AdvertiseExitNode = "";
+ public string $UseExitNode = "";
+ public string $AutoUpdate = "";
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/DashboardInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/DashboardInfo.php
new file mode 100644
index 0000000..316f334
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/DashboardInfo.php
@@ -0,0 +1,30 @@
+.
+*/
+
+namespace Tailscale;
+
+class DashboardInfo
+{
+ /** @var array $TailscaleIPs */
+ public array $TailscaleIPs = array();
+
+ public string $HostName = "";
+ public string $DNSName = "";
+ public string $Online = "";
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php
new file mode 100644
index 0000000..6f283e0
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Info.php
@@ -0,0 +1,493 @@
+.
+*/
+
+namespace Tailscale;
+
+use EDACerton\PluginUtils\Translator;
+
+class Info
+{
+ private string $useNetbios;
+ private string $smbEnabled;
+ private ?Translator $tr;
+ private LocalAPI $localAPI;
+ private \stdClass $status;
+ private \stdClass $prefs;
+ private \stdClass $lock;
+
+ public function __construct(?Translator $tr)
+ {
+ $share_config = parse_ini_file("/boot/config/share.cfg") ?: array();
+ $ident_config = parse_ini_file("/boot/config/ident.cfg") ?: array();
+
+ $this->localAPI = new LocalAPI();
+
+ $this->tr = $tr;
+ $this->smbEnabled = $share_config['shareSMBEnabled'] ?? "";
+ $this->useNetbios = $ident_config['USE_NETBIOS'] ?? "";
+ $this->status = $this->localAPI->getStatus();
+ $this->prefs = $this->localAPI->getPrefs();
+ $this->lock = $this->localAPI->getTkaStatus();
+ }
+
+ public function getStatus(): \stdClass
+ {
+ return $this->status;
+ }
+
+ public function getPrefs(): \stdClass
+ {
+ return $this->prefs;
+ }
+
+ public function getLock(): \stdClass
+ {
+ return $this->lock;
+ }
+
+ private function tr(string $message): string
+ {
+ if ($this->tr === null) {
+ return $message;
+ }
+
+ return $this->tr->tr($message);
+ }
+
+ public function getStatusInfo(): StatusInfo
+ {
+ $status = $this->status;
+ $prefs = $this->prefs;
+ $lock = $this->lock;
+
+ $statusInfo = new StatusInfo();
+
+ $statusInfo->TsVersion = isset($status->Version) ? $status->Version : $this->tr("unknown");
+ $statusInfo->KeyExpiration = isset($status->Self->KeyExpiry) ? $status->Self->KeyExpiry : $this->tr("disabled");
+ $statusInfo->Online = isset($status->Self->Online) ? ($status->Self->Online ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+ $statusInfo->InNetMap = isset($status->Self->InNetworkMap) ? ($status->Self->InNetworkMap ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+ $statusInfo->Tags = isset($status->Self->Tags) ? implode(" ", $status->Self->Tags) : "";
+ $statusInfo->LoggedIn = isset($prefs->LoggedOut) ? ($prefs->LoggedOut ? $this->tr("no") : $this->tr("yes")) : $this->tr("unknown");
+ $statusInfo->TsHealth = isset($status->Health) ? implode(" ", $status->Health) : "";
+ $statusInfo->LockEnabled = $this->getTailscaleLockEnabled() ? $this->tr("yes") : $this->tr("no");
+
+ if ($this->getTailscaleLockEnabled()) {
+ $lockInfo = new LockInfo();
+
+ $lockInfo->LockSigned = $this->getTailscaleLockSigned() ? $this->tr("yes") : $this->tr("no");
+ $lockInfo->LockSigning = $this->getTailscaleLockSigning() ? $this->tr("yes") : $this->tr("no");
+ $lockInfo->PubKey = $this->getTailscaleLockPubkey();
+ $lockInfo->NodeKey = $this->getTailscaleLockNodekey();
+
+ $statusInfo->LockInfo = $lockInfo;
+ }
+
+ return $statusInfo;
+ }
+
+ public function getConnectionInfo(): ConnectionInfo
+ {
+ $status = $this->status;
+ $prefs = $this->prefs;
+
+ $info = new ConnectionInfo();
+
+ $info->HostName = isset($status->Self->HostName) ? $status->Self->HostName : $this->tr("unknown");
+ $info->DNSName = isset($status->Self->DNSName) ? $status->Self->DNSName : $this->tr("unknown");
+ $info->TailscaleIPs = isset($status->TailscaleIPs) ? implode(" ", $status->TailscaleIPs) : $this->tr("unknown");
+ $info->MagicDNSSuffix = isset($status->MagicDNSSuffix) ? $status->MagicDNSSuffix : $this->tr("unknown");
+ $info->AdvertisedRoutes = isset($prefs->AdvertiseRoutes) ? implode(" ", $prefs->AdvertiseRoutes) : $this->tr("none");
+ $info->AcceptRoutes = isset($prefs->RouteAll) ? ($prefs->RouteAll ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+ $info->AcceptDNS = isset($prefs->CorpDNS) ? ($prefs->CorpDNS ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+ $info->RunSSH = isset($prefs->RunSSH) ? ($prefs->RunSSH ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+ $info->ExitNodeLocal = isset($prefs->ExitNodeAllowLANAccess) ? ($prefs->ExitNodeAllowLANAccess ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+ $info->UseExitNode = $this->usesExitNode() ? $this->tr("yes") : $this->tr("no");
+ $info->AutoUpdate = $this->autoUpdateEnabled() ? $this->tr("yes") : $this->tr("no");
+
+ if ($this->advertisesExitNode()) {
+ if ($this->status->Self->ExitNodeOption) {
+ $info->AdvertiseExitNode = $this->tr("yes");
+ } else {
+ $info->AdvertiseExitNode = $this->tr("info.unapproved");
+ }
+ } else {
+ $info->AdvertiseExitNode = $this->tr("no");
+ }
+
+ return $info;
+ }
+
+ public function getDashboardInfo(): DashboardInfo
+ {
+ $status = $this->status;
+
+ $info = new DashboardInfo();
+
+ $info->HostName = isset($status->Self->HostName) ? $status->Self->HostName : $this->tr("Unknown");
+ $info->DNSName = isset($status->Self->DNSName) ? $status->Self->DNSName : $this->tr("Unknown");
+ $info->TailscaleIPs = isset($status->TailscaleIPs) ? $status->TailscaleIPs : array();
+ $info->Online = isset($status->Self->Online) ? ($status->Self->Online ? $this->tr("yes") : $this->tr("no")) : $this->tr("unknown");
+
+ return $info;
+ }
+
+ public function getKeyExpirationWarning(): ?Warning
+ {
+ $status = $this->status;
+
+ if (isset($status->Self->KeyExpiry)) {
+ $expiryTime = new \DateTime($status->Self->KeyExpiry);
+ $expiryTime->setTimezone(new \DateTimeZone(date_default_timezone_get()));
+
+ $interval = $expiryTime->diff(new \DateTime('now'));
+ $expiryPrint = $expiryTime->format(\DateTimeInterface::RFC7231);
+ $intervalPrint = $interval->format('%a');
+
+ $warning = new Warning(sprintf($this->tr("warnings.key_expiration"), $intervalPrint, $expiryPrint));
+
+ switch (true) {
+ case $interval->days <= 7:
+ $warning->Priority = 'error';
+ break;
+ case $interval->days <= 30:
+ $warning->Priority = 'warn';
+ break;
+ default:
+ $warning->Priority = 'system';
+ break;
+ }
+
+ return $warning;
+ }
+ return null;
+ }
+
+ public function getTailscaleLockEnabled(): bool
+ {
+ return $this->lock->Enabled ?? false;
+ }
+
+ public function getTailscaleLockSigned(): bool
+ {
+ if ( ! $this->getTailscaleLockEnabled()) {
+ return false;
+ }
+
+ return $this->lock->NodeKeySigned;
+ }
+
+ public function getTailscaleLockNodekey(): string
+ {
+ if ( ! $this->getTailscaleLockEnabled()) {
+ return "";
+ }
+
+ return $this->lock->NodeKey;
+ }
+
+ public function getTailscaleLockPubkey(): string
+ {
+ if ( ! $this->getTailscaleLockEnabled()) {
+ return "";
+ }
+
+ return $this->lock->PublicKey;
+ }
+
+ public function getTailscaleLockSigning(): bool
+ {
+ if ( ! $this->getTailscaleLockSigned()) {
+ return false;
+ }
+
+ $isTrusted = false;
+ $myKey = $this->getTailscaleLockPubkey();
+
+ foreach ($this->lock->TrustedKeys as $item) {
+ if ($item->Key == $myKey) {
+ $isTrusted = true;
+ }
+ }
+
+ return $isTrusted;
+ }
+
+ /**
+ * @return array
+ */
+ public function getTailscaleLockPending(): array
+ {
+ if ( ! $this->getTailscaleLockSigning()) {
+ return array();
+ }
+
+ $pending = array();
+
+ foreach ($this->lock->FilteredPeers as $item) {
+ $pending[$item->Name] = $item->NodeKey;
+ }
+
+ return $pending;
+ }
+
+ public function getTailscaleLockWarning(): ?Warning
+ {
+ if ($this->getTailscaleLockEnabled() && ( ! $this->getTailscaleLockSigned())) {
+ return new Warning($this->tr("warnings.lock"), "error");
+ }
+ return null;
+ }
+
+ public function getTailscaleNetbiosWarning(): ?Warning
+ {
+ if (($this->useNetbios == "yes") && ($this->smbEnabled != "no")) {
+ return new Warning($this->tr("warnings.netbios"), "warn");
+ }
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getPeerStatus(): array
+ {
+ $result = array();
+
+ foreach ($this->status->Peer as $node => $status) {
+ $peer = new PeerStatus();
+
+ $peer->Name = trim($status->DNSName, ".");
+ $peer->IP = $status->TailscaleIPs;
+
+ $peer->LoginName = $this->status->User->{$status->UserID}->LoginName;
+ $peer->SharedUser = isset($status->ShareeNode);
+
+ if ($status->ExitNode) {
+ $peer->ExitNodeActive = true;
+ } elseif ($status->ExitNodeOption) {
+ $peer->ExitNodeAvailable = true;
+ }
+ $peer->Mullvad = in_array("tag:mullvad-exit-node", $status->Tags ?? array());
+
+ if ($status->TxBytes > 0 || $status->RxBytes > 0) {
+ $peer->Traffic = true;
+ $peer->TxBytes = $status->TxBytes;
+ $peer->RxBytes = $status->RxBytes;
+ }
+
+ if ( ! $status->Online) {
+ $peer->Online = false;
+ $peer->Active = false;
+ } elseif ( ! $status->Active) {
+ $peer->Online = true;
+ $peer->Active = false;
+ } else {
+ $peer->Online = true;
+ $peer->Active = true;
+
+ if (($status->Relay != "") && ($status->CurAddr == "")) {
+ $peer->Relayed = true;
+ $peer->Address = $status->Relay;
+ } elseif ($status->CurAddr != "") {
+ $peer->Relayed = false;
+ $peer->Address = $status->CurAddr;
+ }
+ }
+
+ $result[] = $peer;
+ }
+
+ return $result;
+ }
+
+ public function advertisesExitNode(): bool
+ {
+ foreach (($this->prefs->AdvertiseRoutes ?? array()) as $net) {
+ switch ($net) {
+ case "0.0.0.0/0":
+ case "::/0":
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function usesExitNode(): bool
+ {
+ if (($this->prefs->ExitNodeID ?? "") || ($this->prefs->ExitNodeIP ?? "")) {
+ return true;
+ }
+ return false;
+ }
+
+ public function exitNodeLocalAccess(): bool
+ {
+ return $this->prefs->ExitNodeAllowLANAccess ?? false;
+ }
+
+ public function acceptsDNS(): bool
+ {
+ return $this->prefs->CorpDNS ?? false;
+ }
+
+ public function acceptsRoutes(): bool
+ {
+ return $this->prefs->RouteAll ?? false;
+ }
+
+ public function runsSSH(): bool
+ {
+ return $this->prefs->RunSSH ?? false;
+ }
+
+ public function isOnline(): bool
+ {
+ return $this->status->Self->Online ?? false;
+ }
+
+ public function getAuthURL(): string
+ {
+ return $this->status->AuthURL ?? "";
+ }
+
+ public function needsLogin(): bool
+ {
+ return ($this->status->BackendState ?? "") == "NeedsLogin";
+ }
+
+ /**
+ * @return array
+ */
+ public function getAdvertisedRoutes(): array
+ {
+ $advertisedRoutes = $this->prefs->AdvertiseRoutes ?? array();
+ $exitNodeRoutes = ["0.0.0.0/0", "::/0"];
+ return array_diff($advertisedRoutes, $exitNodeRoutes);
+ }
+
+ public function isApprovedRoute(string $route): bool
+ {
+ return in_array($route, $this->status->Self->AllowedIPs ?? array());
+ }
+
+ public function getTailnetName(): string
+ {
+ return $this->status->CurrentTailnet->Name ?? "";
+ }
+
+ /**
+ * @return array
+ */
+ public function getExitNodes(): array
+ {
+ $exitNodes = array();
+
+ foreach (($this->status->Peer ?? array()) as $node => $status) {
+ if ($status->ExitNodeOption ?? false) {
+ $nodeName = $status->DNSName;
+ if (isset($status->Location->City)) {
+ $nodeName .= " (" . $status->Location->City . ")";
+ }
+ $exitNodes[$status->ID] = $nodeName;
+ }
+ }
+
+ return $exitNodes;
+ }
+
+ public function getCurrentExitNode(): string
+ {
+ foreach (($this->status->Peer ?? array()) as $node => $status) {
+ if ($status->ExitNode ?? false) {
+ return $status->ID;
+ }
+ }
+
+ return "";
+ }
+
+ public function connectedViaTS(): bool
+ {
+ return in_array($_SERVER['SERVER_ADDR'] ?? "", $this->status->TailscaleIPs ?? array());
+ }
+
+ /**
+ * @return array
+ */
+ public function getAllowedFunnelPorts(): array
+ {
+ $allowedPorts = array();
+
+ if (isset($this->status->Self->CapMap)) {
+ $prefix = "https://tailscale.com/cap/funnel-ports?ports=";
+ foreach ($this->status->Self->CapMap as $cap => $value) {
+ if (strpos($cap, $prefix) === 0) {
+ $ports = explode(",", substr($cap, strlen($prefix)));
+ foreach ($ports as $port) {
+ $allowedPorts[] = intval($port);
+ }
+ break;
+ }
+ }
+ }
+ return $allowedPorts;
+ }
+
+ public function getDNSName(): string
+ {
+ if ( ! isset($this->status->Self->DNSName)) {
+ throw new \RuntimeException("DNSName not set in Tailscale status.");
+ }
+
+ return $this->status->Self->DNSName;
+ }
+
+ public function isApprovedPeerRelay(): bool
+ {
+ $netmapRules = (array) $this->localAPI->getPacketFilterRules();
+
+ // Parse the packet filter rules to see if peer relay is approved
+ // TODO: Get a better way to do this from Tailscale
+ foreach ($netmapRules as $rule) {
+ if (isset($rule->CapGrant) && is_array($rule->CapGrant)) {
+ foreach ($rule->CapGrant as $capGrant) {
+ if (is_object($capGrant) && isset($capGrant->CapMap) && is_object($capGrant->CapMap) && isset($capGrant->CapMap->{'tailscale.com/cap/relay'})) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public function getRelayServerPort(): int|false
+ {
+ if (isset($this->prefs->RelayServerPort) && is_int($this->prefs->RelayServerPort)) {
+ return $this->prefs->RelayServerPort;
+ }
+ return false;
+ }
+
+ public function autoUpdateEnabled(): bool
+ {
+ return $this->prefs->AutoUpdate->Apply ?? false;
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php
new file mode 100644
index 0000000..d0bf120
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LocalAPI.php
@@ -0,0 +1,239 @@
+.
+*/
+
+namespace Tailscale;
+
+enum APIMethods
+{
+ case GET;
+ case POST;
+ case PATCH;
+}
+
+class LocalAPI
+{
+ private const tailscaleSocket = '/var/run/tailscale/tailscaled.sock';
+ private Utils $utils;
+
+ public function __construct()
+ {
+ if ( ! defined(__NAMESPACE__ . "\PLUGIN_ROOT") || ! defined(__NAMESPACE__ . "\PLUGIN_NAME")) {
+ throw new \RuntimeException("Common file not loaded.");
+ }
+ $this->utils = new Utils(PLUGIN_NAME);
+ }
+
+ private function tailscaleLocalAPI(string $url, APIMethods $method = APIMethods::GET, object $body = new \stdClass()): string
+ {
+ if (empty($url)) {
+ throw new \InvalidArgumentException("URL cannot be empty");
+ }
+
+ $body_encoded = json_encode($body, JSON_UNESCAPED_SLASHES);
+
+ if ( ! $body_encoded) {
+ throw new \InvalidArgumentException("Failed to encode JSON");
+ }
+
+ $ch = curl_init();
+
+ $headers = [];
+
+ curl_setopt($ch, CURLOPT_TIMEOUT, 5);
+ curl_setopt($ch, CURLOPT_UNIX_SOCKET_PATH, $this::tailscaleSocket);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_URL, "http://local-tailscaled.sock/localapi/{$url}");
+
+ if ($method == APIMethods::POST) {
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body_encoded);
+ $this->utils->logmsg("Tailscale Local API: {$url} POST " . $body_encoded);
+ $headers[] = "Content-Type: application/json";
+ }
+
+ if ($method == APIMethods::PATCH) {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body_encoded);
+ $this->utils->logmsg("Tailscale Local API: {$url} PATCH " . $body_encoded);
+ $headers[] = "Content-Type: application/json";
+ }
+
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+
+ $out = curl_exec($ch);
+ $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($out === false) {
+ throw new \RuntimeException("Tailscale Local API request failed for URL: {$url}");
+ }
+
+ if ($http_code < 200 || $http_code >= 300) {
+ throw new \RuntimeException("Tailscale Local API returned HTTP {$http_code} for URL: {$url}");
+ }
+
+ return strval($out);
+ }
+
+ private function decodeJSONResponse(string $response): \stdClass
+ {
+ $decoded = json_decode($response);
+
+ if (json_last_error() !== JSON_ERROR_NONE) {
+ throw new \RuntimeException("Failed to decode JSON response: " . json_last_error_msg());
+ }
+
+ return (object) $decoded;
+ }
+
+ public function isReady(): bool
+ {
+ // Check if tailscaleSocket exists
+ if ( ! file_exists($this::tailscaleSocket)) {
+ return false;
+ }
+
+ // Check backend state from status endpoint
+ try {
+ $acceptedStates = ['Running', 'NeedsLogin', 'NeedsMachineAuth', 'Stopped'];
+ $status = $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/status'));
+ if (isset($status->BackendState) && in_array($status->BackendState, $acceptedStates, true)) {
+ return true;
+ }
+ } catch (\RuntimeException $e) {
+ // No need to log here, as this is just a readiness check
+ }
+
+ return false;
+ }
+
+ public function getStatus(): \stdClass
+ {
+ try {
+ return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/status'));
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to get status: " . $e->getMessage());
+ return new \stdClass();
+ }
+ }
+
+ public function getPrefs(): \stdClass
+ {
+ try {
+ return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/prefs'));
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to get prefs: " . $e->getMessage());
+ return new \stdClass();
+ }
+ }
+
+ public function getTkaStatus(): \stdClass
+ {
+ try {
+ return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/tka/status'));
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to get TKA status: " . $e->getMessage());
+ return new \stdClass();
+ }
+ }
+
+ public function getServeConfig(): ServeConfig
+ {
+ try {
+ return new ServeConfig($this->decodeJSONResponse($this->tailscaleLocalAPI('v0/serve-config')));
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to get serve config: " . $e->getMessage());
+ return new ServeConfig();
+ }
+ }
+
+ public function getPacketFilterRules(): \stdClass
+ {
+ try {
+ return $this->decodeJSONResponse($this->tailscaleLocalAPI('v0/debug-packet-filter-rules'));
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to get packet filter rules: " . $e->getMessage());
+ return new \stdClass();
+ }
+ }
+
+ public function setServeConfig(ServeConfig $serveConfig): void
+ {
+ try {
+ $this->tailscaleLocalAPI("v0/serve-config", APIMethods::POST, $serveConfig->getConfig());
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to set serve config: " . $e->getMessage());
+ }
+ }
+
+ public function postLoginInteractive(): void
+ {
+ try {
+ $this->tailscaleLocalAPI('v0/login-interactive', APIMethods::POST);
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to post login interactive: " . $e->getMessage());
+ }
+ }
+
+ public function patchPref(string $key, mixed $value): void
+ {
+ $body = [];
+ $body[$key] = $value;
+ $body["{$key}Set"] = true;
+
+ try {
+ $this->tailscaleLocalAPI('v0/prefs', APIMethods::PATCH, (object) $body);
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to patch pref {$key}: " . $e->getMessage());
+ }
+ }
+
+ public function postTkaSign(string $key): void
+ {
+ $body = ["NodeKey" => $key];
+
+ try {
+ $this->tailscaleLocalAPI("v0/tka/sign", APIMethods::POST, (object) $body);
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to sign TKA key: " . $e->getMessage());
+ }
+ }
+
+ public function expireKey(): void
+ {
+ try {
+ $this->tailscaleLocalAPI('v0/set-expiry-sooner?expiry=0', APIMethods::POST);
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to expire key: " . $e->getMessage());
+ }
+ }
+
+ public function setAutoUpdate(bool $enabled): void
+ {
+ $body = [];
+ $body["AutoUpdate"] = ["Apply" => $enabled, "Check" => $enabled];
+ $body["AutoUpdateSet"] = ["ApplySet" => true, "CheckSet" => true];
+
+ try {
+ $this->tailscaleLocalAPI("v0/prefs", APIMethods::PATCH, (object) $body);
+ } catch (\RuntimeException $e) {
+ $this->utils->logmsg("Failed to set AutoUpdate: " . $e->getMessage());
+ }
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LockInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LockInfo.php
new file mode 100644
index 0000000..3e323c6
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/LockInfo.php
@@ -0,0 +1,28 @@
+.
+*/
+
+namespace Tailscale;
+
+class LockInfo
+{
+ public string $LockSigned = "";
+ public string $LockSigning = "";
+ public string $PubKey = "";
+ public string $NodeKey = "";
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/PeerStatus.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/PeerStatus.php
new file mode 100644
index 0000000..ba63cb0
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/PeerStatus.php
@@ -0,0 +1,44 @@
+.
+*/
+
+namespace Tailscale;
+
+class PeerStatus
+{
+ public string $Name = "";
+ public string $LoginName = "";
+ public bool $SharedUser = false;
+
+ /** @var string[] */
+ public array $IP = array();
+
+ public string $Address = "";
+
+ public bool $Online = false;
+ public bool $Active = false;
+ public bool $Relayed = false;
+
+ public bool $Traffic = false;
+ public int $TxBytes = 0;
+ public int $RxBytes = 0;
+
+ public bool $ExitNodeActive = false;
+ public bool $ExitNodeAvailable = false;
+ public bool $Mullvad = false;
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php
new file mode 100644
index 0000000..7673f2e
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/ServeConfig.php
@@ -0,0 +1,226 @@
+.
+*/
+
+namespace Tailscale;
+
+class ServeConfig
+{
+ private \stdClass $config;
+
+ private const CONFIG_FILE = '/boot/config/plugins/tailscale/funnel.json';
+
+ public function __construct(?\stdClass $config = null)
+ {
+ $this->config = $config ?? new \stdClass();
+ }
+
+ public function configureFunnel(string $hostname, string $port, string $target): void
+ {
+ // Validate the hostname
+ if ( ! filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
+ throw new \InvalidArgumentException("Invalid hostname: {$hostname}");
+ }
+
+ // Validate the port
+ if ( ! is_numeric($port) || (int)$port < 1 || (int)$port > 65535) {
+ throw new \InvalidArgumentException("Invalid port: {$port}");
+ }
+
+ $hostAndPort = "{$hostname}:{$port}";
+
+ // Ensure TCP exists
+ if ( ! isset($this->config->TCP)) {
+ $this->config->TCP = new \stdClass();
+ }
+ // Ensure the specific port exists
+ if ( ! isset($this->config->TCP->{$port})) {
+ $this->config->TCP->{$port} = new \stdClass();
+ }
+ $this->config->TCP->{$port}->HTTPS = true;
+
+ // Ensure Web exists
+ if ( ! isset($this->config->Web)) {
+ $this->config->Web = new \stdClass();
+ }
+ // Ensure the specific hostAndPort exists
+ if ( ! isset($this->config->Web->{$hostAndPort})) {
+ $this->config->Web->{$hostAndPort} = new \stdClass();
+ }
+ // Ensure Handlers exists
+ if ( ! isset($this->config->Web->{$hostAndPort}->Handlers)) {
+ $this->config->Web->{$hostAndPort}->Handlers = new \stdClass();
+ }
+ // Ensure the root handler exists
+ if ( ! isset($this->config->Web->{$hostAndPort}->Handlers->{'/'})) {
+ $this->config->Web->{$hostAndPort}->Handlers->{'/'} = new \stdClass();
+ }
+ $this->config->Web->{$hostAndPort}->Handlers->{'/'}->Proxy = $target;
+
+ // Ensure AllowFunnel exists
+ if ( ! isset($this->config->AllowFunnel)) {
+ $this->config->AllowFunnel = new \stdClass();
+ }
+ $this->config->AllowFunnel->{$hostAndPort} = true;
+ }
+
+ public function getConfig(): \stdClass
+ {
+ return $this->config;
+ }
+
+ public function removeServeByPort(string $port): void
+ {
+ // Remove TCP configuration for the port if it exists
+ if (isset($this->config->TCP->{$port})) {
+ unset($this->config->TCP->{$port});
+ }
+
+ // Remove Web configurations matching this port
+ if (isset($this->config->Web)) {
+ foreach ($this->config->Web as $fqdn => $_) {
+ if (str_ends_with($fqdn, ":{$port}")) {
+ unset($this->config->Web->{$fqdn});
+
+ // Also remove corresponding AllowFunnel entry
+ if (isset($this->config->AllowFunnel->{$fqdn})) {
+ unset($this->config->AllowFunnel->{$fqdn});
+ }
+ }
+ }
+ }
+ }
+
+ public function removeFunnel(string $hostname, string $port): void
+ {
+ $hostAndPort = "{$hostname}:{$port}";
+
+ // Remove from TCP if it exists
+ if (isset($this->config->TCP->{$port})) {
+ unset($this->config->TCP->{$port});
+ }
+
+ // Remove from Web if it exists
+ if (isset($this->config->Web->{$hostAndPort})) {
+ unset($this->config->Web->{$hostAndPort});
+ }
+
+ // Remove from AllowFunnel if it exists
+ if (isset($this->config->AllowFunnel->{$hostAndPort})) {
+ unset($this->config->AllowFunnel->{$hostAndPort});
+ }
+ }
+
+ public function resetFunnel(): void
+ {
+ if (isset($this->config->AllowFunnel)) {
+ unset($this->config->AllowFunnel);
+ }
+ }
+
+ public function hasFunnel(): bool
+ {
+ return isset($this->config->AllowFunnel);
+ }
+
+ public function saveWebguiPort(string $port): void
+ {
+ if ($port != "") {
+ $backupCfg = array('webgui_port' => $port);
+ file_put_contents(self::CONFIG_FILE, json_encode($backupCfg));
+ } else {
+ if (file_exists(self::CONFIG_FILE)) {
+ unlink(self::CONFIG_FILE);
+ }
+ }
+ }
+
+ public function getWebguiPort(): ?string
+ {
+ if ( ! file_exists(self::CONFIG_FILE)) {
+ return null;
+ }
+
+ $backupCfg = json_decode(file_get_contents(self::CONFIG_FILE) ?: "", true);
+
+ if ($backupCfg === null || ! is_array($backupCfg)) {
+ return null;
+ }
+
+ if ( ! isset($backupCfg['webgui_port']) || ! is_string($backupCfg['webgui_port'])) {
+ return null;
+ }
+
+ return $backupCfg['webgui_port'];
+ }
+
+ public function getFunnelPort(string $hostname, ?string $port = null): ?string
+ {
+ $serveConfig = $this->config;
+ if ( ! isset($serveConfig->AllowFunnel) || ! $serveConfig->AllowFunnel) {
+ return null; // Funnel not enabled
+ }
+
+ if ($port === null) {
+ // Get the expected target from ident.cfg
+ $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array();
+ if ( ! isset($identCfg['PORT'])) {
+ return null; // Can't determine expected target without ident.cfg PORT
+ }
+ $port = $identCfg['PORT'];
+ }
+
+ return $this->searchFunnelPort($hostname, $port);
+ }
+
+ private function searchFunnelPort(string $hostname, string $port): ?string
+ {
+ $serveConfig = $this->config;
+ $expectedTarget = "http://localhost:" . $port;
+
+ // Get the FQDN (DNS name without trailing dot)
+ $fqdn = trim($hostname, ".");
+
+ // Look for a funnel entry that matches our FQDN and has a corresponding Web entry
+ foreach ($serveConfig->AllowFunnel as $hostAndPort => $_) {
+ // Check if this entry starts with our FQDN followed by a colon and port
+ if (str_starts_with($hostAndPort, $fqdn . ":")) {
+ // Verify this is an "old serve" funnel by checking for a Web entry
+ if (isset($serveConfig->Web->{$hostAndPort})) {
+ // Verify the target matches the expected ident.cfg target
+ if (isset($serveConfig->Web->{$hostAndPort}->Handlers->{'/'}->Proxy) && $serveConfig->Web->{$hostAndPort}->Handlers->{'/'}->Proxy === $expectedTarget) {
+ // Extract the port from the hostAndPort
+ $parts = explode(":", strval($hostAndPort));
+ if (count($parts) == 2 && is_numeric($parts[1])) {
+ return strval($parts[1]);
+ }
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public function updateWebProxy(string $hostAndPort, string $newTarget): void
+ {
+ if (isset($this->config->Web->{$hostAndPort}->Handlers->{'/'}->Proxy)) {
+ $this->config->Web->{$hostAndPort}->Handlers->{'/'}->Proxy = $newTarget;
+ }
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/StatusInfo.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/StatusInfo.php
new file mode 100644
index 0000000..102bd87
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/StatusInfo.php
@@ -0,0 +1,33 @@
+.
+*/
+
+namespace Tailscale;
+
+class StatusInfo
+{
+ public ?LockInfo $LockInfo = null;
+ public string $TsVersion = "";
+ public string $KeyExpiration = "";
+ public string $Online = "";
+ public string $InNetMap = "";
+ public string $Tags = "";
+ public string $LoggedIn = "";
+ public string $TsHealth = "";
+ public string $LockEnabled = "";
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php
new file mode 100644
index 0000000..adb39f3
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/System.php
@@ -0,0 +1,464 @@
+.
+*/
+
+namespace Tailscale;
+
+use EDACerton\PluginUtils\Translator;
+
+enum NotificationType: string
+{
+ case NORMAL = 'normal';
+ case WARNING = 'warning';
+ case ALERT = 'alert';
+}
+
+class System extends \EDACerton\PluginUtils\System
+{
+ public const RESTART_COMMAND = "/usr/local/emhttp/webGui/scripts/reload_services";
+ public const NOTIFY_COMMAND = "/usr/local/emhttp/webGui/scripts/notify";
+
+ public static function addToHostFile(\stdClass $status): void
+ {
+ // Add self to /etc/hosts
+ if (isset($status->Self->DNSName) && isset($status->Self->TailscaleIPs) && is_array($status->Self->TailscaleIPs)) {
+ foreach ($status->Self->TailscaleIPs as $ip) {
+ if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ Utils::logwrap("Adding self {$status->Self->DNSName} with IP {$ip} to hosts file");
+ self::updateHostsFile(rtrim($status->Self->DNSName, '.'), $ip);
+ }
+ }
+ } else {
+ Utils::logwrap("Self DNSName or TailscaleIPs not found, skipping self addition to hosts file.");
+ }
+
+ // Add all peers to /etc/hosts, except those with the tag 'tag:mullvad-exit-node'
+ if (isset($status->Peer) && is_object($status->Peer)) {
+ foreach ((array)$status->Peer as $k => $peer) {
+ if ( ! ($peer instanceof \stdClass)) {
+ continue;
+ }
+ if (isset($peer->Tags) && is_array($peer->Tags) && in_array('tag:mullvad-exit-node', $peer->Tags, true)) {
+ continue;
+ }
+ if (isset($peer->DNSName) && isset($peer->TailscaleIPs) && is_array($peer->TailscaleIPs)) {
+ foreach ($peer->TailscaleIPs as $ip) {
+ if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ Utils::logwrap("Adding peer {$peer->DNSName} with IP {$ip} to hosts file");
+ self::updateHostsFile(rtrim($peer->DNSName, '.'), $ip);
+ }
+ }
+ }
+ }
+ } else {
+ Utils::logwrap("No peers found to add to hosts file.");
+ }
+ }
+
+ public static function fixLocalSubnetRoutes(): void
+ {
+ $ips = parse_ini_file("/boot/config/network.cfg") ?: array();
+ if (array_key_exists(('IPADDR'), $ips)) {
+ $route_table = Utils::runwrap("ip route list table 52", false, false);
+
+ $ipaddr = is_array($ips['IPADDR']) ? $ips['IPADDR'] : array($ips['IPADDR']);
+
+ foreach ($ipaddr as $ip) {
+ foreach ($route_table as $route) {
+ $net = explode(' ', $route)[0];
+ if (Utils::ip4_in_network($ip, $net)) {
+ Utils::logwrap("Detected local IP {$ip} in Tailscale route {$net}, removing");
+ Utils::runwrap("ip route del '{$net}' dev tailscale1 table 52");
+ }
+ }
+ }
+ }
+ }
+
+ public static function checkWebgui(Config $config, string $tailscale_ipv4, bool $allowRestart): bool
+ {
+ // Make certain that the WebGUI is listening on the Tailscale interface
+ if ($config->IncludeInterface) {
+ $ident_config = parse_ini_file("/boot/config/ident.cfg") ?: array();
+
+ $connection = @fsockopen($tailscale_ipv4, $ident_config['PORT']);
+
+ if (is_resource($connection)) {
+ Utils::logwrap("WebGUI listening on {$tailscale_ipv4}:{$ident_config['PORT']}", false, true);
+ } else {
+ if ( ! $allowRestart) {
+ Utils::logwrap("WebGUI not listening on {$tailscale_ipv4}:{$ident_config['PORT']}, waiting for next check");
+ return true;
+ }
+
+ Utils::logwrap("WebGUI not listening on {$tailscale_ipv4}:{$ident_config['PORT']}, terminating and restarting");
+ Utils::runwrap("/etc/rc.d/rc.nginx term");
+ sleep(5);
+ Utils::runwrap("/etc/rc.d/rc.nginx start");
+ }
+ }
+
+ return false;
+ }
+
+ public static function checkServeConfig(Config $config): void
+ {
+ $ident_config = parse_ini_file("/boot/config/ident.cfg") ?: array();
+
+ $httpPort = isset($ident_config['PORT']) && is_scalar($ident_config['PORT'])
+ ? intval($ident_config['PORT']) : 80;
+ $httpsPort = isset($ident_config['PORTSSL']) && is_scalar($ident_config['PORTSSL'])
+ ? intval($ident_config['PORTSSL']) : 443;
+
+ $localAPI = new LocalAPI();
+ $serveConfig = $localAPI->getServeConfig();
+
+ $tcpConfig = $serveConfig->getConfig()->TCP ?? array();
+
+ foreach ($tcpConfig as $key => $val) {
+ $configPort = intval($key);
+
+ if ($configPort == $httpPort || $configPort == $httpsPort) {
+ Utils::logwrap("Serve TCP Port {$configPort} conflicts with WebGUI, removing");
+ self::sendNotification(
+ "Tailscale Serve Port Conflict",
+ "Tailscale Serve Port Conflict",
+ "Port {$configPort} conflicts with WebGUI port. The Tailscale serve config is being updated to remove the conflict.",
+ NotificationType::ALERT
+ );
+
+ $serveConfig->removeServeByPort($key);
+ $localAPI->setServeConfig($serveConfig);
+
+ Utils::runwrap(self::RESTART_COMMAND);
+
+ return;
+ } else {
+ Utils::logwrap("Checked for WebGUI conflict with serve TCP Port {$configPort}", false, true);
+ }
+ }
+
+ // Check if serveConfig has an AllowFunnel property. If this exists, but Config->AllowFunnel is false, reset the config.
+ // This should only be done if the Unraid version is 7.2 or later, as earlier versions do not display the config setting.
+ $vars = parse_ini_file('/usr/local/emhttp/state/var.ini');
+ if (version_compare($vars['version'] ?? "", '7.2', '>=')) {
+ if ($serveConfig->hasFunnel() && $config->AllowFunnel === false) {
+ Utils::logwrap("Tailscale funnel is enabled, but config does not allow it, resetting serve config");
+
+ // Get the hostname and funnel port, then remove the funnel from the serve config
+ // We need an Info object to get the hostname
+ $info = new Info(null);
+
+ $hostname = trim($info->getDNSName(), ".");
+ $currentFunnelPort = $serveConfig->getFunnelPort($hostname);
+
+ if ($currentFunnelPort != '') {
+ $serveConfig->removeFunnel($hostname, $currentFunnelPort);
+ }
+
+ // Remove any remaining funnels, but leave the serve part
+ $serveConfig->resetFunnel();
+ $localAPI->setServeConfig($serveConfig);
+ }
+ }
+ }
+
+ public static function restartSystemServices(Config $config): void
+ {
+ if ($config->IncludeInterface) {
+ self::refreshWebGuiCert(false);
+
+ Utils::runwrap(self::RESTART_COMMAND);
+ }
+
+ if (file_exists('/etc/rc.d/rc.tsidp')) {
+ Utils::runwrap('/etc/rc.d/rc.tsidp restart');
+ }
+ }
+
+ public static function enableIPForwarding(Config $config): void
+ {
+ if ($config->Enable) {
+ Utils::logwrap("Enabling IP forwarding");
+ $sysctl = "net.ipv4.ip_forward = 1" . PHP_EOL . "net.ipv6.conf.all.forwarding = 1";
+ file_put_contents('/etc/sysctl.d/99-tailscale.conf', $sysctl);
+ Utils::runwrap("sysctl -p /etc/sysctl.d/99-tailscale.conf", true);
+ }
+ }
+
+ public static function applyGRO(): void
+ {
+ /** @var array> $ip_route */
+ $ip_route = (array) json_decode(implode(Utils::runwrap('ip -j route get 8.8.8.8')), true);
+
+ // Check if a device was returned
+ if ( ! isset($ip_route[0]['dev'])) {
+ Utils::logwrap("Default interface could not be detected.");
+ return;
+ }
+
+ $dev = $ip_route[0]['dev'];
+
+ /** @var array> $ethtool */
+ $ethtool = ((array) json_decode(implode(Utils::runwrap("ethtool --json -k {$dev}")), true))[0];
+
+ if (isset($ethtool['rx-udp-gro-forwarding']) && ! $ethtool['rx-udp-gro-forwarding']['active']) {
+ Utils::runwrap("ethtool -K {$dev} rx-udp-gro-forwarding on");
+ }
+
+ if (isset($ethtool['rx-gro-list']) && $ethtool['rx-gro-list']['active']) {
+ Utils::runwrap("ethtool -K {$dev} rx-gro-list off");
+ }
+ }
+
+ public static function notifyOnKeyExpiration(): void
+ {
+ $localAPI = new LocalAPI();
+ $status = $localAPI->getStatus();
+
+ if (isset($status->Self->KeyExpiry)) {
+ $expiryTime = new \DateTime($status->Self->KeyExpiry);
+ $expiryTime->setTimezone(new \DateTimeZone(date_default_timezone_get()));
+ $interval = $expiryTime->diff(new \DateTime('now'));
+
+ $expiryPrint = $expiryTime->format(\DateTimeInterface::RFC7231);
+ $intervalPrint = $interval->format('%a');
+
+ $message = "The Tailscale key will expire in {$intervalPrint} days on {$expiryPrint}.";
+ Utils::logwrap($message);
+
+ switch (true) {
+ case $interval->days <= 7:
+ $priority = NotificationType::ALERT;
+ break;
+ case $interval->days <= 30:
+ $priority = NotificationType::WARNING;
+ break;
+ default:
+ return;
+ }
+
+ $event = "Tailscale Key Expiration - {$priority->value} - {$expiryTime->format('Ymd')}";
+ Utils::logwrap("Sending notification for key expiration: {$event}");
+ self::sendNotification($event, "Tailscale key is expiring", $message, $priority);
+ } else {
+ Utils::logwrap("Tailscale key expiration is not set.");
+ }
+ }
+
+ public static function sendNotification(string $event, string $subject, string $message, NotificationType $priority): void
+ {
+ $command = self::NOTIFY_COMMAND . " -l '/Settings/Tailscale' -e " . escapeshellarg($event) . " -s " . escapeshellarg($subject) . " -d " . escapeshellarg("{$message}") . " -i \"{$priority->value}\" -x 2>/dev/null";
+ exec($command);
+ }
+
+ public static function refreshWebGuiCert(bool $restartIfChanged = true): void
+ {
+ $localAPI = new LocalAPI();
+ $status = $localAPI->getStatus();
+
+ $certDomains = $status->CertDomains;
+
+ if (count($certDomains ?? array()) === 0) {
+ Utils::logwrap("Cannot generate certificate for WebGUI -- HTTPS not enabled for Tailnet.");
+ return;
+ }
+
+ $dnsName = $certDomains[0];
+
+ $certFile = "/boot/config/plugins/tailscale/state/certs/{$dnsName}.crt";
+ $keyFile = "/boot/config/plugins/tailscale/state/certs/{$dnsName}.key";
+ $pemFile = "/boot/config/ssl/certs/ts_bundle.pem";
+
+ clearstatcache();
+
+ $pemHash = '';
+ if (file_exists($pemFile)) {
+ $pemHash = sha1_file($pemFile);
+ }
+
+ Utils::logwrap("Certificate bundle hash: {$pemHash}");
+
+ Utils::runwrap("tailscale cert --cert-file={$certFile} --key-file={$keyFile} --min-validity=720h {$dnsName}");
+
+ if (
+ file_exists($certFile) && file_exists($keyFile) && filesize($certFile) > 0 && filesize($keyFile) > 0
+ ) {
+ file_put_contents($pemFile, file_get_contents($certFile));
+ file_put_contents($pemFile, file_get_contents($keyFile), FILE_APPEND);
+
+ if ((sha1_file($pemFile) != $pemHash) && $restartIfChanged) {
+ Utils::logwrap("WebGUI certificate has changed, restarting nginx");
+ Utils::runwrap("/etc/rc.d/rc.nginx reload");
+ }
+ } else {
+ Utils::logwrap("Something went wrong when creating WebGUI certificate, skipping nginx update.");
+ }
+ }
+
+ public static function setExtraInterface(Config $config): void
+ {
+ if (file_exists(self::RESTART_COMMAND)) {
+ $include_array = array();
+ $exclude_interfaces = "";
+ $write_file = true;
+ $network_extra_file = '/boot/config/network-extra.cfg';
+ $ifname = 'tailscale1';
+
+ if (file_exists($network_extra_file)) {
+ $netExtra = parse_ini_file($network_extra_file);
+ if ($netExtra['include_interfaces'] ?? false) {
+ $include_array = explode(' ', $netExtra['include_interfaces']);
+ }
+ if ($netExtra['exclude_interfaces'] ?? false) {
+ $exclude_interfaces = $netExtra['exclude_interfaces'];
+ }
+ $write_file = false;
+ }
+
+ $in_array = in_array($ifname, $include_array);
+
+ if ($in_array != $config->IncludeInterface) {
+ if ($config->IncludeInterface) {
+ $include_array[] = $ifname;
+ Utils::logwrap("{$ifname} added to include_interfaces");
+ } else {
+ $include_array = array_diff($include_array, [$ifname]);
+ Utils::logwrap("{$ifname} removed from include_interfaces");
+ }
+ $write_file = true;
+ }
+
+ if ($write_file) {
+ $include_interfaces = implode(' ', $include_array);
+
+ $file = <<patchPref($flag, false);
+ }
+ }
+
+ public static function applyTailscaleConfig(Config $config): void
+ {
+ $localAPI = new LocalAPI();
+
+ self::disableTailscaleFeature($localAPI, $config->AllowRoutes, 'RouteAll');
+ self::disableTailscaleFeature($localAPI, $config->AllowDNS, 'CorpDNS');
+
+ $localAPI->patchPref('NoStatefulFiltering', true);
+ }
+
+ public static function createTailscaledParamsFile(Config $config): void
+ {
+ $custom_params = "";
+
+ if ($config->WgPort > 0 && $config->WgPort < 65535) {
+ $custom_params .= "-port {$config->WgPort} ";
+ }
+
+ if ($config->NoLogsNoSupport) {
+ $custom_params .= "-no-logs-no-support ";
+ }
+
+ if ( ! $config->UseTPM) {
+ $custom_params .= "-encrypt-state=false -hardware-attestation=false ";
+ }
+
+ file_put_contents('/usr/local/emhttp/plugins/tailscale/custom-params.sh', 'TAILSCALE_CUSTOM_PARAMS="' . $custom_params . '"');
+ }
+
+ public static function createTaildropLink(Config $config): void
+ {
+ $linkPath = '/var/lib/tailscale/Taildrop';
+ if (is_link($linkPath) || file_exists($linkPath)) {
+ unlink($linkPath);
+ }
+
+ if ( ! empty($config->TaildropDir) && is_dir($config->TaildropDir) && is_writable($config->TaildropDir)) {
+ // Create parent directory if it does not exist
+ $parentDir = dirname($linkPath);
+ if ( ! is_dir($parentDir)) {
+ mkdir($parentDir, 0755, true);
+ }
+
+ if (symlink($config->TaildropDir, $linkPath)) {
+ Utils::logwrap("Created Taildrop link from {$linkPath} to {$config->TaildropDir}");
+ } else {
+ Utils::logwrap("Failed to create Taildrop link from {$linkPath} to {$config->TaildropDir}");
+ }
+ } else {
+ Utils::logwrap("Taildrop directory is not set, does not exist, or is not writable, skipping link creation.");
+ }
+ }
+
+ public static function checkFunnelPort(Config $config): void
+ {
+ if ( ! $config->AllowFunnel) {
+ return;
+ }
+
+ // Check if the current port from ident.cfg matches the saved port in the ServeConfig
+ $localAPI = new LocalAPI();
+ $serveConfig = $localAPI->getServeConfig();
+ $tailscaleInfo = new Info(null);
+ $hostname = trim($tailscaleInfo->getDNSName(), ".");
+
+ // Get the current port from ident.cfg
+ $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array();
+ if ( ! isset($identCfg['PORT'])) {
+ return; // Can't determine expected target without ident.cfg PORT
+ }
+ $currentPort = $identCfg['PORT'];
+
+ $savedPort = $serveConfig->getWebguiPort();
+ if ($savedPort === null) {
+ return;
+ }
+
+ if ($currentPort !== $savedPort) {
+ Utils::logwrap("WebGUI port has changed from {$savedPort} to {$currentPort}, updating funnel configuration");
+
+ $funnelPort = $serveConfig->getFunnelPort($hostname, $savedPort);
+ if ($funnelPort === null) {
+ Utils::logwrap("Could not retrieve funnel port, skipping update");
+ return;
+ }
+
+ $serveConfig->updateWebProxy("{$hostname}:{$funnelPort}", "http://localhost:{$currentPort}");
+ $localAPI->setServeConfig($serveConfig);
+
+ // Update the saved port to the current port
+ $serveConfig->saveWebguiPort($currentPort);
+ }
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Utils.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Utils.php
new file mode 100644
index 0000000..f3043d8
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Utils.php
@@ -0,0 +1,203 @@
+.
+*/
+
+namespace Tailscale;
+
+use PhpIP\IPBlock;
+use EDACerton\PluginUtils\Translator;
+
+class Utils extends \EDACerton\PluginUtils\Utils
+{
+ public function setPHPDebug(): void
+ {
+ $debug = file_exists("/boot/config/plugins/tailscale/debug");
+
+ if ($debug && ! defined("PLUGIN_DEBUG")) {
+ error_reporting(E_ALL);
+ define("PLUGIN_DEBUG", true);
+ }
+ }
+
+ public static function printRow(string $title, string $value): string
+ {
+ return "{$title} {$value} " . PHP_EOL;
+ }
+
+ public static function printDash(string $title, string $value): string
+ {
+ return "{$title} {$value} " . PHP_EOL;
+ }
+
+ public static function formatWarning(?Warning $warning): string
+ {
+ if ($warning == null) {
+ return "";
+ }
+
+ return "" . $warning->Message . " ";
+ }
+
+ public static function ip4_in_network(string $ip, string $network): bool
+ {
+ if (strpos($network, '/') === false) {
+ return false;
+ }
+
+ list($subnet, $mask) = explode('/', $network, 2);
+ $ip_bin_string = sprintf("%032b", ip2long($ip));
+ $net_bin_string = sprintf("%032b", ip2long($subnet));
+
+ return (substr_compare($ip_bin_string, $net_bin_string, 0, intval($mask)) === 0);
+ }
+
+ public static function logwrap(string $message, bool $debug = false, bool $rateLimit = false): void
+ {
+ if ( ! defined(__NAMESPACE__ . "\PLUGIN_NAME")) {
+ throw new \RuntimeException("PLUGIN_NAME is not defined.");
+ }
+ $utils = new Utils(PLUGIN_NAME);
+ $utils->logmsg($message, $debug, $rateLimit);
+ }
+
+ /**
+ * @return array
+ */
+ public static function runwrap(string $command, bool $alwaysShow = false, bool $show = true): array
+ {
+ if ( ! defined(__NAMESPACE__ . "\PLUGIN_NAME")) {
+ throw new \RuntimeException("PLUGIN_NAME is not defined.");
+ }
+ $utils = new Utils(PLUGIN_NAME);
+ return $utils->run_command($command, $alwaysShow, $show);
+ }
+
+ public static function validateCidr(string $cidr): bool
+ {
+ try {
+ $block = IPBlock::create($cidr);
+
+ // Check that the IP address is the network address (host bits are zero)
+ return $block->getNetworkAddress()->humanReadable() . '/' . $block->getPrefixLength() === $cidr;
+ } catch (\Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public static function getExitRoutes(): array
+ {
+ return ["0.0.0.0/0", "::/0"];
+ }
+
+ public static function isFunnelAllowed(): bool
+ {
+ $directives = ["allow 127.0.0.1;", "allow ::1;"];
+
+ $nginxConfig = file_get_contents('/etc/nginx/nginx.conf');
+ if ($nginxConfig === false) {
+ return false; // Unable to read the nginx configuration file
+ }
+
+ // Search $nginxConfig for the allow directives.
+ foreach ($directives as $directive) {
+ if (strpos($nginxConfig, $directive) !== false) {
+ return false; // Directive found, funnel not safe to use
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a list of ports that are currently assigned to services.
+ * This is a best-effort approach, especially since docker might not be running during configuration.
+ *
+ * @return array
+ */
+ public function get_assigned_ports(): array
+ {
+ $ports = array();
+ $identCfg = parse_ini_file("/boot/config/ident.cfg", false, INI_SCANNER_RAW) ?: array();
+ if (isset($identCfg['PORT'])) {
+ $ports[] = intval($identCfg['PORT']);
+ }
+ if (isset($identCfg['PORTSSL']) && isset($identCfg['USE_SSL']) && $identCfg['USE_SSL'] === 'yes') {
+ $ports[] = intval($identCfg['PORTSSL']);
+ }
+ if (isset($identCfg['PORTTELNET']) && isset($identCfg['USE_TELNET']) && $identCfg['USE_TELNET'] === 'yes') {
+ $ports[] = intval($identCfg['PORTTELNET']);
+ }
+ if (isset($identCfg['PORTSSH']) && isset($identCfg['USE_SSH']) && $identCfg['USE_SSH'] === 'yes') {
+ $ports[] = intval($identCfg['PORTSSH']);
+ }
+
+ // Get any open TCP ports from the system
+ $netstatOutput = shell_exec("netstat -tuln | grep LISTEN");
+ if ($netstatOutput) {
+ $lines = explode("\n", trim($netstatOutput));
+ foreach ($lines as $line) {
+ if (preg_match('/:(\d+)\s+/', $line, $matches)) {
+ $port = intval($matches[1]);
+ if ($port > 0 && $port < 65536) {
+ $ports[] = $port;
+ }
+ }
+ }
+ }
+
+ return array_unique($ports);
+ }
+
+ public static function pageChecks(Translator $tr): bool
+ {
+ static $config = null;
+ static $localAPI = null;
+
+ if ($config === null) {
+ $config = new Config();
+ }
+ if ($localAPI === null) {
+ $localAPI = new LocalAPI();
+ }
+
+ if ( ! $config->Enable) {
+ echo($tr->tr("tailscale_disabled"));
+ return false;
+ }
+
+ if ( ! $localAPI->isReady()) {
+ echo($tr->tr("warnings.not_ready"));
+ echo(<<
+ $(function() {
+ setTimeout(function() {
+ window.location = window.location.href;
+ }, 5000);
+ });
+
+ EOT
+ );
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Warning.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Warning.php
new file mode 100644
index 0000000..366f900
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Warning.php
@@ -0,0 +1,32 @@
+.
+*/
+
+namespace Tailscale;
+
+class Warning
+{
+ public string $Message;
+ public string $Priority;
+
+ public function __construct(string $message = "", string $priority = "system")
+ {
+ $this->Message = $message;
+ $this->Priority = $priority;
+ }
+}
diff --git a/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php
new file mode 100644
index 0000000..d04aeb9
--- /dev/null
+++ b/src/usr/local/php/unraid-tailscale-utils/unraid-tailscale-utils/Watcher.php
@@ -0,0 +1,109 @@
+.
+*/
+
+namespace Tailscale;
+
+class Watcher
+{
+ private Config $config;
+
+ public function __construct()
+ {
+ $this->config = new Config();
+ }
+
+ public function run(): void
+ {
+ $timer = 15;
+ $need_ip = true;
+ $allow_check_restart = false;
+
+ $tsName = '';
+
+ if ( ! defined(__NAMESPACE__ . '\PLUGIN_ROOT') || ! defined(__NAMESPACE__ . '\PLUGIN_NAME')) {
+ throw new \RuntimeException("Common file not loaded.");
+ }
+ $utils = new Utils(PLUGIN_NAME);
+
+ $utils->logmsg("Starting tailscale-watcher");
+
+ while ( ! file_exists('/var/local/emhttp/var.ini')) {
+ $utils->logmsg("Waiting for system to finish booting");
+ sleep(10);
+ }
+
+ // @phpstan-ignore while.alwaysTrue
+ while (true) {
+ unset($tailscale_ipv4);
+
+ $interfaces = net_get_interfaces();
+
+ if (isset($interfaces["tailscale1"]["unicast"])) {
+ foreach ($interfaces["tailscale1"]["unicast"] as $interface) {
+ if (isset($interface["address"])) {
+ if ($interface["family"] == 2) {
+ $tailscale_ipv4 = $interface["address"];
+ $timer = 60;
+ }
+ }
+ }
+ }
+
+ if (isset($tailscale_ipv4)) {
+ if ($need_ip) {
+ $utils->logmsg("Tailscale IP detected, applying configuration");
+ $need_ip = false;
+
+ $localAPI = new LocalAPI();
+ $status = $localAPI->getStatus();
+ $tsName = $status->Self->DNSName;
+
+ $utils->run_task('Tailscale\System::applyTailscaleConfig', array($this->config));
+ $utils->run_task('Tailscale\System::applyGRO');
+ $utils->run_task('Tailscale\System::restartSystemServices', array($this->config));
+ if ($this->config->AddPeersToHosts) {
+ $utils->run_task('Tailscale\System::addToHostFile', array($status));
+ }
+ }
+
+ $allow_check_restart = $utils->run_task('Tailscale\System::checkWebgui', array($this->config, $tailscale_ipv4, $allow_check_restart));
+ $utils->run_task('Tailscale\System::checkServeConfig', array($this->config));
+ $utils->run_task('Tailscale\System::fixLocalSubnetRoutes');
+ $utils->run_task('Tailscale\System::checkFunnelPort', array($this->config));
+
+ // Watch for changes to the DNS name (e.g., if someone changes the tailnet name or the Tailscale name of the server via the admin console)
+ // If a change happens, refresh the Tailscale WebGUI certificate
+ $localAPI = new LocalAPI();
+ $status = $localAPI->getStatus();
+ $newTsName = $status->Self->DNSName;
+
+ if ($newTsName != $tsName) {
+ $utils->logmsg("Detected DNS name change");
+ $tsName = $newTsName;
+
+ $utils->run_task('Tailscale\System::refreshWebGuiCert');
+ }
+ } else {
+ $utils->logmsg("Waiting for Tailscale IP");
+ }
+
+ sleep($timer);
+ }
+ }
+}
diff --git a/tools/build-plugin.sh b/tools/build-plugin.sh
deleted file mode 100755
index d37e214..0000000
--- a/tools/build-plugin.sh
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/bin/sh
-
-FILENAME=tailscale; jinja -d plugin/tailscale.json -D filename $FILENAME -D branch main plugin/tailscale.j2 > ../plugin/$FILENAME.plg
-FILENAME=tailscale-preview; jinja -d plugin/tailscale.json -D filename $FILENAME -D branch preview plugin/tailscale.j2 > ../plugin/$FILENAME.plg
-FILENAME=tailscale-trunk; jinja -d plugin/tailscale.json -D filename $FILENAME -D branch trunk plugin/tailscale.j2 > ../plugin/$FILENAME.plg
\ No newline at end of file
diff --git a/tools/plugin/files/CHANGELOG.md b/tools/plugin/files/CHANGELOG.md
deleted file mode 100644
index b8d250e..0000000
--- a/tools/plugin/files/CHANGELOG.md
+++ /dev/null
@@ -1,17 +0,0 @@
-###2025.05.25###
-- Remove usage reporting
-
-###2025.05.21###
-
-- Update Tailscale to 1.84.0
-- Update localization files
-
-###2025.05.08###
-
-- Fix: advertising exit node on 7.1
-
-###2025.05.06a###
-
-- Monitor serve config for port conflicts with WebGUI
-
-For older releases, see https://github.com/unraid/unraid-tailscale/releases
diff --git a/tools/plugin/files/install.sh b/tools/plugin/files/install.sh
deleted file mode 100644
index 1705cf1..0000000
--- a/tools/plugin/files/install.sh
+++ /dev/null
@@ -1,73 +0,0 @@
-if [ -d "{{ pluginDirectory }}" ]; then
- rm -rf {{ pluginDirectory }}
-fi
-
-upgradepkg --install-new --reinstall {{ configDirectory }}/unraid-tailscale-utils-{{ packageVersion }}-noarch-1.txz
-
-mkdir -p {{ pluginDirectory }}/bin
-tar xzf {{ configDirectory }}/{{ tailscaleVersion }}.tgz --strip-components 1 -C {{ pluginDirectory }}/bin
-
-echo "state" > {{ configDirectory }}/.gitignore
-
-ln -s {{ pluginDirectory }}/bin/tailscale /usr/local/sbin/tailscale
-ln -s {{ pluginDirectory }}/bin/tailscaled /usr/local/sbin/tailscaled
-
-mkdir -p /var/local/emhttp/plugins/tailscale
-echo "VERSION={{ version }}" > /var/local/emhttp/plugins/tailscale/tailscale.ini
-echo "BRANCH={{ branch }}" >> /var/local/emhttp/plugins/tailscale/tailscale.ini
-
-# remove other branches (e.g., if switching from main to preview)
-{% if branch != 'main' -%}
-rm -f /boot/config/plugins/tailscale.plg
-rm -f /var/log/plugins/tailscale.plg
-{% endif -%}
-{% if branch != 'preview' -%}
-rm -f /boot/config/plugins/tailscale-preview.plg
-rm -f /var/log/plugins/tailscale-preview.plg
-{% endif -%}
-{% if branch != 'trunk' -%}
-rm -f /boot/config/plugins/tailscale-trunk.plg
-rm -f /var/log/plugins/tailscale-trunk.plg
-{% endif %}
-
-{% if branch != 'main' -%}
-# Update plugin name for non-main branches
-sed -i "s/Tailscale\*\*/Tailscale ({{ branch.capitalize() }})**/" {{ pluginDirectory }}/README.md
-{% endif %}
-
-# start tailscaled
-{{ pluginDirectory }}/restart.sh
-
-# Bash completion
-tailscale completion bash > /etc/bash_completion.d/tailscale
-
-# cleanup old versions
-rm -f /boot/config/plugins/{{ name }}/tailscale-utils-*.txz
-rm -f $(ls /boot/config/plugins/{{ name }}/unraid-tailscale-utils-*.txz 2>/dev/null | grep -v '{{ packageVersion }}')
-rm -f $(ls /boot/config/plugins/{{ name }}/unraid-plugin-diagnostics-*.txz 2>/dev/null)
-rm -f $(ls /boot/config/plugins/{{ name }}/*.tgz 2>/dev/null | grep -v '{{ tailscaleVersion }}')
-
-# check to see if the state file has been backed up to Unraid Connect
-if [ -d "/boot/.git" ]; then
- if git --git-dir /boot/.git log --all --name-only --diff-filter=A -- config/plugins/tailscale/state/tailscaled.state | grep -q . ; then
- echo "******************************"
- echo "* WARNING *"
- echo "******************************"
- echo " "
- echo "The Tailscale state file has been backed up to Unraid Connect via Flash backup."
- echo " "
- echo "To remove this backup, please perform the following steps:"
- echo "1. Go to Settings -> Management Access".
- echo "2. Under Unraid Connect, deactivate flash backup. Select the option to also delete cloud backup."
- echo "3. Reactivate flash backup."
-
- /usr/local/emhttp/webGui/scripts/notify -l '/Settings/ManagementAccess' -i 'alert' -e 'Tailscale State' -s 'Tailscale state backed up to Unraid connect.' -d 'The Tailscale state file has been backed up to Unraid connect. This is a potential security risk. From the Management Settings page, deactivate flash backup and delete cloud backups, then reactivate flash backup.'
- fi
-fi
-
-echo ""
-echo "----------------------------------------------------"
-echo " {{ name }} has been installed."
-echo " Version: {{ version }}"
-echo "----------------------------------------------------"
-echo ""
\ No newline at end of file
diff --git a/tools/plugin/files/remove.sh b/tools/plugin/files/remove.sh
deleted file mode 100644
index fcb1bb1..0000000
--- a/tools/plugin/files/remove.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-# Stop service
-/etc/rc.d/rc.tailscale stop 2>/dev/null
-
-rm /usr/local/sbin/tailscale
-rm /usr/local/sbin/tailscaled
-
-removepkg unraid-tailscale-utils
-
-rm -rf {{ pluginDirectory }}
-rm -f {{ configDirectory }}/*.tgz
-rm -f {{ configDirectory }}/*.txz
\ No newline at end of file
diff --git a/tools/plugin/tailscale.j2 b/tools/plugin/tailscale.j2
deleted file mode 100644
index b8a4390..0000000
--- a/tools/plugin/tailscale.j2
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-https://pkgs.tailscale.com/stable/{{ tailscaleVersion }}.tgz
-{{ tailscaleSHA256 }}
-
-
-
-https://github.com/unraid/unraid-tailscale-utils/releases/download/{{ packageVersion }}/unraid-tailscale-utils-{{ packageVersion }}-noarch-1.txz
-{{ packageSHA256 }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tools/plugin/tailscale.json b/tools/plugin/tailscale.json
deleted file mode 100644
index 452e7e0..0000000
--- a/tools/plugin/tailscale.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "name": "tailscale",
- "author": "Derek Kaser",
- "githubRepository": "unraid/unraid-tailscale",
- "version": "2025.07.07",
- "tailscaleVersion": "tailscale_1.84.0_amd64",
- "tailscaleSHA256": "c91eb43a92c209108bfaf1237696ac2089cc3d8fcf35d570d348cbfb19d8fb31",
- "packageVersion": "4.1.0",
- "packageSHA256": "97b01db93921e0b2ee58c7b47fa6246eff2c81c872f70043cf85bf33f6572a15",
- "pluginDirectory": "/usr/local/emhttp/plugins/tailscale",
- "configDirectory": "/boot/config/plugins/tailscale",
- "minver": "7.0.0"
-}
\ No newline at end of file