🌈 Sync 2026-09-24 15:19:50

This commit is contained in:
github-actions[bot] committed 2026-09-24 15:19:50 +08:00
1 parent 765ac2b267
commit 87101cc7f2
126 files changed
+4403 -2879

No files matched your search

+10 -6
View File
@@ -10,12 +10,12 @@ AGENTFLOW_ARCH_x86_64:=amd64
AGENTFLOW_ARCH_aarch64:=arm64
AGENTFLOW_ARCH:=$(AGENTFLOW_ARCH_$(ARCH))
AGENTFLOW_HASH_x86_64:=9174fec77dc835640f94ea8bef84b580b84f947823f4dd63fc495c4c58a68297
AGENTFLOW_HASH_aarch64:=256adf7bcb836259dacbf583c52af04f0134587328689a49811c135260fa5aed
AGENTFLOW_HASH_x86_64:=3bdd9b3af7a95c78d6fb6589117931e2be3f2800056fc7836aa95abe48d0a33d
AGENTFLOW_HASH_aarch64:=8962a1ec6f57ee394ddfdf61abd72191f5378644c5ab1ce85040b132b757cc43
PKG_NAME:=agentflow
PKG_VERSION:=0.3.3
PKG_RELEASE:=13
PKG_VERSION:=0.3.4
PKG_RELEASE:=14
AGENTFLOW_URL_FILE:=agentflow-linux-$(AGENTFLOW_ARCH)
AGENTFLOW_DOWNLOAD:=$(AGENTFLOW_URL_FILE)-$(AGENTFLOW_HASH_$(ARCH))
@@ -40,7 +40,7 @@ define Package/$(PKG_NAME)
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=AgentFlow
DEPENDS:=@(x86_64||aarch64) +git +git-http +mise
DEPENDS:=@(x86_64||aarch64) +git +git-http +mise +linkease-app-entry
URL:=https://agentflow.geili.ai/
endef
@@ -58,6 +58,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/30-agentflow.json 2>/dev/null)" = "/usr/share/agentflow/agentflow-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/30-agentflow.json
fi
if [ "$$(readlink /usr/share/linkease/apps.d/30-agentflow.json 2>/dev/null)" = "/usr/share/agentflow/agentflow-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/30-agentflow.json
fi
fi
exit 0
endef
@@ -74,7 +77,7 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config $(1)/usr/share/agentflow/www $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config $(1)/usr/share/agentflow/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_BIN) $(PKG_BUILD_DIR)/agentflow $(1)/usr/sbin/agentflow
$(INSTALL_BIN) ./files/agentflow.init $(1)/etc/init.d/agentflow
$(INSTALL_BIN) ./files/agentflow.uci-default $(1)/etc/uci-defaults/09-agentflow
@@ -82,6 +85,7 @@ define Package/$(PKG_NAME)/install
$(INSTALL_DATA) ./files/agentflow-plugin.json $(1)/usr/share/agentflow/agentflow-plugin.json
$(INSTALL_DATA) ./files/www/logo.svg $(1)/usr/share/agentflow/www/logo.svg
ln -sf /usr/share/agentflow/agentflow-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/30-agentflow.json
ln -sf /usr/share/agentflow/agentflow-plugin.json $(1)/usr/share/linkease/apps.d/30-agentflow.json
endef
$(eval $(call Download,agentflow))
+1 -1
View File
@@ -12,7 +12,7 @@
"standalone": {
"entry": "index.html",
"basePath": "/apps/agentflow/",
"url": "/cgi-bin/luci/admin/services/agentflow/open",
"url": "/apps/agentflow/",
"externalOpen": {
"enabled": true,
"label": "Open AgentFlow"
@@ -19,7 +19,7 @@ class DesktopManifestContractTest(unittest.TestCase):
self.assertEqual(self.manifest["standalone"]["basePath"], "/apps/agentflow/")
self.assertEqual(
self.manifest["standalone"]["url"],
"/cgi-bin/luci/admin/services/agentflow/open",
"/apps/agentflow/",
)
self.assertEqual(
self.manifest["standalone"]["externalOpen"],
+7 -3
View File
@@ -11,7 +11,7 @@ PKG_ARCH_BAIDUDRIVE:=$(ARCH)
PKG_NAME:=baidudrive
PKG_VERSION:=1.0.6
PKG_RELEASE:=21
PKG_RELEASE:=22
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/baidudrive-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
@@ -29,7 +29,7 @@ define Package/$(PKG_NAME)
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=BaiduDrive
DEPENDS:=@(x86_64||aarch64) +curl
DEPENDS:=@(x86_64||aarch64) +curl +linkease-app-entry
URL:=https://github.com/linkease/istore-packages
endef
@@ -47,6 +47,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/10-baidudrive-plugin.json 2>/dev/null)" = "/usr/share/baidudrive/baidudrive-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/10-baidudrive-plugin.json
fi
if [ "$$(readlink /usr/share/linkease/apps.d/10-baidudrive-plugin.json 2>/dev/null)" = "/usr/share/baidudrive/baidudrive-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/10-baidudrive-plugin.json
fi
fi
exit 0
endef
@@ -58,11 +61,12 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec/baidudrive $(1)/usr/share/baidudrive/www $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/opt/baidunas-sdk $(1)/opt/baidunas-glibc $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec/baidudrive $(1)/usr/share/baidudrive/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/opt/baidunas-sdk $(1)/opt/baidunas-glibc $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/etc/config
$(INSTALL_BIN) $(PKG_BUILD_DIR)/baidudrive.$(PKG_ARCH_BAIDUDRIVE) $(1)/usr/sbin/baidudrive
$(CP) $(PKG_BUILD_DIR)/web/dist/. $(1)/usr/share/baidudrive/www/
$(INSTALL_DATA) ./files/baidudrive-plugin.json $(1)/usr/share/baidudrive/baidudrive-plugin.json
ln -sf /usr/share/baidudrive/baidudrive-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/10-baidudrive-plugin.json
ln -sf /usr/share/baidudrive/baidudrive-plugin.json $(1)/usr/share/linkease/apps.d/10-baidudrive-plugin.json
$(INSTALL_BIN) $(PKG_BUILD_DIR)/nas_sdk/$(PKG_ARCH_BAIDUDRIVE)/baiduNas $(1)/opt/baidunas-sdk/baiduNas
$(INSTALL_BIN) $(PKG_BUILD_DIR)/nas_sdk/$(PKG_ARCH_BAIDUDRIVE)/P2PClient.bin $(1)/opt/baidunas-sdk/P2PClient.bin
$(INSTALL_DATA) $(PKG_BUILD_DIR)/nas_sdk/$(PKG_ARCH_BAIDUDRIVE)/libkernel.so $(1)/opt/baidunas-sdk/libkernel.so
+2 -1
View File
@@ -21,7 +21,8 @@
"defaultPort": 10780,
"upstreamBasePath": "/apps/baidudrive/",
"apiPath": "api/",
"pathMode": "preserve"
"pathMode": "preserve",
"externalBasePath": "/"
},
"window": {
"width": 1180,
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=dae
PKG_VERSION:=2026.09.20
PKG_RELEASE:=46
PKG_VERSION:=2026.09.23
PKG_RELEASE:=47
PKG_SOURCE:=dae-src-2026.09.20-2a846ee5a7e4.tar.gz
PKG_SOURCE:=dae-src-2026.09.23-c9f580bcaa8a.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip
+3 -3
View File
@@ -5,10 +5,10 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=daed
PKG_VERSION:=2026.09.20
PKG_RELEASE:=57
PKG_VERSION:=2026.09.23
PKG_RELEASE:=58
PKG_SOURCE:=daed-src-2026.09.20-d00211e69e94.tar.gz
PKG_SOURCE:=daed-src-2026.09.23-fcc80bac4542.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip
@@ -1,23 +0,0 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-06-23
Subject: [PATCH] daed: detach BPF hooks when stopping from dashboard
Detach BPF hooks when the dashboard stops dae through EmptyConfig.
Reported: https://github.com/kenzok8/openwrt-daede/issues/13
---
--- a/dae/run.go
+++ b/dae/run.go
@@ -138,6 +138,13 @@
/* dae-wing start */
newConf := newReloadMsg.Config
/* dae-wing end */
+
+ // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks.
+ if newConf == EmptyConfig {
+ if e := c.DetachBpfHooks(); e != nil {
+ log.Errorln("[Stop] DetachBpfHooks:", e)
+ }
+ }
// New logger.
oldLogOutput := log.Out
log = logrus.New()
@@ -1,18 +0,0 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-06-29
Subject: [PATCH] daed: use bounded probes for manual latency tests
Use the existing bounded probe for manual dashboard latency tests.
Reported: https://github.com/kenzok8/openwrt-daede/issues/20
---
--- a/graphql/service/node/latency_mutation_utils.go
+++ b/graphql/service/node/latency_mutation_utils.go
@@ -108,7 +108,7 @@ func testSingleNodeLatency(option *diale
}
defer d.Close()
- result, err := d.ProbeLatency()
+ result, err := d.ProbeLatencyFast()
if err != nil {
msg := err.Error()
resolver.MessageV = &msg
@@ -1,76 +1,80 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-09-23
Subject: [PATCH] daed: bound startup network wait on new wing
Return an error after 60 seconds and retry on the next generation.
---
diff --git a/dae/run.go b/dae/run.go
index d8f86c2..a0a665d 100644
index 7c9a5cc..7f00748 100644
--- a/dae/run.go
+++ b/dae/run.go
@@ -32,7 +32,8 @@ var ChReloadConfigs = make(chan *ReloadMessage)
var GracefullyExit = make(chan struct{})
var EmptyConfig *daeConfig.Config
var c *control.ControlPlane
@@ -19,6 +19,7 @@ import (
"github.com/daeuniverse/dae/control"
"github.com/daeuniverse/dae/pkg/config_parser"
"github.com/daeuniverse/dae/pkg/logger"
+ "github.com/daeuniverse/outbound/netproxy"
"github.com/daeuniverse/outbound/protocol/direct"
"github.com/mohae/deepcopy"
"github.com/sirupsen/logrus"
@@ -43,7 +44,8 @@ var EmptyConfig *daeConfig.Config
// active is the generation currently serving traffic. It is nil before the
// first generation is ready and while a reload has retired the old one.
var active atomic.Pointer[control.ControlPlane]
-var onceWaitingNetwork sync.Once
+var waitingNetworkMu sync.Mutex
+var networkReady bool
func init() {
sections, err := config_parser.Parse(`global{} routing{}`)
@@ -52,6 +53,19 @@ func ControlPlane() (*control.ControlPlane, error) {
@@ -64,6 +66,19 @@ func ControlPlane() (*control.ControlPlane, error) {
return c, nil
}
+func waitForNetwork(log *logrus.Logger) error {
+func waitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) error {
+ waitingNetworkMu.Lock()
+ defer waitingNetworkMu.Unlock()
+ if networkReady {
+ return nil
+ }
+ if err := WaitForNetwork(log); err != nil {
+ if err := WaitForNetwork(log, directDialer); err != nil {
+ return err
+ }
+ networkReady = true
+ return nil
+}
+
func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string, disableTimestamp bool, dry bool) (err error) {
defer close(GracefullyExit)
// Not really run dae.
@@ -221,6 +235,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
// Deep copy to prevent modification.
conf = deepcopy.Copy(conf).(*daeConfig.Config)
+ if bpf == nil {
+ control.PurgeStaleTCFilters(log)
+ }
// Init Direct Dialers.
direct.InitDirectDialers(conf.Global.FallbackResolver)
@@ -228,9 +245,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
// generation is one control plane with its listener. retired is set before
// the plane is torn down so a Serve error from a plane that is being replaced
// is not mistaken for the active datapath dying.
@@ -345,9 +360,9 @@ func newControlPlane(log *logrus.Logger, dnsCache map[string]*control.DnsCache,
if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 {
// Wait for network for WAN ready.
- onceWaitingNetwork.Do(func() {
- WaitForNetwork(log)
- WaitForNetwork(log, directDialers.Symmetric)
- })
+ if err = waitForNetwork(log); err != nil {
+ if err = waitForNetwork(log, directDialers.Symmetric); err != nil {
+ return nil, err
+ }
}
/// Get subscription -> nodeList mapping.
diff --git a/dae/utils.go b/dae/utils.go
index 65965a2..dc43cbc 100644
index 2b4f6bf..93efb7e 100644
--- a/dae/utils.go
+++ b/dae/utils.go
@@ -94,8 +94,9 @@ func preprocessWanInterfaceAuto(params *daeConfig.Config) error {
return nil
}
-func WaitForNetwork(log *logrus.Logger) {
+func WaitForNetwork(log *logrus.Logger) error {
-func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) {
+func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) error {
epo := 5 * time.Second
+ deadline := time.Now().Add(60 * time.Second)
client := http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (c net.Conn, err error) {
@@ -115,6 +116,9 @@ func WaitForNetwork(log *logrus.Logger) {
@@ -114,6 +115,9 @@ func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) {
}
log.Infoln("Waiting for network...")
for i := 0; ; i++ {
@@ -80,7 +84,7 @@ index 65965a2..dc43cbc 100644
resp, err := client.Get(cmd.CheckNetworkLinks[i%len(cmd.CheckNetworkLinks)])
if err != nil {
log.Debugln("CheckNetwork:", err)
@@ -134,4 +138,5 @@ func WaitForNetwork(log *logrus.Logger) {
@@ -133,4 +137,5 @@ func WaitForNetwork(log *logrus.Logger, directDialer netproxy.Dialer) {
time.Sleep(epo)
}
log.Infoln("Network online.")
@@ -1,22 +1,9 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: kenzok8 <kenzok8@noreply>
Date: 2026-09-23
Subject: [PATCH] daed: strictly reconcile subscription updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
订阅更新按节点链接精确对账,普通组里的失效节点删除,fixed 组固定节点转成独立节点保留。
数据库提交后按运行状态应用配置,补齐并发地址校验、导入失败回滚和 reload 超时恢复。
Reconcile nodes by link, preserve fixed-group nodes, and apply committed changes.
---
cmd/run.go | 6 +-
graphql/mutation.go | 9 +-
graphql/root_schema.go | 2 +-
graphql/service/config/mutation_utils.go | 59 ++++-
.../service/subscription/mutation_utils.go | 181 +++++++++++----
.../subscription/mutation_utils_test.go | 212 ++++++++++++++++++
6 files changed, 408 insertions(+), 61 deletions(-)
create mode 100644 graphql/service/subscription/mutation_utils_test.go
diff --git a/cmd/run.go b/cmd/run.go
index 7dce577..978f46c 100644
--- a/cmd/run.go
@@ -43,10 +30,10 @@ index 7dce577..978f46c 100644
}
diff --git a/graphql/mutation.go b/graphql/mutation.go
index fcbf440..adb747c 100644
index af065fd..d462ecc 100644
--- a/graphql/mutation.go
+++ b/graphql/mutation.go
@@ -256,14 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct {
@@ -256,16 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct {
func (r *MutationResolver) Run(args *struct {
Dry bool
}) (int32, error) {
@@ -56,7 +43,9 @@ index fcbf440..adb747c 100644
- tx.Rollback()
- return 0, err
- }
- tx.Commit()
- if err = tx.Commit().Error; err != nil {
- return 0, fmt.Errorf("commit run: %w", err)
- }
- return ret, nil
+ return config.Run(context.TODO(), args.Dry)
}
@@ -76,7 +65,7 @@ index 8b82db6..a96cace 100644
# updateSubscriptionLink is to update the subscription link without re-fetching nodes.
diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go
index fd61554..feb94d3 100644
index e6bf9da..8ab756b 100644
--- a/graphql/service/config/mutation_utils.go
+++ b/graphql/service/config/mutation_utils.go
@@ -21,6 +21,7 @@ import (
@@ -163,7 +152,7 @@ index fd61554..feb94d3 100644
// Running -> false
diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go
index e55e65b..e01a342 100644
index 7e6dc08..e87bf78 100644
--- a/graphql/service/subscription/mutation_utils.go
+++ b/graphql/service/subscription/mutation_utils.go
@@ -18,6 +18,7 @@ import (
@@ -174,7 +163,7 @@ index e55e65b..e01a342 100644
"github.com/daeuniverse/dae-wing/graphql/service/node"
"github.com/daeuniverse/dae/common/subscription"
"github.com/go-co-op/gocron"
@@ -171,8 +172,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) {
@@ -175,8 +176,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) {
}
var (
@@ -186,7 +175,7 @@ index e55e65b..e01a342 100644
)
func UpdateAll(ctx context.Context) {
@@ -258,68 +260,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) {
@@ -262,68 +264,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) {
return &Resolver{Subscription: m}, nil
}
@@ -212,7 +201,7 @@ index e55e65b..e01a342 100644
- tx := db.BeginTx(ctx)
- defer func() {
- if err == nil {
- tx.Commit()
- err = tx.Commit().Error
+ current := make(map[string]struct{}, len(existing))
+ var staleIds []uint
+ for _, n := range existing {
@@ -303,7 +292,7 @@ index e55e65b..e01a342 100644
}
- hasAnyCandidate := false
for _, r := range result {
- if r.Error == nil {
- if r.Error == nil || *r.Error == node.DuplicatedError.Error() {
- hasAnyCandidate = true
- break
+ if r.Error != nil {
@@ -388,7 +377,7 @@ index e55e65b..e01a342 100644
return &m, nil
}
@@ -399,6 +494,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver,
@@ -406,6 +501,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver,
if err != nil {
return nil, err
}
@@ -397,11 +386,11 @@ index e55e65b..e01a342 100644
tx := db.BeginTx(ctx)
defer func() {
diff --git a/graphql/service/subscription/mutation_utils_test.go b/graphql/service/subscription/mutation_utils_test.go
diff --git a/graphql/service/subscription/reconcile_patch_test.go b/graphql/service/subscription/reconcile_patch_test.go
new file mode 100644
index 0000000..18ff9b9
--- /dev/null
+++ b/graphql/service/subscription/mutation_utils_test.go
+++ b/graphql/service/subscription/reconcile_patch_test.go
@@ -0,0 +1,212 @@
+/*
+ * SPDX-License-Identifier: AGPL-3.0-only
@@ -1,44 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH] daed: apply subscription cron changes without restart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
改 cron 后先提交事务再重建调度器。
原来在事务里就去重建,调度器另开连接读到的还是旧表达式,必须重启 daed 才生效。
---
graphql/service/subscription/mutation_utils.go | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go
index e2aec32..4f6bcfc 100644
--- a/graphql/service/subscription/mutation_utils.go
+++ b/graphql/service/subscription/mutation_utils.go
@@ -561,10 +561,12 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable
}
tx := db.BeginTx(ctx)
+ if tx.Error != nil {
+ return nil, tx.Error
+ }
+ committed := false
defer func() {
- if err == nil {
- tx.Commit()
- } else {
+ if !committed {
tx.Rollback()
}
}()
@@ -583,6 +585,10 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable
}).Error; err != nil {
return nil, err
}
+ if err = tx.Commit().Error; err != nil {
+ return nil, err
+ }
+ committed = true
// Update scheduler
RemoveUpdateScheduler(id)
@@ -0,0 +1,28 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-09-23
Subject: [PATCH] daed: keep reload callbacks nonblocking
A timed-out request must not block the daemon when reload later finishes.
---
diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go
index 5996eaa..64229e8 100644
--- a/graphql/service/config/mutation_utils.go
+++ b/graphql/service/config/mutation_utils.go
@@ -298,7 +298,7 @@ func ApplyIfRunning(ctx context.Context) error {
func runLocked(ctx context.Context, noLoad bool) (n int32, err error) {
//// Dry run.
if noLoad {
- ch := make(chan error)
+ ch := make(chan error, 1)
reloadMsg := &dae.ReloadMessage{
Config: dae.EmptyConfig,
Callback: ch,
@@ -523,7 +523,7 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) {
readTx = nil
/// Reload with current config.
- chReloadCallback := make(chan error)
+ chReloadCallback := make(chan error, 1)
reloadMsg := &dae.ReloadMessage{
Config: c,
Callback: chReloadCallback,
@@ -1,837 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: kenzok8 <kenzok8@gmail.com>
Date: Sun, 20 Sep 2026 16:59:23 +0800
Subject: [PATCH] Adapt wing to new dae core
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
改动:适配新核心生命周期,并修复空配置停止和重载回滚。
原因:旧接口删除且新控制面交接流程已改变。
---
dae-core/config/marshal.go | 5 +
dae/run.go | 402 +++++++++++++-----
dae/run_handoff_test.go | 33 ++
dae/runtime.go | 6 +-
dae/utils.go | 9 +-
db/node_utils.go | 3 +-
graphql/service/config/mutation_utils.go | 9 +-
graphql/service/node/latency_cache.go | 2 +-
.../service/node/latency_mutation_utils.go | 59 ++-
graphql/service/routing/resolver.go | 2 +-
10 files changed, 415 insertions(+), 115 deletions(-)
create mode 100644 dae/run_handoff_test.go
diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go
index f68006a..69a5325 100644
--- a/dae-core/config/marshal.go
+++ b/dae-core/config/marshal.go
@@ -42,6 +42,11 @@ type Marshaller struct {
buf bytes.Buffer
}
+// Bytes returns the serialized section buffer without exposing mutable state.
+func (m *Marshaller) Bytes() []byte {
+ return m.buf.Bytes()
+}
+
func (m *Marshaller) writeLine(depth int, line string) {
if depth < 0 {
depth = 0
diff --git a/dae/run.go b/dae/run.go
index a0a665d..96b579c 100644
--- a/dae/run.go
+++ b/dae/run.go
@@ -6,6 +6,8 @@
package dae
import (
+ "context"
+ "errors"
"fmt"
"net/netip"
"runtime"
@@ -28,6 +30,22 @@ type ReloadMessage struct {
Callback chan<- error
}
+func closeReloadResources(closers ...func() error) error {
+ var errs error
+ for _, closeResource := range closers {
+ if closeResource != nil {
+ errs = errors.Join(errs, closeResource())
+ }
+ }
+ return errs
+}
+
+func notifyReloadCallback(callback chan<- error, err error) {
+ if callback != nil {
+ callback <- err
+ }
+}
+
var ChReloadConfigs = make(chan *ReloadMessage)
var GracefullyExit = make(chan struct{})
var EmptyConfig *daeConfig.Config
@@ -77,88 +95,115 @@ func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string,
case nil:
break dryLoop
default:
- newConf.Callback <- nil
+ notifyReloadCallback(newConf.Callback, nil)
}
}
return nil
}
// New c.
- c, err = newControlPlane(log, nil, nil, conf, externGeoDataDirs)
+ processSessions := control.NewSessionManager(context.Background())
+ defer func() { _ = processSessions.Close() }()
+ c, err = newControlPlane(context.Background(), log, nil, nil, conf, externGeoDataDirs, false)
if err != nil {
return err
}
+ if err = c.AttachSessionManager(processSessions); err != nil {
+ _ = c.Close()
+ return err
+ }
+ datapathDetached := false
+ if conf == EmptyConfig {
+ if err = c.DetachBpfHooks(); err != nil {
+ _ = c.Close()
+ return fmt.Errorf("detach initial empty datapath: %w", err)
+ }
+ datapathDetached = true
+ }
- // Serve tproxy TCP/UDP server util signals.
var listener *control.Listener
+ if err = control.GetDaeNetns().With(func() error {
+ var listenErr error
+ listener, listenErr = c.Listen(conf.Global.TproxyPort)
+ if listenErr != nil {
+ return listenErr
+ }
+ return listener.ValidateCurrentNetns()
+ }); err != nil {
+ if listener != nil {
+ _ = listener.Close()
+ }
+ _ = c.Close()
+ return fmt.Errorf("listen in dae netns: %w", err)
+ }
+ readyChan := make(chan bool, 1)
+ serveDone := make(chan error, 1)
+ go func() { serveDone <- c.Serve(readyChan, listener) }()
+ if !<-readyChan {
+ serveErr := <-serveDone
+ _ = closeReloadResources(listener.Close, c.Close)
+ if serveErr == nil {
+ serveErr = fmt.Errorf("initial control plane was not ready")
+ }
+ return serveErr
+ }
+ log.Infoln("Ready")
go func() {
- readyChan := make(chan bool, 1)
- go func() {
- <-readyChan
- log.Infoln("Ready")
- }()
- control.GetDaeNetns().With(func() error {
- if listener, err = c.ListenAndServe(readyChan, conf.Global.TproxyPort); err != nil {
- log.Errorln("ListenAndServe:", err)
- }
- return err
- })
- // Exit
+ if serveErr := <-serveDone; serveErr != nil {
+ log.WithError(serveErr).Errorln("control plane serve stopped")
+ }
ChReloadConfigs <- nil
}()
reloading := false
- /* dae-wing start */
- var errReload error
- var chCallback chan<- error
- /* dae-wing end */
loop:
for newReloadMsg := range ChReloadConfigs {
switch newReloadMsg {
case nil:
- /* dae-wing start */
- // We will receive nil after control plane being Closed.
- // We'll judge if we are in a reloading.
- /* dae-wing end */
-
if reloading {
- if listener == nil {
- // Failed to listen. Exit.
- break loop
- }
- // Serve.
+ // The old generation's Serve loop reports its shutdown after the
+ // candidate has already taken over the cloned listener.
reloading = false
- log.Warnln("[Reload] Serve")
- readyChan := make(chan bool, 1)
- go func() {
- if err := c.Serve(readyChan, listener); err != nil {
- log.Errorln("ListenAndServe:", err)
- }
- // Exit
- ChReloadConfigs <- nil
- }()
- <-readyChan
- log.Warnln("[Reload] Finished")
- /* dae-wing start */
- chCallback <- errReload
- /* dae-wing end */
+ continue
} else {
- // Listening error.
break loop
}
default:
// Reload signal.
log.Warnln("[Reload] Received reload signal; prepare to reload")
- /* dae-wing start */
newConf := newReloadMsg.Config
- /* dae-wing end */
-
- // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks.
- if newConf == EmptyConfig {
- if e := c.DetachBpfHooks(); e != nil {
- log.Errorln("[Stop] DetachBpfHooks:", e)
+ if datapathDetached && newConf == EmptyConfig {
+ notifyReloadCallback(newReloadMsg.Callback, nil)
+ continue
+ }
+ oldC := c
+ wasStopped := datapathDetached
+ failReload := func(cause, cleanupErr error) error {
+ cause = errors.Join(cause, cleanupErr)
+ if cleanupErr == nil && wasStopped {
+ if detachErr := oldC.DetachBpfHooks(); detachErr != nil {
+ cause = errors.Join(cause, fmt.Errorf("restore stopped datapath: %w", detachErr))
+ cleanupErr = detachErr
+ } else {
+ datapathDetached = true
+ }
}
+ notifyReloadCallback(newReloadMsg.Callback, cause)
+ if cleanupErr != nil {
+ return cause
+ }
+ return nil
}
+ if wasStopped {
+ if restoreErr := oldC.RestoreDatapathForReloadRollback(); restoreErr != nil {
+ if fatalErr := failReload(fmt.Errorf("prepare stopped datapath: %w", restoreErr), nil); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ datapathDetached = false
+ }
+
// New logger.
oldLogOutput := log.Out
log = logrus.New()
@@ -167,65 +212,223 @@ loop:
log.SetOutput(oldLogOutput) // FIXME: THIS IS A HACK.
logrus.SetOutput(oldLogOutput)
- // New control plane.
- obj := c.EjectBpf()
var dnsCache map[string]*control.DnsCache
if conf.Dns.IpVersionPrefer == newConf.Dns.IpVersionPrefer {
// Only keep dns cache when ip version preference not change.
dnsCache = c.CloneDnsCache()
}
- log.Warnln("[Reload] Load new control plane")
- newC, err := newControlPlane(log, obj, dnsCache, newConf, externGeoDataDirs)
+ log.Warnln("[Reload] Prepare new control plane")
+ freshState, err := c.SnapshotFreshDatapathState()
if err != nil {
- /* dae-wing start */
- errReload = err
- /* dae-wing end */
-
- log.WithFields(logrus.Fields{
- "err": err,
- }).Errorln("[Reload] Failed to reload; try to roll back configuration")
- // Load last config back.
- newC, err = newControlPlane(log, obj, dnsCache, conf, externGeoDataDirs)
- if err != nil {
- obj.Close()
- c.Close()
- log.WithFields(logrus.Fields{
- "err": err,
- }).Fatalln("[Reload] Failed to roll back configuration")
+ if fatalErr := failReload(err, nil); fatalErr != nil {
+ return fatalErr
}
- newConf = conf
- log.Errorln("[Reload] Last reload failed; rolled back configuration")
- } else {
- log.Warnln("[Reload] Stopped old control plane")
-
- /* dae-wing start */
- errReload = nil
- /* dae-wing end */
+ continue
}
+ newC, err := newControlPlane(context.Background(), log, freshState, dnsCache, newConf, externGeoDataDirs, true)
+ if err == nil {
+ err = newC.AttachSessionManager(processSessions)
+ }
+ if err != nil {
+ var cleanupErr error
+ if newC != nil {
+ cleanupErr = newC.Close()
+ }
+ if fatalErr := failReload(err, cleanupErr); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ stagedListener, err := listener.Clone()
+ if err != nil {
+ if fatalErr := failReload(fmt.Errorf("clone listener: %w", err), newC.Close()); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ if err = oldC.StopDNSListener(); err != nil {
+ cleanupErr := closeReloadResources(stagedListener.Close, newC.Close, oldC.RestartDNSListener)
+ if fatalErr := failReload(fmt.Errorf("stop old DNS listener: %w", err), cleanupErr); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ dnsStopped := true
+ readyChan := make(chan bool, 1)
+ serveDone := make(chan error, 1)
+ go func() { serveDone <- newC.Serve(readyChan, stagedListener) }()
+ ready := <-readyChan
+ if !ready {
+ serveErr := <-serveDone
+ if serveErr == nil {
+ serveErr = fmt.Errorf("prepared control plane was not ready")
+ }
+ cleanupErr := closeReloadResources(
+ stagedListener.Close,
+ newC.Close,
+ oldC.RestartDNSListener,
+ )
+ dnsStopped = false
+ if fatalErr := failReload(serveErr, cleanupErr); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ hookPrepared := false
+ provisionalOwner := false
+ hookFlipCommitted := false
+ hookSetAdopted := false
+ flowDatapathAdopted := false
+ rollbackCleanupFailed := false
+ rollbackCandidate := func(cause error) error {
+ var rollbackErr error
+ addRollbackErr := func(err error) {
+ if err != nil {
+ rollbackCleanupFailed = true
+ rollbackErr = errors.Join(rollbackErr, err)
+ }
+ }
+ if flowDatapathAdopted {
+ addRollbackErr(oldC.AdoptProcessFlowDatapath(newC))
+ }
+ if hookSetAdopted {
+ if ownerErr := newC.RestorePreparedTCHookSet(oldC); ownerErr != nil {
+ rollbackCleanupFailed = true
+ return errors.Join(cause, rollbackErr, fmt.Errorf("restore TC HookSet owner: %w", ownerErr))
+ }
+ }
+ if hookFlipCommitted {
+ addRollbackErr(newC.RollbackPreparedBpfHookFlip())
+ }
+ if hookPrepared && !hookSetAdopted && !hookFlipCommitted {
+ addRollbackErr(newC.ClearPreparedTCHookHandoff())
+ }
+ if provisionalOwner {
+ newC.UnregisterProvisionalRoutingEpochExecutionOwner()
+ }
+ addRollbackErr(newC.DetachBpfHooks())
+ addRollbackErr(stagedListener.Close())
+ addRollbackErr(newC.Close())
+ if dnsStopped {
+ addRollbackErr(oldC.RestartDNSListener())
+ dnsStopped = false
+ }
+ return errors.Join(cause, rollbackErr)
+ }
+ if err = newC.PrepareTCHookHandoff(oldC); err != nil {
+ rollbackErr := rollbackCandidate(err)
+ if rollbackCleanupFailed {
+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr)
+ return rollbackErr
+ }
+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ hookPrepared = true
+ if err = newC.RegisterProvisionalRoutingEpochExecutionOwner(); err != nil {
+ rollbackErr := rollbackCandidate(err)
+ if rollbackCleanupFailed {
+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr)
+ return rollbackErr
+ }
+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ provisionalOwner = true
+ if err = newC.CommitPreparedBpfHookFlip(); err != nil {
+ rollbackErr := rollbackCandidate(err)
+ if rollbackCleanupFailed {
+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr)
+ return rollbackErr
+ }
+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ hookFlipCommitted = true
+ if err = newC.AdoptPreparedTCHookSet(oldC); err != nil {
+ rollbackErr := rollbackCandidate(err)
+ if rollbackCleanupFailed {
+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr)
+ return rollbackErr
+ }
+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ hookSetAdopted = true
+ if err = newC.AdoptProcessFlowDatapath(oldC); err != nil {
+ rollbackErr := rollbackCandidate(err)
+ if rollbackCleanupFailed {
+ notifyReloadCallback(newReloadMsg.Callback, rollbackErr)
+ return rollbackErr
+ }
+ if fatalErr := failReload(rollbackErr, nil); fatalErr != nil {
+ return fatalErr
+ }
+ continue
+ }
+ flowDatapathAdopted = true
+ newC.UnregisterProvisionalRoutingEpochExecutionOwner()
+ provisionalOwner = false
+ if err = newC.FinalizePreparedTCHooks(); err != nil {
+ fatalErr := errors.Join(fmt.Errorf("finalize prepared TC hooks: %w", err), closeReloadResources(
+ listener.Close,
+ stagedListener.Close,
+ newC.Close,
+ oldC.Close,
+ ))
+ notifyReloadCallback(newReloadMsg.Callback, fatalErr)
+ return fatalErr
+ }
+ hookPrepared = false
+ hookFlipCommitted = false
+ hookSetAdopted = false
- // Inject bpf objects into the new control plane life-cycle.
- newC.InjectBpf(obj)
-
- // Prepare new context.
- oldC := c
+ oldListener := listener
c = newC
conf = newConf
+ listener = stagedListener
+ datapathDetached = false
+ retireErr := closeReloadResources(oldListener.Close, oldC.Close)
+ if newConf == EmptyConfig {
+ if retireErr != nil {
+ fatalErr := errors.Join(fmt.Errorf("retire previous control plane: %w", retireErr), closeReloadResources(stagedListener.Close, newC.Close))
+ notifyReloadCallback(newReloadMsg.Callback, fatalErr)
+ return fatalErr
+ }
+ if detachErr := newC.DetachBpfHooks(); detachErr != nil {
+ fatalErr := errors.Join(fmt.Errorf("detach stopped datapath: %w", detachErr), closeReloadResources(stagedListener.Close, newC.Close))
+ notifyReloadCallback(newReloadMsg.Callback, fatalErr)
+ return fatalErr
+ }
+ datapathDetached = true
+ } else if retireErr != nil {
+ log.WithError(retireErr).Warnln("[Reload] Failed to close old control plane")
+ }
reloading = true
- /* dae-wing start */
- chCallback = newReloadMsg.Callback
- /* dae-wing end */
-
- // Ready to close.
- oldC.Close()
+ notifyReloadCallback(newReloadMsg.Callback, nil)
+ go func(done <-chan error) {
+ if serveErr := <-done; serveErr != nil {
+ log.WithError(serveErr).Errorln("control plane serve stopped")
+ }
+ ChReloadConfigs <- nil
+ }(serveDone)
}
}
- if e := c.Close(); e != nil {
+ if e := closeReloadResources(listener.Close, c.Close); e != nil {
return fmt.Errorf("close control plane: %w", e)
}
return nil
}
-func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*control.DnsCache, conf *daeConfig.Config, externGeoDataDirs []string) (c *control.ControlPlane, err error) {
+func newControlPlane(ctx context.Context, log *logrus.Logger, bpf interface{}, dnsCache map[string]*control.DnsCache, conf *daeConfig.Config, externGeoDataDirs []string, prepared bool) (c *control.ControlPlane, err error) {
// Print configuration.
if log.IsLevelEnabled(logrus.DebugLevel) {
@@ -239,10 +442,6 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
control.PurgeStaleTCFilters(log)
}
- // Init Direct Dialers.
- direct.InitDirectDialers(conf.Global.FallbackResolver)
- netutils.FallbackDns = netip.MustParseAddrPort(conf.Global.FallbackResolver)
-
if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 {
// Wait for network for WAN ready.
if err = waitForNetwork(log); err != nil {
@@ -266,7 +465,10 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
}
// New dae control plane.
- c, err = control.NewControlPlane(
+ directDialers := direct.NewDirectDialers(conf.Global.FallbackResolver)
+ systemDNSResolver := netutils.NewSystemDNSResolver(netip.MustParseAddrPort(conf.Global.FallbackResolver))
+ c, err = control.NewControlPlaneWithContextOptions(
+ ctx,
log,
bpf,
dnsCache,
@@ -276,6 +478,14 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
&conf.Global,
&conf.Dns,
externGeoDataDirs,
+ control.ControlPlaneBuildOptions{
+ DelayDatapathCommit: prepared,
+ DelayDNSListenerStart: prepared,
+ IsReload: prepared || bpf != nil,
+ DirectDialer: directDialers.Symmetric,
+ FullconeDirectDialer: directDialers.Fullcone,
+ SystemDNSResolver: systemDNSResolver,
+ },
)
if err != nil {
return nil, err
diff --git a/dae/run_handoff_test.go b/dae/run_handoff_test.go
new file mode 100644
index 0000000..0ffb2ec
--- /dev/null
+++ b/dae/run_handoff_test.go
@@ -0,0 +1,33 @@
+package dae
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestCloseReloadResourcesOrder(t *testing.T) {
+ var got []string
+ err := closeReloadResources(
+ func() error { got = append(got, "old-listener"); return nil },
+ func() error { got = append(got, "staged-listener"); return nil },
+ func() error { got = append(got, "new-control-plane"); return nil },
+ func() error { got = append(got, "old-control-plane"); return nil },
+ )
+ if err != nil {
+ t.Fatalf("closeReloadResources() error = %v", err)
+ }
+ want := []string{"old-listener", "staged-listener", "new-control-plane", "old-control-plane"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("close order = %v, want %v", got, want)
+ }
+}
+
+func TestCloseReloadResourcesJoinsErrors(t *testing.T) {
+ one := errors.New("old listener")
+ two := errors.New("old control plane")
+ err := closeReloadResources(func() error { return one }, func() error { return two })
+ if !errors.Is(err, one) || !errors.Is(err, two) {
+ t.Fatalf("joined error = %v, want both close errors", err)
+ }
+}
diff --git a/dae/runtime.go b/dae/runtime.go
index 2ab0d68..af46089 100644
--- a/dae/runtime.go
+++ b/dae/runtime.go
@@ -30,18 +30,16 @@ type RuntimeOverview struct {
}
func GetRuntimeOverview(windowSec int, maxPoints int) (*RuntimeOverview, error) {
- activeTCPConnections := 0
ctl, err := ControlPlane()
+ var snapshot control.RuntimeStatsSnapshot
if err != nil {
if !errors.Is(err, ErrControlPlaneNotInit) {
return nil, err
}
} else {
- activeTCPConnections = ctl.ActiveTCPConnections()
+ snapshot = ctl.SnapshotRuntimeStats(windowSec, maxPoints)
}
- snapshot := control.SnapshotRuntimeStats(activeTCPConnections, control.DefaultUdpEndpointPool.Count(), windowSec, maxPoints)
-
samples := make([]RuntimeTrafficSample, 0, len(snapshot.Samples))
for _, sample := range snapshot.Samples {
samples = append(samples, RuntimeTrafficSample{
diff --git a/dae/utils.go b/dae/utils.go
index dc43cbc..ca61c85 100644
--- a/dae/utils.go
+++ b/dae/utils.go
@@ -32,8 +32,11 @@ var (
EmptyGlobalSection = `global {}`
)
-func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string) {
- f := daeConfig.FunctionOrStringToFunction(routing.Fallback)
+func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string, err error) {
+ f, err := daeConfig.ParseFunctionOrString(routing.Fallback)
+ if err != nil {
+ return nil, err
+ }
outbounds = append(outbounds, f.Name)
for _, r := range routing.Rules {
outbound := r.Outbound.Name
@@ -42,7 +45,7 @@ func NecessaryOutbounds(routing *daeConfig.Routing) (outbounds []string) {
}
outbounds = append(outbounds, outbound)
}
- return daeCommon.Deduplicate(outbounds)
+ return daeCommon.Deduplicate(outbounds), nil
}
func ParseConfig(globalSection *string, dnsSection *string, routingSection *string) (*daeConfig.Config, error) {
diff --git a/db/node_utils.go b/db/node_utils.go
index b7b43b8..7ca8fc3 100644
--- a/db/node_utils.go
+++ b/db/node_utils.go
@@ -6,6 +6,7 @@
package db
import (
+ "context"
"strings"
"github.com/daeuniverse/dae-wing/common"
@@ -25,7 +26,7 @@ func NewNodeModel(link string, tag *string, subscriptionId *uint) (*Node, error)
}
_tag = *tag
}
- d, err := dialer.NewFromLink(&dialer.GlobalOption{
+ d, err := dialer.NewFromLinkContext(context.Background(), &dialer.GlobalOption{
Log: logrus.StandardLogger(),
}, dialer.InstanceOption{DisableCheck: false}, link, _tag)
if err != nil {
diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go
index 59f0ebd..c7d6b78 100644
--- a/graphql/service/config/mutation_utils.go
+++ b/graphql/service/config/mutation_utils.go
@@ -298,7 +298,7 @@ func ApplyIfRunning(ctx context.Context) error {
func runLocked(ctx context.Context, noLoad bool) (n int32, err error) {
//// Dry run.
if noLoad {
- ch := make(chan error)
+ ch := make(chan error, 1)
reloadMsg := &dae.ReloadMessage{
Config: dae.EmptyConfig,
Callback: ch,
@@ -384,7 +384,10 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) {
}
/// Fill in necessary groups and nodes.
// Find groups needed by routing.
- outbounds := dae.NecessaryOutbounds(&c.Routing)
+ outbounds, err := dae.NecessaryOutbounds(&c.Routing)
+ if err != nil {
+ return 0, err
+ }
var groups []db.Group
q = d.Model(&db.Group{}).
Where("name in ?", outbounds).
@@ -523,7 +526,7 @@ func runLocked(ctx context.Context, noLoad bool) (n int32, err error) {
readTx = nil
/// Reload with current config.
- chReloadCallback := make(chan error)
+ chReloadCallback := make(chan error, 1)
reloadMsg := &dae.ReloadMessage{
Config: c,
Callback: chReloadCallback,
diff --git a/graphql/service/node/latency_cache.go b/graphql/service/node/latency_cache.go
index ba66794..e0ec91e 100644
--- a/graphql/service/node/latency_cache.go
+++ b/graphql/service/node/latency_cache.go
@@ -162,7 +162,7 @@ func refreshLatencyCache(ctx context.Context) error {
return err
}
- results := testLatencyResultsForNodes(option, nodes)
+ results := testLatencyResultsForNodes(ctx, option, nodes)
storeLatencyResults(results)
if ctl, err := dae.ControlPlane(); err == nil {
diff --git a/graphql/service/node/latency_mutation_utils.go b/graphql/service/node/latency_mutation_utils.go
index 056f9fa..5026b33 100644
--- a/graphql/service/node/latency_mutation_utils.go
+++ b/graphql/service/node/latency_mutation_utils.go
@@ -8,6 +8,8 @@ package node
import (
"context"
"io"
+ "net/netip"
+ "strings"
"sync"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/daeuniverse/dae-wing/dae"
"github.com/daeuniverse/dae-wing/db"
dialer "github.com/daeuniverse/dae/component/outbound/dialer"
+ "github.com/daeuniverse/outbound/netproxy"
"github.com/graph-gophers/graphql-go"
"github.com/sirupsen/logrus"
)
@@ -32,12 +35,12 @@ func TestLatencies(ctx context.Context, ids *[]graphql.ID) ([]*LatencyResolver,
return nil, err
}
- results := testLatencyResultsForNodes(option, nodes)
+ results := testLatencyResultsForNodes(ctx, option, nodes)
storeLatencyResults(results)
return results, nil
}
-func testLatencyResultsForNodes(option *dialer.GlobalOption, nodes []db.Node) []*LatencyResolver {
+func testLatencyResultsForNodes(ctx context.Context, option *dialer.GlobalOption, nodes []db.Node) []*LatencyResolver {
results := make([]*LatencyResolver, len(nodes))
sem := make(chan struct{}, latencyProbeConcurrency)
var wg sync.WaitGroup
@@ -51,7 +54,7 @@ func testLatencyResultsForNodes(option *dialer.GlobalOption, nodes []db.Node) []
defer func() { <-sem }()
node := nodes[index]
- results[index] = testSingleNodeLatency(option, &node)
+ results[index] = testSingleNodeLatency(ctx, option, &node)
}()
}
@@ -93,14 +96,14 @@ func latencyProbeNodes(ctx context.Context, ids *[]graphql.ID) ([]db.Node, error
return nodes, nil
}
-func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyResolver {
+func testSingleNodeLatency(ctx context.Context, option *dialer.GlobalOption, node *db.Node) *LatencyResolver {
resolver := &LatencyResolver{
NodeID: node.ID,
AliveVal: false,
TestedAtV: time.Now(),
}
- d, err := dialer.NewFromLink(option, dialer.InstanceOption{DisableCheck: false}, node.Link, "")
+ d, err := dialer.NewFromLinkContext(ctx, option, dialer.InstanceOption{DisableCheck: false}, node.Link, "")
if err != nil {
msg := err.Error()
resolver.MessageV = &msg
@@ -108,7 +111,7 @@ func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyR
}
defer d.Close()
- result, err := d.ProbeLatencyFast()
+ result, err := probeLatencyFast(ctx, d)
if err != nil {
msg := err.Error()
resolver.MessageV = &msg
@@ -128,3 +131,47 @@ func testSingleNodeLatency(option *dialer.GlobalOption, node *db.Node) *LatencyR
}
return resolver
}
+
+func probeLatencyFast(ctx context.Context, d *dialer.Dialer) (*dialer.LatencyProbeResult, error) {
+ opt, err := d.TcpCheckOptionRaw.Option()
+ if err != nil {
+ return nil, err
+ }
+ network, err := netproxy.ParseMagicNetwork(d.TcpCheckOptionRaw.ResolverNetwork)
+ if err != nil {
+ return nil, err
+ }
+ result := &dialer.LatencyProbeResult{CheckedAt: time.Now()}
+ var messages []string
+ for _, probe := range []struct {
+ index int
+ ip netip.Addr
+ }{
+ {index: dialer.IdxTcp4, ip: opt.Ip4},
+ {index: dialer.IdxTcp6, ip: opt.Ip6},
+ } {
+ if !probe.ip.IsValid() {
+ continue
+ }
+ probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
+ started := time.Now()
+ ok, probeErr := d.HttpCheck(probeCtx, probe.index, opt.Url, probe.ip, opt.Method, network.Mark, network.Mptcp)
+ cancel()
+ if ok {
+ latency := time.Since(started)
+ if !result.Alive || latency < result.Latency {
+ result.Latency = latency
+ }
+ result.Alive = true
+ } else if probeErr != nil {
+ messages = append(messages, probeErr.Error())
+ }
+ }
+ if !result.Alive {
+ result.Message = strings.Join(messages, "; ")
+ if result.Message == "" {
+ result.Message = "no latency result"
+ }
+ }
+ return result, nil
+}
diff --git a/graphql/service/routing/resolver.go b/graphql/service/routing/resolver.go
index 7822d64..3ed61aa 100644
--- a/graphql/service/routing/resolver.go
+++ b/graphql/service/routing/resolver.go
@@ -41,7 +41,7 @@ func (r *Resolver) Selected() bool {
return r.Model.Selected
}
-func (r *Resolver) ReferenceGroups() (outbounds []string) {
+func (r *Resolver) ReferenceGroups() (outbounds []string, err error) {
return dae.NecessaryOutbounds(r.DaeRouting)
}
@@ -0,0 +1,21 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-09-23
Subject: [PATCH] daed: expose config marshaller bytes
The current wing generator calls Marshaller.Bytes when updating global config.
---
diff --git a/dae-core/config/marshal.go b/dae-core/config/marshal.go
index f68006a..dfd6777 100644
--- a/dae-core/config/marshal.go
+++ b/dae-core/config/marshal.go
@@ -42,6 +42,10 @@ type Marshaller struct {
buf bytes.Buffer
}
+func (m *Marshaller) Bytes() []byte {
+ return m.buf.Bytes()
+}
+
func (m *Marshaller) writeLine(depth int, line string) {
if depth < 0 {
depth = 0
+7 -3
View File
@@ -10,7 +10,7 @@ PKG_ARCH_DOCKERMANAGER:=$(ARCH)
PKG_NAME:=dockermanager
PKG_VERSION:=0.1.1
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/dockermanager-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
@@ -26,7 +26,7 @@ define Package/$(PKG_NAME)
CATEGORY:=System
TITLE:=Docker Manager
URL:=https://github.com/linkease/linkease-docker-manager
DEPENDS:=+docker +dockerd +ca-bundle
DEPENDS:=+docker +dockerd +ca-bundle +linkease-app-entry
endef
define Package/$(PKG_NAME)/description
@@ -43,6 +43,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/20-dockermanager-plugin.json 2>/dev/null)" = "/usr/share/dockermanager/dockermanager-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/20-dockermanager-plugin.json
fi
if [ "$$(readlink /usr/share/linkease/apps.d/20-dockermanager-plugin.json 2>/dev/null)" = "/usr/share/dockermanager/dockermanager-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/20-dockermanager-plugin.json
fi
fi
exit 0
endef
@@ -54,12 +57,13 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/dockermanager/www $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/dockermanager/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config
$(INSTALL_BIN) $(PKG_BUILD_DIR)/docker-manager.$(PKG_ARCH_DOCKERMANAGER) $(1)/usr/sbin/docker-manager
$(CP) $(PKG_BUILD_DIR)/www/* $(1)/usr/share/dockermanager/www/
$(INSTALL_DATA) ./files/logo.svg $(1)/usr/share/dockermanager/www/logo.svg
$(INSTALL_DATA) ./files/dockermanager-plugin.json $(1)/usr/share/dockermanager/dockermanager-plugin.json
ln -sf /usr/share/dockermanager/dockermanager-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/20-dockermanager-plugin.json
ln -sf /usr/share/dockermanager/dockermanager-plugin.json $(1)/usr/share/linkease/apps.d/20-dockermanager-plugin.json
$(INSTALL_BIN) ./files/dockermanager.init $(1)/etc/init.d/dockermanager
$(INSTALL_CONF) ./files/dockermanager.config $(1)/etc/config/dockermanager
endef
+19 -3
View File
@@ -10,7 +10,7 @@ PKG_ARCH_FASTNET:=$(ARCH)
PKG_NAME:=fastnet
PKG_VERSION:=0.7.7
PKG_RELEASE:=4
PKG_RELEASE:=5
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/fastnet-runtime-v$(PKG_VERSION)/
@@ -28,7 +28,20 @@ define Package/$(PKG_NAME)
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=FastNet - network test Web UI
DEPENDS:=@(arm||x86_64||aarch64)
DEPENDS:=@(arm||x86_64||aarch64) +linkease-app-entry
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkease/apps.d/17-fastnet.json 2>/dev/null)" = "/usr/share/fastnet/fastnet-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/17-fastnet.json
fi
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/17-fastnet.json 2>/dev/null)" = "/usr/share/fastnet/fastnet-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/17-fastnet.json
fi
fi
exit 0
endef
define Package/$(PKG_NAME)/description
@@ -54,11 +67,14 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/fastnet $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_BIN) $(PKG_BUILD_DIR)/FastNet.$(PKG_ARCH_FASTNET) $(1)/usr/sbin/FastNet
$(INSTALL_CONF) ./files/fastnet.config $(1)/etc/config/fastnet
$(INSTALL_BIN) ./files/fastnet.init $(1)/etc/init.d/fastnet
$(INSTALL_BIN) ./files/fastnet.uci-default $(1)/etc/uci-defaults/fastnet
$(INSTALL_DATA) ./files/fastnet-plugin.json $(1)/usr/share/fastnet/fastnet-plugin.json
ln -sf /usr/share/fastnet/fastnet-plugin.json $(1)/usr/share/linkease/apps.d/17-fastnet.json
ln -sf /usr/share/fastnet/fastnet-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/17-fastnet.json
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+44
View File
@@ -0,0 +1,44 @@
{
"schemaVersion": 1,
"id": "fastnet",
"name": "FastNet",
"desktop": {
"mode": "iframe",
"target": {
"scheme": "http",
"hostMode": "request-host",
"port": {
"default": 3200,
"keys": {
"uci": "fastnet.@fastnet[0].port"
}
},
"path": "/"
},
"access": {
"scope": "lan",
"blockedTitle": "请在局域网内打开 FastNet",
"blockedMessage": "浏览器需要直接连接设备才能准确测量局域网速率。请连接设备所在局域网后,使用设备的局域网地址重新打开。"
}
},
"standalone": {
"basePath": "/apps/fastnet/",
"externalOpen": {
"enabled": true,
"label": "在浏览器中打开 FastNet"
}
},
"config": {
"providerOrder": ["uci"],
"providers": {
"uci": {"type": "uci"}
}
},
"window": {
"width": 1180,
"height": 760,
"singleton": true
},
"capabilities": ["speed-test", "diagnostics", "network"],
"categories": ["network", "service"]
}
+2 -2
View File
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=headscale
PKG_VERSION:=0.29.3
PKG_RELEASE:=8
PKG_VERSION:=0.29.4
PKG_RELEASE:=9
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/juanfont/headscale/tar.gz/v$(PKG_VERSION)?
+7 -3
View File
@@ -12,7 +12,7 @@ PKG_ARCH_ISTOREENHANCE:=$(ARCH)
PKG_NAME:=istoreenhance
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=0.8.0
PKG_RELEASE:=8
PKG_RELEASE:=9
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
ARCH_HEXCODE=8664
@@ -41,7 +41,7 @@ define Package/$(PKG_NAME)
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=iStoreEnhance - Fix some net issues for istore
DEPENDS:=@(arm||x86_64||aarch64)
DEPENDS:=@(arm||x86_64||aarch64) +linkease-app-entry
PKGARCH:=all
URL:=https://www.kspeeder.com/
endef
@@ -68,6 +68,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/20-kspeeder.json 2>/dev/null)" = "/usr/share/kspeeder/kspeeder-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/20-kspeeder.json
fi
if [ "$$(readlink /usr/share/linkease/apps.d/20-kspeeder.json 2>/dev/null)" = "/usr/share/kspeeder/kspeeder-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/20-kspeeder.json
fi
fi
exit 0
endef
@@ -79,7 +82,7 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/kspeeder/www $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/kspeeder/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_BIN) $(PKG_BUILD_DIR)/iStoreEnhance.$(PKG_ARCH_ISTOREENHANCE) $(1)/usr/sbin/iStoreEnhance
$(INSTALL_CONF) ./files/istoreenhance.config $(1)/etc/config/istoreenhance
$(INSTALL_BIN) ./files/istoreenhance.init $(1)/etc/init.d/istoreenhance
@@ -93,6 +96,7 @@ define Package/$(PKG_NAME)/install
fi
$(INSTALL_DATA) ./files/logo.png $(1)/usr/share/kspeeder/www/logo.png
ln -sf /usr/share/kspeeder/kspeeder-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/20-kspeeder.json
ln -sf /usr/share/kspeeder/kspeeder-plugin.json $(1)/usr/share/linkease/apps.d/20-kspeeder.json
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+43
View File
@@ -0,0 +1,43 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=kai-agent
PKG_VERSION:=linkease-app-entry-runtime-v3.0.22
PKG_RELEASE:=1
PKG_SOURCE:=kai-agent-bundle-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/kai-agent-bundle-$(PKG_VERSION)
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=net
CATEGORY:=System
TITLE:=KAI OpenCode Agent bundle
URL:=https://github.com/linkease/linkease-skills
endef
define Package/$(PKG_NAME)/description
Versioned local OpenCode instructions and skills for KAI on iStoreOS.
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)
$(CP) $(PKG_BUILD_DIR)/agents $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/
$(CP) $(PKG_BUILD_DIR)/skills $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/
$(INSTALL_DATA) $(PKG_BUILD_DIR)/home-prompts.json $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/
$(INSTALL_DATA) $(PKG_BUILD_DIR)/manifest.json $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/
$(INSTALL_DATA) $(PKG_BUILD_DIR)/opencode.json $(1)/usr/share/kai/agent-bundles/$(PKG_VERSION)/
$(LN) agent-bundles/$(PKG_VERSION) $(1)/usr/share/kai/agent
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+23 -4
View File
@@ -10,10 +10,10 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_kai:=$(ARCH)
PKG_NAME:=kai
PKG_VERSION:=0.0.6
PKG_RELEASE:=5
PKG_VERSION:=linkease-app-entry-runtime-v3.0.22
PKG_RELEASE:=6
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/linkease/istore-packages/releases/download/prebuilt/
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-binary-$(PKG_VERSION)
@@ -27,6 +27,7 @@ define Package/$(PKG_NAME)
CATEGORY:=System
SUBMENU:=
TITLE:=KAI
DEPENDS:=+kai_session +kai-agent +luci-lib-linkeaseauth +linkease-app-entry
URL:=https://github.com/Carseason/openwrt-packages
endef
@@ -38,6 +39,19 @@ define Package/$(PKG_NAME)/conffiles
/etc/config/kai
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkease/apps.d/05-kai-plugin.json 2>/dev/null)" = "/usr/share/kai/kai-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/05-kai-plugin.json
fi
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/05-kai-plugin.json 2>/dev/null)" = "/usr/share/kai/kai-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/05-kai-plugin.json
fi
fi
exit 0
endef
define Build/Configure
endef
@@ -45,10 +59,15 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/init.d $(1)/etc/config
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/libexec $(1)/etc/init.d $(1)/etc/config $(1)/usr/share/kai/www $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_BIN) $(PKG_BUILD_DIR)/kai.$(PKG_ARCH_kai) $(1)/usr/sbin/kai_bin
$(INSTALL_BIN) ./files/kai-session-launch $(1)/usr/libexec/kai-session-launch
$(INSTALL_BIN) ./files/kai.init $(1)/etc/init.d/kai
$(INSTALL_CONF) ./files/kai.config $(1)/etc/config/kai
$(INSTALL_DATA) ./files/kai-plugin.json $(1)/usr/share/kai/kai-plugin.json
$(INSTALL_DATA) ./files/logo.png $(1)/usr/share/kai/www/logo.png
ln -sf /usr/share/kai/kai-plugin.json $(1)/usr/share/linkease/apps.d/05-kai-plugin.json
ln -sf /usr/share/kai/kai-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/05-kai-plugin.json
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+59
View File
@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"id": "kai",
"name": "KAI",
"icon": "logo.png",
"staticRoot": "/usr/share/kai/www",
"desktop": {
"mode": "iframe",
"target": {
"scheme": "http",
"hostMode": "request-host",
"port": {
"default": 8197,
"keys": {
"uci": "kai.@kai[0].port"
}
},
"path": "/apps/kai/"
},
"access": {
"scope": "lan",
"blockedTitle": "请在局域网内打开 KAI",
"blockedMessage": "KAI 运行在设备的独立服务端口,请连接设备所在局域网后再打开。"
}
},
"standalone": {
"basePath": "/apps/kai/",
"externalOpen": {
"enabled": true,
"label": "在浏览器中打开 KAI"
}
},
"config": {
"providerOrder": ["uci"],
"providers": {
"uci": {
"type": "uci"
}
}
},
"backend": {
"type": "http",
"transport": "tcp",
"scheme": "http",
"host": "127.0.0.1",
"portFromUci": "kai.@kai[0].port",
"defaultPort": 8197,
"upstreamBasePath": "/apps/kai/",
"pathMode": "preserve",
"proxyMode": "app-base"
},
"window": {
"width": 1180,
"height": 760,
"singleton": true
},
"capabilities": ["assistant", "chat"],
"categories": ["other"]
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
if [ "$#" -ne 1 ] || [ -z "$1" ]; then
echo "usage: kai-session-launch WORKING_DIRECTORY" >&2
exit 2
fi
cd "$1"
exec /usr/sbin/kai_session serve --port 8196 --hostname 127.0.0.1
+1
View File
@@ -2,3 +2,4 @@ config kai
option 'enabled' '0'
option 'data_dir' ''
option 'port' '8197'
option 'auth_mode' 'openwrt_luci'
+14 -71
View File
@@ -7,74 +7,12 @@ get_config() {
config_get_bool enabled $1 enabled 1
config_get data_dir $1 data_dir ""
config_get port $1 port "8197"
}
http_get() {
local url="$1"
if command -v uclient-fetch >/dev/null 2>&1; then
uclient-fetch -O /dev/stdout "$url" 2>/dev/null
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$url" 2>/dev/null
return $?
fi
if command -v curl >/dev/null 2>&1; then
curl -fsL "$url" 2>/dev/null
return $?
fi
return 1
}
maybe_refresh_opencode_skills_cache() {
[ -n "$data_dir" ] || return 0
[ -n "$port" ] || return 0
command -v jsonfilter >/dev/null 2>&1 || return 0
local cache_root="${data_dir}/cache/opencode/skills"
local state_root="${data_dir}/state/opencode"
local version_file="${state_root}/skills.manifest.version"
local managed_file="${state_root}/skills.managed.list"
local manifest_url="http://127.0.0.1:${port}/agentconf/skills/manifest.json"
[ -d "$cache_root" ] || mkdir -p "$cache_root"
[ -d "$state_root" ] || mkdir -p "$state_root"
local manifest version prev_version skills
manifest="$(http_get "$manifest_url")" || return 0
version="$(printf '%s' "$manifest" | jsonfilter -e '@.version' 2>/dev/null)"
[ -n "$version" ] || return 0
prev_version=""
[ -f "$version_file" ] && prev_version="$(cat "$version_file" 2>/dev/null)"
[ "$version" = "$prev_version" ] && return 0
logger -t kai "opencode skills manifest changed; refreshing managed skill cache"
if [ -f "$managed_file" ]; then
while IFS= read -r s; do
[ -n "$s" ] || continue
rm -rf "${cache_root}/${s}"
done <"$managed_file"
fi
skills="$(printf '%s' "$manifest" | jsonfilter -e '@.skills[*]' 2>/dev/null)"
if [ -n "$skills" ]; then
printf '%s\n' "$skills" >"$managed_file"
printf '%s\n' "$skills" | while IFS= read -r s; do
[ -n "$s" ] || continue
rm -rf "${cache_root}/${s}"
done
else
: >"$managed_file"
fi
printf '%s\n' "$version" >"$version_file"
return 0
config_get auth_mode $1 auth_mode "openwrt_luci"
}
start_kai_bin(){
procd_open_instance kai_bin
procd_set_param env KAI_AUTH_MODE="$auth_mode"
procd_set_param command /usr/sbin/kai_bin -a 0.0.0.0 -p "$port"
procd_set_param stderr 1
procd_set_param respawn
@@ -87,15 +25,21 @@ start_kai_session(){
procd_open_instance kai_session
local root="${data_dir}"
local cwd="${data_dir}/cwd"
local agent_root="/usr/share/kai/agent"
if [ ! -f "${agent_root}/opencode.json" ] || [ ! -d "${agent_root}/skills" ]; then
logger -t kai "local OpenCode Agent bundle is missing under ${agent_root}"
return 1
fi
procd_set_param env \
OPENCODE_CWD="${cwd}" \
OPENCODE_CONFIG="http://127.0.0.1:${port}/agentconf/opencode.json" \
OPENCODE_CONFIG="${agent_root}/opencode.json" \
KAIPLUS_SKILLS_DIR="${agent_root}/skills" \
ISTORE_AI_HELPER_BASE="http://127.0.0.1:${port}" \
XDG_CACHE_HOME="${root}/cache" \
XDG_DATA_HOME="${root}/data" \
XDG_CONFIG_HOME="${root}/config" \
XDG_STATE_HOME="${root}/state"
procd_set_param command /usr/sbin/kai_session
procd_append_param command serve --port "8196" --hostname "127.0.0.1"
procd_set_param command /usr/libexec/kai-session-launch "${cwd}"
procd_set_param file "${agent_root}/opencode.json" "${agent_root}/manifest.json"
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
@@ -103,7 +47,7 @@ start_kai_session(){
start_service() {
config_load kai
config_foreach get_config kai
if [ $enabled != 1 ]; then
if [ "$enabled" != 1 ]; then
return 1
fi
if [ -z "${data_dir}" ]; then
@@ -119,8 +63,7 @@ start_service() {
mkdir_cwd "${data_dir}/data"
mkdir_cwd "${data_dir}/config"
mkdir_cwd "${data_dir}/state"
maybe_refresh_opencode_skills_cache
start_kai_session
start_kai_session || return 1
logger -t kai "Starting KAI Service Completed"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+4 -3
View File
@@ -10,10 +10,10 @@ include $(TOPDIR)/rules.mk
PKG_ARCH_kai_session:=$(ARCH)
PKG_NAME:=kai_session
PKG_VERSION:=0.0.6
PKG_RELEASE:=5
PKG_VERSION:=linkease-app-entry-runtime-v3.0.22
PKG_RELEASE:=6
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/linkease/istore-packages/releases/download/prebuilt/
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-binary-$(PKG_VERSION)
@@ -54,6 +54,7 @@ endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/kai_session.$(PKG_ARCH_kai_session) $(1)/usr/sbin/kai_session
$(INSTALL_BIN) $(PKG_BUILD_DIR)/rg.$(PKG_ARCH_kai_session) $(1)/usr/sbin/rg
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+7 -2
View File
@@ -10,7 +10,7 @@ PKG_ARCH_kaiplus:=$(ARCH)
PKG_NAME:=kaiplus
PKG_VERSION:=1.0.9
PKG_RELEASE:=13
PKG_RELEASE:=14
PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kaiplus-runtime-v$(PKG_VERSION)/
PKG_HASH:=skip
@@ -27,6 +27,7 @@ define Package/$(PKG_NAME)
SUBMENU:=
TITLE:=KaiPlus
URL:=https://github.com/linkease/istore-packages
DEPENDS:=+linkease-app-entry
endef
define Package/$(PKG_NAME)/description
@@ -43,6 +44,9 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then
if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/00-kaiplus-plugin.json 2>/dev/null)" = "/usr/share/kaiplus/kaiplus-plugin.json" ]; then
rm -f /usr/share/linkeasefull/desktop-apps.d/00-kaiplus-plugin.json
fi
if [ "$$(readlink /usr/share/linkease/apps.d/00-kaiplus-plugin.json 2>/dev/null)" = "/usr/share/kaiplus/kaiplus-plugin.json" ]; then
rm -f /usr/share/linkease/apps.d/00-kaiplus-plugin.json
fi
fi
exit 0
endef
@@ -54,7 +58,7 @@ define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/kaiplus/www $(1)/usr/share/kaiplus/defaults $(1)/usr/share/kaiplus/helpers $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config
$(INSTALL_DIR) $(1)/usr/sbin $(1)/usr/share/kaiplus/www $(1)/usr/share/kaiplus/defaults $(1)/usr/share/kaiplus/helpers $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d $(1)/etc/init.d $(1)/etc/config
$(INSTALL_BIN) $(PKG_BUILD_DIR)/kaiplus_bin.$(PKG_ARCH_kaiplus) $(1)/usr/sbin/kaiplus_bin
$(CP) $(PKG_BUILD_DIR)/www/* $(1)/usr/share/kaiplus/www/
$(INSTALL_DATA) ./files/logo.png $(1)/usr/share/kaiplus/www/logo.png
@@ -62,6 +66,7 @@ define Package/$(PKG_NAME)/install
$(INSTALL_BIN) $(PKG_BUILD_DIR)/helpers/kaiplus_workspace_tool.* $(1)/usr/share/kaiplus/helpers/
$(INSTALL_DATA) ./files/kaiplus-plugin.json $(1)/usr/share/kaiplus/kaiplus-plugin.json
ln -sf /usr/share/kaiplus/kaiplus-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/00-kaiplus-plugin.json
ln -sf /usr/share/kaiplus/kaiplus-plugin.json $(1)/usr/share/linkease/apps.d/00-kaiplus-plugin.json
$(INSTALL_BIN) ./files/kaiplus.init $(1)/etc/init.d/kaiplus
$(INSTALL_CONF) ./files/kaiplus.config $(1)/etc/config/kaiplus
endef
+76
View File
@@ -0,0 +1,76 @@
#
# Copyright (C) 2026 iStoreOS
#
# This is free software, licensed under the GNU General Public License v3.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=linkease-app-entry
# This lightweight runtime is released independently from the full desktop.
PKG_VERSION:=linkease-app-entry-runtime-v3.0.22
PKG_RELEASE:=1
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
ARCH_HEXCODE=8664
LINKEASE_RUNTIME_ARCH:=amd64
PKG_HASH:=skip
else ifeq ($(ARCH),aarch64)
ARCH_HEXCODE=aa64
LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip
endif
PKG_SOURCE_VERSION:=d3d23965623e4182de554dde64418bb9a20c9078
PKG_SOURCE:=linkease-app-entry-runtime-$(PKG_VERSION)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-app-entry-runtime-v$(PKG_VERSION)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-app-entry-runtime-$(PKG_VERSION)-linux-$(LINKEASE_RUNTIME_ARCH)
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=net
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=LinkEase application entry arbiter and fallback gateway
DEPENDS:=@(x86_64||aarch64) +ca-bundle +luci-lib-linkeaseauth
PKGARCH:=all
URL:=https://www.linkease.com/
endef
define Package/$(PKG_NAME)/description
Owns the shared /apps listener, launches LinkEaseFull directly when healthy,
and runs the manifest-driven fallback gateway only when LinkEaseFull is not available.
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/linkease_app_entry
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
[ -f /etc/uci-defaults/linkease-app-entry ] && /etc/uci-defaults/linkease-app-entry && rm -f /etc/uci-defaults/linkease-app-entry
fi
exit 0
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults
$(INSTALL_BIN) $(PKG_BUILD_DIR)/bin/linkease-app-entry $(1)/usr/sbin/linkease-app-entry
$(INSTALL_CONF) ./files/linkease-app-entry.config $(1)/etc/config/linkease_app_entry
$(INSTALL_BIN) ./files/linkease-app-entry.init $(1)/etc/init.d/linkease-app-entry
$(INSTALL_BIN) ./files/linkease-app-entry.uci-default $(1)/etc/uci-defaults/linkease-app-entry
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
@@ -0,0 +1,7 @@
config entry 'main'
option enabled '1'
option listen '127.0.0.1:19290'
option ready_timeout '75s'
option max_primary_failures '3'
option crash_window '60s'
option state_file '/var/run/linkease-app-entry/state.json'
@@ -0,0 +1,115 @@
#!/bin/sh /etc/rc.common
START=99
STOP=11
USE_PROCD=1
ENTRY=/usr/sbin/linkease-app-entry
PRIMARY=/usr/bin/linkease-full
FORCE_GATEWAY=/var/run/linkease-app-entry/force-gateway
DATA_ROOT_WAIT_SECONDS=${LINKEASEFULL_DATA_ROOT_WAIT_SECONDS:-60}
append_primary_arg() {
procd_append_param command --primary-arg "$1"
}
primary_enabled() {
[ -x "$PRIMARY" ] || return 1
[ ! -e "$FORCE_GATEWAY" ] || return 1
enabled="$(uci -q get linkeasefull.@linkeasefull[0].enabled 2>/dev/null || echo 1)"
[ "$enabled" = 1 ]
}
ensure_linkease_config() {
if [ ! -f /etc/config/linkease ]; then
touch /etc/config/linkease || return 1
fi
if ! uci -q get linkease.@linkease[0] >/dev/null; then
uci -q add linkease linkease >/dev/null || return 1
uci -q set linkease.@linkease[0].enabled='0' || return 1
uci -q set linkease.@linkease[0].port='8897' || return 1
uci -q set linkease.@linkease[0].allowPublic='0' || return 1
uci -q commit linkease || return 1
fi
}
prepare_linkeasefull_runtime() {
local data_root_parent data_root recycle_root wait_left
ensure_linkease_config || true
data_root_parent="$(uci -q get linkease.@linkease[0].local_home 2>/dev/null || true)"
case "$data_root_parent" in
""|/tmp|/tmp/*|/var|/var/*|/run|/run/*|/dev|/dev/*|/proc|/proc/*|/sys|/sys/*|/rom|/rom/*|/overlay|/overlay/*) data_root_parent="" ;;
esac
if [ -n "$data_root_parent" ]; then
wait_left="$DATA_ROOT_WAIT_SECONDS"
case "$wait_left" in ""|*[!0-9]*) wait_left=60 ;; esac
while [ "$wait_left" -gt 0 ] && [ ! -d "$data_root_parent" ]; do
sleep 1
wait_left=$((wait_left - 1))
done
[ -d "$data_root_parent" ] || data_root_parent=""
fi
if [ -n "$data_root_parent" ]; then
data_root="$data_root_parent/.linkease_data"
recycle_root="$data_root_parent/.linkease_recycle"
mkdir -p "$data_root/users/admin" "$data_root/system" "$data_root/tmp" "$data_root/config" "$data_root/apptunnel" "$data_root/mountremote" "$recycle_root"
fi
mkdir -p /tmp/linkeasefull/logs
rm -f /var/run/linkease.sock
}
start_service() {
config_load linkease_app_entry
config_get_bool entry_enabled main enabled 1
[ "$entry_enabled" = 1 ] || return 1
config_get listen main listen "127.0.0.1:19290"
config_get ready_timeout main ready_timeout "75s"
config_get max_failures main max_primary_failures "3"
config_get crash_window main crash_window "60s"
config_get state_file main state_file "/var/run/linkease-app-entry/state.json"
mkdir -p /var/run/linkease-app-entry
primary=0
primary_enabled && primary=1
[ "$primary" = 1 ] && prepare_linkeasefull_runtime
procd_open_instance entry
procd_set_param limits nofile="65535 65535"
procd_set_param command "$ENTRY" supervisor \
--listen "$listen" \
--primary "$PRIMARY" \
--primary-enabled="$primary" \
--fallback "$ENTRY" \
--fallback-arg gateway \
--state-file "$state_file" \
--ready-timeout "$ready_timeout" \
--max-primary-failures "$max_failures" \
--crash-window "$crash_window"
append_primary_arg --host
append_primary_arg 127.0.0.1
append_primary_arg --port
append_primary_arg 19290
append_primary_arg --mode
append_primary_arg release
append_primary_arg --basePath
append_primary_arg /apps/
append_primary_arg --deviceAddr
append_primary_arg 0.0.0.0:8897
append_primary_arg --localApi
append_primary_arg /var/run/linkease.sock
append_primary_arg --supplierCode
append_primary_arg linkease
procd_set_param env \
"LINKEASE_AUTH_PROVIDER=openwrt" \
"KAIPLUS_ENABLED=0"
procd_set_param respawn 3600 5 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "linkease_app_entry" "linkeasefull"
}
@@ -0,0 +1,29 @@
#!/bin/sh
. /lib/functions.sh
mapping='/apps=http://127.0.0.1:19290'
found=0
# During upgrade the old linkeasefull init may still own 19290. Stop that
# legacy procd instance before starting the single-owner entry module.
if grep -q 'procd_open_instance linkeasefull' /etc/init.d/linkeasefull 2>/dev/null; then
/etc/init.d/linkeasefull stop >/dev/null 2>&1 || true
fi
check_mapping() {
[ "$1" = "$mapping" ] && found=1
}
config_load uhttpd
config_list_foreach main proxy_prefix check_mapping
uci -q show uhttpd.main.proxy_prefix 2>/dev/null | grep -Fq "$mapping" && found=1
if [ "$found" != 1 ]; then
uci -q add_list "uhttpd.main.proxy_prefix=$mapping"
uci -q commit uhttpd
/etc/init.d/uhttpd reload >/dev/null 2>&1 || true
fi
/etc/init.d/linkease-app-entry enable
/etc/init.d/linkease-app-entry restart >/dev/null 2>&1 || true
exit 0
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease-common-bin
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=33
PKG_RELEASE:=34
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
PKG_SOURCE_VERSION:=2e0e85147d88db69256be9b38f7b3128d0126877
PKG_SOURCE_VERSION:=8e06ab67451edac1442855713ca3256c72f34be9
PKG_SOURCE:=linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-common-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+2 -2
View File
@@ -12,7 +12,7 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkease
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=1.7.5
PKG_RELEASE:=36
PKG_RELEASE:=37
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -28,7 +28,7 @@ else ifeq ($(ARCH),mipsel)
ARCH_HEXCODE=1b0c
endif
PKG_SOURCE_VERSION:=2e0e85147d88db69256be9b38f7b3128d0126877
PKG_SOURCE_VERSION:=8e06ab67451edac1442855713ca3256c72f34be9
PKG_SOURCE:=linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkease-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-bin-$(PKG_SOURCE_DATE)-linux-$(PKG_ARCH_LINKEASE)
+4 -4
View File
@@ -10,8 +10,8 @@ PKG_ARCH_LINKEASE:=$(ARCH)
PKG_NAME:=linkeasefull
# use PKG_SOURCE_DATE instead of PKG_VERSION for compitable
PKG_SOURCE_DATE:=3.0.20
PKG_RELEASE:=35
PKG_SOURCE_DATE:=3.0.22
PKG_RELEASE:=36
ARCH_HEXCODE:=
ifeq ($(ARCH),x86_64)
@@ -24,7 +24,7 @@ LINKEASE_RUNTIME_ARCH:=arm64
PKG_HASH:=skip
endif
PKG_SOURCE_VERSION:=2e0e85147d88db69256be9b38f7b3128d0126877
PKG_SOURCE_VERSION:=8e06ab67451edac1442855713ca3256c72f34be9
PKG_SOURCE:=linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH).tar.gz
PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/linkeasefull-runtime-v$(PKG_SOURCE_DATE)/
PKG_BUILD_DIR:=$(BUILD_DIR)/linkease-runtime-$(PKG_SOURCE_DATE)-linux-$(LINKEASE_RUNTIME_ARCH)
@@ -39,7 +39,7 @@ define Package/$(PKG_NAME)
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
TITLE:=LinkEase Full - the local web desktop
DEPENDS:=@(x86_64||aarch64) +linkease-common-bin +ca-bundle +cifsmount +kmod-fs-cifs
DEPENDS:=@(x86_64||aarch64) +linkease-app-entry +linkease-common-bin +ca-bundle +cifsmount +kmod-fs-cifs
PKGARCH:=all
URL:=https://www.linkease.com/
endef
+50 -112
View File
@@ -1,123 +1,61 @@
#!/bin/sh /etc/rc.common
# Compatibility control shim. linkease-app-entry is the only owner of port
# 19290 and the only procd instance allowed to launch linkease-full.
START=100
STOP=10
USE_PROCD=1
PROG=/usr/bin/linkease-full
LINKEASE_FULL_PORT=19290
LINKEASE_BASE_PATH=/apps/
LINKEASE_LEGACY_ADDR=0.0.0.0:8897
LOCAL_API=/var/run/linkease.sock
DATA_ROOT_WAIT_SECONDS=${LINKEASEFULL_DATA_ROOT_WAIT_SECONDS:-60}
ENTRY_INIT=/etc/init.d/linkease-app-entry
FORCE_GATEWAY=/var/run/linkease-app-entry/force-gateway
STATE_FILE=/var/run/linkease-app-entry/state.json
ensure_linkease_config() {
if [ ! -f /etc/config/linkease ]; then
touch /etc/config/linkease || return 1
fi
if ! uci -q get linkease.@linkease[0] >/dev/null; then
uci -q add linkease linkease >/dev/null || return 1
uci -q set linkease.@linkease[0].enabled='0' || return 1
uci -q set linkease.@linkease[0].port='8897' || return 1
uci -q set linkease.@linkease[0].allowPublic='0' || return 1
uci -q commit linkease || return 1
fi
}
is_allowed_data_root_parent() {
[ -n "$1" ] || return 1
case "$1" in
/tmp|/tmp/*|/var|/var/*|/run|/run/*|/dev|/dev/*|/proc|/proc/*|/sys|/sys/*|/rom|/rom/*|/overlay|/overlay/*) return 1 ;;
esac
return 0
}
wait_for_data_root_parent() {
is_allowed_data_root_parent "$1" || return 1
wait_left="$DATA_ROOT_WAIT_SECONDS"
case "$wait_left" in
""|*[!0-9]*) wait_left=60 ;;
esac
while [ "$wait_left" -gt 0 ]; do
[ -d "$1" ] && return 0
sleep 1
wait_left=$((wait_left - 1))
done
[ -d "$1" ]
}
is_persistent_data_root_parent() {
is_allowed_data_root_parent "$1" || return 1
[ -d "$1" ] || return 1
return 0
}
read_config() {
section="$1"
config_get_bool enabled "$section" enabled 1
ensure_linkease_config || true
data_root_parent="$(uci -q get linkease.@linkease[0].local_home 2>/dev/null || true)"
if [ -n "$data_root_parent" ] && ! wait_for_data_root_parent "$data_root_parent"; then
data_root_parent=""
fi
if [ -n "$data_root_parent" ] && ! is_persistent_data_root_parent "$data_root_parent"; then
data_root_parent=""
fi
if [ -n "$data_root_parent" ]; then
data_root="$data_root_parent/.linkease_data"
recycle_root="$data_root_parent/.linkease_recycle"
fi
log_root="/tmp/linkeasefull/logs"
}
start_service() {
config_load linkeasefull
config_foreach read_config linkeasefull
[ "$enabled" = "1" ] || return 1
[ -x "$PROG" ] || return 1
rm -f "$LOCAL_API"
if [ -n "$data_root_parent" ]; then
mkdir -p "$data_root/users/admin" "$data_root/system" "$data_root/tmp" "$recycle_root"
mkdir -p "$data_root/config" "$data_root/apptunnel" "$data_root/mountremote"
sync_force_gateway() {
mkdir -p "$(dirname "$FORCE_GATEWAY")"
enabled="$(uci -q get linkeasefull.@linkeasefull[0].enabled 2>/dev/null || echo 0)"
if [ "$enabled" = 1 ]; then
rm -f "$FORCE_GATEWAY"
else
echo "linkeasefull: data root is not initialized; starting for first-run setup" >&2
: > "$FORCE_GATEWAY"
fi
mkdir -p "$log_root"
# Do not set LINKEASE_CONFIG on OpenWrt. That variable belongs to
# Docker/file-backend deployments where users explicitly mount
# LINKEASE_CONFIG/preconfig.data. OpenWrt binding state is authoritative in
# UCI and must be accessed through /usr/sbin/linkease-config.sh. This is
# intentional: the user's data disk may be replaced, and the device must
# still be able to recover its binding from flash-backed OpenWrt config.
#
# Do not export LINKEASE_APPTUNNEL_PRECONFIG_SCRIPT or
# LINKEASE_APPTUNNEL_DEFAULT_ROOT_DIR here. Different devices have different
# storage roots, and hard-coded env wiring can pin apptunnel to a removed
# disk. The embedded clientruntime must discover linkease-config.sh and call
# local_load/local_save-compatible business APIs for the current root.
procd_open_instance linkeasefull
procd_set_param limits nofile="65535 65535"
procd_set_param command "$PROG" \
--host "0.0.0.0" \
--port "$LINKEASE_FULL_PORT" \
--mode "release" \
--basePath "$LINKEASE_BASE_PATH" \
--deviceAddr "$LINKEASE_LEGACY_ADDR" \
--localApi "$LOCAL_API" \
--supplierCode "linkease"
procd_set_param env \
"LINKEASE_AUTH_PROVIDER=openwrt" \
"KAIPLUS_ENABLED=0"
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "linkeasefull"
start() {
mkdir -p "$(dirname "$FORCE_GATEWAY")"
rm -f "$FORCE_GATEWAY"
uci -q set linkeasefull.@linkeasefull[0].enabled='1'
uci -q commit linkeasefull
"$ENTRY_INIT" restart
}
stop() {
mkdir -p "$(dirname "$FORCE_GATEWAY")"
: > "$FORCE_GATEWAY"
"$ENTRY_INIT" restart
}
restart() {
start
}
reload() {
sync_force_gateway
"$ENTRY_INIT" restart
}
running() {
[ -r "$STATE_FILE" ] && grep -q '"active":"linkeasefull"' "$STATE_FILE"
}
status() {
if running; then
echo "running"
return 0
fi
if [ -r "$STATE_FILE" ]; then
cat "$STATE_FILE"
else
echo "stopped"
fi
return 1
}
+4 -1
View File
@@ -30,6 +30,9 @@ uci -q batch <<-EOF >/dev/null
commit ucitrack
EOF
/etc/init.d/linkeasefull enable
/etc/init.d/linkeasefull disable
/etc/init.d/linkease-app-entry enable
rm -f /var/run/linkease-app-entry/force-gateway
/etc/init.d/linkease-app-entry restart >/dev/null 2>&1 || true
exit 0
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=AgentFlow
PKG_VERSION:=1.0.1
PKG_RELEASE:=7
LUCI_DEPENDS:=+agentflow +luci-compat +luci-lib-taskd
PKG_RELEASE:=8
LUCI_DEPENDS:=+agentflow +luci-compat +luci-lib-taskd +luci-lib-linkeaseauth +linkease-app-entry
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
@@ -1,18 +1,5 @@
local http = require "luci.http"
module("luci.controller.agentflow", package.seeall)
local APPS_PROXY_PREFIX = "/apps=http://127.0.0.1:19290"
local DEFAULT_BASE_PATH = "/apps/agentflow/"
local DEFAULT_PORT = 9000
local AGENTS = {
{ id = "codexcli", package = "@openai/codex" },
{ id = "claude-code", package = "@anthropic-ai/claude-code" },
{ id = "opencode", package = "opencode-ai" },
{ id = "kimi", package = "@moonshot-ai/kimi-code" },
{ id = "reasonix", package = "reasonix" }
}
function index()
entry({"admin", "services", "agentflow_status"}, call("agentflow_status"))
local open = entry({"admin", "services", "agentflow", "open"}, call("agentflow_open"))
@@ -20,277 +7,29 @@ function index()
open.dependent = false
open.sysauth = false
if not nixio.fs.access("/etc/config/agentflow") then
return
end
local install = entry({"admin", "services", "agentflow", "agent_install"}, call("agentflow_agent_install"))
install.leaf = true
local page = entry({"admin", "services", "agentflow"}, cbi("agentflow"), _("AgentFlow"), 100)
page.dependent = true
if not nixio.fs.access("/etc/config/agentflow") then return end
entry({"admin", "services", "agentflow"}, cbi("agentflow"), _("AgentFlow"), 100).dependent = true
end
local function write_json(obj)
http.prepare_content("application/json")
http.write_json(obj)
end
local function require_post_csrf()
local function compat()
local dispatcher = require "luci.dispatcher"
local context = dispatcher.context or {}
local expected = context.authtoken or context.token
if (http.getenv("REQUEST_METHOD") or "") ~= "POST" then
http.status(405, "Method Not Allowed")
write_json({ ok = false, error = "method not allowed" })
return false
end
if not context.authsession or not expected or http.formvalue("token") ~= expected then
http.status(403, "Forbidden")
write_json({ ok = false, error = "invalid csrf token" })
return false
end
return true
end
local function uhttpd_has_apps_proxy_prefix()
local uci = require "luci.model.uci".cursor()
local mappings = uci:get_list("uhttpd", "main", "proxy_prefix") or {}
for _, mapping in ipairs(mappings) do
if mapping == APPS_PROXY_PREFIX then
return true
end
end
return false
end
local function uhttpd_supports_proxy_prefix()
local sys = require "luci.sys"
return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0
end
local function uhttpd_apps_proxy_available()
return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix()
end
local function linkeasefull_running()
local sys = require "luci.sys"
return sys.call("[ -x /etc/init.d/linkeasefull ] && /etc/init.d/linkeasefull running >/dev/null 2>&1") == 0
end
local function normalized_base_path(path)
path = path or DEFAULT_BASE_PATH
if path:sub(1, 1) ~= "/" then
path = "/" .. path
end
if path:sub(-1) ~= "/" then
path = path .. "/"
end
return path
end
local function authority_host(authority)
if not authority or authority == "" then
return ""
end
if authority:sub(1, 1) == "[" then
return authority:match("^%[([^%]]+)%]") or ""
end
return authority:match("^([^:]+)") or authority
end
local function url_authority(host, port)
if not host or host == "" then
host = "127.0.0.1"
end
if host:find(":") and host:sub(1, 1) ~= "[" then
host = "[" .. host .. "]"
end
return host .. ":" .. tostring(port)
end
local function request_or_lan_host()
local uci = require "luci.model.uci".cursor()
local host = authority_host(http.getenv("HTTP_HOST") or "")
if host ~= "" then
return host
end
return uci:get("network", "lan", "ipaddr") or "127.0.0.1"
end
local function agentflow_config()
local uci = require "luci.model.uci".cursor()
local port = tonumber(uci:get_first("agentflow", "agentflow", "port")) or DEFAULT_PORT
if port < 1 or port > 65535 then
port = DEFAULT_PORT
end
local base_path = normalized_base_path(uci:get_first("agentflow", "agentflow", "base_path"))
return port, base_path
end
local function agentflow_entry_url()
local port, base_path = agentflow_config()
if linkeasefull_running() and uhttpd_apps_proxy_available() then
return base_path
end
return "http://" .. url_authority(request_or_lan_host(), port) .. base_path
end
local function node_modules_root()
local sys = require "luci.sys"
local util = require "luci.util"
local script = table.concat({
'. /lib/functions/mise.sh 2>/dev/null || exit 1',
'istore_runtime_env >/dev/null 2>&1 || exit 1',
'mise_bin="$(command -v mise-istore || command -v mise)"',
'[ -n "$mise_bin" ] || exit 1',
'node_dir="$("$mise_bin" where node@lts 2>/dev/null)"',
'[ -n "$node_dir" ] || exit 1',
'printf "%s/lib/node_modules" "$node_dir"'
}, "; ")
local root = sys.exec("/bin/sh -c " .. util.shellquote(script)) or ""
root = root:match("^%s*(.-)%s*$")
if root == "" or root:sub(1, 1) ~= "/" then
return nil
end
return root
end
local function agent_statuses()
local fs = require "nixio.fs"
local jsonc = require "luci.jsonc"
local root = node_modules_root()
local statuses = {}
for _, agent in ipairs(AGENTS) do
local status = { id = agent.id, installed = false }
if root then
local package_file = root .. "/" .. agent.package .. "/package.json"
local package_data = fs.readfile(package_file)
local package_json = package_data and jsonc.parse(package_data) or nil
if type(package_json) == "table" then
status.installed = true
status.version = package_json.version
end
end
statuses[#statuses + 1] = status
end
return statuses, root ~= nil
return require("luci.model.linkease.apps_compat").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function agentflow_status()
local sys = require "luci.sys"
local port, base_path = agentflow_config()
local entry_url = agentflow_entry_url()
local agents, agents_available = agent_statuses()
local status = {
running = (sys.call("pidof agentflow >/dev/null") == 0),
port = port,
base_path = base_path,
entry_url = entry_url,
proxy_prefix_supported = uhttpd_supports_proxy_prefix(),
proxy_prefix_enabled = uhttpd_apps_proxy_available(),
linkeasefull_running = linkeasefull_running(),
agents_available = agents_available,
agents = agents
}
write_json(status)
end
function agentflow_agent_install()
local fs = require "nixio.fs"
local jsonc = require "luci.jsonc"
local sys = require "luci.sys"
local util = require "luci.util"
local task_id = "agentflow-agent-install"
local task_script = "/tmp/agentflow-agent-install.sh"
local installer_url = "https://fw.koolcenter.com/binary/geili/agentflow/releases/installapp/installapp-mise.sh"
local supported_agents = {}
for _, supported_agent in ipairs(AGENTS) do
supported_agents[supported_agent.id] = true
end
if not require_post_csrf() then
return
end
local agent = http.formvalue("agent") or ""
if not supported_agents[agent] then
write_json({ ok = false, error = "unsupported agent" })
return
end
if not fs.access("/etc/init.d/tasks") then
write_json({ ok = false, error = "taskd is not available" })
return
end
if not fs.access("/lib/functions/mise.sh") then
write_json({ ok = false, error = "mise environment helper is not available" })
return
end
local task_status = sys.exec("/etc/init.d/tasks task_status " .. task_id .. " 2>/dev/null")
local status = jsonc.parse(task_status) or {}
local running = type(status) == "table" and status.running
if running == true or running == 1 or running == "1" or running == "true" then
write_json({ ok = true, busy = true, task_id = task_id })
return
end
local install_script = table.concat({
"set -e",
"agent=" .. util.shellquote(agent),
"installer_url=" .. util.shellquote(installer_url),
'installer="/tmp/agentflow-installapp-mise.$$"',
'cleanup() { rm -f "$installer" "$0"; }',
"trap cleanup 0 HUP INT TERM",
'. /lib/functions/mise.sh',
'if ! istore_runtime_env; then echo "[agentflow] Failed to initialize the shared runtime environment" >&2; exit 1; fi',
"set -u",
"export MISE_YES=1",
'echo "[agentflow] Downloading installer: $installer_url"',
'if command -v wget >/dev/null 2>&1; then',
'\twget -O "$installer" "$installer_url"',
'elif command -v curl >/dev/null 2>&1; then',
'\tcurl -fL -o "$installer" "$installer_url"',
'elif command -v uclient-fetch >/dev/null 2>&1; then',
'\tuclient-fetch -O "$installer" "$installer_url"',
"else",
'\techo "[agentflow] No HTTPS download tool is available" >&2',
"\texit 1",
"fi",
'if [ ! -s "$installer" ]; then echo "[agentflow] Failed to download the agent installer" >&2; exit 1; fi',
'chmod 0700 "$installer"',
'echo "[agentflow] Running installapp-mise.sh for $agent in $HOME"',
'/bin/sh "$installer" "$agent"'
}, "\n")
if not fs.writefile(task_script, install_script .. "\n") then
write_json({ ok = false, error = "failed to create install task", task_id = task_id })
return
end
if sys.call("chmod 0600 " .. util.shellquote(task_script)) ~= 0 then
fs.unlink(task_script)
write_json({ ok = false, error = "failed to secure install task", task_id = task_id })
return
end
local command = "/bin/sh " .. util.shellquote(task_script)
local rc = sys.call("/etc/init.d/tasks task_add " .. task_id .. " " .. util.shellquote(command) .. " >/dev/null 2>&1")
if rc ~= 0 then
fs.unlink(task_script)
write_json({ ok = false, error = "failed to start install task", task_id = task_id })
return
end
write_json({ ok = true, task_id = task_id })
local uci = require "luci.model.uci".cursor()
compat():legacy_status("agentflow", {
running = sys.call("pidof agentflow >/dev/null") == 0,
port = uci:get_first("agentflow", "agentflow", "port") or "9000",
base_path = uci:get_first("agentflow", "agentflow", "base_path") or "/apps/agentflow/"
})
end
function agentflow_open()
local entry_url = agentflow_entry_url()
http.redirect(entry_url)
compat():open("agentflow")
end
@@ -9,21 +9,23 @@ class AgentFlowLuciOpenContractTest(unittest.TestCase):
def read(self, relative):
return (APP_DIR / relative).read_text(encoding="utf-8")
def test_luci_open_matches_linkease_proxy_pattern(self):
def test_luci_open_delegates_to_shared_apps_entry(self):
makefile = self.read("Makefile")
controller = self.read("luasrc/controller/agentflow.lua")
status = self.read("luasrc/view/agentflow/status.htm")
self.assertNotIn("+luci-lib-linkeaseauth", makefile)
self.assertIn("+luci-lib-linkeaseauth", makefile)
self.assertIn("+linkease-app-entry", makefile)
self.assertIn('entry({"admin", "services", "agentflow", "open"}', controller)
self.assertIn("open.sysauth = false", controller)
self.assertIn("function agentflow_open()", controller)
self.assertIn("uhttpd_apps_proxy_available()", controller)
self.assertIn("linkeasefull_running()", controller)
self.assertIn("return base_path", controller)
self.assertIn("url_authority(request_or_lan_host(), port)", controller)
self.assertIn("http.redirect(entry_url)", controller)
self.assertNotIn('linkease_auth_url("auth")', controller)
self.assertIn('compat():open("agentflow")', controller)
self.assertIn('compat():legacy_status("agentflow"', controller)
self.assertIn('http = require "luci.http"', controller)
self.assertIn('resolver = require("luci.model.linkease.apps_openwrt").new()', controller)
self.assertIn('auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")', controller)
self.assertNotIn("uhttpd_apps_proxy_available", controller)
self.assertNotIn("linkeasefull_running", controller)
self.assertIn('url("admin/services/agentflow/open")', status)
self.assertNotIn('window.location.hostname + ":" + st.port', status)
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=baidudrive
PKG_VERSION:=1.0.2
PKG_RELEASE:=3
LUCI_DEPENDS:=+baidudrive +luci-compat
PKG_RELEASE:=4
LUCI_DEPENDS:=+baidudrive +luci-compat +luci-lib-linkeaseauth +linkease-app-entry
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
@@ -1,29 +1,39 @@
local http = require "luci.http"
module("luci.controller.baidudrive", package.seeall)
function index()
if not nixio.fs.access("/etc/config/baidudrive") then
return
end
local page
page = entry({"admin", "services", "baidudrive"}, cbi("baidudrive"), _("BaiduDrive"), 100)
page.dependent = true
if not nixio.fs.access("/etc/config/baidudrive") then return end
entry({"admin", "services", "baidudrive"}, cbi("baidudrive"), _("BaiduDrive"), 100).dependent = true
entry({"admin", "services", "baidudrive_status"}, call("baidudrive_status"))
local open = entry({"admin", "services", "baidudrive", "open"}, call("baidudrive_open"))
open.leaf = true
open.dependent = false
open.sysauth = "root"
open.sysauth_authenticator = "htmlauth"
end
local function compat()
local dispatcher = require "luci.dispatcher"
return require("luci.model.linkease.apps_compat").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function baidudrive_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = uci:get_first("baidudrive", "baidudrive", "port") or "10780"
local app_running = (sys.call("pidof baidudrive >/dev/null") == 0)
local sdk_running = (sys.call("pidof baiduNas >/dev/null") == 0)
local status = {
local uci = require("luci.model.uci").cursor()
local app_running = sys.call("pidof baidudrive >/dev/null") == 0
local sdk_running = sys.call("pidof baiduNas >/dev/null") == 0
compat():legacy_status("baidudrive", {
running = app_running and sdk_running,
app_running = app_running,
sdk_running = sdk_running,
port = port
}
luci.http.prepare_content("application/json")
luci.http.write_json(status)
port = uci:get_first("baidudrive", "baidudrive", "port") or "10780"
})
end
function baidudrive_open()
compat():open("baidudrive")
end
@@ -9,7 +9,7 @@ XHR.poll(5, '<%=url("admin/services/baidudrive_status")%>', null, function(x, st
}
if (st.running) {
el.innerHTML = '<br/><em style=\"color:green\"><%:The BaiduDrive service is running.%></em>'
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open BaiduDrive%> \" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>";
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open BaiduDrive%> \" onclick=\"window.open('<%=url("admin/services/baidudrive/open")%>')\"/>";
}
}
});
+2 -2
View File
@@ -7,11 +7,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for Docker Manager
LUCI_DEPENDS:=+dockermanager +luci-lib-linkeaseauth
LUCI_DEPENDS:=+dockermanager +luci-lib-linkeaseauth +linkease-app-entry
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.1
PKG_RELEASE:=2
PKG_RELEASE:=3
include $(TOPDIR)/feeds/luci/luci.mk
@@ -1,7 +1,5 @@
module("luci.controller.dockermanager", package.seeall)
local APPS_PROXY_PREFIX = "/apps=http://127.0.0.1:19290"
function index()
entry({"admin", "services", "dockermanager_status"}, call("dockermanager_status"))
local open = entry({"admin", "services", "dockermanager", "open"}, call("dockermanager_open"))
@@ -10,189 +8,33 @@ function index()
open.sysauth = "root"
open.sysauth_authenticator = "htmlauth"
if not nixio.fs.access("/etc/config/dockermanager") then
return
end
if not nixio.fs.access("/etc/config/dockermanager") then return end
entry({"admin", "services", "dockermanager"}, cbi("dockermanager"), _("Docker Manager"), 22).dependent = true
end
local function uhttpd_has_apps_proxy_prefix()
local uci = require "luci.model.uci".cursor()
local mappings = uci:get_list("uhttpd", "main", "proxy_prefix") or {}
for _, mapping in ipairs(mappings) do
if mapping == APPS_PROXY_PREFIX then
return true
end
end
return false
end
local function uhttpd_supports_proxy_prefix()
local sys = require "luci.sys"
return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0
end
local function uhttpd_apps_proxy_available()
return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix()
end
local function linkeasefull_running()
local sys = require "luci.sys"
return sys.call("[ -x /etc/init.d/linkeasefull ] && /etc/init.d/linkeasefull running >/dev/null 2>&1") == 0
end
local function normalized_base_path(path)
path = path or "/apps/dockermanager/"
if path:sub(1, 1) ~= "/" then
path = "/" .. path
end
if path:sub(-1) ~= "/" then
path = path .. "/"
end
return path
end
local function cookie_encode(value)
return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
return string.format("%%%02X", char:byte())
end)
end
local function authority_host(authority)
if not authority or authority == "" then
return ""
end
if authority:sub(1, 1) == "[" then
return authority:match("^%[([^%]]+)%]") or ""
end
return authority:match("^([^:]+)") or authority
end
local function url_authority(host, port)
if not host or host == "" then
host = "127.0.0.1"
end
if host:find(":") and host:sub(1, 1) ~= "[" then
host = "[" .. host .. "]"
end
return host .. ":" .. tostring(port)
end
local function request_or_lan_host()
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local host = authority_host(http.getenv("HTTP_HOST") or "")
if host ~= "" then
return host
end
return uci:get("network", "lan", "ipaddr") or "127.0.0.1"
end
local function linkease_auth_url(name)
local function compat()
local dispatcher = require "luci.dispatcher"
return dispatcher.build_url("admin", "services", "linkease_auth", name)
end
local function dockermanager_config()
local uci = require "luci.model.uci".cursor()
local port = uci:get_first("dockermanager", "dockermanager", "port") or "8192"
local base_path = normalized_base_path(uci:get_first("dockermanager", "dockermanager", "base_path"))
local external_port_enabled = uci:get_first("dockermanager", "dockermanager", "external_port_enabled") == "1"
local listen_mode = uci:get_first("dockermanager", "dockermanager", "listen_mode") or "auto"
local socket_path = uci:get_first("dockermanager", "dockermanager", "socket_path") or "/var/run/dockermanager.sock"
return port, base_path, external_port_enabled, listen_mode, socket_path
end
local function dockermanager_entry_url()
local port, base_path, external_port_enabled = dockermanager_config()
if linkeasefull_running() and uhttpd_apps_proxy_available() then
return base_path
end
if not external_port_enabled then
return nil
end
return "http://" .. url_authority(request_or_lan_host(), port) .. base_path
end
local function enable_external_port()
local uci = require "luci.model.uci".cursor()
local section = nil
uci:foreach("dockermanager", "dockermanager", function(s)
if not section then
section = s[".name"]
end
end)
if section then
uci:set("dockermanager", section, "external_port_enabled", "1")
uci:set("dockermanager", section, "listen_mode", "tcp")
uci:commit("dockermanager")
end
local sys = require "luci.sys"
sys.call("/etc/init.d/dockermanager restart >/dev/null 2>&1; sleep 1")
end
local function render_enable_port_confirm()
local dispatcher = require "luci.dispatcher"
local confirm_url = dispatcher.build_url("admin", "services", "dockermanager", "open") .. "?enable_port=1"
luci.http.prepare_content("text/html; charset=utf-8")
luci.http.write("<!doctype html><html><head><meta charset='utf-8'><title>Docker Manager</title></head><body>")
luci.http.write("<h2>Docker Manager</h2>")
luci.http.write("<p>当前系统无法通过 LinkEase 桌面代理打开 Docker Manager。</p>")
luci.http.write("<p>可以启用局域网端口 8192 后继续打开。</p>")
luci.http.write("<p><a class='cbi-button cbi-button-apply' href='" .. confirm_url .. "'>启用 8192 并打开</a></p>")
luci.http.write("</body></html>")
end
local function render_open_failed()
luci.http.prepare_content("text/html; charset=utf-8")
luci.http.write("<!doctype html><html><head><meta charset='utf-8'><title>Docker Manager</title></head><body>")
luci.http.write("<h2>Docker Manager</h2>")
luci.http.write("<p>Docker Manager 已安装,但当前没有可用访问入口。</p>")
luci.http.write("<p>请启用 LinkEaseFull 的 /apps 代理,或在 Docker Manager 设置中开放 8192 端口。</p>")
luci.http.write("</body></html>")
return require("luci.model.linkease.apps_compat").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function dockermanager_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port, base_path, external_port_enabled, listen_mode, socket_path = dockermanager_config()
local entry_url = dockermanager_entry_url()
local status = {
running = (sys.call("pidof docker-manager >/dev/null") == 0),
port = port,
base_path = base_path,
listen_mode = listen_mode,
socket_path = socket_path,
external_port_enabled = external_port_enabled,
entry_url = entry_url or "",
lan_ip = uci:get("network", "lan", "ipaddr") or "",
proxy_prefix_supported = uhttpd_supports_proxy_prefix(),
proxy_prefix_enabled = uhttpd_apps_proxy_available(),
linkeasefull_running = linkeasefull_running()
}
luci.http.prepare_content("application/json")
luci.http.write_json(status)
local uci = require("luci.model.uci").cursor()
compat():legacy_status("dockermanager", {
running = sys.call("pidof docker-manager >/dev/null") == 0,
port = uci:get_first("dockermanager", "dockermanager", "port") or "8192",
base_path = uci:get_first("dockermanager", "dockermanager", "base_path") or "/apps/dockermanager/",
listen_mode = uci:get_first("dockermanager", "dockermanager", "listen_mode") or "auto",
socket_path = uci:get_first("dockermanager", "dockermanager", "socket_path") or "/var/run/dockermanager.sock",
external_port_enabled = uci:get_first("dockermanager", "dockermanager", "external_port_enabled") == "1",
lan_ip = uci:get("network", "lan", "ipaddr") or ""
})
end
function dockermanager_open()
local http = require "luci.http"
if http.formvalue("enable_port") == "1" then
enable_external_port()
end
local entry_url = dockermanager_entry_url()
if entry_url then
http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(entry_url))
return
end
if http.formvalue("enable_port") == "1" then
render_open_failed()
return
end
render_enable_port_confirm()
compat():open("dockermanager")
end
@@ -8,7 +8,7 @@
var status = st.running
? '<br/><em style=\"color:green\"><%:The Docker Manager service is running.%></em>'
: '<br/><em style=\"color:red\"><%:The Docker Manager service is not running.%></em>';
if (st.proxy_prefix_enabled && st.linkeasefull_running)
if (st.proxy_prefix_enabled && st.app_entry_running)
{
status = status + '<br/><em><%:Access mode: LinkEase Desktop proxy.%></em>';
}
+3 -4
View File
@@ -6,11 +6,10 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for fastnet
LUCI_DEPENDS:=+fastnet
LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry
LUCI_PKGARCH:=all
PKG_VERSION:=0.7.7-r1
# PKG_RELEASE MUST be empty for luci.mk
PKG_RELEASE:=2
PKG_VERSION:=0.7.7
PKG_RELEASE:=3
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
+21 -31
View File
@@ -1,44 +1,34 @@
module("luci.controller.fastnet", package.seeall)
function index()
local fs = require "nixio.fs"
if not fs.access("/etc/config/fastnet") then
return
end
if not nixio.fs.access("/etc/config/fastnet") then return end
entry({"admin", "services", "fastnet"}, cbi("fastnet"), _("FastNet"), 50).dependent = true
entry({"admin", "services", "fastnet", "status"}, call("action_status")).leaf = true
local open = entry({"admin", "services", "fastnet", "open"}, call("action_open"))
open.leaf = true
open.dependent = false
open.sysauth = false
end
local function compat()
local dispatcher = require "luci.dispatcher"
return require("luci.model.linkease.apps_compat").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function action_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local http = require "luci.http"
local function get_host()
local host = http.getenv("HTTP_HOST") or http.getenv("SERVER_NAME") or ""
host = host:gsub(":%d+$", "")
if host == "_redirect2ssl" or host == "redirect2ssl" or host == "" then
host = http.getenv("SERVER_ADDR") or "localhost"
end
return host
end
local running = (sys.call("pidof FastNet >/dev/null") == 0)
local host = get_host()
local port = uci:get_first("fastnet", "fastnet", "port") or "3200"
local token = uci:get_first("fastnet", "fastnet", "token") or ""
local url = "http://" .. host .. ":" .. port .. "/"
if token ~= "" then
url = url .. "?token=" .. token
end
http.prepare_content("application/json")
http.write_json({
running = running,
host = host,
port = port,
url = url
compat():legacy_status("fastnet", {
running = sys.call("pidof FastNet >/dev/null") == 0,
port = uci:get_first("fastnet", "fastnet", "port") or "3200"
})
end
function action_open()
compat():open("fastnet")
end
+2 -17
View File
@@ -1,27 +1,12 @@
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local http = require "luci.http"
local dispatcher = require "luci.dispatcher"
local m = Map("fastnet", translate("FastNet"))
m.description = translate("FastNet provides network testing tools and a Web UI.")
local function get_host()
local host = http.getenv("HTTP_HOST") or http.getenv("SERVER_NAME") or ""
host = host:gsub(":%d+$", "")
if host == "_redirect2ssl" or host == "redirect2ssl" or host == "" then
host = http.getenv("SERVER_ADDR") or "localhost"
end
return host
end
local st = m:section(SimpleSection, translate("Status"))
local running = (sys.call("pidof FastNet >/dev/null") == 0)
local listen_port = uci:get_first("fastnet", "fastnet", "port") or "3200"
local token = uci:get_first("fastnet", "fastnet", "token") or ""
local url = "http://" .. get_host() .. ":" .. listen_port .. "/"
if token ~= "" then
url = url .. "?token=" .. token
end
local url = dispatcher.build_url("admin", "services", "linkease_apps", "open") .. "?id=fastnet"
st.template = "fastnet/status"
st.running = running
+1 -1
View File
@@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +logd +jsonfilter
LUCI_PKGARCH:=all
PKG_VERSION:=0.1.45
PKG_RELEASE:=34
PKG_RELEASE:=35
# Reproducible build: honor SOURCE_DATE_EPOCH from the build environment (set by docker-sdk.sh / CI).
ifdef SOURCE_DATE_EPOCH
@@ -36,7 +36,9 @@ function chipValueNodes(spec, val) {
return [
label + ': ',
E('strong', { 'class': 'fwlive-chip-not' }, [_('not')]),
' contains ',
' ',
_('contains'),
' ',
valueNode
];
@@ -11,5 +11,5 @@
* GENERATED — do not edit. Edit fwlive.css and run: node scripts/embed-fwlive-css.js
*/
return baseclass.extend({
styleText: "\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-display-bar-label {\n\tflex: 0 0 auto;\n\tfoLine truncated
styleText: "\n/* SPDX-License-Identifier: Apache-2.0 */\n.fwlive-map {\n\tmax-width: none;\n\twidth: 100%;\n\t--fwlive-pass-color: var(--success-color-high, var(--success-color, #46a546));\n\t--fwlive-deny-color: var(--error-color-high, var(--error-color, #ca3c3c));\n\t--fwlive-bg-medium: var(--background-color-medium, var(--white-color-low, #f9f9f9));\n\t--fwlive-paused-color: #b45309;\n}\n/* Wide page in Detail only — Simple keeps LuCI’s 940px column. */\n#maincontent.container:has(.fwlive-map[data-view=\"detailed\"]) {\n\tmax-width: none;\n\tpadding-left: 16px;\n\tpadding-right: 16px;\n}\n.fwlive-map[data-row-tint=\"accessible\"] {\n\t--fwlive-pass-color: #0d9488;\n\t--fwlive-deny-color: #c2410c;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row,\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tbackground: rgba(180, 83, 9, 0.08);\n\tbackground: color-mix(in srgb, var(--fwlive-paused-color) 8%, transparent);\n}\n.fwlive-map.fwlive-watch-paused .fwlive-title-row {\n\tmargin: 0 -8px;\n\tpadding: 6px 8px 0;\n\tborder-radius: 3px 3px 0 0;\n}\n.fwlive-map.fwlive-watch-paused .fwlive-watch-strip {\n\tmargin: 0 -8px 10px;\n\tpadding: 8px;\n\tborder-radius: 0 0 3px 3px;\n\tborder-bottom-color: transparent;\n}\n.fwlive-title-row {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: baseline;\n\tjustify-content: space-between;\n\tgap: 8px 16px;\n\tmargin: 0 0 4px;\n}\n.fwlive-title-row h2 {\n\tmargin: 0;\n\tflex: 0 1 auto;\n}\n.fwlive-title-status {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 8px;\n\tflex-wrap: wrap;\n\tmin-width: 0;\n\tmargin-left: auto;\n}\n.fwlive-watch-strip {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: stretch;\n\tjustify-content: flex-start;\n\tgap: 0;\n\tmin-height: 40px;\n\tpadding: 8px 0 10px;\n\tborder-bottom: 1px solid var(--border-color-medium);\n\tmargin-bottom: 10px;\n}\n.fwlive-watch-group {\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 6px;\n\tmin-height: 28px;\n\tpadding: 0 10px;\n\tborder-left: 1px solid var(--border-color-medium);\n\tflex-wrap: wrap;\n}\n.fwlive-watch-group:last-child {\n\tpadding-right: 0;\n}\n.fwlive-watch-group-label {\n\tmargin-right: 2px;\n\tcolor: var(--text-color-medium);\n\tfont-size: 0.72em;\n\tfont-weight: 700;\n\tletter-spacing: 0.08em;\n\ttext-transform: uppercase;\n\twhite-space: nowrap;\n}\n.fwlive-watch-seg {\n\tdisplay: inline-flex;\n\talign-items: stretch;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\toverflow: hidden;\n}\n.fwlive-watch-seg .fwlive-seg-btn {\n\tmargin: 0;\n\tborder: 0;\n\tborder-radius: 0;\n\tborder-right: 1px solid var(--border-color-medium);\n\tbackground: transparent;\n\tpadding: 4px 10px;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n}\n.fwlive-watch-seg .fwlive-seg-btn:last-child {\n\tborder-right: 0;\n}\n.fwlive-watch-seg .fwlive-seg-btn[aria-pressed=\"true\"] {\n\tbackground: rgba(26, 111, 156, 0.12);\n\tbackground: color-mix(in srgb, var(--primary-color-high) 12%, transparent);\n\tcolor: var(--primary-color-high);\n\tfont-weight: 600;\n}\n.fwlive-watch-label { font-weight: 600; }\n.fwlive-watch-paused .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-map.fwlive-watch-paused .fwlive-title-status .fwlive-watch-label { color: var(--fwlive-paused-color); }\n.fwlive-dot {\n\twidth: 8px;\n\theight: 8px;\n\tborder-radius: 50%;\n\tbackground: var(--text-color-low, var(--border-color-high));\n\tdisplay: inline-block;\n\tflex-shrink: 0;\n}\n.fwlive-dot.fwlive-dot-on {\n\tbackground: var(--fwlive-pass-color);\n\tbox-shadow: 0 0 0 3px color-mix(in srgb, var(--fwlive-pass-color) 22%, transparent);\n}\n.fwlive-btn-ghost {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n}\n.fwlive-btn-quiet {\n\tbackground: transparent;\n\tborder-color: var(--border-color-medium);\n\tcolor: var(--text-color-medium);\n\tfont-weight: 500;\n}\n.fwlive-log-merged {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 6px;\n\tpadding: 4px 10px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-medium, var(--white-color-low, var(--background-color-high)));\n\tcolor: var(--text-color-high);\n\tfont-weight: 500;\n\tfont-size: 0.88em;\n\tline-height: 1.25;\n\tmin-height: 28px;\n\tcursor: pointer;\n}\n.fwlive-log-merged:hover {\n\tbackground: var(--background-color-low, var(--background-color-medium));\n}\n.fwlive-log-merged:disabled {\n\topacity: 0.65;\n\tcursor: default;\n}\n.fwlive-log-on-dot {\n\twidth: 6px;\n\theight: 6px;\n\tborder-radius: 50%;\n\tbackground: var(--fwlive-pass-color);\n\tflex-shrink: 0;\n}\n.fwlive-log-rate {\n\tcolor: var(--text-color-medium);\n\tfont-weight: 400;\n}\n.fwlive-display-bar {\n\tdisplay: flex;\n\tflex-wrap: wrap;\n\talign-items: center;\n\tgap: 8px 14px;\n\tmargin: 0 0 12px;\n\tpadding: 8px 12px;\n\tborder: 1px solid var(--border-color-medium);\n\tborder-radius: 3px;\n\tbackground: var(--background-color-high);\n}\n.fwlive-dLine truncated
});
@@ -1,3 +1,4 @@
/* SPDX-License-Identifier: Apache-2.0 */
.fwlive-map {
max-width: none;
width: 100%;
@@ -5,81 +5,103 @@
/**
* Shared classify logic mirrors core/fwlive-log.js CLASSIFY_SPEC — keep in sync
* (gen-luci-wrapper.js gates full-spec drift; ./scripts/gen-all.sh verifies).
* (gen-luci-wrapper.js gates full-spec + regex drift; ./scripts/gen-all.sh verifies).
* LuCI-only helpers live in the @fwlive-codegen:luci-preserve region.
*/
const CLASSIFY_SPEC = {
glueKeys: [
'IN',
'OUT',
'SRC',
'DST',
'PROTO',
'SPT',
'DPT',
'LEN',
'MAC',
'TYPE',
'CODE',
'TTL',
'TOS',
'PREC',
'DF'
],
nonFirewallPrefixes: [
'dnsmasq',
'procd',
'ubusd',
'netifd',
'odhcpd',
'logd',
'dropbear',
'uhttpd',
'hostapd',
'wpad'
],
nonFirewallPrefixHyphenContinuation: true,
firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'],
actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'],
rules: [
{
or: [
{ and: [{ kv: ['SRC'] }, { kv: ['DST'] }] },
{
and: [
{ kvAny: ['IN', 'OUT'] },
{ kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] }
]
},
{ and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] }
]
},
{ and: [{ hint: true }, { action: 'known' }] },
{ and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] }
]
};
function wordPattern(words) {
const alt = words.join('|');
return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i');
}
/* ---- spec-derived classification regexes (mirror core/fwlive-log.js) ---- */
const DENY_CLASS_WORDS = CLASSIFY_SPEC.actionWords.slice(3);
const NON_FIREWALL_PREFIX = new RegExp(
'^(' +
CLASSIFY_SPEC.nonFirewallPrefixes.join('|') +
')(' +
(CLASSIFY_SPEC.nonFirewallPrefixHyphenContinuation ? '[^A-Za-z0-9_-]' : '[^A-Za-z0-9_]') +
'|$)',
'i'
);
const FIREWALL_HINT = wordPattern(CLASSIFY_SPEC.firewallHints);
const ACTION_RE = wordPattern(CLASSIFY_SPEC.actionWords);
const DENY_ACTION = wordPattern(DENY_CLASS_WORDS);
const DENY_ACTION_UNDERSCORE = new RegExp(
'(?:^|[^A-Za-z0-9])(?:' + DENY_CLASS_WORDS.join('|') + ')(?:[^A-Za-z0-9]|$)',
'i'
);
const NETFILTER_KV_GLUE = new RegExp(
'([^\\s])(?=(' + CLASSIFY_SPEC.glueKeys.join('|') + ')=)',
'g'
);
return baseclass.extend({
CLASSIFY_SPEC: {
glueKeys: [
'IN',
'OUT',
'SRC',
'DST',
'PROTO',
'SPT',
'DPT',
'LEN',
'MAC',
'TYPE',
'CODE',
'TTL',
'TOS',
'PREC',
'DF'
],
nonFirewallPrefixes: [
'dnsmasq',
'procd',
'ubusd',
'netifd',
'odhcpd',
'logd',
'dropbear',
'uhttpd',
'hostapd',
'wpad'
],
nonFirewallPrefixHyphenContinuation: true,
firewallHints: ['fw4', 'nft', 'iptables', 'kernel', 'firewall'],
actionWords: ['ACCEPT', 'ALLOW', 'PASS', 'DROP', 'REJECT', 'DENY', 'BLOCK'],
rules: [
{
or: [
{ and: [{ kv: ['SRC'] }, { kv: ['DST'] }] },
{
and: [
{ kvAny: ['IN', 'OUT'] },
{ kvAny: ['SRC', 'DST', 'PROTO', 'SPT', 'DPT'] }
]
},
{ and: [{ action: 'known' }, { kvAny: ['IN', 'OUT', 'PROTO', 'SRC', 'DST'] }] }
]
},
{ and: [{ hint: true }, { action: 'known' }] },
{ and: [{ hint: true }, { kvAny: ['IN', 'OUT', 'SRC', 'DST', 'PROTO'] }] }
]
},
CLASSIFY_SPEC: CLASSIFY_SPEC,
TCP_FLAG_TAIL:
/\b((?:SYN|ACK|FIN|RST|PSH|URG)(?:\s+(?:SYN|ACK|FIN|RST|PSH|URG))*)(?:\s+[A-Z][A-Z0-9_]*=[^\s]+)*\s*$/i,
NETFILTER_KV_GLUE:
/([^\s])(?=(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=)/g,
wordPattern: function (words) {
const alt = words.join('|');
return new RegExp('(^|[^A-Za-z0-9_])(' + alt + ')([^A-Za-z0-9_]|$)', 'i');
},
NETFILTER_KV_GLUE: NETFILTER_KV_GLUE,
kvHas: function (msg, key) {
return new RegExp('(^|[^A-Za-z0-9_])' + key + '=').test(msg);
},
NON_FIREWALL_PREFIX:
/^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^A-Za-z0-9_-]|$)/i,
FIREWALL_HINT: /(^|[^A-Za-z0-9_])(fw4|nft|iptables|kernel|firewall)([^A-Za-z0-9_]|$)/i,
ACTION_RE: /(^|[^A-Za-z0-9_])(ACCEPT|ALLOW|PASS|DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i,
DENY_ACTION: /(^|[^A-Za-z0-9_])(DROP|REJECT|DENY|BLOCK)([^A-Za-z0-9_]|$)/i,
DENY_ACTION_UNDERSCORE: /(?:^|[^A-Za-z0-9])(?:DROP|REJECT|DENY|BLOCK)(?:[^A-Za-z0-9]|$)/i,
NON_FIREWALL_PREFIX: NON_FIREWALL_PREFIX,
FIREWALL_HINT: FIREWALL_HINT,
ACTION_RE: ACTION_RE,
DENY_ACTION: DENY_ACTION,
DENY_ACTION_UNDERSCORE: DENY_ACTION_UNDERSCORE,
MAX_DATE_SECONDS: 8640000000000,
normalizeNetfilterMessage: function (message) {
@@ -288,24 +310,6 @@ return baseclass.extend({
return '%s:%s:%s'.format(pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds()));
},
formatFlowDisplay: function (row) {
const src = row && row.src ? String(row.src) : '';
const dst = row && row.dst ? String(row.dst) : '';
const sport = row && row.sport ? String(row.sport) : '';
const dport = row && row.dport ? String(row.dport) : '';
let left = src;
let right = dst;
if (sport) left = left ? left + ':' + sport : ':' + sport;
if (dport) right = right ? right + ':' + dport : ':' + dport;
if (!left && !right) return '—';
if (!right) return left;
if (!left) return '→ ' + right;
return left + ' → ' + right;
},
formatCell: function (value) {
if (value == null || value === '') return '';
@@ -459,14 +463,7 @@ return baseclass.extend({
const p = this.parseFilterValue(val);
if (!p.value) return '';
if (p.negate) {
if (field === 'q' || field === 'src' || field === 'dst')
return '%s: not contains %s'.format(field, p.value);
return '%s: not %s'.format(field, p.value);
}
return '%s: %s'.format(field, val);
return _('%s: %s').format(field, val);
},
matchesTextField: function (haystack, spec) {
@@ -9,7 +9,7 @@
*
* renderToolbar(host, state, callbacks) → void
* host - #fwlive-logging-bar strip slot (cleared and rebuilt; element kept)
* state - { loggingStatus, loggingBusy, entriesLength, loggingNotice }
* state - { loggingStatus, loggingBusy, loggingNotice }
* callbacks - { onEnable(), onDisable() }
*
* G Hybrid chrome: when WAN logging is on, one merged control carries status +
@@ -854,7 +854,6 @@ return view.extend({
return {
loggingStatus: this.loggingStatus,
loggingBusy: this.loggingBusy,
entriesLength: this.entries.length,
loggingNotice: this.loggingNotice,
showConsent: this.shouldShowLoggingConsent()
};
+8
View File
@@ -16,6 +16,10 @@ msgstr ""
msgid "%d matching · %d/%d stored"
msgstr "%d matching · %d/%d stored"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466
msgid "%s: %s"
msgstr "%s: %s"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929
msgid "0 matching · %d/%d stored"
msgstr "0 matching · %d/%d stored"
@@ -643,6 +647,10 @@ msgstr "allow/deny rules, LAN logging, or anything else."
msgid "buffer full"
msgstr "Puffer voll"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40
msgid "contains"
msgstr "enthält"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81
msgid "default 10/minute"
msgstr "Standard 10/Minute"
+8
View File
@@ -17,6 +17,10 @@ msgstr ""
msgid "%d matching · %d/%d stored"
msgstr "%d matching · %d/%d stored"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466
msgid "%s: %s"
msgstr "%s: %s"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929
msgid "0 matching · %d/%d stored"
msgstr "0 matching · %d/%d stored"
@@ -648,6 +652,10 @@ msgstr "allow/deny rules, LAN logging, or anything else."
msgid "buffer full"
msgstr "буфер заполнен"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40
msgid "contains"
msgstr "содержит"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81
msgid "default 10/minute"
msgstr "по умолчанию 10/мин"
File diff suppressed because it is too large. Load diff
@@ -16,6 +16,10 @@ msgstr ""
msgid "%d matching · %d/%d stored"
msgstr "%d matching · %d/%d stored"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/log.js:466
msgid "%s: %s"
msgstr "%s: %s"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/view/status/fwlive.js:929
msgid "0 matching · %d/%d stored"
msgstr "0 matching · %d/%d stored"
@@ -612,6 +616,10 @@ msgstr "allow/deny rules, LAN logging, or anything else."
msgid "buffer full"
msgstr "缓冲区已满"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/chips.js:40
msgid "contains"
msgstr "包含"
#: openwrt-feed/luci-app-fwlive/htdocs/luci-static/resources/fwlive/logging.js:81
msgid "default 10/minute"
msgstr "默认 10 条/分钟"
@@ -64,6 +64,8 @@ function json_unhex4(h, n, i, c, v) {
return n
}
function json_get_msg(obj, s, i, c, esc, out, hex, n) {
# Summary decoder is ASCII/Latin-1 oriented. \uXXXX outside 1..255 is
# collapsed (not UTF-8). Invalid \u appends u. NUL is dropped.
if (!match(obj, /"msg"[[:space:]]*:[[:space:]]*"/)) return ""
s = substr(obj, RSTART + RLENGTH)
out = ""
@@ -79,6 +81,8 @@ function json_get_msg(obj, s, i, c, esc, out, hex, n) {
else if (c == "u") {
hex = substr(s, i + 1, 4)
n = (length(hex) == 4) ? json_unhex4(hex) : -1
# 1..255: Latin-1 %c. n>255: collapse. n<0: append u.
# n==0 (NUL): BusyBox awk strings cannot hold NUL; drop it.
if (n >= 1 && n <= 255) out = out sprintf("%c", n)
else if (n < 0) out = out "u"
if (n >= 0) i += 4
@@ -28,12 +28,15 @@ NF_LOG_IPV6_READY=false
# firewall.<zone>.log bit, computes a target, then uci set + uci commit. Two
# concurrent callers could otherwise interleave and last-commit-wins.
#
# BusyBox flock constraint: it has NO -w timeout. A stuck lock holder blocks
# any waiter until the holder exits or the device reboots. The critical
# section MUST stay SHORT (a few uci commands). Do NOT hold the lock across
# the /etc/init.d/firewall reload (can take seconds); the lock is released
# before reload, and reload-failure rollback is a best-effort UCI write
# outside the lock.
# BusyBox flock constraint: it has NO -w timeout. The lock is opened with
# `exec 9>>` + `flock 9`. In ash/dash, `>>` clears close-on-exec, so a live
# child keeps fd 9 after this holder dies. A stuck lock therefore blocks any
# waiter until the last fd-9 inheritor exits or the device reboots (killing
# the holder alone may not release it). The critical section MUST stay SHORT
# (a few uci commands). Do NOT hold the lock across the /etc/init.d/firewall
# reload (can take seconds); the lock is released before reload.
# Reload-failure rollback re-acquires the lock so the read-compare-restore
# is atomic against concurrent writers; do not drop that re-acquire.
# Overridable for tests/containers (default is root-only /etc/fwlive).
WAN_LOG_LOCK_FILE="${FWLIVE_WAN_LOG_LOCK_FILE:-/etc/fwlive/logging.lock}"
WAN_LOG_BASELINE_FILE="${FWLIVE_WAN_LOG_BASELINE_FILE:-/etc/fwlive/wan-log-baseline}"
@@ -435,7 +438,17 @@ restore_wan_log_baseline() {
logger -t fwlive "WAN log baseline restore skipped: firewall changes pending" 2>/dev/null || true
return 1
fi
if ! commit_wan_log_change "$zone" "$zone_json" "$baseline"; then
_commit_rc=0
commit_wan_log_change "$zone" "$zone_json" "$baseline" || _commit_rc=$?
if [ "$_commit_rc" -eq 2 ]; then
# Commit landed; live fw4 still needs a reload. Baseline file
# stays so a later restore can retry — read-back was not ours.
release_wan_log_lock
reload_firewall || logger -t fwlive "WAN log baseline restore: firewall reload failed after raced commit" 2>/dev/null || true
logger -t fwlive "WAN log baseline restore: post-commit verify raced" 2>/dev/null || true
return 1
fi
if [ "$_commit_rc" -ne 0 ]; then
release_wan_log_lock
logger -t fwlive "WAN log baseline restore: commit gate failed" 2>/dev/null || true
return 1
@@ -701,7 +714,19 @@ restore_wan_zone_log() {
logger -t fwlive "WAN log rollback skipped after stage: firewall changes staged by another writer" 2>/dev/null || true
return 1
fi
uci commit firewall 2>/dev/null || true
if ! uci commit firewall 2>/dev/null; then
# Drop our staged rollback delta so a later toggle is not stuck
# on firewall_changes_pending from this package's own orphaned
# write. Mirror commit_wan_log_change: revert only when the
# remaining staging is entirely our log option.
_staged=$(uci -q changes firewall 2>/dev/null || true)
_total=$(printf '%s\n' "$_staged" | grep -c . 2>/dev/null || true)
_ours=$(wan_log_count_our_staged_lines "$zone" "$_staged")
if [ "${_total:-0}" -gt 0 ] && [ "${_total:-0}" -eq "${_ours:-0}" ]; then
uci -q revert firewall 2>/dev/null || true
fi
return 1
fi
}
# Stage + commit the WAN log bit. Caller MUST hold the logging lock; this
@@ -721,10 +746,11 @@ restore_wan_zone_log() {
# for a failed reload.
# Residual window: a writer that stages between the post-stage check and
# `uci commit` can still ride along; post-commit verification detects a
# mismatched log bit. Same-option races (another writer also staging
# firewall.<wan>.log) are not distinguishable in the changes list.
# The caller-reported error is firewall_changes_pending: the LuCI view already
# maps it to the accurate "another change is staged" notice.
# mismatched log bit and reports firewall_commit_raced (no blind-revert).
# Same-option races (another writer also staging firewall.<wan>.log) are
# not distinguishable in the changes list. Pre-stage races report
# firewall_changes_pending: the LuCI view already maps it to the accurate
# "another change is staged" notice.
#
# target: value to stage; EMPTY means delete the option (bit fully cleared).
# Prints the failure JSON and returns 1 on abort or failure.
@@ -807,14 +833,44 @@ commit_wan_log_change() {
# Post-commit verification: confirm the committed config really
# carries what we wrote (empty target = option must now be gone/empty).
# A mismatch means another writer overtook the commit: warn loudly but do
# NOT blind-revert (that would destroy unrelated committed data); the
# reload-failure rollback path still guards the ordinary failure case.
# NOT blind-revert (that would destroy unrelated committed data). Return 2
# so the caller still reloads (live fw4 must track committed UCI) and then
# reports firewall_commit_raced instead of ok:true. Do not print JSON here:
# reload may still fail. Return 1 is reserved for pre-commit aborts that
# already printed an error body.
if ! verify_wan_log_commit "$zone" "$target"; then
logger -t fwlive "WAN log post-commit verify FAILED: wrote '${target:-<deleted>}', read back differs" 2>/dev/null || true
return 2
fi
return 0
}
# Caller MUST have released the logging lock. rc from commit_wan_log_change:
# 0 — our target is committed; reload and report success.
# 1 — abort JSON already printed; no commit, skip reload.
# 2 — commit succeeded but verify raced; still reload, then report
# firewall_commit_raced (or firewall_reload_failed). Do not use
# reload_and_report_wan_log: a reload failure must not restore
# `previous` over a foreign committed value.
report_wan_log_after_commit() {
_rc="$1"
zone_json="$2"
if [ "$_rc" -eq 1 ]; then
return 0
fi
if [ "$_rc" -eq 2 ]; then
if ! reload_firewall; then
logger -t fwlive "Firewall reload failed after raced WAN log commit" 2>/dev/null || true
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_reload_failed"}' "$zone_json"
return 0
fi
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_commit_raced"}' "$zone_json"
return 0
fi
shift 2
reload_and_report_wan_log "$@"
}
# Firewall reload + best-effort UCI rollback on reload failure. The reload
# itself runs WITHOUT the logging lock (it can take seconds and a held lock
# would block a concurrent toggle until the holder exits — BusyBox flock has
@@ -918,13 +974,11 @@ enable_wan_logging() {
fi
target=$(wan_filter_log_target_value "$current")
if ! commit_wan_log_change "$zone" "$zone_json" "$target"; then
release_wan_log_lock
return 0
fi
_commit_rc=0
commit_wan_log_change "$zone" "$zone_json" "$target" || _commit_rc=$?
release_wan_log_lock
reload_and_report_wan_log "$zone" "$current" "$target" \
report_wan_log_after_commit "$_commit_rc" "$zone_json" \
"$zone" "$current" "$target" \
'Firewall reload failed after enable; reverted UCI WAN log' \
'WAN zone logging enabled' \
"$zone_json"
@@ -971,13 +1025,11 @@ disable_wan_logging() {
# not restore that bit.
target=$(wan_filter_log_clear_value "$current")
if ! commit_wan_log_change "$zone" "$zone_json" "$target"; then
release_wan_log_lock
return 0
fi
_commit_rc=0
commit_wan_log_change "$zone" "$zone_json" "$target" || _commit_rc=$?
release_wan_log_lock
reload_and_report_wan_log "$zone" "$current" "$target" \
report_wan_log_after_commit "$_commit_rc" "$zone_json" \
"$zone" "$current" "$target" \
'Firewall reload failed after disable; reverted UCI WAN log' \
'WAN zone logging disabled' \
"$zone_json"
+248 -55
View File
@@ -39,6 +39,11 @@ RESOLVE_BUDGET=5
POLL_LINES_MAX=2000
# Bound nft ruleset dumps (corrupted/hung nft must not block LuCI rules load).
NFT_TIMEOUT=5
# Bound poll log.read (wedged logd must not hold an rpcd worker past the UI cadence).
# Integer seconds — BusyBox timeout truncates fractions to 0 (see RESOLVE_TIMEOUT).
POLL_TIMEOUT=5
# Host tests override this to a pinned libubox helper; devices keep the package path.
: "${FWLIVE_JSHN_SH:=/usr/share/libubox/jshn.sh}"
# json_escape lives in fwlive-logging.sh so prerm can source that file alone.
# shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh
@@ -170,15 +175,70 @@ $(uci_rule_names)
EOF
}
# One awk pass: prefix<TAB>comment. Last quoted log prefix / comment on
# the line, including \" units (same capture as the former sed).
nft_dump_fields() {
LC_ALL=C awk '
function last_at(s, needle, p, i, found) {
found = 0
p = 1
while ((i = index(substr(s, p), needle)) > 0) {
found = p + i - 1
p = found + 1
}
return found
}
function quoted_at(s, needle, i, rest, out, c, n, esc, j) {
i = last_at(s, needle)
if (i == 0)
return ""
rest = substr(s, i + length(needle))
n = length(rest)
out = ""
esc = 0
for (j = 1; j <= n; j++) {
c = substr(rest, j, 1)
if (esc) {
out = out c
esc = 0
continue
}
if (c == "\\") {
out = out c
esc = 1
continue
}
if (c == "\"")
return out
out = out c
}
return ""
}
{
prefix = quoted_at($0, "log prefix \"")
comment = quoted_at($0, "comment \"")
if (prefix == "" && comment == "")
next
print prefix "\t" comment
}
'
}
# pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats
# an earlier cosmetic for the same prefix under first-wins.
# Reads TSV from nft_dump_fields; stops once the map cap trips.
map_from_nft_stream() {
pass="${1:-all}"
while IFS= read -r line; do
# Capture [^"]* plus escaped-quote units (\"...) so a log prefix or
# comment containing an escaped quote is not truncated at the quote.
prefix=$(echo "$line" | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
comment=$(echo "$line" | sed -n 's/.*comment "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
_tab=$(printf '\t')
# IFS= keeps a leading tab (empty prefix + comment-only !fw4: line).
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$_map_overflow" ] && break
[ -n "$line" ] || continue
prefix="${line%%${_tab}*}"
case "$line" in
*"$_tab"*) comment="${line#*"$_tab"}" ;;
*) comment="" ;;
esac
label=''
case "$comment" in
@@ -209,14 +269,6 @@ nft_list_ruleset() {
run_with_timeout "$NFT_TIMEOUT" nft list ruleset
}
detect_firewall_backend() {
if command -v nft >/dev/null 2>&1 && nft_list_ruleset >/dev/null; then
printf '%s' nft
return 0
fi
printf '%s' unknown
}
_fwlive_tmp_dir_ok() {
# Fail closed unless the dump dir is a real directory with the sticky
# bit. Without sticky, another uid can unlink a just-created mktemp
@@ -251,15 +303,29 @@ enrich_rules_dump() {
_rules_dump=$(_fwlive_mktemp "$_dump_prefix") || _rules_dump=''
if [ -n "$_rules_dump" ]; then
# One dump serves detect + parse. Empty/failed dump is not nft
# (unknown/no_backend, same as a failed detect probe).
if nft_list_ruleset >"$_rules_dump" 2>/dev/null; then
map_from_nft_stream labeled <"$_rules_dump"
map_from_nft_stream unlabeled <"$_rules_dump"
elif [ -z "$_enrich_error" ]; then
_enrich_error='nft_failed'
backend=nft
# Stream awk output to a second temp so a huge dump is
# not materialized in the rpcd worker's shell heap.
_nft_tsv=$(_fwlive_mktemp "fwlive-nft-tsv")
if [ -z "$_nft_tsv" ]; then
_enrich_error='mktemp_failed'
else
nft_dump_fields <"$_rules_dump" >"$_nft_tsv"
map_from_nft_stream labeled <"$_nft_tsv"
if [ -z "$_map_overflow" ]; then
map_from_nft_stream unlabeled <"$_nft_tsv"
fi
rm -f "$_nft_tsv"
fi
fi
rm -f "$_rules_dump"
else
# nft is on PATH (caller checked); skip enrichment without a dump.
_enrich_error='mktemp_failed'
backend=nft
fi
}
@@ -268,13 +334,14 @@ build_rules_map() {
_map_keys=0
_map_overflow=''
_enrich_error=''
backend=$(detect_firewall_backend)
backend=unknown
# UCI first, then labeled stream prefixes, then unlabeled cosmetics
# First-wins keeps UCI over nft and !fw4: over cosmetic.
map_uci_rule_names
if [ "$backend" = nft ]; then
# Temp file first, then a single nft_list_ruleset; backend from success.
if command -v nft >/dev/null 2>&1; then
enrich_rules_dump fwlive-nft
fi
@@ -301,7 +368,8 @@ poll_clamp_lines() {
return 0
;;
esac
_digits=$(printf '%s' "$_val" | sed 's/^0*//')
# Strip leading zeros in-shell so default-50 poll does not pay for sed.
_digits=${_val#${_val%%[!0]*}}
if [ -z "$_digits" ]; then
printf '%s' "50"
return 0
@@ -317,33 +385,52 @@ poll_clamp_lines() {
fi
}
# Failed jshn reads still default to 50 (silent numeric contract; syslog named cause only).
poll_lines_from_input() {
input="$1"
lines=50
_cause=
if ! command -v jshn >/dev/null 2>&1; then
printf '%s' "$lines"
return 0
_cause=jshn_missing
else
_rc=0
# jshn uses intentionally unset state variables. Keep nounset disabled in
# this subshell so strict mode is restored automatically on every exit.
# exit 2: library missing or source failed; exit 1: JSON parse failed.
# Missing addresses[0] is an intentional silent default, not a parse error.
_first=$( (
set +u
# shellcheck disable=SC1090,SC1091
. "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" 2>/dev/null || exit 2
json_init >/dev/null 2>&1 || exit 2
# json_load masks a failing jshn exit through eval. Check the real
# parser once, then evaluate only its generated shell assignments.
_json_no_warning=1
_json_code=$(jshn -r "$input" 2>/dev/null) || exit 1
eval "$_json_code" || exit 1
first=
if json_select addresses 2>/dev/null; then
json_get_var first 1 || first=
fi
printf '%s' "${first:-}"
) ) || _rc=$?
if [ "$_rc" -eq 2 ]; then
_cause=jshn_lib_missing
elif [ "$_rc" -ne 0 ]; then
_cause=invalid_input
else
case "${_first:-}" in
''|*[!0-9]*) ;;
*) lines="$_first" ;;
esac
fi
fi
# jshn uses intentionally unset state variables. Keep nounset disabled in
# this subshell so strict mode is restored automatically on every exit.
_first=$( (
set +u
. /usr/share/libubox/jshn.sh
# json_load masks a failing jshn exit through eval. Check the real
# parser once, then evaluate only its generated shell assignments.
_json_no_warning=1
_json_code=$(jshn -r "$input" 2>/dev/null) || exit 1
eval "$_json_code" || exit 1
if ! json_select addresses 2>/dev/null; then exit 1; fi
json_get_var first 1 || exit 1
printf '%s' "${first:-}"
) ) || _first=
case "${_first:-}" in
''|*[!0-9]*) ;;
*) lines="$_first" ;;
esac
# Named cause goes to syslog only: poll JSON "error" is connection-lost in the view.
if [ -n "$_cause" ]; then
logger -t fwlive "$_cause" 2>/dev/null || true
fi
lines=$(poll_clamp_lines "$lines")
@@ -360,7 +447,7 @@ fetch_firewall_logs() {
# Adaptive plan prints: lines shed bucket
start_cs=$(fwlive_adaptive_clock_cs)
# Drop dead oneshot flag — only consumed on logd's stream branch.
if ! raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false}" 2>/dev/null); then
if ! raw=$(run_with_timeout "$POLL_TIMEOUT" ubus call log read "{\"lines\":$lines,\"stream\":false}"); then
# Do not record ~0 ms as cold: failure is not healthy fast processing
# and must not clear an existing hot/shed cap.
out='{"log":[],"error":"log_read_failed"}'
@@ -383,7 +470,7 @@ fetch_firewall_logs() {
# Empty success is not valid poll JSON — the shipped filter always
# prints {"log":[...]}, but a stub/regression must not emit a blank body.
_filter_ok=1
if ! out=$(printf '%s' "$raw" | "$FILTER_SH" /tmp); then
if ! out=$(printf '%s' "$raw" | run_with_timeout "$POLL_TIMEOUT" "$FILTER_SH" /tmp); then
_filter_ok=0
if [ -z "$out" ]; then
out='{"log":[],"error":"filter_failed"}'
@@ -423,16 +510,29 @@ poll_logs() {
# "<ptr> name = <host>"; fall back to "Address N: <ip> <host>".
# Trailing dots are stripped. Empty / server-only output is not a name.
parse_nslookup_name() {
# Prefer "<ptr> name = <host>". The "Address N: ip host" fallback
# must ignore the Server block (BusyBox mini nslookup prints the
# resolver via the same Address N: format first). Only accept
# Address N: after a blank line (the answer section).
# Prefer "<ptr> name = <host>". "name =" must be a whole token
# (not a suffix of hostname, not the phrase "domain name =").
# The "Address N: ip host" fallback must ignore the Server
# block (BusyBox mini nslookup prints the resolver via the
# same Address N: format first). Only accept Address N: after
# a blank line (the answer section).
printf '%s\n' "$1" | awk '
tolower($0) ~ /name[ \t]*=/ {
sub(/.*[Nn][Aa][Mm][Ee][ \t]*=[ \t]*/, "")
gsub(/[ \t].*$/, "")
gsub(/\.$/, "")
if ($0 != "") { print; exit }
{
low = tolower($0)
host = ""
if (match(low, /^name[ \t]*=[ \t]*/)) {
host = substr($0, RLENGTH + 1)
} else if (match(low, /[^a-z0-9_]name[ \t]*=[ \t]*/)) {
pre = substr(low, 1, RSTART - 1)
sub(/[ \t]+$/, "", pre)
if (pre != "domain" && pre !~ /[^a-z0-9_]domain$/)
host = substr($0, RSTART + RLENGTH)
}
if (host != "") {
gsub(/[ \t].*$/, "", host)
gsub(/\.$/, "", host)
if (host != "") { print host; exit }
}
}
NF == 0 { in_answer = 1; next }
in_answer && $1 == "Address" && $2 ~ /^[0-9]+:/ && NF >= 4 {
@@ -575,7 +675,8 @@ resolve_addresses() {
_addresses=$( (
set +u
. /usr/share/libubox/jshn.sh
# shellcheck disable=SC1090,SC1091
. "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}"
# json_load masks a failing jshn exit through eval. Check the real
# parser once, then evaluate only its generated shell assignments.
_json_no_warning=1
@@ -752,19 +853,23 @@ run_selftest() {
# Escaped-quote capture: a log prefix containing \" must not be truncated
# at the escaped quote (nft dumps strings with backslash escapes).
got=$(printf '%s\n' 'log prefix "My \" Rule"' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
_tab=$(printf '\t')
got=$(printf '%s\n' 'log prefix "My \" Rule"' | nft_dump_fields)
got="${got%%${_tab}*}"
if [ "$got" != 'My \" Rule' ]; then
echo "escaped-quote prefix capture: expected 'My \\\" Rule' got '$got'" >&2
return 1
fi
# Plain prefix still captures unchanged.
got=$(printf '%s\n' 'log prefix "Plain Rule"' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
got=$(printf '%s\n' 'log prefix "Plain Rule"' | nft_dump_fields)
got="${got%%${_tab}*}"
if [ "$got" != 'Plain Rule' ]; then
echo "plain prefix capture: expected 'Plain Rule' got '$got'" >&2
return 1
fi
# Empty prefix edge.
got=$(printf '%s\n' 'log prefix ""' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
got=$(printf '%s\n' 'log prefix ""' | nft_dump_fields)
got="${got%%${_tab}*}"
if [ "$got" != '' ]; then
echo "empty prefix capture: expected '' got '$got'" >&2
return 1
@@ -774,6 +879,10 @@ run_selftest() {
check_eq poll_clamp_lines "$got" "$POLL_LINES_MAX" "over-long expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_clamp_lines "0")
check_eq poll_clamp_lines "$got" "50" "zero expected 50 got $got" || return 1
got=$(poll_clamp_lines "000")
check_eq poll_clamp_lines "$got" "50" "all-zeros expected 50 got $got" || return 1
got=$(poll_clamp_lines "050")
check_eq poll_clamp_lines "$got" "50" "leading-zero expected 50 got $got" || return 1
got=$(poll_clamp_lines "2001")
check_eq poll_clamp_lines "$got" "$POLL_LINES_MAX" "2001 expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_clamp_lines "500")
@@ -816,6 +925,16 @@ Address 1: 8.8.8.8 dns.google")
got=$(parse_nslookup_name "Server: 127.0.0.1
Address: 127.0.0.1:53")
check_eq parse_nslookup_name "$got" '' "server-only expected empty got '$got'" || return 1
got=$(parse_nslookup_name "hostname = evil.example.")
check_eq parse_nslookup_name "$got" '' "hostname = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "domain name = other.example.")
check_eq parse_nslookup_name "$got" '' "domain name = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "domain name = two.example.")
check_eq parse_nslookup_name "$got" '' "domain name = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "domain name = tab.example.")
check_eq parse_nslookup_name "$got" '' "domain tab name = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "name = dns.google. hostname = evil.example.")
check_eq parse_nslookup_name "$got" "dns.google" "standalone name = must beat hostname = got '$got'" || return 1
run_logging_selftest || return 1
@@ -834,6 +953,18 @@ Address: 127.0.0.1:53")
echo "skip: jshn not available (poll cap via jshn not tested)" >&2
return 0
fi
# Dash rejects libubox `export -n` in json_init. Skip numeric poll-cap
# checks unless this interpreter can actually use the helper; poll still
# defaults to 50 and logs a named cause in that case.
if ! (
set +u
# shellcheck disable=SC1090,SC1091
. "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" 2>/dev/null || exit 1
json_init >/dev/null 2>&1
); then
echo "skip: jshn library not usable (poll cap via jshn not tested)" >&2
return 0
fi
got=$(poll_lines_from_input '{"addresses":["999999"]}')
check_eq poll_lines_from_input "$got" "$POLL_LINES_MAX" "expected $POLL_LINES_MAX got $got" || return 1
@@ -851,6 +982,64 @@ Address: 127.0.0.1:53")
got=$(poll_lines_from_input '{"addresses":["100"]}')
check_eq poll_lines_from_input "$got" "100" "expected 100 got $got" || return 1
# jshn failures must log a named cause; stdout stays the numeric default.
# logger() override is inherited by the $() subshell; command -v still finds
# the binary, so the unqualified logger call is what this intercepts.
_poll_log="/tmp/fwlive-poll-cause.$$"
: > "$_poll_log"
logger() { printf '%s\n' "$*" >> "$_poll_log"; }
got=$(poll_lines_from_input '{"addresses":["500"]}')
check_eq poll_lines_from_input "$got" "500" "override path expected 500 got $got" || return 1
case "$(cat "$_poll_log")" in
*jshn_missing*|*jshn_lib_missing*|*invalid_input*)
echo "poll_lines_from_input: successful cap must stay silent, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1
;;
esac
: > "$_poll_log"
got=$(poll_lines_from_input '{}')
check_eq poll_lines_from_input "$got" "50" "missing addresses expected 50 got $got" || return 1
case "$(cat "$_poll_log")" in
*jshn_missing*|*jshn_lib_missing*|*invalid_input*)
echo "poll_lines_from_input: missing addresses[0] must stay silent, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1
;;
esac
: > "$_poll_log"
got=$(poll_lines_from_input 'not-json{{{')
check_eq poll_lines_from_input "$got" "50" "invalid JSON expected 50 got $got" || return 1
case "$(cat "$_poll_log")" in
*invalid_input*) ;;
*) echo "poll_lines_from_input: expected invalid_input log, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1 ;;
esac
: > "$_poll_log"
_saved_jshn_sh="$FWLIVE_JSHN_SH"
FWLIVE_JSHN_SH="/no/such/fwlive-jshn.sh"
got=$(poll_lines_from_input '{"addresses":["500"]}')
FWLIVE_JSHN_SH="$_saved_jshn_sh"
check_eq poll_lines_from_input "$got" "50" "missing library expected 50 got $got" || return 1
case "$(cat "$_poll_log")" in
*jshn_lib_missing*) ;;
*) echo "poll_lines_from_input: expected jshn_lib_missing log, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1 ;;
esac
rm -f "$_poll_log"
unset -f logger
return 0
}
@@ -863,6 +1052,10 @@ case "${1:-}" in
poll_clamp_lines "${2:-}"
exit $?
;;
__poll_lines)
poll_lines_from_input "${2:-}"
exit $?
;;
__tmp_dir_ok)
_fwlive_tmp_dir_ok "${2:-}"
exit $?
+3 -4
View File
@@ -6,11 +6,10 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for KSpeeder
LUCI_DEPENDS:=+istoreenhance
LUCI_DEPENDS:=+istoreenhance +luci-lib-linkeaseauth +linkease-app-entry
LUCI_PKGARCH:=all
PKG_VERSION:=0.6.6-r2
# PKG_RELEASE MUST be empty for luci.mk
PKG_RELEASE:=2
PKG_VERSION:=0.8.0
PKG_RELEASE:=3
include $(TOPDIR)/feeds/luci/luci.mk
@@ -1,26 +1,34 @@
module("luci.controller.istoreenhance", package.seeall)
function index()
if not nixio.fs.access("/etc/config/istoreenhance") then
return
end
if not nixio.fs.access("/etc/config/istoreenhance") then return end
entry({"admin", "services", "istoreenhance"}, cbi("istoreenhance"), _("KSpeeder"), 20).dependent = true
entry({"admin", "services", "istoreenhance_status"}, call("istoreenhance_status"))
local open = entry({"admin", "services", "istoreenhance", "open"}, call("istoreenhance_open"))
open.leaf = true
open.dependent = false
open.sysauth = false
end
local function compat()
local dispatcher = require "luci.dispatcher"
return require("luci.model.linkease.apps_compat").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function istoreenhance_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = tonumber(uci:get_first("istoreenhance", "istoreenhance", "adminport"))
local status = {
running = (sys.call("pidof iStoreEnhance >/dev/null") == 0),
port = (port or 5003)
}
luci.http.prepare_content("application/json")
luci.http.write_json(status)
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
compat():legacy_status("kspeeder", {
running = sys.call("pidof iStoreEnhance >/dev/null") == 0,
port = uci:get_first("istoreenhance", "istoreenhance", "adminport") or "5003"
})
end
function istoreenhance_open()
compat():open("kspeeder")
end
@@ -8,7 +8,7 @@
if (st.running)
{
tb.innerHTML = '<br/><em style=\"color:green\"><%:The KSpeeder service is running.%></em>'
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open KSpeeder%> \" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>";
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open KSpeeder%> \" onclick=\"window.open('<%=url("admin/services/istoreenhance/open")%>')\"/>";
}
else
{
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=kai
PKG_VERSION:=1.0.2
PKG_RELEASE:=10
LUCI_DEPENDS:=+kai +kai_session +luci-compat
PKG_RELEASE:=11
LUCI_DEPENDS:=+kai +luci-compat +luci-lib-linkeaseauth +linkease-app-entry
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
+7 -3
View File
@@ -16,10 +16,14 @@ end
function kai_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port = tonumber(uci:get_first("kai", "kai", "port")) or 8197
if port < 1 or port > 65535 or port % 1 ~= 0 then
port = 8197
end
local status = {
running = (sys.call("pidof kai_bin >/dev/null") == 0),
port = 8197
port = port
}
luci.http.prepare_content("application/json")
luci.http.write_json(status)
http.prepare_content("application/json")
http.write_json(status)
end
+4 -3
View File
@@ -1,4 +1,6 @@
<script <script type="text/javascript">//<![CDATA[
<script type="text/javascript">//<![CDATA[
var kaiOpenUrl = '<%=url("admin/services/linkease_apps/open")%>?id=kai';
XHR.poll(5, '<%=url("admin/services/kai_status")%>', null,function(x, st){
var el = document.getElementById('kai_status');
if (st && el){
@@ -7,7 +9,7 @@ XHR.poll(5, '<%=url("admin/services/kai_status")%>', null,function(x, st){
}
if (st.running){
el.innerHTML = '<br/><em style=\"color:green\"><%:The KAI service is running.%></em>'
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open KAI%> \" onclick=\"window.open('http://" + window.location.hostname + ":" + st.port + "/')\"/>";
+ "<br/><br/><input class=\"btn cbi-button cbi-button-apply\" type=\"button\" value=\" <%:Click to open KAI%> \" onclick=\"window.open(kaiOpenUrl, '_blank', 'noopener')\"/>";
}
}
});
@@ -19,4 +21,3 @@ XHR.poll(5, '<%=url("admin/services/kai_status")%>', null,function(x, st){
<em><%:Collecting data...%></em>
</p>
</fieldset>
+2 -2
View File
@@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=KaiPlus
PKG_VERSION:=1.0.9
PKG_RELEASE:=10
LUCI_DEPENDS:=+kaiplus +luci-compat +luci-lib-linkeaseauth
PKG_RELEASE:=11
LUCI_DEPENDS:=+kaiplus +luci-compat +luci-lib-linkeaseauth +linkease-app-entry
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
+19 -175
View File
@@ -1,7 +1,5 @@
module("luci.controller.kaiplus", package.seeall)
local APPS_PROXY_PREFIX = "/apps=http://127.0.0.1:19290"
function index()
entry({"admin", "services", "kaiplus_status"}, call("kaiplus_status"))
local open = entry({"admin", "services", "kaiplus", "open"}, call("kaiplus_open"))
@@ -10,188 +8,34 @@ function index()
open.sysauth = "root"
open.sysauth_authenticator = "htmlauth"
if not nixio.fs.access("/etc/config/kaiplus") then
return
end
local page
page = entry({"admin", "services", "kaiplus"}, cbi("kaiplus"), _("KaiPlus"), 100)
page.dependent = true
if not nixio.fs.access("/etc/config/kaiplus") then return end
entry({"admin", "services", "kaiplus"}, cbi("kaiplus"), _("KaiPlus"), 100).dependent = true
end
local function uhttpd_has_apps_proxy_prefix()
local uci = require "luci.model.uci".cursor()
local mappings = uci:get_list("uhttpd", "main", "proxy_prefix") or {}
for _, mapping in ipairs(mappings) do
if mapping == APPS_PROXY_PREFIX then
return true
end
end
return false
end
local function uhttpd_supports_proxy_prefix()
local sys = require "luci.sys"
return sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0
end
local function uhttpd_apps_proxy_available()
return uhttpd_supports_proxy_prefix() and uhttpd_has_apps_proxy_prefix()
end
local function linkeasefull_running()
local sys = require "luci.sys"
return sys.call("[ -x /etc/init.d/linkeasefull ] && /etc/init.d/linkeasefull running >/dev/null 2>&1") == 0
end
local function normalized_base_path(path)
path = path or "/apps/kaiplus/"
if path:sub(1, 1) ~= "/" then
path = "/" .. path
end
if path:sub(-1) ~= "/" then
path = path .. "/"
end
return path
end
local function cookie_encode(value)
return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
return string.format("%%%02X", char:byte())
end)
end
local function authority_host(authority)
if not authority or authority == "" then
return ""
end
if authority:sub(1, 1) == "[" then
return authority:match("^%[([^%]]+)%]") or ""
end
return authority:match("^([^:]+)") or authority
end
local function url_authority(host, port)
if not host or host == "" then
host = "127.0.0.1"
end
if host:find(":") and host:sub(1, 1) ~= "[" then
host = "[" .. host .. "]"
end
return host .. ":" .. tostring(port)
end
local function request_or_lan_host()
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local host = authority_host(http.getenv("HTTP_HOST") or "")
if host ~= "" then
return host
end
return uci:get("network", "lan", "ipaddr") or "127.0.0.1"
end
local function linkease_auth_url(name)
local function compat()
local dispatcher = require "luci.dispatcher"
return dispatcher.build_url("admin", "services", "linkease_auth", name)
end
local function kaiplus_config()
local uci = require "luci.model.uci".cursor()
local port = uci:get_first("kaiplus", "kaiplus", "port", "8189")
local base_path = normalized_base_path(uci:get_first("kaiplus", "kaiplus", "base_path"))
local external_port_enabled = uci:get_first("kaiplus", "kaiplus", "external_port_enabled") == "1"
local listen_mode = uci:get_first("kaiplus", "kaiplus", "listen_mode") or "auto"
local socket_path = uci:get_first("kaiplus", "kaiplus", "socket_path") or "/var/run/kaiplus.sock"
return port, base_path, external_port_enabled, listen_mode, socket_path
end
local function kaiplus_entry_url()
local port, base_path, external_port_enabled = kaiplus_config()
if linkeasefull_running() and uhttpd_apps_proxy_available() then
return base_path
end
if not external_port_enabled then
return nil
end
return "http://" .. url_authority(request_or_lan_host(), port) .. base_path
end
local function enable_external_port()
local uci = require "luci.model.uci".cursor()
local section = nil
uci:foreach("kaiplus", "kaiplus", function(s)
if not section then
section = s[".name"]
end
end)
if section then
uci:set("kaiplus", section, "external_port_enabled", "1")
uci:set("kaiplus", section, "listen_mode", "tcp")
uci:commit("kaiplus")
end
local sys = require "luci.sys"
sys.call("/etc/init.d/kaiplus restart >/dev/null 2>&1; sleep 1")
end
local function render_enable_port_confirm()
local dispatcher = require "luci.dispatcher"
local confirm_url = dispatcher.build_url("admin", "services", "kaiplus", "open") .. "?enable_port=1"
luci.http.prepare_content("text/html; charset=utf-8")
luci.http.write("<!doctype html><html><head><meta charset='utf-8'><title>KaiPlus</title></head><body>")
luci.http.write("<h2>KaiPlus</h2>")
luci.http.write("<p>当前系统无法通过 LinkEase 桌面代理打开 KaiPlus。</p>")
luci.http.write("<p>可以启用局域网端口 8189 后继续打开。</p>")
luci.http.write("<p><a class='cbi-button cbi-button-apply' href='" .. confirm_url .. "'>启用 8189 并打开</a></p>")
luci.http.write("</body></html>")
end
local function render_open_failed()
luci.http.prepare_content("text/html; charset=utf-8")
luci.http.write("<!doctype html><html><head><meta charset='utf-8'><title>KaiPlus</title></head><body>")
luci.http.write("<h2>KaiPlus</h2>")
luci.http.write("<p>KaiPlus 已安装,但当前没有可用访问入口。</p>")
luci.http.write("<p>请启用 LinkEaseFull 的 /apps 代理,或在 KaiPlus 设置中开放 8189 端口。</p>")
luci.http.write("</body></html>")
return require("luci.model.linkease.apps_compat").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function kaiplus_status()
local sys = require "luci.sys"
local uci = require "luci.model.uci".cursor()
local port, base_path, external_port_enabled, listen_mode, socket_path = kaiplus_config()
local entry_url = kaiplus_entry_url()
local status = {
running = (sys.call("pidof kaiplus_bin >/dev/null") == 0),
port = port,
base_path = base_path,
listen_mode = listen_mode,
socket_path = socket_path,
local uci = require("luci.model.uci").cursor()
local external_port_enabled = uci:get_first("kaiplus", "kaiplus", "external_port_enabled") == "1"
compat():legacy_status("kaiplus", {
running = sys.call("pidof kaiplus_bin >/dev/null") == 0,
port = uci:get_first("kaiplus", "kaiplus", "port") or "8189",
base_path = uci:get_first("kaiplus", "kaiplus", "base_path") or "/apps/kaiplus/",
listen_mode = uci:get_first("kaiplus", "kaiplus", "listen_mode") or "auto",
socket_path = uci:get_first("kaiplus", "kaiplus", "socket_path") or "/var/run/kaiplus.sock",
external_port_enabled = external_port_enabled,
entry_url = entry_url or "",
lan_ip = uci:get("network", "lan", "ipaddr") or "",
proxy_prefix_supported = uhttpd_supports_proxy_prefix(),
proxy_prefix_enabled = uhttpd_apps_proxy_available(),
linkeasefull_running = linkeasefull_running()
}
luci.http.prepare_content("application/json")
luci.http.write_json(status)
lan_ip = uci:get("network", "lan", "ipaddr") or ""
})
end
function kaiplus_open()
local http = require "luci.http"
if http.formvalue("enable_port") == "1" then
enable_external_port()
end
local entry_url = kaiplus_entry_url()
if entry_url then
http.redirect(linkease_auth_url("auth") .. "?return=" .. cookie_encode(entry_url))
return
end
if http.formvalue("enable_port") == "1" then
render_open_failed()
return
end
render_enable_port_confirm()
compat():open("kaiplus")
end
@@ -5,7 +5,7 @@ XHR.poll(5, '<%=url("admin/services/kaiplus_status")%>', null, function(x, st) {
var status = st.running
? '<br/><em style=\"color:green\"><%:The KaiPlus service is running.%></em>'
: '<br/><em style=\"color:red\"><%:The KaiPlus service is not running.%></em>';
if (st.proxy_prefix_enabled && st.linkeasefull_running) {
if (st.proxy_prefix_enabled && st.app_entry_running) {
status = status + '<br/><em><%:Access mode: LinkEase Desktop proxy.%></em>';
}
else if (st.external_port_enabled) {
+74
View File
@@ -0,0 +1,74 @@
#
# Copyright (C) 2026 iStoreOS
#
# This is free software, licensed under the GNU General Public License v3.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-linkeasefull-embed
PKG_VERSION:=1.1.0
PKG_RELEASE:=1
PKG_BUILD_PARALLEL:=1
PKG_USE_MIPS16:=0
include $(INCLUDE_DIR)/package.mk
define Package/$(PKG_NAME)
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=LinkEaseFull LuCI desktop embed
DEPENDS:=+linkeasefull +luci-base +luci-theme-istorenas
PKGARCH:=all
endef
define Package/$(PKG_NAME)/description
Register OpenWrt LuCI as a LinkEaseFull desktop module and provide embedded-mode assets.
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
set -e
root="$${IPKG_INSTROOT:-}"
maintainer="$${root}/usr/libexec/linkeasefull/openwrt-luci-maintain"
[ ! -x "$${maintainer}" ] || "$${maintainer}" install "$${root}"
exit 0
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
set -e
root="$${IPKG_INSTROOT:-}"
maintainer="$${root}/usr/libexec/linkeasefull/openwrt-luci-maintain"
[ ! -x "$${maintainer}" ] || "$${maintainer}" remove "$${root}"
registration="$${root}/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json"
if [ "$$(readlink "$${registration}" 2>/dev/null)" = "/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json" ]; then
rm -f "$${registration}"
fi
exit 0
endef
define Build/Configure
endef
define Build/Compile
endef
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/share/linkeasefull/openwrt-luci/protocol-fallbacks $(1)/usr/share/linkeasefull/desktop-apps.d
$(INSTALL_DIR) $(1)/usr/libexec/linkeasefull
$(INSTALL_DIR) $(1)/www/luci-static/linkeasefull-embed
$(INSTALL_DATA) ./files/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json
$(INSTALL_DATA) ./files/openwrt-luci/openwrt.svg $(1)/usr/share/linkeasefull/openwrt-luci/openwrt.svg
$(INSTALL_DATA) ./files/www/embed.css $(1)/www/luci-static/linkeasefull-embed/embed.css
$(INSTALL_DATA) ./files/www/embed-prelude.js $(1)/www/luci-static/linkeasefull-embed/embed-prelude.js
$(INSTALL_DATA) ./files/protocol/directip.js $(1)/usr/share/linkeasefull/openwrt-luci/protocol-fallbacks/directip.js
$(INSTALL_DATA) ./files/protocol/bonding.js $(1)/usr/share/linkeasefull/openwrt-luci/protocol-fallbacks/bonding.js
$(INSTALL_DATA) ./files/protocol/wwan.js $(1)/usr/share/linkeasefull/openwrt-luci/protocol-fallbacks/wwan.js
$(INSTALL_BIN) ./files/openwrt-luci-maintain.sh $(1)/usr/libexec/linkeasefull/openwrt-luci-maintain
ln -sf /usr/share/linkeasefull/openwrt-luci/openwrt-luci.json $(1)/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
+30
View File
@@ -0,0 +1,30 @@
# luci-app-linkeasefull-embed
Registers OpenWrt LuCI as a LinkEaseFull desktop module without using an iframe.
It is installed by the `app-meta-linkeasefull` product bundle while remaining
separate from the independently installable `linkeasefull` runtime package.
This package installs:
- `/usr/share/linkeasefull/openwrt-luci/openwrt-luci.json`
- `/usr/share/linkeasefull/desktop-apps.d/15-openwrt-luci.json`
- `/www/luci-static/linkeasefull-embed/embed-prelude.js`
- `/www/luci-static/linkeasefull-embed/embed.css`
- privately owned protocol fallback shims under
`/usr/share/linkeasefull/openwrt-luci/protocol-fallbacks/`
The package post-install maintainer exposes a fallback at the corresponding
`/www/luci-static/resources/protocol/` path only when no real LuCI protocol
module exists. Existing modules are never replaced, and package removal only
removes symlinks that still point to this package's private fallback directory.
This avoids file ownership conflicts with protocol packages installed later.
The desktop manifest uses the builtin `LuciContainer` component with
`isolation=scoped-dom-css`. That default scopes common Vue/React mount points,
redirects dynamic styles and portal nodes into the LuCI container, and scopes CSS
to `.le-luci-document`.
Users can create additional LuCI plugin desktop entries from LinkEaseFull by
adding a name, an icon URL, and a LuCI URL under `/cgi-bin/luci/...`. Content-only
plugin entries, such as DDNSTO, should use `contentOnly=true` so the LuCI menu is
hidden and only the plugin content is shown.
@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
ACTION=${1:-}
ROOT=${2:-}
PRIVATE_DIR=/usr/share/linkeasefull/openwrt-luci/protocol-fallbacks
PUBLIC_DIR=/www/luci-static/resources/protocol
case "$ROOT" in
""|/*) ;;
*) echo "invalid root: $ROOT" >&2; exit 2 ;;
esac
case "/$ROOT/" in
*/../*) echo "invalid root: $ROOT" >&2; exit 2 ;;
esac
install_fallbacks() {
mkdir -p "$ROOT$PUBLIC_DIR"
for name in bonding.js directip.js wwan.js; do
source="$ROOT$PRIVATE_DIR/$name"
destination="$ROOT$PUBLIC_DIR/$name"
[ -f "$source" ] || continue
if [ ! -e "$destination" ] && [ ! -L "$destination" ]; then
ln -s "$PRIVATE_DIR/$name" "$destination"
fi
done
}
remove_fallbacks() {
for name in bonding.js directip.js wwan.js; do
destination="$ROOT$PUBLIC_DIR/$name"
if [ "$(readlink "$destination" 2>/dev/null || true)" = "$PRIVATE_DIR/$name" ]; then
rm -f "$destination"
fi
done
}
case "$ACTION" in
install) install_fallbacks ;;
remove) remove_fallbacks ;;
*) echo "usage: $0 {install|remove} [root]" >&2; exit 2 ;;
esac
@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"id": "openwrt-luci",
"name": "OpenWrt",
"icon": "openwrt.svg",
"staticRoot": "/usr/share/linkeasefull/openwrt-luci",
"desktop": {
"mode": "builtin",
"component": "LuciContainer",
"props": {
"initialPath": "/cgi-bin/luci/admin/status/overview",
"isolation": "scoped-dom-css",
"preloadScripts": [
"/luci-static/linkeasefull-embed/embed-prelude.js"
],
"preloadStyles": [
"/luci-static/linkeasefull-embed/embed.css"
]
}
},
"standalone": {
"url": "/cgi-bin/luci/"
},
"window": {
"width": 1180,
"height": 760,
"singleton": true
},
"capabilities": ["openwrt", "luci", "settings"],
"categories": ["settings", "openwrt", "luci"]
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="OpenWrt">
<rect width="64" height="64" rx="14" fill="#0f766e"/>
<path fill="#ffffff" d="M18 36.5c0-7.7 6.3-14 14-14s14 6.3 14 14h-5c0-4.9-4.1-9-9-9s-9 4.1-9 9h-5z"/>
<path fill="#a7f3d0" d="M12 39h40v6H12z"/>
<circle cx="20" cy="20" r="4" fill="#ccfbf1"/>
<circle cx="44" cy="20" r="4" fill="#ccfbf1"/>
</svg>

After

Width:  |  Height:  |  Size: 403 B

@@ -0,0 +1,24 @@
'use strict';
'require network';
return network.registerProtocol('bonding', {
getI18n: function() {
return _('Bonding');
},
getPackageName: function() {
return 'luci-proto-bonding';
},
isFloating: function() {
return true;
},
isVirtual: function() {
return true;
},
getDevices: function() {
return null;
}
});
@@ -0,0 +1,24 @@
'use strict';
'require network';
return network.registerProtocol('directip', {
getI18n: function() {
return _('Direct-IP');
},
getPackageName: function() {
return 'comgt-directip';
},
isFloating: function() {
return true;
},
isVirtual: function() {
return true;
},
getDevices: function() {
return null;
}
});
@@ -0,0 +1,24 @@
'use strict';
'require network';
return network.registerProtocol('wwan', {
getI18n: function() {
return _('WWAN');
},
getPackageName: function() {
return 'wwan';
},
isFloating: function() {
return true;
},
isVirtual: function() {
return true;
},
getDevices: function() {
return null;
}
});
@@ -0,0 +1,403 @@
'use strict';
(function() {
window.__LINKEASE_LUCI_EMBEDDED__ = true;
function root() {
return window.__LINKEASE_LUCI_ROOT__ ||
document.querySelector('.le-luci-document') ||
document;
}
function query(selector) {
return root().querySelector(selector);
}
function queryAll(selector) {
return root().querySelectorAll(selector);
}
function config() {
return window.__LINKEASE_LUCI_CONFIG__ || {};
}
function isolationMode() {
var mode = config().isolation;
switch (mode) {
case 'none':
case 'scoped-dom':
case 'scoped-dom-css':
case 'strict':
return mode;
default:
return 'scoped-dom-css';
}
}
function shouldScopeDOM() {
return isolationMode() !== 'none';
}
function shouldScopeDynamicNodes() {
var mode = isolationMode();
return mode === 'scoped-dom-css' || mode === 'strict';
}
function shouldScopeCSS() {
var mode = isolationMode();
return mode === 'scoped-dom-css' || mode === 'strict';
}
function shouldGuardGlobals() {
return isolationMode() === 'strict';
}
function ensureHost(name) {
var scope = root();
if (!scope || scope === document)
return null;
var attr = 'data-linkease-luci-' + name + '-host';
var host = scope.querySelector('[' + attr + ']');
if (host)
return host;
host = document.createElement('div');
host.setAttribute(attr, 'true');
if (name === 'style')
host.hidden = true;
scope.appendChild(host);
return host;
}
function findScopedId(scope, id) {
if (scope.id === id)
return scope;
var nodes = scope.getElementsByTagName ? scope.getElementsByTagName('*') : [];
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].id === id)
return nodes[i];
}
return null;
}
function simpleIdSelector(selector) {
var match = /^#([A-Za-z_][A-Za-z0-9_-]*)$/.exec(String(selector || '').trim());
return match ? match[1] : null;
}
function mountIds() {
var defaults = ['root', 'app', 'app-root', 'mount', 'vue-app', 'react-root'];
var configured = window.__LINKEASE_LUCI_CONFIG__ &&
Array.isArray(window.__LINKEASE_LUCI_CONFIG__.mountIds)
? window.__LINKEASE_LUCI_CONFIG__.mountIds
: [];
var seen = {};
return defaults.concat(configured).filter(function(id) {
if (typeof id != 'string' || !id || seen[id])
return false;
seen[id] = true;
return true;
});
}
function isMountId(id) {
return mountIds().indexOf(id) >= 0;
}
function getScopedElementById(id, fallback) {
if (!shouldScopeDOM())
return fallback();
if (!isMountId(id))
return fallback();
if (window.__LINKEASE_LUCI_EMBEDDED__) {
var scope = root();
if (scope && scope !== document) {
var scoped = findScopedId(scope, id);
if (scoped)
return scoped;
return null;
}
}
return fallback();
}
function installDocumentIdScope() {
if (document.__linkeaseLuciIdScopeVersion >= 2)
return;
var originalGetElementById =
document.__linkeaseLuciOriginalGetElementById ||
document.getElementById.bind(document);
var originalQuerySelector =
document.__linkeaseLuciOriginalQuerySelector ||
document.querySelector.bind(document);
document.getElementById = function(id) {
return getScopedElementById(String(id), function() {
return originalGetElementById(id);
});
};
document.querySelector = function(selector) {
var id = simpleIdSelector(selector);
if (id) {
return getScopedElementById(id, function() {
return originalQuerySelector(selector);
});
}
return originalQuerySelector(selector);
};
document.__linkeaseLuciOriginalGetElementById = originalGetElementById;
document.__linkeaseLuciOriginalQuerySelector = originalQuerySelector;
document.__linkeaseLuciIdScopeInstalled = true;
document.__linkeaseLuciIdScopeVersion = 2;
}
function isStylesheetNode(node) {
if (!node || node.nodeType !== 1)
return false;
var tag = node.tagName ? node.tagName.toLowerCase() : '';
return tag === 'style' ||
(tag === 'link' && /\bstylesheet\b/i.test(node.getAttribute('rel') || ''));
}
function isPortalNode(node) {
if (!node || node.nodeType !== 1)
return false;
var value = [
node.id || '',
node.className || '',
node.getAttribute('role') || '',
node.getAttribute('data-testid') || ''
].join(' ');
return /\b(modal|toast|message|notification|notify|overlay|popover|drawer|loading|tooltip)\b/i.test(value) ||
/\b(dialog|alertdialog)\b/i.test(value);
}
function rebaseCssUrls(css, baseHref) {
return String(css || '').replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, function(match, quote, rawUrl) {
var url = String(rawUrl || '').trim();
if (!url || url[0] === '/' || url[0] === '#' || /^[a-z][a-z0-9+.-]*:/i.test(url))
return match;
try {
return 'url("' + new URL(url, baseHref).href + '")';
} catch (e) {
return match;
}
});
}
function scopeSelector(selector) {
var trimmed = String(selector || '').trim();
if (!trimmed || trimmed.indexOf('.le-luci-document') === 0)
return trimmed;
var rootScoped = trimmed.replace(/^(html|body|:root|#app)(?=$|[\s.#:[>+~])/, '.le-luci-document');
if (rootScoped !== trimmed)
return rootScoped;
return '.le-luci-document ' + trimmed;
}
function scopeCss(css) {
return String(css || '')
.replace(/position\s*:\s*fixed/gi, 'position: absolute')
.replace(/\b100vh\b|\b100vw\b/gi, '100%')
.replace(/(^|})\s*([^@{}][^{]+)\{/g, function(match, close, selectorList) {
var scoped = selectorList.split(',').map(scopeSelector).join(',');
return close + scoped + '{';
});
}
function prepareStyleNode(node, baseHref) {
if (!node || !node.tagName || node.tagName.toLowerCase() !== 'style')
return node;
var css = rebaseCssUrls(node.textContent || '', baseHref || window.location.href);
node.textContent = shouldScopeCSS() ? scopeCss(css) : css;
return node;
}
function loadScopedStylesheet(linkNode, host) {
var href = linkNode.href || linkNode.getAttribute('href');
if (!href || typeof fetch != 'function')
return;
fetch(href, { credentials: 'include' })
.then(function(response) {
if (!response.ok)
throw new Error('stylesheet load failed');
return response.text();
})
.then(function(css) {
var style = document.createElement('style');
style.setAttribute('data-linkease-luci-from', href);
css = rebaseCssUrls(css, href);
style.textContent = shouldScopeCSS() ? scopeCss(css) : css;
host.appendChild(style);
})
.catch(function() {});
}
function installDynamicNodeScope() {
if (document.__linkeaseLuciDynamicNodeScopeInstalled)
return;
var originalHeadAppendChild = document.head.appendChild.bind(document.head);
var originalHeadInsertBefore = document.head.insertBefore.bind(document.head);
var originalBodyAppendChild = document.body.appendChild.bind(document.body);
var originalBodyInsertBefore = document.body.insertBefore.bind(document.body);
function redirectHeadNode(node, fallback) {
if (window.__LINKEASE_LUCI_EMBEDDED__ && shouldScopeDynamicNodes() && isStylesheetNode(node)) {
var host = ensureHost('style');
if (host) {
if (node.tagName && node.tagName.toLowerCase() === 'link') {
loadScopedStylesheet(node, host);
return node;
}
return host.appendChild(prepareStyleNode(node));
}
}
return fallback();
}
function redirectBodyNode(node, fallback) {
if (window.__LINKEASE_LUCI_EMBEDDED__ && shouldScopeDynamicNodes() && isPortalNode(node)) {
var host = ensureHost('portal');
if (host)
return host.appendChild(node);
}
return fallback();
}
document.head.appendChild = function(node) {
return redirectHeadNode(node, function() {
return originalHeadAppendChild(node);
});
};
document.head.insertBefore = function(node, child) {
return redirectHeadNode(node, function() {
return originalHeadInsertBefore(node, child);
});
};
document.body.appendChild = function(node) {
return redirectBodyNode(node, function() {
return originalBodyAppendChild(node);
});
};
document.body.insertBefore = function(node, child) {
return redirectBodyNode(node, function() {
return originalBodyInsertBefore(node, child);
});
};
document.__linkeaseLuciDynamicNodeScopeInstalled = true;
}
function installStrictGlobalGuard() {
if (!shouldGuardGlobals()) {
if (document.__linkeaseLuciStrictGlobalGuardObserver) {
document.__linkeaseLuciStrictGlobalGuardObserver.disconnect();
document.__linkeaseLuciStrictGlobalGuardObserver = null;
}
document.__linkeaseLuciStrictGlobalGuardInstalled = false;
return;
}
if (document.__linkeaseLuciStrictGlobalGuardInstalled || typeof MutationObserver === 'undefined')
return;
var html = document.documentElement;
var body = document.body;
var original = [
{ node: html, className: html.getAttribute('class'), style: html.getAttribute('style') },
{ node: body, className: body.getAttribute('class'), style: body.getAttribute('style') }
];
var restoring = false;
function restoreAttribute(node, name, value) {
if (value == null)
node.removeAttribute(name);
else
node.setAttribute(name, value);
}
function restore() {
if (restoring)
return;
restoring = true;
original.forEach(function(item) {
restoreAttribute(item.node, 'class', item.className);
restoreAttribute(item.node, 'style', item.style);
});
restoring = false;
}
var observer = new MutationObserver(function(mutations) {
if (restoring)
return;
if (mutations.some(function(mutation) {
return mutation.type === 'attributes' &&
(mutation.attributeName === 'class' || mutation.attributeName === 'style');
})) {
queueMicrotask(restore);
}
});
observer.observe(html, { attributes: true, attributeFilter: ['class', 'style'] });
observer.observe(body, { attributes: true, attributeFilter: ['class', 'style'] });
document.__linkeaseLuciStrictGlobalGuardInstalled = true;
document.__linkeaseLuciStrictGlobalGuardObserver = observer;
}
function keepSingleMainMenu() {
var mainMenu = query('#mainmenu');
if (!mainMenu)
return;
var navs = Array.prototype.slice.call(mainMenu.querySelectorAll(':scope > ul.nav'));
navs.slice(0, -1).forEach(function(node) {
node.parentNode.removeChild(node);
});
if (navs.length)
mainMenu.style.display = '';
}
window.__LINKEASE_LUCI_EMBED = {
root: root,
query: query,
queryAll: queryAll,
keepSingleMainMenu: keepSingleMainMenu,
isolationMode: isolationMode
};
installDocumentIdScope();
installDynamicNodeScope();
installStrictGlobalGuard();
document.addEventListener('luci-loaded', keepSingleMainMenu, true);
})();
@@ -0,0 +1,52 @@
.le-luci-document {
background: #fff;
}
.le-luci-document,
.le-luci-document > * {
box-sizing: border-box;
}
.le-luci-document #maincontainer,
.le-luci-document #maincontent,
.le-luci-document .main {
min-height: 100%;
}
.le-luci-document #mainmenu {
z-index: 20;
}
.le-luci-document #maincontent {
overflow-wrap: anywhere;
}
.le-luci-document.le-luci-content-only .main-left,
.le-luci-document.le-luci-content-only .main-right > header,
.le-luci-document.le-luci-content-only .main-right > footer,
.le-luci-document.le-luci-content-only #maincontent > footer,
.le-luci-document.le-luci-content-only footer.mobile-hide,
.le-luci-document.le-luci-content-only .main-right > .darkMask,
.le-luci-document.le-luci-content-only .showSide {
display: none !important;
}
.le-luci-document.le-luci-content-only .main,
.le-luci-document.le-luci-content-only .main-right,
.le-luci-document.le-luci-content-only #maincontent {
width: 100% !important;
min-height: 100% !important;
margin: 0 !important;
}
.le-luci-document.le-luci-content-only #maincontent > .container {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 !important;
}
.le-luci-document .modal-overlay,
.le-luci-document #modal_overlay {
position: absolute;
}
+1 -1
View File
@@ -11,7 +11,7 @@ LUCI_DEPENDS:=+linkeasefull +luci-lib-linkeaseauth +luci-lib-linkeasefile
LUCI_PKGARCH:=all
PKG_VERSION:=1.0.0
PKG_RELEASE:=5
PKG_RELEASE:=6
include $(TOPDIR)/feeds/luci/luci.mk
@@ -69,7 +69,24 @@ s = m:section(TypedSection, "linkeasefull", translate("Storage"))
s.addremove = false
s.anonymous = true
s:option(Flag, "enabled", translate("Enable")).rmempty = false
local enabled = s:option(Flag, "enabled", translate("Enable"))
enabled.rmempty = false
function enabled.write(self, section, value)
local old_value = self.map.uci:get("linkeasefull", section, "enabled") or "0"
self.map.uci:set("linkeasefull", section, "enabled", value)
if old_value == value then
return
end
local force_gateway = "/var/run/linkease-app-entry/force-gateway"
fs.mkdirr("/var/run/linkease-app-entry")
if value == "1" then
fs.remove(force_gateway)
else
fs.writefile(force_gateway, "")
end
end
local data = s:option(ListValue, "_local_home", translate("Storage path"), translate("Choose a mounted persistent disk. /tmp is not allowed."))
data.rmempty = false
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.9.16
PKG_RELEASE:=287
PKG_RELEASE:=288
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -272,9 +272,8 @@ o = s:option(Value, "user_agent", translate("User-Agent"))
o.default = "passwall"
o:value("passwall", "PassWall")
o:value("v2rayN/9.99", "v2rayN")
o:value("clash.meta", "Clash.Meta")
o:value("Clash", "Clash")
o:value("curl", "Curl")
o:value("clash.meta/v9.99.99", "Clash.Meta")
o:value("Shadowrocket/9999 CFNetwork/3896.100.1.2.1 Darwin/27.0.0 iPhone19,7", "Shadowrocket")
o:value("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Linux")
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Windows")
@@ -43,19 +43,25 @@ local current_node = map:get(section)
}
function b64DecodeUnicode(str) {
return decodeURIComponent(Array.prototype.map.call(atob(str), function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
try {
return decodeURIComponent(Array.prototype.map.call(atob(str), function (c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
} catch (e) {}
return str;
}
function b64decutf8safe(str) {
var l;
const orig = str;
let l;
str = str.replace(/-/g, "+").replace(/_/g, "/");
l = str.length;
l = (4 - l % 4) % 4;
if (l)
str = padright(str, l, "=");
return b64DecodeUnicode(str);
const text = b64DecodeUnicode(str);
if (text !== str) return text;
return orig;
}
function b64encsafe(str) {
@@ -81,6 +87,15 @@ local current_node = map:get(section)
return b64decsafe(v);
}
function is_json(str) {
try {
const result = JSON.parse(str);
return typeof result === 'object' && result !== null;
} catch (e) {
return false;
}
}
function parseNodeUrl(url) {
let protocol = url.substring(0, url.indexOf("://")) + ":"
let str = "http" + url.substring(url.indexOf("://"))
@@ -1312,7 +1327,6 @@ local current_node = map:get(section)
}
if (link_type === "vmess") {
var sstr = b64DecodeUnicode((link_split[1] || "").replace(/#.*/, "").trim());
var ploc = sstr.indexOf("/?");
if (vmess_type == "sing-box" && has_singbox) {
dom_prefix = "singbox_";
opt.set('type', "sing-box");
@@ -1321,19 +1335,65 @@ local current_node = map:get(section)
opt.set('type', "Xray");
}
opt.set(dom_prefix + 'protocol', "vmess");
var url0, param = "";
if (ploc > 0) {
url0 = sstr.substr(0, ploc);
param = sstr.substr(ploc + 2);
let ssm = {};
if (is_json(sstr)) {
// vmess://base64(json)
ssm = JSON.parse(sstr);
} else if (sstr.indexOf("?") > 0) {
// vmess://base64(auto:uuid@host:port)?tfo=1&remark=xxx&&alterId=0&obfs=websocket&path=%2F&obfsParam=host (obfs ~= ws obfsParam={})
sstr = sstr.replace("/?", "?");
const ploc = sstr.indexOf("?");
let url0 = sstr.substr(0, ploc);
let paramStr = sstr.substr(ploc + 2);
const m = new URL("http://" + b64decsafe(url0));
if (m.username !== "") {
ssm.security = m.username;
ssm.id = m.password || m.username;
} else {
ssm.security = "auto";
ssm.id = m.password || m.username;
}
ssm.add = unbracketIP(m.hostname);
ssm.port = m.port || "443";
const params = {};
const pairs = paramStr.split("&");
for (let i = 0; i < pairs.length; i++) {
const v = pairs[i];
const s = v.indexOf("=");
if (s > 0) {
params[v.substring(0, s)] = decodeURIComponent(v.substring(s + 1));
}
}
ssm.ps = params.remark || params.remarks;
ssm.net = (params.obfs === "websocket") ? "ws" : (params.obfs || "tcp");
ssm.path = params.path;
ssm.aid = params.alterId || "0";
ssm.tls = params.tls;
ssm.sni = params.peer;
ssm.tfo = params.tfo;
if (is_json(params.obfsParam)) {
const op_info = JSON.parse(params.obfsParam);
if (op_info.header) ssm.type = op_info.header;
if (op_info.Host) ssm.host = op_info.Host;
} else {
ssm.host = params.obfsParam;
}
ssm.allowinsecure = params.allowInsecure;
} else {
s.innerHTML = "<font color='red'><%:Invalid Share URL Format%></font>";
return false;
}
var ssm = JSON.parse(sstr);
opt.set('remarks', ssm.ps);
opt.set(dom_prefix + 'address', unbracketIP(ssm.add));
opt.set(dom_prefix + 'port', ssm.port);
opt.set(dom_prefix + 'uuid', ssm.id);
opt.set(dom_prefix + 'security', ssm.security || ssm.scy || "auto");
opt.set(dom_prefix + 'tls', ssm.tls === "tls");
if (ssm.tls === "tls") {
opt.set(dom_prefix + 'tls', ssm.tls === "tls" || ssm.tls === "1");
if (ssm.tls === "tls" || ssm.tls === "1") {
opt.set(dom_prefix + 'tls_serverName', ssm.sni || ssm.host);
opt.set(dom_prefix + 'alpn', ssm.alpn || "default");
if (ssm.fp && ssm.fp !== "") {
@@ -842,33 +842,37 @@ update_wan_sets() {
[ -z "$(command -v get_wan_ips)" ] && . "$UTILS_PATH"
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
ipset -F "$IPSET_WAN"
for wan_ip in $WAN_IP; do
ipset -! add "$IPSET_WAN" "$wan_ip"
done
[ "$log" = "log" ] && {
local wan_ip
for wan_ip in $WAN_IP; do
echolog " - [$?]加入WAN IPv4到ipset[$IPSET_WAN]:${wan_ip}"
done
}
}
(
flock -x 9 || exit 1
local WAN6_IP=$(get_wan_ips ip6)
[ -n "$WAN6_IP" ] && {
ipset -F "$IPSET_WAN6"
for wan6_ip in $WAN6_IP; do
ipset -! add "$IPSET_WAN6" "$wan6_ip"
done
[ "$log" = "log" ] && {
local wan6_ip
for wan6_ip in $WAN6_IP; do
echolog " - [$?]加入WAN IPv6到ipset[$IPSET_WAN6]:${wan6_ip}"
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
# ipset -F "$IPSET_WAN"
for wan_ip in $WAN_IP; do
ipset -! add "$IPSET_WAN" "$wan_ip"
done
[ "$log" = "log" ] && {
local wan_ip
for wan_ip in $WAN_IP; do
echolog " - [$?]加入WAN IPv4到ipset[$IPSET_WAN]:${wan_ip}"
done
}
}
}
local WAN6_IP=$(get_wan_ips ip6)
[ -n "$WAN6_IP" ] && {
# ipset -F "$IPSET_WAN6"
for wan6_ip in $WAN6_IP; do
ipset -! add "$IPSET_WAN6" "$wan6_ip"
done
[ "$log" = "log" ] && {
local wan6_ip
for wan6_ip in $WAN6_IP; do
echolog " - [$?]加入WAN IPv6到ipset[$IPSET_WAN6]:${wan6_ip}"
done
}
}
) 9>"${LOCK_PATH}/${CONFIG}_update_wan_sets.lock"
}
set_tproxy_sysctl() {
@@ -931,29 +931,33 @@ update_wan_sets() {
[ -z "$(command -v get_wan_ips)" ] && . "$UTILS_PATH"
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
nft flush set $NFTABLE_NAME $NFTSET_WAN
echo "$WAN_IP" | insert_nftset $NFTSET_WAN
[ "$log" = "log" ] && {
local wan_ip
for wan_ip in $WAN_IP; do
echolog " - [$?]加入WAN IPv4到nftset[$NFTSET_WAN]:${wan_ip}"
done
}
}
(
flock -x 9 || exit 1
local WAN6_IP=$(get_wan_ips ip6)
[ -n "${WAN6_IP}" ] && {
nft flush set $NFTABLE_NAME $NFTSET_WAN6
echo "$WAN6_IP" | insert_nftset $NFTSET_WAN6
[ "$log" = "log" ] && {
local wan6_ip
for wan6_ip in $WAN6_IP; do
echolog " - [$?]加入WAN IPv6到nftset[$NFTSET_WAN6]:${wan6_ip}"
done
local WAN_IP=$(get_wan_ips ip4)
[ -n "$WAN_IP" ] && {
# nft flush set $NFTABLE_NAME $NFTSET_WAN
echo "$WAN_IP" | insert_nftset $NFTSET_WAN
[ "$log" = "log" ] && {
local wan_ip
for wan_ip in $WAN_IP; do
echolog " - [$?]加入WAN IPv4到nftset[$NFTSET_WAN]:${wan_ip}"
done
}
}
}
local WAN6_IP=$(get_wan_ips ip6)
[ -n "${WAN6_IP}" ] && {
# nft flush set $NFTABLE_NAME $NFTSET_WAN6
echo "$WAN6_IP" | insert_nftset $NFTSET_WAN6
[ "$log" = "log" ] && {
local wan6_ip
for wan6_ip in $WAN6_IP; do
echolog " - [$?]加入WAN IPv6到nftset[$NFTSET_WAN6]:${wan6_ip}"
done
}
}
) 9>"${LOCK_PATH}/${CONFIG}_update_wan_sets.lock"
}
set_tproxy_sysctl() {
@@ -596,7 +596,6 @@ local function processData(szType, content, add_mode, group, sub_cfg)
-- if ssr_group then result.ssr_group = ssr_group end
result.remarks = base64Decode(params.remarks)
elseif szType == 'vmess' then
local info = jsonParse(content)
if sub_vmess_type == "sing-box" and has_singbox then
result.type = 'sing-box'
elseif sub_vmess_type == "xray" and has_xray then
@@ -605,6 +604,58 @@ local function processData(szType, content, add_mode, group, sub_cfg)
log("跳过 VMess 节点,因未适配到 VMess 核心程序,或未正确设置节点使用类型。")
return nil
end
-- vmess://base64(json)
local info = jsonParse(content)
if not info then
-- vmess://base64(auto:uuid@host:port)?tfo=1&remark=xxx&&alterId=0&obfs=websocket&path=%2F&obfsParam=host (obfs ~= ws obfsParam={})
if content:find("?", 1, true) then
info = {}
local Info = split(content:gsub("/%?", "?"), "%?")
local sp = split(base64Decode(Info[1]), "@")
local id_info = split(sp[1], ":")
info.security = (#id_info > 1 and id_info[1] ~= "") and id_info[1] or "auto"
info.id = id_info[#id_info]
local addr, port = sp[2], "443"
if api.is_ipv6addrport(addr) then
local a, p = addr:match("^%[(.+)%]:(%d+)$")
if a then addr, port = a, p end
addr = api.get_ipv6_only(addr)
else
local host_port = split(addr, ":")
addr = host_port[1]
if #host_port > 1 then port = host_port[#host_port] end
end
info.add, info.port = addr, port
local params = {}
for _, v in pairs(split(Info[2], '&')) do
local s = v:find("=", 1, true)
if s and s > 1 then
params[v:sub(1, s - 1)] = UrlDecode(v:sub(s + 1))
end
end
info.ps = params.remark or params.remarks
info.net = (params.obfs == "websocket") and "ws" or (params.obfs or "tcp")
info.path = params.path
info.aid = params.alterId or "0"
info.tls = params.tls
info.sni = params.peer
info.tfo = params.tfo
local op_info = jsonParse(params.obfsParam)
if op_info then
if op_info.header then info.type = op_info.header end
if op_info.Host then info.host = op_info.Host end
else
info.host = params.obfsParam
end
info.allowinsecure = params.allowInsecure
else
log("跳过 VMess 节点,该节点 URI 格式无法解析。")
return nil
end
end
result.alter_id = info.aid
result.address = info.add
result.port = info.port
+3 -3
View File
@@ -17,12 +17,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-wwand
PKG_VERSION:=1.6.7_p15
PKG_RELEASE:=27
PKG_VERSION:=1.6.8
PKG_RELEASE:=28
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
PKG_SOURCE_VERSION:=33741b35256ff21c4322e9f9351c9749cd369a97
PKG_SOURCE_VERSION:=2f0623c14baf7b6af44eaead8238bf9245693586
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
+3 -3
View File
@@ -2,11 +2,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI shared OpenWrt auth bridge for LinkEase apps
LUCI_TITLE:=LuCI shared integration for LinkEase apps
LUCI_PKGARCH:=all
PKG_VERSION:=1.0.3
PKG_RELEASE:=5
PKG_VERSION:=1.2.1
PKG_RELEASE:=6
include $(TOPDIR)/feeds/luci/luci.mk
@@ -0,0 +1,31 @@
module("luci.controller.linkease_apps", package.seeall)
function index()
local open = entry({"admin", "services", "linkease_apps", "open"}, call("open"))
open.leaf = true
open.dependent = false
open.sysauth = false
local status = entry({"admin", "services", "linkease_apps", "status"}, call("status"))
status.leaf = true
status.dependent = false
status.sysauth = "root"
status.sysauth_authenticator = "htmlauth"
end
local function handler()
local dispatcher = require "luci.dispatcher"
return require("luci.model.linkease.apps_http").new({
http = require "luci.http",
resolver = require("luci.model.linkease.apps_openwrt").new(),
auth_url = dispatcher.build_url("admin", "services", "linkease_auth", "auth")
})
end
function open()
handler():open()
end
function status()
handler():status()
end
@@ -4,8 +4,7 @@ function index()
local auth = entry({"admin", "services", "linkease_auth", "auth"}, call("linkease_auth"))
auth.leaf = true
auth.dependent = false
auth.sysauth = "root"
auth.sysauth_authenticator = "htmlauth"
auth.sysauth = false
local auth_finish = entry({"admin", "services", "linkease_auth", "auth_finish"}, call("linkease_auth_finish"))
auth_finish.leaf = true
@@ -14,202 +13,14 @@ function index()
auth_finish.sysauth_authenticator = "htmlauth"
end
local function retrieve_luci_session()
local http = require "luci.http"
local util = require "luci.util"
for _, key in ipairs({"sysauth_https", "sysauth_http", "sysauth"}) do
local sid = http.getcookie(key)
if sid and sid ~= "" then
local sdat = util.ubus("session", "get", { ubus_rpc_session = sid })
if sdat and type(sdat.values) == "table" then
return sid
end
end
end
return nil
end
local function authority_host(authority)
if not authority or authority == "" then
return ""
end
if authority:sub(1, 1) == "[" then
return authority:match("^%[([^%]]+)%]") or ""
end
return authority:match("^([^:]+)") or authority
end
local function valid_authority(authority)
return authority and authority ~= "" and authority:match("^[A-Za-z0-9%._%-%[%]:]+$") ~= nil
end
local function request_authority()
local http = require "luci.http"
local forwarded_host = http.getenv("HTTP_X_FORWARDED_HOST") or ""
local request_host = http.getenv("HTTP_HOST") or ""
if valid_authority(forwarded_host) then
return forwarded_host
end
if valid_authority(request_host) then
return request_host
end
return ""
end
local function request_scheme()
local http = require "luci.http"
local forwarded_proto = http.getenv("HTTP_X_FORWARDED_PROTO") or ""
if forwarded_proto == "https" then
return "https"
end
if http.getenv("HTTPS") == "on" then
return "https"
end
return "http"
end
local function absolute_luci_url(path)
if not path or path == "" or path:match("^https?://") then
return path
end
local authority = request_authority()
if authority == "" then
return path
end
return request_scheme() .. "://" .. authority .. path
end
local function valid_apps_return(value)
if not value or value == "" then
return false
end
local function valid_path(path)
if path == "/apps" then
return true
end
local prefix = path:sub(1, 6)
return prefix == "/apps/" or prefix == "/apps?" or prefix == "/apps#"
end
if value:sub(1, 1) == "/" then
return valid_path(value)
end
local scheme, authority, path = value:match("^(https?://)([^/]+)(/.*)$")
if not scheme or not authority or not valid_path(path) then
return false
end
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local request_host = request_authority()
local lan_host = uci:get("network", "lan", "ipaddr") or ""
local authority_host_value = authority_host(authority)
if authority_host_value ~= "" and authority_host_value == authority_host(request_host) then
return true
end
if authority_host_value ~= "" and authority_host_value == authority_host(lan_host) then
return true
end
-- Example: http://192.168.30.93:8192/apps/dockermanager/
return false
end
local function valid_cookie_value(value)
return value and value:match("^[A-Za-z0-9._%-_]+$") ~= nil
end
local pending_return_cookie = "linkease_openwrt_pending_return"
local bridge_return_cookie = "linkease_openwrt_return"
local pending_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth"
local bridge_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth/auth"
local function cookie_encode(value)
return tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
return string.format("%%%02X", char:byte())
end)
end
local function cookie_decode(value)
if not value or value == "" then
return nil
end
return value:gsub("%%(%x%x)", function(hex)
return string.char(tonumber(hex, 16))
end)
end
local function safe_return_target(value)
if valid_apps_return(value) then
return value
end
return "/apps/"
end
local function set_pending_return_cookie(target)
local http = require "luci.http"
http.header("Set-Cookie", pending_return_cookie .. "=" .. cookie_encode(target) .. "; Path=" .. pending_return_cookie_path .. "; Max-Age=300; HttpOnly; SameSite=Lax")
end
local function clear_pending_return_cookie()
local http = require "luci.http"
http.header("Set-Cookie", pending_return_cookie .. "=; Path=" .. pending_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
end
local function clear_bridge_return_cookie()
local http = require "luci.http"
http.header("Set-Cookie", bridge_return_cookie .. "=; Path=" .. bridge_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
end
local function requested_return_target()
local http = require "luci.http"
return safe_return_target(http.formvalue("return") or cookie_decode(http.getcookie(bridge_return_cookie)) or "/apps/")
end
local function pending_return_target()
local http = require "luci.http"
return safe_return_target(cookie_decode(http.getcookie(pending_return_cookie)))
end
local function auth_finish_url()
local dispatcher = require "luci.dispatcher"
return absolute_luci_url(dispatcher.build_url("admin", "services", "linkease_auth", "auth_finish"))
local function bridge()
return require("luci.model.linkease.auth").new()
end
function linkease_auth()
local http = require "luci.http"
local sid = retrieve_luci_session()
local target = requested_return_target()
if valid_cookie_value(sid) then
clear_bridge_return_cookie()
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
return
end
set_pending_return_cookie(target)
http.redirect(auth_finish_url())
bridge():auth()
end
function linkease_auth_finish()
local http = require "luci.http"
local sid = retrieve_luci_session()
if not valid_cookie_value(sid) then
http.status(403, "Forbidden")
return
end
local target = pending_return_target()
clear_pending_return_cookie()
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
function linkease_auth_finish(state)
bridge():auth_finish(state)
end
@@ -0,0 +1,113 @@
local M = {}
local Engine = {}
Engine.__index = Engine
local function valid_id(id)
return type(id) == "string"
and id:match("^[a-z0-9][a-z0-9_-]*$") ~= nil
and #id <= 64
end
local function safe_apps_url(value, id)
if type(value) ~= "string" then
return "/apps/" .. id .. "/"
end
local expected = "/apps/" .. id
if value == expected or value:sub(1, #expected + 1) == expected .. "/" then
return value
end
return "/apps/" .. id .. "/"
end
local function safe_external_url(value)
return type(value) == "string"
and #value <= 2048
and value:match("^https?://[^/@]+%f[/]") ~= nil
and value:find("[%c]") == nil
end
local function base_result(id, entry)
entry = type(entry) == "table" and entry or {}
return {
schemaVersion = 1,
id = id,
registered = false,
available = false,
mode = "unavailable",
worker = entry.worker or "unknown",
proxy = {
supported = entry.proxy_supported == true,
enabled = entry.proxy_enabled == true
}
}
end
local function adapter_call(adapter, name, ...)
local fn = adapter[name]
if type(fn) ~= "function" then
return nil
end
local ok, value = pcall(fn, ...)
if not ok then
return nil
end
return value
end
function M.new(adapter)
assert(type(adapter) == "table", "apps decision adapter is required")
return setmetatable({ adapter = adapter }, Engine)
end
function Engine:resolve(id)
if not valid_id(id) then
local result = base_result(type(id) == "string" and id or "", nil)
result.reason = "invalid_id"
return result
end
local app = adapter_call(self.adapter, "lookup", id)
if type(app) ~= "table" then
local result = base_result(id, nil)
result.reason = "not_registered"
return result
end
local entry = adapter_call(self.adapter, "entry_status")
local result = base_result(id, entry)
result.registered = true
if type(entry) == "table"
and app.entry_supported ~= false
and entry.available == true
and entry.proxy_supported == true
and entry.proxy_enabled == true
and (entry.worker == "gateway" or entry.worker == "linkeasefull") then
result.available = true
result.mode = "apps"
result.url = safe_apps_url(app.url, id)
result.reason = "entry_available"
return result
end
local external = adapter_call(self.adapter, "external_status", app)
if type(external) == "table" and external.available == true and safe_external_url(external.url) then
result.available = true
result.mode = "external"
result.url = external.url
result.reason = "external_available"
return result
end
local entry_reason = app.entry_supported == false and "entry_unsupported" or "entry_unavailable"
local external_reason = type(external) == "table" and external.reason or "external_unavailable"
result.reason = entry_reason .. "_and_" .. tostring(external_reason)
return result
end
function Engine:status(id)
return self:resolve(id)
end
M.valid_id = valid_id
return M
@@ -0,0 +1,38 @@
local M = {}
local Compat = {}
Compat.__index = Compat
function M.new(options)
assert(type(options) == "table" and options.http and options.resolver, "apps compatibility options are required")
return setmetatable({
http = options.http,
resolver = options.resolver,
auth_url = assert(options.auth_url, "auth_url is required")
}, Compat)
end
function Compat:open(id)
local decision = self.resolver:resolve(id)
if decision.available ~= true then
self.http.status(decision.registered == true and 503 or 404, "Service Unavailable")
self.http.prepare_content("application/json")
self.http.write_json(decision)
return
end
self.http.redirect(self.auth_url .. "?return=" .. self.http.urlencode(decision.url))
end
function Compat:legacy_status(id, fields)
local decision = self.resolver:status(id)
local response = fields or {}
response.entry_url = decision.url or ""
response.proxy_prefix_supported = decision.proxy and decision.proxy.supported == true or false
response.proxy_prefix_enabled = decision.proxy and decision.proxy.enabled == true or false
response.app_entry_running = decision.mode == "apps" and decision.available == true
response.access = decision
self.http.status(200, "OK")
self.http.prepare_content("application/json")
self.http.write_json(response)
end
return M
@@ -0,0 +1,49 @@
local M = {}
local Handler = {}
Handler.__index = Handler
local status_messages = {
[200] = "OK",
[400] = "Bad Request",
[404] = "Not Found",
[503] = "Service Unavailable"
}
local function response_status(decision)
if decision.reason == "invalid_id" then return 400 end
if decision.registered ~= true then return 404 end
if decision.available ~= true then return 503 end
return 200
end
function M.new(options)
assert(type(options) == "table" and options.http and options.resolver, "apps HTTP options are required")
return setmetatable({
http = options.http,
resolver = options.resolver,
auth_url = assert(options.auth_url, "auth_url is required")
}, Handler)
end
function Handler:write(decision, code)
self.http.status(code, status_messages[code])
self.http.prepare_content("application/json")
self.http.write_json(decision)
end
function Handler:status()
local decision = self.resolver:status(self.http.formvalue("id"))
self:write(decision, response_status(decision))
end
function Handler:open()
local decision = self.resolver:resolve(self.http.formvalue("id"))
local code = response_status(decision)
if code ~= 200 then
self:write(decision, code)
return
end
self.http.redirect(self.auth_url .. "?return=" .. self.http.urlencode(decision.url))
end
return M
@@ -0,0 +1,189 @@
local M = {}
local proxy_mapping = "/apps=http://127.0.0.1:19290"
local registration_dirs = {
"/usr/share/linkease/apps.d",
"/usr/share/linkeasefull/desktop-apps.d"
}
local function defaults()
local sys = require "luci.sys"
return {
fs = require "nixio.fs",
http = require "luci.http",
json = require "luci.jsonc",
sys = sys,
read_uci = function(key)
if type(key) ~= "string" or key:match("^[%w_@%[%].-]+$") == nil then return nil end
local value = sys.exec("uci -q get " .. string.format("%q", key) .. " 2>/dev/null") or ""
value = value:match("^%s*(.-)%s*$")
if value == "" then return nil end
return value
end,
uci = require("luci.model.uci").cursor()
}
end
local function normalize_path(value, id)
local path = type(value) == "string" and value or "/apps/" .. id .. "/"
if path:sub(1, 1) ~= "/" then path = "/" .. path end
if path:sub(-1) ~= "/" then path = path .. "/" end
return path
end
local function authority_host(authority)
if not authority or authority == "" then return "" end
if authority:sub(1, 1) == "[" then return authority:match("^%[([^%]]+)%]") or "" end
return authority:match("^([^:]+)") or authority
end
local function url_host(host)
if host:find(":") and host:sub(1, 1) ~= "[" then return "[" .. host .. "]" end
return host
end
local function uci_key(cursor, key, read_committed)
if type(key) ~= "string" then return nil end
local config, section_type, _, option = key:match("^([%w_-]+)%.@([%w_-]+)%[(%d+)%]%.([%w_-]+)$")
if config and section_type and option then
return cursor:get_first(config, section_type, option) or (read_committed and read_committed(key))
end
local direct_config, section, direct_option = key:match("^([%w_-]+)%.([%w_-]+)%.([%w_-]+)$")
if direct_config then return cursor:get(direct_config, section, direct_option) or (read_committed and read_committed(key)) end
return nil
end
local function configured_value(cursor, specification, read_committed)
if type(specification) ~= "table" then return nil end
local keys = specification.keys
local value = type(keys) == "table" and uci_key(cursor, keys.uci, read_committed) or nil
if value ~= nil then return value end
return specification.default
end
local function bool_value(value)
return value == true or value == 1 or value == "1" or value == "true"
end
local function standalone_external_path(app)
local standalone = type(app.manifest.standalone) == "table" and app.manifest.standalone or {}
local external = type(standalone.externalOpen) == "table" and standalone.externalOpen or {}
if external.enabled == true and type(external.path) == "string" then
return external.path
end
return nil
end
function M.new(dependencies)
local deps = dependencies or defaults()
local function lookup(id)
for _, directory in ipairs(deps.registration_dirs or registration_dirs) do
local iterator = deps.fs.dir(directory)
if iterator then
for filename in iterator do
if filename:match("%.json$") then
local raw = deps.fs.readfile(directory .. "/" .. filename)
local manifest = raw and deps.json.parse(raw) or nil
if type(manifest) == "table" and manifest.id == id then
local standalone = type(manifest.standalone) == "table" and manifest.standalone or {}
local desktop = type(manifest.desktop) == "table" and manifest.desktop or {}
return {
id = id,
url = normalize_path(standalone.url or standalone.basePath, id),
entry_supported = desktop.mode ~= "builtin",
manifest = manifest
}
end
end
end
end
end
return nil
end
local function entry_status()
local mappings = deps.uci:get_list("uhttpd", "main", "proxy_prefix") or {}
local enabled = false
for _, mapping in ipairs(mappings) do
if mapping == proxy_mapping then enabled = true end
end
-- A synthetic LuCI test session may make the session-aware UCI cursor
-- hide list values. The committed config remains the source of truth.
if not enabled then
local config = deps.fs.readfile("/etc/config/uhttpd") or ""
enabled = config:find(proxy_mapping, 1, true) ~= nil
end
local supported = deps.sys.call("grep -qr 'proxy_prefix' /etc/init.d/uhttpd /lib/functions /usr/share/uhttpd 2>/dev/null") == 0
local state_path = deps.uci:get("linkease_app_entry", "main", "state_file") or "/var/run/linkease-app-entry/state.json"
local raw = deps.fs.readfile(state_path)
local state = raw and deps.json.parse(raw) or {}
local worker = type(state) == "table" and state.active or "stopped"
local running = worker == "gateway" or worker == "linkeasefull"
return {
available = running and supported and enabled,
worker = worker,
proxy_supported = supported,
proxy_enabled = enabled
}
end
local function external_status(app)
local desktop = app.manifest.desktop or {}
local target = type(desktop.target) == "table" and desktop.target or nil
if desktop.mode == "iframe" and target and target.hostMode == "request-host"
and (target.scheme == "http" or target.scheme == "https") then
local port = tonumber(configured_value(deps.uci, target.port, deps.read_uci))
if not port or port < 1 or port > 65535 or port % 1 ~= 0 then
return { available = false, reason = "external_invalid" }
end
local host = authority_host(deps.http.getenv("HTTP_HOST") or "")
if host == "" then host = deps.uci:get("network", "lan", "ipaddr") or "127.0.0.1" end
local external_path = standalone_external_path(app)
or (type(target.path) == "string" and target.path or "/")
if external_path:sub(1, 1) ~= "/" or external_path:sub(1, 2) == "//"
or external_path:find("[%c]") then
external_path = "/"
end
return {
available = true,
url = target.scheme .. "://" .. url_host(host) .. ":" .. tostring(port) .. external_path
}
end
local backend = app.manifest.backend or {}
local values = type(backend.values) == "table" and backend.values or {}
local enabled
local port
if type(values.externalPortEnabled) == "table" then
enabled = bool_value(configured_value(deps.uci, values.externalPortEnabled, deps.read_uci))
port = configured_value(deps.uci, values.port, deps.read_uci)
else
enabled = backend.portFromUci ~= nil or backend.defaultPort ~= nil
port = uci_key(deps.uci, backend.portFromUci, deps.read_uci) or backend.defaultPort
end
if not enabled then return { available = false, reason = "external_disabled" } end
port = tonumber(port)
if not port or port < 1 or port > 65535 or port % 1 ~= 0 then
return { available = false, reason = "external_invalid" }
end
local host = authority_host(deps.http.getenv("HTTP_HOST") or "")
if host == "" then host = deps.uci:get("network", "lan", "ipaddr") or "127.0.0.1" end
local external_path = standalone_external_path(app) or backend.externalBasePath or app.url
if type(external_path) ~= "string" or external_path:sub(1, 1) ~= "/"
or external_path:sub(1, 2) == "//" or external_path:find("[%c]") then
external_path = app.url
end
return {
available = true,
url = "http://" .. url_host(host) .. ":" .. tostring(port) .. external_path
}
end
return require("luci.model.linkease.apps").new({
lookup = lookup,
entry_status = entry_status,
external_status = external_status
})
end
return M
@@ -0,0 +1,191 @@
local M = {}
local Bridge = {}
Bridge.__index = Bridge
local pending_return_cookie = "linkease_openwrt_pending_return"
local bridge_return_cookie = "linkease_openwrt_return"
local pending_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth"
local bridge_return_cookie_path = "/cgi-bin/luci/admin/services/linkease_auth/auth"
local max_return_length = 2048
local function encode_state(value)
if type(value) ~= "string" or #value > max_return_length then return nil end
local encoded = require("nixio").bin.b64encode(value)
return (encoded:gsub("%+", "-"):gsub("/", "_"):gsub("=+$", ""))
end
local function decode_state(value)
if type(value) ~= "string" or #value == 0 or #value > 4096
or value:match("^[A-Za-z0-9_-]+$") == nil then
return nil
end
local encoded = value:gsub("-", "+"):gsub("_", "/")
encoded = encoded .. string.rep("=", (4 - #encoded % 4) % 4)
local ok, decoded = pcall(require("nixio").bin.b64decode, encoded)
if not ok or type(decoded) ~= "string" or #decoded > max_return_length then return nil end
return decoded
end
local function default_dependencies()
local http = require "luci.http"
local util = require "luci.util"
local dispatcher = require "luci.dispatcher"
return {
http = http,
ubus = function(object, method, request) return util.ubus(object, method, request) end,
lan_ip = function()
return require("luci.model.uci").cursor():get("network", "lan", "ipaddr") or ""
end,
build_url = function(...) return dispatcher.build_url(...) end
}
end
local function authority_host(authority)
if not authority or authority == "" then return "" end
if authority:sub(1, 1) == "[" then return authority:match("^%[([^%]]+)%]") or "" end
return authority:match("^([^:]+)") or authority
end
local function authority_port(authority)
if not authority then return nil end
local value = authority:match("^%[[^%]]+%]:(%d+)$") or authority:match("^[^:]+:(%d+)$")
value = tonumber(value)
if value and value >= 1 and value <= 65535 then return value end
return nil
end
local function valid_authority(authority)
return authority and authority ~= "" and authority:match("^[A-Za-z0-9%._%-%[%]:]+$") ~= nil
end
local function cookie_encode(value)
return (tostring(value or ""):gsub("([^A-Za-z0-9._~-])", function(char)
return string.format("%%%02X", char:byte())
end))
end
local function cookie_decode(value)
if not value or value == "" then return nil end
return (value:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end))
end
local function valid_cookie_value(value)
return value and value:match("^[A-Za-z0-9._%-_]+$") ~= nil
end
function M.new(dependencies)
return setmetatable({ dependencies = dependencies or default_dependencies() }, Bridge)
end
function Bridge:request_authority()
local http = self.dependencies.http
local forwarded_host = http.getenv("HTTP_X_FORWARDED_HOST") or ""
local request_host = http.getenv("HTTP_HOST") or ""
if valid_authority(forwarded_host) then return forwarded_host end
if valid_authority(request_host) then return request_host end
return ""
end
function Bridge:request_scheme()
local http = self.dependencies.http
if http.getenv("HTTP_X_FORWARDED_PROTO") == "https" or http.getenv("HTTPS") == "on" then return "https" end
return "http"
end
function Bridge:absolute_luci_url(path)
if not path or path == "" or path:match("^https?://") then return path end
local authority = self:request_authority()
if authority == "" then return path end
return self:request_scheme() .. "://" .. authority .. path
end
function Bridge:valid_apps_return(value)
if not value or value == "" then return false end
if #value > max_return_length or value:find("[%z\1-\31\127]") then return false end
local function valid_path(path)
if path == "/apps" then return true end
local prefix = path:sub(1, 6)
return prefix == "/apps/" or prefix == "/apps?" or prefix == "/apps#"
end
if value:sub(1, 1) == "/" then return valid_path(value) end
local scheme, authority, path = value:match("^(https?://)([^/]+)(/.*)$")
if not authority or not valid_authority(authority) then return false end
local candidate = authority_host(authority)
local same_device = candidate ~= "" and (
candidate == authority_host(self:request_authority())
or candidate == authority_host(self.dependencies.lan_ip())
)
if not same_device then return false end
if valid_path(path) then return true end
if path:sub(1, 1) ~= "/" or path:sub(1, 2) == "//" then return false end
local target_port = authority_port(authority)
local request_port = authority_port(self:request_authority())
or (self:request_scheme() == "https" and 443 or 80)
local target_default = scheme == "https://" and 443 or 80
return target_port ~= nil and target_port ~= request_port and target_port ~= target_default
end
function Bridge:safe_return_target(value)
if self:valid_apps_return(value) then return value end
return "/apps/"
end
function Bridge:retrieve_luci_session()
local http = self.dependencies.http
for _, key in ipairs({"sysauth_https", "sysauth_http", "sysauth"}) do
local sid = http.getcookie(key)
if sid and sid ~= "" then
local data = self.dependencies.ubus("session", "get", { ubus_rpc_session = sid })
if data and type(data.values) == "table" then return sid end
end
end
return nil
end
function Bridge:requested_return_target()
local http = self.dependencies.http
return self:safe_return_target(http.formvalue("return") or cookie_decode(http.getcookie(bridge_return_cookie)) or "/apps/")
end
function Bridge:pending_return_target()
return self:safe_return_target(cookie_decode(self.dependencies.http.getcookie(pending_return_cookie)))
end
function Bridge:auth()
local http = self.dependencies.http
local sid = self:retrieve_luci_session()
local target = self:requested_return_target()
if valid_cookie_value(sid) then
http.header("Set-Cookie", bridge_return_cookie .. "=; Path=" .. bridge_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
return
end
http.header("Set-Cookie", pending_return_cookie .. "=" .. cookie_encode(target) .. "; Path=" .. pending_return_cookie_path .. "; Max-Age=300; HttpOnly; SameSite=Lax")
local state = encode_state(target)
local finish = self.dependencies.build_url("admin", "services", "linkease_auth", "auth_finish", state)
http.redirect(self:absolute_luci_url(finish))
end
function Bridge:auth_finish(state)
local http = self.dependencies.http
local sid = self:retrieve_luci_session()
if not valid_cookie_value(sid) then
http.status(403, "Forbidden")
return
end
local decoded = decode_state(state)
local target
if state and state ~= "" then
target = self:safe_return_target(decoded)
else
target = self:pending_return_target()
end
http.header("Set-Cookie", pending_return_cookie .. "=; Path=" .. pending_return_cookie_path .. "; Max-Age=0; HttpOnly; SameSite=Lax")
http.header("Set-Cookie", "linkease_openwrt_sid=" .. sid .. "; Path=/apps; HttpOnly; SameSite=Lax")
http.redirect(target)
end
return M
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more