From e97ac22932034d52229617e549186c6c2d5e8f93 Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 03:24:38 +0000 Subject: [PATCH 1/5] kernel: support strict Snap confinement --- config/Config-kernel.in | 55 +- feeds/packages/utils/apparmor/Makefile | 125 +- .../utils/apparmor/files/apparmor.init | 2 +- .../packages/utils/apparmor/files/apparmor.sh | 56 +- ...-apparmor-backport-af-unix-mediation.patch | 1327 +++++++++++++++++ target/linux/generic/config-6.12 | 2 + target/linux/x86/64/config-6.12 | 1 + 7 files changed, 1503 insertions(+), 65 deletions(-) create mode 100644 target/linux/generic/backport-6.12/950-apparmor-backport-af-unix-mediation.patch diff --git a/config/Config-kernel.in b/config/Config-kernel.in index c79392bc5c4..0fca8c68980 100644 --- a/config/Config-kernel.in +++ b/config/Config-kernel.in @@ -1426,6 +1426,55 @@ config KERNEL_AUDIT config KERNEL_SECURITY bool "Enable different security models" +config KERNEL_SECURITYFS + bool "Security filesystem support" + default y if KERNEL_SECURITY_APPARMOR + +config KERNEL_SECURITY_PATH + bool "Path based security hooks" + default y if KERNEL_SECURITY_APPARMOR + select KERNEL_SECURITY + +config KERNEL_SECURITY_APPARMOR + bool "AppArmor support" + default y if PACKAGE_snapd + select KERNEL_SECURITY + select KERNEL_SECURITYFS + select KERNEL_SECURITY_PATH + select KERNEL_SECURITY_NETWORK + select KERNEL_AUDIT + select KERNEL_SQUASHFS_XATTR + +config KERNEL_SECURITY_APPARMOR_DEBUG + bool "Build AppArmor with debug code" + depends on KERNEL_SECURITY_APPARMOR + default n + +config KERNEL_SECURITY_APPARMOR_INTROSPECT_POLICY + bool "Allow loaded AppArmor policy introspection" + depends on KERNEL_SECURITY_APPARMOR + default y + +config KERNEL_SECURITY_APPARMOR_HASH + bool "Enable AppArmor profile hash introspection" + depends on KERNEL_SECURITY_APPARMOR_INTROSPECT_POLICY + default y + +config KERNEL_SECURITY_APPARMOR_HASH_DEFAULT + bool "Enable AppArmor profile hashing by default" + depends on KERNEL_SECURITY_APPARMOR_HASH + default y + +config KERNEL_SECURITY_APPARMOR_EXPORT_BINARY + bool "Allow exporting raw AppArmor policy" + depends on KERNEL_SECURITY_APPARMOR_INTROSPECT_POLICY + default y + +config KERNEL_SECURITY_APPARMOR_PARANOID_LOAD + bool "Fully verify loaded AppArmor policy" + depends on KERNEL_SECURITY_APPARMOR + default y + config KERNEL_SECURITY_NETWORK bool "Socket and Networking Security Hooks" select KERNEL_SECURITY @@ -1457,8 +1506,10 @@ config KERNEL_SECURITY_SELINUX_SID2STR_CACHE_SIZE config KERNEL_LSM string - default "lockdown,yama,loadpin,safesetid,integrity,selinux" - depends on KERNEL_SECURITY_SELINUX + default "lockdown,yama,loadpin,safesetid,integrity,apparmor,selinux" if KERNEL_SECURITY_APPARMOR && KERNEL_SECURITY_SELINUX + default "lockdown,yama,loadpin,safesetid,integrity,apparmor" if KERNEL_SECURITY_APPARMOR + default "lockdown,yama,loadpin,safesetid,integrity,selinux" if KERNEL_SECURITY_SELINUX + depends on KERNEL_SECURITY_APPARMOR || KERNEL_SECURITY_SELINUX config KERNEL_EROFS_FS_SECURITY bool "EROFS Security Labels" diff --git a/feeds/packages/utils/apparmor/Makefile b/feeds/packages/utils/apparmor/Makefile index 84e497e974e..c3e0495e43b 100644 --- a/feeds/packages/utils/apparmor/Makefile +++ b/feeds/packages/utils/apparmor/Makefile @@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=apparmor PKG_VERSION:=3.0.13 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_SOURCE_PROTO:=git PKG_SOURCE_VERSION:=v$(PKG_VERSION) @@ -15,11 +15,15 @@ PKG_MAINTAINER:=Oskari Rauta PKG_LICENSE:=GPL-2.0-only PKG_LICENSE_FILES:=LICENSE -PKG_BUILD_DEPENDS:=swig/host python-setuptools-scm/host - include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/nls.mk -include ../../lang/python/python3-package.mk + +PKG_BUILD_DEPENDS:=PACKAGE_python3-apparmor:swig/host PACKAGE_python3-apparmor:python-setuptools-scm/host + +ifneq ($(CONFIG_PACKAGE_python3-apparmor),) + include ../../lang/python/python3-package.mk + APPARMOR_WITH_PYTHON:=1 +endif define Package/apparmor/Default SECTION:=utils @@ -41,13 +45,19 @@ define Package/python3-apparmor CATEGORY:=Languages SUBMENU:=Python URL:=https://apparmor.net - DEPENDS:=+libapparmor +python3 + DEPENDS:=+libapparmor +PACKAGE_python3-apparmor:python3 +endef + +define Package/apparmor-parser + $(call Package/apparmor/Default) + TITLE:=AppArmor parser and runtime service + DEPENDS:=$(INTL_DEPENDS) +libapparmor +findutils-xargs endef define Package/apparmor-utils $(call Package/apparmor/Default) TITLE:=AppArmor utils - DEPENDS:=$(INTL_DEPENDS) +libapparmor +python3-apparmor +python3 +python3-readline +python3-psutil +ss +findutils-xargs + DEPENDS:=$(INTL_DEPENDS) +apparmor-parser +PACKAGE_apparmor-utils:python3-apparmor +PACKAGE_apparmor-utils:python3 +PACKAGE_apparmor-utils:python3-readline +PACKAGE_apparmor-utils:python3-psutil +PACKAGE_apparmor-utils:ss endef define Package/apparmor-profiles @@ -59,9 +69,14 @@ define Package/libapparmor/description Library to support AppArmor userspace utilities endef +define Package/apparmor-parser/description + Minimal AppArmor runtime containing apparmor_parser, the OpenWrt service + integration, and common tunables. It does not pull in Python management tools. +endef + define Package/apparmor-utils/description - AppArmor application security system init script and - userspace utils to assist with AppArmor profile management + AppArmor userspace utilities to assist with AppArmor profile management. + The parser and service integration are provided by apparmor-parser. endef define Package/apparmor-profiles/description @@ -70,23 +85,29 @@ endef CONFIGURE_PATH=libraries/libapparmor +CONFIGURE_VARS += \ + SHELL=$(bash) + +ifneq ($(APPARMOR_WITH_PYTHON),) TARGET_LDFLAGS += \ -L$(PYTHON3_LIB_DIR) CONFIGURE_VARS += \ - SHELL=$(bash) \ PYTHON_VERSION=$(PYTHON3_VERSION) \ PYTHON_VERSIONS=$(PYTHON3) \ PYTHON=$(PYTHON3) \ PYTHON_CONFIG=$(STAGING_DIR_ROOT)/host/bin/python$(PYTHON3_VERSION)-config \ - PYTHON_LDFLAGS="-L$(STAGING_DIR)/usr/lib -L$(PYTHON3_LIB_DIR)" \ - PYTHON_CPPFLAGS="-I$(STAGING_DIR)/usr/include/python$(PYTHON3_VERSION)" \ PYTHON_LDFLAGS="-I$(PYTHON3_INC_DIR) -L$(STAGING_DIR)/usr/lib -L$(PYTHON3_LIB_DIR)" \ + PYTHON_CPPFLAGS="-I$(STAGING_DIR)/usr/include/python$(PYTHON3_VERSION)" \ PYTHON_EXTRA_LDFLAGS="-L$(STAGING_DIR)/usr/lib -L$(PYTHON3_LIB_DIR)/config-$(PYTHON3_VERSION) -lpthread -ldl -lm -lz -lpython$(PYTHON3_VERSION)" \ ac_cv_path_PYTHON_CONFIG="$(STAGING_DIR)/host/bin/python$(PYTHON3_VERSION)-config" +CONFIGURE_ARGS += --with-python +else +CONFIGURE_ARGS += --without-python +endif + CONFIGURE_ARGS += \ - --with-python \ --without-perl \ --without-ruby \ --disable-man-pages @@ -102,7 +123,9 @@ APPARMOR_LDFLAGS = -L$(PKG_BUILD_DIR)/libraries/libapparmor/src/.libs define Build/Configure $(MAKE) -C $(PKG_BUILD_DIR)/libraries/libapparmor configure $(RM) $(PKG_BUILD_DIR)/libraries/libapparmor/Makefile +ifneq ($(APPARMOR_WITH_PYTHON),) $(SED) 's#ac_cv_path_PYTHON_CONFIG=#ac_cv_path_X_PYTHON_CONFIG=#g' $(PKG_BUILD_DIR)/libraries/libapparmor/configure +endif $(call Build/Configure/Default) endef @@ -112,51 +135,60 @@ define Build/Compile CFLAGS="$(TARGET_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/libraries/libapparmor \ $(MAKE_FLAGS) # Building parser - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) \ + +$(MAKE_VARS) PYTHON=python3 \ CFLAGS="$(TARGET_CFLAGS) $(APPARMOR_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS) $(APPARMOR_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS) $(APPARMOR_LDFLAGS) -lgcc_s" USE_SYSTEM=0 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/parser \ $(MAKE_FLAGS) apparmor_parser - # Building binutils - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) \ +ifneq ($(CONFIG_PACKAGE_apparmor-utils),) + # Building optional management utilities + +$(MAKE_VARS) PYTHON=$(HOST_PYTHON3_BIN) \ CFLAGS="$(TARGET_CFLAGS) $(APPARMOR_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS) $(APPARMOR_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS) $(APPARMOR_LDFLAGS)" USE_SYSTEM=0 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/binutils \ $(MAKE_FLAGS) - # Building utils - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) \ + +$(MAKE_VARS) PYTHON=$(HOST_PYTHON3_BIN) \ CFLAGS="$(TARGET_CFLAGS) $(APPARMOR_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS) $(APPARMOR_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS) $(APPARMOR_LDFLAGS)" USE_SYSTEM=0 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/utils \ $(MAKE_FLAGS) - # Building profiles - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) \ - CFLAGS="$(TARGET_CFLAGS) $(APPARMOR_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS) $(APPARMOR_CFLAGS")" LDFLAGS="$(TARGET_LDFLAGS) $(APPARMOR_LDFLAGS)" USE_SYSTEM=0 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/profiles \ +endif +ifneq ($(CONFIG_PACKAGE_apparmor-profiles),) + +$(MAKE_VARS) PYTHON=python3 \ + CFLAGS="$(TARGET_CFLAGS) $(APPARMOR_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS) $(APPARMOR_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS) $(APPARMOR_LDFLAGS)" USE_SYSTEM=0 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/profiles \ $(MAKE_FLAGS) +endif endef define Build/Install $(INSTALL_DIR) $(PKG_INSTALL_DIR)-libapparmor $(PKG_INSTALL_DIR)-utils $(PKG_INSTALL_DIR)-profiles - # Installing libapparmor - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) VERSION=$(PYTHON3_VERSION) \ - CFLAGS="$(TARGET_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" \ + # Installing libapparmor and parser + +$(MAKE_VARS) CFLAGS="$(TARGET_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" \ $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/libraries/libapparmor \ $(MAKE_FLAGS) DESTDIR="$(PKG_INSTALL_DIR)-libapparmor" install - # Installing parser - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) VERSION=$(PYTHON3_VERSION) \ - CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/parser \ + +$(MAKE_VARS) PYTHON=python3 CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 \ + $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/parser \ $(MAKE_FLAGS) DESTDIR="$(PKG_INSTALL_DIR)-utils" install - # Installing binutils - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) VERSION=$(PYTHON3_VERSION) \ - CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/binutils \ +ifneq ($(CONFIG_PACKAGE_apparmor-utils),) + +$(MAKE_VARS) PYTHON=$(HOST_PYTHON3_BIN) CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 \ + $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/binutils \ $(MAKE_FLAGS) DESTDIR="$(PKG_INSTALL_DIR)-utils" install - # Installing utils - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) VERSION=$(PYTHON3_VERSION) \ - CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/utils \ + +$(MAKE_VARS) PYTHON=$(HOST_PYTHON3_BIN) CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 \ + $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/utils \ $(MAKE_FLAGS) DESTDIR="$(PKG_INSTALL_DIR)-utils" install - # Installing profiles - +$(MAKE_VARS) PYTHON=$(HOST_PYTHON) VERSION=$(PYTHON3_VERSION) \ - CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/profiles \ +endif +ifneq ($(CONFIG_PACKAGE_apparmor-profiles),) + +$(MAKE_VARS) PYTHON=python3 CFLAGS="$(TARGET_CFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" USE_SYSTEM=1 \ + $(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/profiles \ $(MAKE_FLAGS) DESTDIR="$(PKG_INSTALL_DIR)-profiles" install +endif +endef + +define Build/InstallDev + $(INSTALL_DIR) $(1)/usr/include/aalogparse $(1)/usr/include/sys $(1)/usr/lib/pkgconfig + $(INSTALL_DATA) $(PKG_INSTALL_DIR)-libapparmor/usr/include/aalogparse/aalogparse.h $(1)/usr/include/aalogparse/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)-libapparmor/usr/include/sys/apparmor.h $(PKG_INSTALL_DIR)-libapparmor/usr/include/sys/apparmor_private.h $(1)/usr/include/sys/ + $(CP) $(PKG_INSTALL_DIR)-libapparmor/usr/lib/libapparmor.{a,so*} $(1)/usr/lib/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)-libapparmor/usr/lib/pkgconfig/libapparmor.pc $(1)/usr/lib/pkgconfig/ endef define Package/libapparmor/install $(INSTALL_DIR) $(1)/usr/lib - $(CP) $(PKG_INSTALL_DIR)-libapparmor/usr/lib/libapparmor.so.1 $(1)/usr/lib/ + $(CP) $(PKG_INSTALL_DIR)-libapparmor/usr/lib/libapparmor.so.1* $(1)/usr/lib/ $(LN) libapparmor.so.1 $(1)/usr/lib/libapparmor.so endef @@ -176,13 +208,21 @@ define Package/python3-apparmor/install $(1)/usr/lib/python$(PYTHON3_VERSION)/site-packages/LibAppArmor/_LibAppArmor.so endef -define Package/apparmor-utils/install - $(INSTALL_DIR) $(1)/etc/apparmor $(1)/usr/sbin $(1)/sbin +define Package/apparmor-parser/install + $(INSTALL_DIR) $(1)/etc/apparmor $(1)/etc/apparmor.d $(1)/sbin $(INSTALL_BIN) $(PKG_INSTALL_DIR)-utils/sbin/apparmor_parser $(1)/sbin/ - $(INSTALL_CONF) $(PKG_INSTALL_DIR)-utils/etc/apparmor/*.conf $(1)/etc/apparmor/ $(INSTALL_CONF) $(PKG_BUILD_DIR)/parser/parser.conf $(1)/etc/apparmor/ + $(CP) -a $(PKG_BUILD_DIR)/profiles/apparmor.d/tunables $(1)/etc/apparmor.d/ + $(CP) -a $(PKG_BUILD_DIR)/profiles/apparmor.d/abstractions $(1)/etc/apparmor.d/ + $(CP) -a $(PKG_BUILD_DIR)/profiles/apparmor.d/abi $(1)/etc/apparmor.d/ + $(INSTALL_DIR) $(1)/etc/init.d $(1)/lib/functions + $(INSTALL_BIN) ./files/apparmor.sh $(1)/lib/functions/ + $(INSTALL_BIN) ./files/apparmor.init $(1)/etc/init.d/apparmor +endef + +define Package/apparmor-utils/install + $(INSTALL_DIR) $(1)/etc/apparmor $(1)/usr/sbin $(INSTALL_DATA) $(PKG_INSTALL_DIR)-utils/etc/apparmor/severity.db $(1)/etc/apparmor/ - $(INSTALL_BIN) $(PKG_INSTALL_DIR)-utils/sbin/apparmor_parser $(1)/sbin/ $(INSTALL_BIN) $(PKG_INSTALL_DIR)-utils/usr/bin/{aa-exec,aa-easyprof,aa-enabled,aa-features-abi} $(1)/usr/sbin/ $(INSTALL_BIN) $(PKG_INSTALL_DIR)-utils/usr/sbin/{aa-audit,aa-autodep,aa-cleanprof,aa-complain,aa-decode,aa-disable,aa-enforce,aa-genprof,aa-logprof,aa-mergeprof,aa-remove-unknown,aa-status,aa-unconfined} $(1)/usr/sbin/ $(LN) aa-status $(1)/usr/sbin/apparmor_status @@ -199,11 +239,7 @@ define Package/apparmor-utils/install $(1)/usr/lib/python$(PYTHON3_VERSION)/site-packages/apparmor/rule $(INSTALL_DATA) $(PKG_INSTALL_DIR)-utils/usr/lib/python$(PYTHON3_VERSION)/site-packages/apparmor-$(PKG_VERSION)-py$(PYTHON3_VERSION).egg-info/* \ $(1)/usr/lib/python$(PYTHON3_VERSION)/site-packages/apparmor-$(PKG_VERSION)-py$(PYTHON3_VERSION).egg-info/ - $(INSTALL_DIR) $(1)/etc/init.d $(1)/lib/functions - $(INSTALL_BIN) ./files/apparmor.sh $(1)/lib/functions/ - $(INSTALL_BIN) ./files/apparmor.init $(1)/etc/init.d/apparmor endef - define Package/apparmor-profiles/install $(INSTALL_DIR) $(1)/etc/apparmor.d $(1)/usr/share/apparmor/extra-profiles $(CP) -aR $(PKG_INSTALL_DIR)-profiles/etc/apparmor.d/** $(1)/etc/apparmor.d/ @@ -212,5 +248,6 @@ endef $(eval $(call BuildPackage,libapparmor)) $(eval $(call BuildPackage,python3-apparmor)) +$(eval $(call BuildPackage,apparmor-parser)) $(eval $(call BuildPackage,apparmor-utils)) $(eval $(call BuildPackage,apparmor-profiles)) diff --git a/feeds/packages/utils/apparmor/files/apparmor.init b/feeds/packages/utils/apparmor/files/apparmor.init index 576df39ace5..0a57948a0d3 100755 --- a/feeds/packages/utils/apparmor/files/apparmor.init +++ b/feeds/packages/utils/apparmor/files/apparmor.init @@ -3,7 +3,7 @@ START=75 USE_PROCD=1 -. /lib/functions/apparmor.sh +. "${IPKG_INSTROOT:-}/lib/functions/apparmor.sh" restart() { apparmor_restart diff --git a/feeds/packages/utils/apparmor/files/apparmor.sh b/feeds/packages/utils/apparmor/files/apparmor.sh index 5e9edb9dd50..c2ada21088f 100755 --- a/feeds/packages/utils/apparmor/files/apparmor.sh +++ b/feeds/packages/utils/apparmor/files/apparmor.sh @@ -70,6 +70,11 @@ skip_profile() { local profile="$1" + # snapd owns this profile and reloads it with the exact kernel feature ABI + # it detected. Loading it generically before snapd starts can compile it + # against a broader parser ABI than the running kernel accepts. + [ "${profile##*/}" = "usr.lib.snapd.snap-confine" ] && return 2 + if [ "${profile%.rpmnew}" != "$profile" ] || \ [ "${profile%.rpmsave}" != "$profile" ] || \ [ "${profile%.orig}" != "$profile" ] || \ @@ -127,28 +132,43 @@ __parse_profiles_dir() { local xargs_args="" [ "$nprocs" -ge 2 ] && xargs_args="--max-procs=$nprocs" - "$PARSER" $PARSER_OPTS "$parser_cmd" -- "$profile_dir" || { + # apparmor_parser can consume a whole directory efficiently, but doing so + # bypasses skip_profile(). If the directory contains package-manager backup + # files or a profile owned by another subsystem (notably snap-confine), use + # the filtered per-file path from the outset instead of first producing a + # spurious parser failure and only then falling back. + local needs_filtering=0 + for profile in "$profile_dir"/*; do + skip_profile "$profile" + [ "$?" -ne 0 ] && { + needs_filtering=1 + break + } + done - for profile in "$profile_dir"/*; do - skip_profile "$profile" - skip=$? - [ "$skip" -ne 0 ] && { - [ "$skip" -ne 2 ] && log_write info "Skipped loading profile $profile" - continue - } - [ -f "$profile" ] || continue - echo "$profile" - done | \ + if [ "$needs_filtering" -eq 0 ]; then + "$PARSER" $PARSER_OPTS "$parser_cmd" -- "$profile_dir" && return 0 + fi - # Use xargs to parallelize calls to the parser over all CPUs + for profile in "$profile_dir"/*; do + skip_profile "$profile" + skip=$? + [ "$skip" -ne 0 ] && { + [ "$skip" -ne 2 ] && log_write info "Skipped loading profile $profile" + continue + } + [ -f "$profile" ] || continue + echo "$profile" + done | \ - /usr/libexec/xargs-findutils -n1 -d"\n" $xargs_args \ - "$PARSER" $PARSER_OPTS "$parser_cmd" -- + # Use xargs to parallelize calls to the parser over all CPUs - [ "$?" -ne 0 ] && { - rc=1 - log_write err "At least one profile failed to load" - } + /usr/libexec/xargs-findutils -n1 -d"\n" $xargs_args \ + "$PARSER" $PARSER_OPTS "$parser_cmd" -- + + [ "$?" -ne 0 ] && { + rc=1 + log_write err "At least one profile failed to load" } return $rc diff --git a/target/linux/generic/backport-6.12/950-apparmor-backport-af-unix-mediation.patch b/target/linux/generic/backport-6.12/950-apparmor-backport-af-unix-mediation.patch new file mode 100644 index 00000000000..2d97196aad3 --- /dev/null +++ b/target/linux/generic/backport-6.12/950-apparmor-backport-af-unix-mediation.patch @@ -0,0 +1,1327 @@ +From: Cao Yuhang +Subject: [PATCH] apparmor: backport AF_UNIX mediation for snap confinement + +Backport the AppArmor 3.x AF_UNIX mediation model used by Ubuntu Noble +to Linux 6.12. The implementation is based on Canonical's Noble 6.8 +AppArmor sources and adapted to the 6.12 ruleset, path-audit, and AF_UNIX +locking APIs. + +Expose the compatibility network/af_unix and dbus feature ABI only with +the actual AF_UNIX mediation hooks present. This lets AppArmor 3.x +userspace compile and load the socket/DBus portions of strict snap +profiles instead of silently dropping them. + +Signed-off-by: Cao Yuhang +--- +--- a/security/apparmor/Makefile 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/Makefile 2026-08-19 04:41:42.440490870 +0000 +@@ -6,7 +6,7 @@ + apparmor-y := apparmorfs.o audit.o capability.o task.o ipc.o lib.o match.o \ + path.o domain.o policy.o policy_unpack.o procattr.o lsm.o \ + resource.o secid.o file.o policy_ns.o label.o mount.o net.o \ +- policy_compat.o ++ policy_compat.o af_unix.o + apparmor-$(CONFIG_SECURITY_APPARMOR_HASH) += crypto.o + + obj-$(CONFIG_SECURITY_APPARMOR_KUNIT_TEST) += apparmor_policy_unpack_test.o +--- a/security/apparmor/af_unix.c 1970-01-01 00:00:00.000000000 +0000 ++++ b/security/apparmor/af_unix.c 2026-08-19 04:46:20.382312540 +0000 +@@ -0,0 +1,716 @@ ++// SPDX-License-Identifier: GPL-2.0-only ++/* ++ * AppArmor security module ++ * ++ * This file contains AppArmor af_unix fine grained mediation ++ * ++ * Copyright 2018 Canonical Ltd. ++ * ++ * This program is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU General Public License as ++ * published by the Free Software Foundation, version 2 of the ++ * License. ++ */ ++ ++#include ++#include ++ ++#include "include/audit.h" ++#include "include/af_unix.h" ++#include "include/apparmor.h" ++#include "include/file.h" ++#include "include/label.h" ++#include "include/path.h" ++#include "include/policy.h" ++#include "include/cred.h" ++ ++static inline struct sock *aa_unix_sk(struct unix_sock *u) ++{ ++ return &u->sk; ++} ++ ++static umode_t sock_i_mode(struct sock *sk) ++{ ++ umode_t mode; ++ ++ read_lock_bh(&sk->sk_callback_lock); ++ mode = sk->sk_socket ? SOCK_INODE(sk->sk_socket)->i_mode : 0; ++ read_unlock_bh(&sk->sk_callback_lock); ++ return mode; ++} ++ ++static bool label_mediates_class(struct aa_label *label, unsigned char class) ++{ ++ struct aa_profile *profile; ++ struct label_it i; ++ ++ label_for_each(i, label, profile) { ++ if (ANY_RULE_MEDIATES(&profile->rules, class)) ++ return true; ++ } ++ return false; ++} ++ ++static inline int unix_fs_perm(const char *op, u32 mask, ++ const struct cred *subj_cred, ++ struct aa_label *label, ++ struct unix_sock *u, int flags) ++{ ++ AA_BUG(!label); ++ AA_BUG(!u); ++ AA_BUG(!UNIX_FS(aa_unix_sk(u))); ++ ++ if (unconfined(label) || !label_mediates_class(label, AA_CLASS_FILE)) ++ return 0; ++ ++ mask &= NET_FS_PERMS; ++ if (!u->path.dentry) { ++ struct path_cond cond = { ++ .uid = sock_i_uid(&u->sk), ++ .mode = sock_i_mode(&u->sk), ++ }; ++ struct aa_perms perms = { }; ++ struct aa_profile *profile; ++ ++ /* socket path has been cleared because it is being shutdown ++ * can only fall back to original sun_path request ++ */ ++ struct aa_sk_ctx *ctx = aa_sock(&u->sk); ++ ++ if (ctx->path.dentry) ++ return aa_path_perm(op, subj_cred, label, &ctx->path, ++ flags, mask, ++ &cond); ++ return fn_for_each_confined(label, profile, ++ ((flags | profile->path_flags) & PATH_MEDIATE_DELETED) ? ++ __aa_path_perm(op, subj_cred, profile, ++ u->addr->name->sun_path, mask, ++ &cond, flags, &perms) : ++ aa_audit_file(subj_cred, profile, &nullperms, ++ op, mask, ++ u->addr->name->sun_path, NULL, ++ NULL, cond.uid, ++ "Failed name lookup - deleted entry", ++ -EACCES)); ++ } else { ++ /* the sunpath may not be valid for this ns so use the path */ ++ struct inode *inode = u->path.dentry->d_inode; ++ vfsuid_t vfsuid = i_uid_into_vfsuid(mnt_idmap(u->path.mnt), inode); ++ struct path_cond cond = { ++ .uid = vfsuid_into_kuid(vfsuid), ++ .mode = inode->i_mode, ++ }; ++ ++ return aa_path_perm(op, subj_cred, label, &u->path, flags, ++ mask, &cond); ++ } ++ ++ return 0; ++} ++ ++/* passing in state returned by PROFILE_MEDIATES_AF */ ++static unsigned int match_to_prot(struct aa_dfa *dfa, ++ unsigned int state, int type, int protocol, ++ const char **info) ++{ ++ __be16 buffer[2]; ++ ++ buffer[0] = cpu_to_be16(type); ++ buffer[1] = cpu_to_be16(protocol); ++ state = aa_dfa_match_len(dfa, state, (char *) &buffer, 4); ++ if (!state) ++ *info = "failed type and protocol match"; ++ return state; ++} ++ ++static unsigned int match_addr(struct aa_dfa *dfa, unsigned int state, ++ struct sockaddr_un *addr, int addrlen) ++{ ++ if (addr) ++ /* include leading \0 */ ++ state = aa_dfa_match_len(dfa, state, addr->sun_path, ++ unix_addr_len(addrlen)); ++ else ++ /* anonymous end point */ ++ state = aa_dfa_match_len(dfa, state, "\x01", 1); ++ /* todo change to out of band */ ++ state = aa_dfa_null_transition(dfa, state); ++ return state; ++} ++ ++static unsigned int match_to_local(struct aa_dfa *dfa, ++ unsigned int state, int type, int protocol, ++ struct sockaddr_un *addr, int addrlen, ++ const char **info) ++{ ++ state = match_to_prot(dfa, state, type, protocol, info); ++ if (state) { ++ state = match_addr(dfa, state, addr, addrlen); ++ if (state) { ++ /* todo: local label matching */ ++ state = aa_dfa_null_transition(dfa, state); ++ if (!state) ++ *info = "failed local label match"; ++ } else ++ *info = "failed local address match"; ++ } ++ ++ return state; ++} ++ ++static unsigned int match_to_sk(struct aa_dfa *dfa, ++ unsigned int state, struct unix_sock *u, ++ const char **info) ++{ ++ struct sockaddr_un *addr = NULL; ++ int addrlen = 0; ++ ++ if (u->addr) { ++ addr = u->addr->name; ++ addrlen = u->addr->len; ++ } ++ ++ return match_to_local(dfa, state, u->sk.sk_type, u->sk.sk_protocol, ++ addr, addrlen, info); ++} ++ ++#define CMD_ADDR 1 ++#define CMD_LISTEN 2 ++#define CMD_OPT 4 ++ ++static inline unsigned int match_to_cmd(struct aa_dfa *dfa, ++ unsigned int state, struct unix_sock *u, ++ char cmd, const char **info) ++{ ++ state = match_to_sk(dfa, state, u, info); ++ if (state) { ++ state = aa_dfa_match_len(dfa, state, &cmd, 1); ++ if (!state) ++ *info = "failed cmd selection match"; ++ } ++ ++ return state; ++} ++ ++static inline unsigned int match_to_peer(struct aa_dfa *dfa, ++ unsigned int state, ++ struct unix_sock *u, ++ struct sockaddr_un *peer_addr, ++ int peer_addrlen, ++ const char **info) ++{ ++ state = match_to_cmd(dfa, state, u, CMD_ADDR, info); ++ if (state) { ++ state = match_addr(dfa, state, peer_addr, peer_addrlen); ++ if (!state) ++ *info = "failed peer address match"; ++ } ++ return state; ++} ++ ++static int do_perms(struct aa_profile *profile, struct aa_ruleset *rule, ++ unsigned int state, u32 request, ++ struct apparmor_audit_data *ad) ++{ ++ struct aa_perms perms; ++ ++ AA_BUG(!profile); ++ ++ perms = *aa_lookup_perms(rule->policy, state); ++ aa_apply_modes_to_perms(profile, &perms); ++ return aa_check_perms(profile, &perms, request, ad, ++ audit_net_cb); ++} ++ ++static int match_label(struct aa_profile *profile, struct aa_ruleset *rule, ++ struct aa_profile *peer, unsigned int state, u32 request, ++ struct apparmor_audit_data *ad) ++{ ++ AA_BUG(!profile); ++ AA_BUG(!peer); ++ ++ ad->peer = &peer->label; ++ ++ if (state) { ++ state = aa_dfa_match(rule->policy->dfa, state, ++ peer->base.hname); ++ if (!state) ++ ad->info = "failed peer label match"; ++ } ++ return do_perms(profile, rule, state, request, ad); ++} ++ ++ ++/* unix sock creation comes before we know if the socket will be an fs ++ * socket ++ * v6 - semantics are handled by mapping in profile load ++ * v7 - semantics require sock create for tasks creating an fs socket. ++ */ ++static int profile_create_perm(struct aa_profile *profile, int family, ++ int type, int protocol) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), list); ++ aa_state_t state; ++ DEFINE_AUDIT_NET(ad, OP_CREATE, NULL, family, type, protocol); ++ ++ AA_BUG(!profile); ++ AA_BUG(profile_unconfined(profile)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ state = match_to_prot(rules->policy->dfa, state, type, ++ protocol, &ad.info); ++ return do_perms(profile, rules, state, AA_MAY_CREATE, &ad); ++ } ++ ++ return aa_profile_af_perm(profile, &ad, AA_MAY_CREATE, family, type); ++} ++ ++int aa_unix_create_perm(struct aa_label *label, int family, int type, ++ int protocol) ++{ ++ struct aa_profile *profile; ++ ++ if (unconfined(label)) ++ return 0; ++ ++ return fn_for_each_confined(label, profile, ++ profile_create_perm(profile, family, type, protocol)); ++} ++ ++ ++static inline int profile_sk_perm(struct aa_profile *profile, ++ struct apparmor_audit_data *ad, ++ u32 request, struct sock *sk) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), ++ list); ++ unsigned int state; ++ ++ AA_BUG(!profile); ++ AA_BUG(!sk); ++ AA_BUG(UNIX_FS(sk)); ++ AA_BUG(profile_unconfined(profile)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ state = match_to_sk(rules->policy->dfa, state, unix_sk(sk), ++ &ad->info); ++ return do_perms(profile, rules, state, request, ad); ++ } ++ ++ return aa_profile_af_sk_perm(profile, ad, request, sk); ++} ++ ++int aa_unix_label_sk_perm(const struct cred *subj_cred, ++ struct aa_label *label, const char *op, u32 request, ++ struct sock *sk) ++{ ++ if (!unconfined(label)) { ++ struct aa_profile *profile; ++ DEFINE_AUDIT_SK(ad, op, sk); ++ ++ ad.subj_cred = subj_cred; ++ return fn_for_each_confined(label, profile, ++ profile_sk_perm(profile, &ad, request, sk)); ++ } ++ return 0; ++} ++ ++static int unix_label_sock_perm(const struct cred *subj_cred, ++ struct aa_label *label, const char *op, ++ u32 request, struct socket *sock) ++{ ++ if (unconfined(label)) ++ return 0; ++ if (UNIX_FS(sock->sk)) ++ return unix_fs_perm(op, request, subj_cred, label, ++ unix_sk(sock->sk), 0); ++ ++ return aa_unix_label_sk_perm(subj_cred, label, op, request, sock->sk); ++} ++ ++/* revaliation, get/set attr */ ++int aa_unix_sock_perm(const char *op, u32 request, struct socket *sock) ++{ ++ struct aa_label *label; ++ int error; ++ ++ label = begin_current_label_crit_section(); ++ error = unix_label_sock_perm(current_cred(), label, op, request, sock); ++ end_current_label_crit_section(label); ++ ++ return error; ++} ++ ++static int profile_bind_perm(struct aa_profile *profile, struct sock *sk, ++ struct sockaddr *addr, int addrlen) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), list); ++ unsigned int state; ++ DEFINE_AUDIT_SK(ad, OP_BIND, sk); ++ ++ ad.subj_cred = current_cred(); ++ AA_BUG(!profile); ++ AA_BUG(!sk); ++ AA_BUG(addr->sa_family != AF_UNIX); ++ AA_BUG(profile_unconfined(profile)); ++ AA_BUG(unix_addr_fs(addr, addrlen)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ /* bind for abstract socket */ ++ ad.net.addr = unix_addr(addr); ++ ad.net.addrlen = addrlen; ++ ++ state = match_to_local(rules->policy->dfa, state, ++ sk->sk_type, sk->sk_protocol, ++ unix_addr(addr), addrlen, ++ &ad.info); ++ return do_perms(profile, rules, state, AA_MAY_BIND, &ad); ++ } ++ ++ return aa_profile_af_sk_perm(profile, &ad, AA_MAY_BIND, sk); ++} ++ ++int aa_unix_bind_perm(struct socket *sock, struct sockaddr *address, ++ int addrlen) ++{ ++ struct aa_profile *profile; ++ struct aa_label *label; ++ int error = 0; ++ ++ label = begin_current_label_crit_section(); ++ /* fs bind is handled by mknod */ ++ if (!(unconfined(label) || unix_addr_fs(address, addrlen))) ++ error = fn_for_each_confined(label, profile, ++ profile_bind_perm(profile, sock->sk, address, ++ addrlen)); ++ end_current_label_crit_section(label); ++ ++ return error; ++} ++ ++int aa_unix_connect_perm(struct socket *sock, struct sockaddr *address, ++ int addrlen) ++{ ++ /* unix connections are covered by the ++ * - unix_stream_connect (stream) and unix_may_send hooks (dgram) ++ * - fs connect is handled by open ++ */ ++ return 0; ++} ++ ++static int profile_listen_perm(struct aa_profile *profile, struct sock *sk, ++ int backlog) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), list); ++ unsigned int state; ++ DEFINE_AUDIT_SK(ad, OP_LISTEN, sk); ++ ++ AA_BUG(!profile); ++ AA_BUG(!sk); ++ AA_BUG(UNIX_FS(sk)); ++ AA_BUG(profile_unconfined(profile)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ __be16 b = cpu_to_be16(backlog); ++ ++ state = match_to_cmd(rules->policy->dfa, state, unix_sk(sk), ++ CMD_LISTEN, &ad.info); ++ if (state) { ++ state = aa_dfa_match_len(rules->policy->dfa, state, ++ (char *) &b, 2); ++ if (!state) ++ ad.info = "failed listen backlog match"; ++ } ++ return do_perms(profile, rules, state, AA_MAY_LISTEN, &ad); ++ } ++ ++ return aa_profile_af_sk_perm(profile, &ad, AA_MAY_LISTEN, sk); ++} ++ ++int aa_unix_listen_perm(struct socket *sock, int backlog) ++{ ++ struct aa_profile *profile; ++ struct aa_label *label; ++ int error = 0; ++ ++ label = begin_current_label_crit_section(); ++ if (!(unconfined(label) || UNIX_FS(sock->sk))) ++ error = fn_for_each_confined(label, profile, ++ profile_listen_perm(profile, sock->sk, ++ backlog)); ++ end_current_label_crit_section(label); ++ ++ return error; ++} ++ ++ ++static inline int profile_accept_perm(struct aa_profile *profile, ++ struct sock *sk, ++ struct sock *newsk) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), list); ++ unsigned int state; ++ DEFINE_AUDIT_SK(ad, OP_ACCEPT, sk); ++ ++ AA_BUG(!profile); ++ AA_BUG(!sk); ++ AA_BUG(UNIX_FS(sk)); ++ AA_BUG(profile_unconfined(profile)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ state = match_to_sk(rules->policy->dfa, state, unix_sk(sk), ++ &ad.info); ++ return do_perms(profile, rules, state, AA_MAY_ACCEPT, &ad); ++ } ++ ++ return aa_profile_af_sk_perm(profile, &ad, AA_MAY_ACCEPT, sk); ++} ++ ++/* ability of sock to connect, not peer address binding */ ++int aa_unix_accept_perm(struct socket *sock, struct socket *newsock) ++{ ++ struct aa_profile *profile; ++ struct aa_label *label; ++ int error = 0; ++ ++ label = begin_current_label_crit_section(); ++ if (!(unconfined(label) || UNIX_FS(sock->sk))) ++ error = fn_for_each_confined(label, profile, ++ profile_accept_perm(profile, sock->sk, ++ newsock->sk)); ++ end_current_label_crit_section(label); ++ ++ return error; ++} ++ ++ ++/* dgram handled by unix_may_sendmsg, right to send on stream done at connect ++ * could do per msg unix_stream here ++ */ ++/* sendmsg, recvmsg */ ++int aa_unix_msg_perm(const char *op, u32 request, struct socket *sock, ++ struct msghdr *msg, int size) ++{ ++ return 0; ++} ++ ++ ++static int profile_opt_perm(struct aa_profile *profile, const char *op, u32 request, ++ struct sock *sk, int level, int optname) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), list); ++ unsigned int state; ++ DEFINE_AUDIT_SK(ad, op, sk); ++ ++ AA_BUG(!profile); ++ AA_BUG(!sk); ++ AA_BUG(UNIX_FS(sk)); ++ AA_BUG(profile_unconfined(profile)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ __be16 b = cpu_to_be16(optname); ++ ++ state = match_to_cmd(rules->policy->dfa, state, unix_sk(sk), ++ CMD_OPT, &ad.info); ++ if (state) { ++ state = aa_dfa_match_len(rules->policy->dfa, state, ++ (char *) &b, 2); ++ if (!state) ++ ad.info = "failed sockopt match"; ++ } ++ return do_perms(profile, rules, state, request, &ad); ++ } ++ ++ return aa_profile_af_sk_perm(profile, &ad, request, sk); ++} ++ ++int aa_unix_opt_perm(const char *op, u32 request, struct socket *sock, int level, ++ int optname) ++{ ++ struct aa_profile *profile; ++ struct aa_label *label; ++ int error = 0; ++ ++ label = begin_current_label_crit_section(); ++ if (!(unconfined(label) || UNIX_FS(sock->sk))) ++ error = fn_for_each_confined(label, profile, ++ profile_opt_perm(profile, op, request, ++ sock->sk, level, optname)); ++ end_current_label_crit_section(label); ++ ++ return error; ++} ++ ++/* null peer_label is allowed, in which case the peer_sk label is used */ ++static int profile_peer_perm(struct aa_profile *profile, const char *op, u32 request, ++ struct sock *sk, struct sock *peer_sk, ++ struct aa_label *peer_label, ++ struct apparmor_audit_data *ad) ++{ ++ struct aa_ruleset *rules = list_first_entry(&profile->rules, ++ typeof(*rules), list); ++ unsigned int state; ++ ++ AA_BUG(!profile); ++ AA_BUG(profile_unconfined(profile)); ++ AA_BUG(!sk); ++ AA_BUG(!peer_sk); ++ AA_BUG(UNIX_FS(peer_sk)); ++ ++ state = RULE_MEDIATES_AF(rules, AF_UNIX); ++ if (state) { ++ struct aa_sk_ctx *peer_ctx = aa_sock(peer_sk); ++ struct aa_profile *peerp; ++ struct sockaddr_un *addr = NULL; ++ int len = 0; ++ ++ if (unix_sk(peer_sk)->addr) { ++ addr = unix_sk(peer_sk)->addr->name; ++ len = unix_sk(peer_sk)->addr->len; ++ } ++ state = match_to_peer(rules->policy->dfa, state, unix_sk(sk), ++ addr, len, &ad->info); ++ if (!peer_label) ++ peer_label = peer_ctx->label; ++ return fn_for_each_in_ns(peer_label, peerp, ++ match_label(profile, rules, peerp, state, ++ request, ad)); ++ } ++ ++ return aa_profile_af_sk_perm(profile, ad, request, sk); ++} ++ ++/** ++ * ++ * Requires: lock held on both @sk and @peer_sk ++ */ ++int aa_unix_peer_perm(const struct cred *subj_cred, ++ struct aa_label *label, const char *op, u32 request, ++ struct sock *sk, struct sock *peer_sk, ++ struct aa_label *peer_label) ++{ ++ struct unix_sock *peeru = unix_sk(peer_sk); ++ struct unix_sock *u = unix_sk(sk); ++ ++ AA_BUG(!label); ++ AA_BUG(!sk); ++ AA_BUG(!peer_sk); ++ ++ if (UNIX_FS(aa_unix_sk(peeru))) { ++ return unix_fs_perm(op, request, subj_cred, label, peeru, 0); ++ } else if (UNIX_FS(aa_unix_sk(u))) { ++ return unix_fs_perm(op, request, subj_cred, label, u, 0); ++ } else { ++ struct aa_profile *profile; ++ DEFINE_AUDIT_SK(ad, op, sk); ++ ++ ad.net.peer_sk = peer_sk; ++ ad.subj_cred = subj_cred; ++ /* TODO: explicitly mediate cross-network-namespace peers. */ ++ ++ if (unconfined(label)) ++ return 0; ++ ++ return fn_for_each_confined(label, profile, ++ profile_peer_perm(profile, op, request, sk, ++ peer_sk, peer_label, &ad)); ++ } ++} ++ ++ ++/* from net/unix/af_unix.c */ ++static void unix_state_double_lock(struct sock *sk1, struct sock *sk2) ++{ ++ if (unlikely(sk1 == sk2) || !sk2) { ++ unix_state_lock(sk1); ++ return; ++ } ++ if (sk1 > sk2) { ++ struct sock *tmp = sk1; ++ ++ sk1 = sk2; ++ sk2 = tmp; ++ } ++ unix_state_lock(sk1); ++ unix_state_lock(sk2); ++} ++ ++static void unix_state_double_unlock(struct sock *sk1, struct sock *sk2) ++{ ++ if (unlikely(sk1 == sk2) || !sk2) { ++ unix_state_unlock(sk1); ++ return; ++ } ++ unix_state_unlock(sk1); ++ unix_state_unlock(sk2); ++} ++ ++int aa_unix_file_perm(const struct cred *subj_cred, struct aa_label *label, ++ const char *op, u32 request, struct socket *sock) ++{ ++ struct sock *peer_sk = NULL; ++ u32 sk_req = request & ~NET_PEER_MASK; ++ int error = 0; ++ ++ AA_BUG(!label); ++ AA_BUG(!sock); ++ AA_BUG(!sock->sk); ++ AA_BUG(sock->sk->sk_family != AF_UNIX); ++ ++ /* TODO: update sock label with new task label */ ++ unix_state_lock(sock->sk); ++ peer_sk = unix_peer(sock->sk); ++ if (peer_sk) ++ sock_hold(peer_sk); ++ if (!unix_connected(sock) && sk_req) { ++ error = unix_label_sock_perm(subj_cred, label, op, sk_req, ++ sock); ++ /* TODO: update the socket label after successful mediation. */ ++ } ++ unix_state_unlock(sock->sk); ++ if (!peer_sk) ++ return error; ++ ++ unix_state_double_lock(sock->sk, peer_sk); ++ if (UNIX_FS(sock->sk)) { ++ error = unix_fs_perm(op, request, subj_cred, label, ++ unix_sk(sock->sk), ++ PATH_SOCK_COND); ++ } else if (UNIX_FS(peer_sk)) { ++ error = unix_fs_perm(op, request, subj_cred, label, ++ unix_sk(peer_sk), ++ PATH_SOCK_COND); ++ } else { ++ struct aa_sk_ctx *pctx = aa_sock(peer_sk); ++ ++ if (sk_req) ++ error = aa_unix_label_sk_perm(subj_cred, ++ label, op, sk_req, ++ sock->sk); ++ last_error(error, ++ xcheck(aa_unix_peer_perm(subj_cred, label, op, ++ MAY_READ | MAY_WRITE, ++ sock->sk, peer_sk, NULL), ++ aa_unix_peer_perm(sock->file ? sock->file->f_cred : NULL, ++ pctx->label, op, ++ MAY_READ | MAY_WRITE, ++ peer_sk, sock->sk, label))); ++ } ++ ++ unix_state_double_unlock(sock->sk, peer_sk); ++ sock_put(peer_sk); ++ ++ return error; ++} +--- a/security/apparmor/apparmorfs.c 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/apparmorfs.c 2026-08-19 04:41:42.442467866 +0000 +@@ -2435,6 +2435,11 @@ + { } + }; + ++static struct aa_sfs_entry aa_sfs_entry_dbus[] = { ++ AA_SFS_FILE_STRING("mask", "acquire send receive"), ++ { } ++}; ++ + static struct aa_sfs_entry aa_sfs_entry_query_label[] = { + AA_SFS_FILE_STRING("perms", "allow deny audit quiet"), + AA_SFS_FILE_BOOLEAN("data", 1), +@@ -2457,6 +2462,7 @@ + AA_SFS_DIR("domain", aa_sfs_entry_domain), + AA_SFS_DIR("file", aa_sfs_entry_file), + AA_SFS_DIR("network_v8", aa_sfs_entry_network), ++ AA_SFS_DIR("network", aa_sfs_entry_network_compat), + AA_SFS_DIR("mount", aa_sfs_entry_mount), + AA_SFS_DIR("namespaces", aa_sfs_entry_ns), + AA_SFS_FILE_U64("capability", VFS_CAP_FLAGS_MASK), +@@ -2464,6 +2470,7 @@ + AA_SFS_DIR("caps", aa_sfs_entry_caps), + AA_SFS_DIR("ptrace", aa_sfs_entry_ptrace), + AA_SFS_DIR("signal", aa_sfs_entry_signal), ++ AA_SFS_DIR("dbus", aa_sfs_entry_dbus), + AA_SFS_DIR("query", aa_sfs_entry_query), + AA_SFS_DIR("io_uring", aa_sfs_entry_io_uring), + { } +--- a/security/apparmor/file.c 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/file.c 2026-08-19 04:46:20.381622805 +0000 +@@ -16,6 +16,7 @@ + + #include "include/apparmor.h" + #include "include/audit.h" ++#include "include/af_unix.h" + #include "include/cred.h" + #include "include/file.h" + #include "include/match.h" +@@ -212,7 +213,7 @@ + return state; + } + +-static int __aa_path_perm(const char *op, const struct cred *subj_cred, ++int __aa_path_perm(const char *op, const struct cred *subj_cred, + struct aa_profile *profile, const char *name, + u32 request, struct path_cond *cond, int flags, + struct aa_perms *perms) +@@ -221,7 +222,8 @@ + typeof(*rules), list); + int e = 0; + +- if (profile_unconfined(profile)) ++ if (profile_unconfined(profile) || ++ ((flags & PATH_SOCK_COND) && !RULE_MEDIATES_AF(rules, AF_UNIX))) + return 0; + aa_str_perms(rules->file, rules->file->start[AA_CLASS_FILE], + name, cond, perms); +--- a/security/apparmor/include/af_unix.h 1970-01-01 00:00:00.000000000 +0000 ++++ b/security/apparmor/include/af_unix.h 2026-08-19 04:42:15.168798004 +0000 +@@ -0,0 +1,123 @@ ++/* SPDX-License-Identifier: GPL-2.0-only */ ++/* ++ * AppArmor security module ++ * ++ * This file contains AppArmor af_unix fine grained mediation ++ * ++ * Copyright 2014 Canonical Ltd. ++ * ++ * This program is free software; you can redistribute it and/or ++ * modify it under the terms of the GNU General Public License as ++ * published by the Free Software Foundation, version 2 of the ++ * License. ++ */ ++#ifndef __AA_AF_UNIX_H ++#define __AA_AF_UNIX_H ++ ++#include ++ ++#include "label.h" ++//#include "include/net.h" ++ ++#define unix_addr_len(L) ((L) - sizeof(sa_family_t)) ++#define unix_abstract_name_len(L) (unix_addr_len(L) - 1) ++#define unix_abstract_len(U) (unix_abstract_name_len((U)->addr->len)) ++#define addr_unix_abstract_name(B) ((B)[0] == 0) ++#define addr_unix_anonymous(U) (addr_unix_len(U) <= 0) ++#define addr_unix_abstract(U) (!addr_unix_anonymous(U) && addr_unix_abstract_name((U)->addr)) ++//#define unix_addr_fs(U) (!unix_addr_anonymous(U) && !unix_addr_abstract_name((U)->addr)) ++ ++#define unix_addr(A) ((struct sockaddr_un *)(A)) ++#define unix_addr_anon(A, L) ((A) && unix_addr_len(L) <= 0) ++#define unix_addr_fs(A, L) (!unix_addr_anon(A, L) && \ ++ !addr_unix_abstract_name(unix_addr(A)->sun_path)) ++ ++#define UNIX_ANONYMOUS(U) (!unix_sk(U)->addr) ++/* from net/unix/af_unix.c */ ++#define UNIX_ABSTRACT(U) (!UNIX_ANONYMOUS(U) && \ ++ unix_sk(U)->addr->hash < UNIX_HASH_SIZE) ++#define UNIX_FS(U) (!UNIX_ANONYMOUS(U) && unix_sk(U)->addr->name->sun_path[0]) ++#define unix_peer(sk) (unix_sk(sk)->peer) ++#define unix_connected(S) ((S)->state == SS_CONNECTED) ++ ++static inline void print_unix_addr(struct sockaddr_un *A, int L) ++{ ++ char *buf = (A) ? (char *) &(A)->sun_path : NULL; ++ int len = unix_addr_len(L); ++ ++ if (!buf || len <= 0) ++ pr_warn(" "); ++ else if (buf[0]) ++ pr_warn(" %s", buf); ++ else ++ /* abstract name len includes leading \0 */ ++ pr_warn(" %d @%.*s", len - 1, len - 1, buf+1); ++}; ++ ++#define print_unix_sk(SK) \ ++do { \ ++ struct unix_sock *u = unix_sk(SK); \ ++ \ ++ pr_warn("%s: f %d, t %d, p %d", #SK, \ ++ (SK)->sk_family, (SK)->sk_type, (SK)->sk_protocol); \ ++ if (u->addr) \ ++ print_unix_addr(u->addr->name, u->addr->len); \ ++ else \ ++ print_unix_addr(NULL, sizeof(sa_family_t)); \ ++} while (0) ++ ++#define print_sk(SK) \ ++do { \ ++ if (!(SK)) { \ ++ pr_warn("%s: %s is null\n", __func__, #SK); \ ++ } else if ((SK)->sk_family == PF_UNIX) { \ ++ print_unix_sk(SK); \ ++ pr_warn("\n"); \ ++ } else { \ ++ pr_warn("%s: %s: family %d\n", __func__, #SK, \ ++ (SK)->sk_family); \ ++ } \ ++} while (0) ++ ++#define print_sock_addr(U) \ ++do { \ ++ pr_warn("%s:\n", __func__); \ ++ pr_warn" sock %s:", sock_ctx && sock_ctx->label ? \ ++ aa_label_printk(sock_ctx->label, GFP_ATOMIC); : \ ++ ""); print_sk(sock); \ ++ pr_warn(" other %s:", other_ctx && other_ctx->label ? \ ++ aa_label_printk(other_ctx->label, GFP_ATOMIC); : \ ++ ""); print_sk(other); \ ++ pr_warn(" new %s", new_ctx && new_ctx->label ? \ ++ aa_label_printk(new_ctx->label, GFP_ATOMIC); : \ ++ ""); print_sk(newsk); \ ++} while (0) ++ ++ ++ ++ ++int aa_unix_peer_perm(const struct cred *subj_cred, ++ struct aa_label *label, const char *op, u32 request, ++ struct sock *sk, struct sock *peer_sk, ++ struct aa_label *peer_label); ++int aa_unix_label_sk_perm(const struct cred *subj_cred, ++ struct aa_label *label, const char *op, u32 request, ++ struct sock *sk); ++int aa_unix_sock_perm(const char *op, u32 request, struct socket *sock); ++int aa_unix_create_perm(struct aa_label *label, int family, int type, ++ int protocol); ++int aa_unix_bind_perm(struct socket *sock, struct sockaddr *address, ++ int addrlen); ++int aa_unix_connect_perm(struct socket *sock, struct sockaddr *address, ++ int addrlen); ++int aa_unix_listen_perm(struct socket *sock, int backlog); ++int aa_unix_accept_perm(struct socket *sock, struct socket *newsock); ++int aa_unix_msg_perm(const char *op, u32 request, struct socket *sock, ++ struct msghdr *msg, int size); ++int aa_unix_opt_perm(const char *op, u32 request, struct socket *sock, int level, ++ int optname); ++int aa_unix_file_perm(const struct cred *subj_cred, ++ struct aa_label *label, const char *op, u32 request, ++ struct socket *sock); ++ ++#endif /* __AA_AF_UNIX_H */ +--- a/security/apparmor/include/file.h 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/include/file.h 2026-08-19 04:46:20.381981857 +0000 +@@ -83,6 +83,11 @@ + const char *name, struct path_cond *cond, + struct aa_perms *perms); + ++int __aa_path_perm(const char *op, const struct cred *subj_cred, ++ struct aa_profile *profile, const char *name, ++ u32 request, struct path_cond *cond, int flags, ++ struct aa_perms *perms); ++ + int aa_path_perm(const char *op, const struct cred *subj_cred, + struct aa_label *label, const struct path *path, + int flags, u32 request, struct path_cond *cond); +--- a/security/apparmor/include/net.h 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/include/net.h 2026-08-19 04:41:42.440952314 +0000 +@@ -49,6 +49,7 @@ + struct aa_sk_ctx { + struct aa_label *label; + struct aa_label *peer; ++ struct path path; + }; + + static inline struct aa_sk_ctx *aa_sock(const struct sock *sk) +@@ -73,14 +74,17 @@ + (SK)->sk_protocol) + + +-#define af_select(FAMILY, FN, DEF_FN) \ +-({ \ +- int __e; \ +- switch ((FAMILY)) { \ +- default: \ +- __e = DEF_FN; \ +- } \ +- __e; \ ++#define af_select(FAMILY, FN, DEF_FN)\ ++({\ ++ int __e;\ ++ switch ((FAMILY)) {\ ++ case PF_UNIX:\ ++ __e = aa_unix_ ## FN;\ ++ break;\ ++ default:\ ++ __e = DEF_FN;\ ++ }\ ++ __e;\ + }) + + struct aa_secmark { +@@ -91,6 +95,7 @@ + }; + + extern struct aa_sfs_entry aa_sfs_entry_network[]; ++extern struct aa_sfs_entry aa_sfs_entry_network_compat[]; + + void audit_net_cb(struct audit_buffer *ab, void *va); + int aa_profile_af_perm(struct aa_profile *profile, +--- a/security/apparmor/include/path.h 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/include/path.h 2026-08-19 04:41:42.440753408 +0000 +@@ -13,6 +13,7 @@ + + enum path_flags { + PATH_IS_DIR = 0x1, /* path is a directory */ ++ PATH_SOCK_COND = 0x2, /* socket path mediation conditional */ + PATH_CONNECT_PATH = 0x4, /* connect disconnected paths to / */ + PATH_CHROOT_REL = 0x8, /* do path lookup relative to chroot */ + PATH_CHROOT_NSCONNECT = 0x10, /* connect paths that are at ns root */ +--- a/security/apparmor/include/policy.h 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/include/policy.h 2026-08-19 04:41:42.441126469 +0000 +@@ -304,8 +304,11 @@ + aa_state_t state = RULE_MEDIATES(rules, AA_CLASS_NET); + __be16 be_af = cpu_to_be16(AF); + +- if (!state) +- return DFA_NOMATCH; ++ if (!state) { ++ state = RULE_MEDIATES(rules, AA_CLASS_DEPRECATED); ++ if (!state) ++ return DFA_NOMATCH; ++ } + return aa_dfa_match_len(rules->policy->dfa, state, (char *) &be_af, 2); + } + +--- a/security/apparmor/lsm.c 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/lsm.c 2026-08-19 04:44:12.171488929 +0000 +@@ -23,9 +23,11 @@ + #include + #include + #include ++#include + #include + #include + ++#include "include/af_unix.h" + #include "include/apparmor.h" + #include "include/apparmorfs.h" + #include "include/audit.h" +@@ -1062,6 +1064,7 @@ + + aa_put_label(ctx->label); + aa_put_label(ctx->peer); ++ path_put(&ctx->path); + } + + /** +@@ -1082,6 +1085,102 @@ + if (new->peer) + aa_put_label(new->peer); + new->peer = aa_get_label(ctx->peer); ++ new->path = ctx->path; ++ path_get(&new->path); ++} ++ ++static struct path *UNIX_FS_CONN_PATH(struct sock *sk, struct sock *newsk) ++{ ++ if (sk->sk_family == PF_UNIX && UNIX_FS(sk)) ++ return &unix_sk(sk)->path; ++ else if (newsk->sk_family == PF_UNIX && UNIX_FS(newsk)) ++ return &unix_sk(newsk)->path; ++ return NULL; ++} ++ ++/** ++ * apparmor_unix_stream_connect - check perms before making unix domain conn ++ * ++ * peer is locked when this hook is called ++ */ ++static int apparmor_unix_stream_connect(struct sock *sk, struct sock *peer_sk, ++ struct sock *newsk) ++{ ++ struct aa_sk_ctx *sk_ctx = aa_sock(sk); ++ struct aa_sk_ctx *peer_ctx = aa_sock(peer_sk); ++ struct aa_sk_ctx *new_ctx = aa_sock(newsk); ++ struct aa_label *label; ++ struct path *path; ++ int error; ++ ++ label = __begin_current_label_crit_section(); ++ error = aa_unix_peer_perm(current_cred(), label, OP_CONNECT, ++ (AA_MAY_CONNECT | AA_MAY_SEND | AA_MAY_RECEIVE), ++ sk, peer_sk, NULL); ++ if (!UNIX_FS(peer_sk)) { ++ last_error(error, ++ aa_unix_peer_perm(current_cred(), ++ peer_ctx->label, OP_CONNECT, ++ (AA_MAY_ACCEPT | AA_MAY_SEND | AA_MAY_RECEIVE), ++ peer_sk, sk, label)); ++ } ++ __end_current_label_crit_section(label); ++ ++ if (error) ++ return error; ++ ++ /* label newsk if it wasn't labeled in post_create. Normally this ++ * would be done in sock_graft, but because we are directly looking ++ * at the peer_sk to obtain peer_labeling for unix socks this ++ * does not work ++ */ ++ if (!new_ctx->label) ++ new_ctx->label = aa_get_label(peer_ctx->label); ++ ++ /* Cross reference the peer labels for SO_PEERSEC */ ++ if (new_ctx->peer) ++ aa_put_label(new_ctx->peer); ++ ++ if (sk_ctx->peer) ++ aa_put_label(sk_ctx->peer); ++ ++ new_ctx->peer = aa_get_label(sk_ctx->label); ++ sk_ctx->peer = aa_get_label(peer_ctx->label); ++ ++ path = UNIX_FS_CONN_PATH(sk, peer_sk); ++ if (path) { ++ new_ctx->path = *path; ++ sk_ctx->path = *path; ++ path_get(path); ++ path_get(path); ++ } ++ return 0; ++} ++ ++/** ++ * apparmor_unix_may_send - check perms before conn or sending unix dgrams ++ * ++ * other is locked when this hook is called ++ * ++ * dgram connect calls may_send, peer setup but path not copied????? ++ */ ++static int apparmor_unix_may_send(struct socket *sock, struct socket *peer) ++{ ++ struct aa_sk_ctx *peer_ctx = aa_sock(peer->sk); ++ struct aa_label *label; ++ int error; ++ ++ label = __begin_current_label_crit_section(); ++ error = xcheck(aa_unix_peer_perm(current_cred(), ++ label, OP_SENDMSG, AA_MAY_SEND, ++ sock->sk, peer->sk, NULL), ++ aa_unix_peer_perm(peer->file ? peer->file->f_cred : NULL, ++ peer_ctx->label, OP_SENDMSG, ++ AA_MAY_RECEIVE, ++ peer->sk, sock->sk, label)); ++ __end_current_label_crit_section(label); ++ ++ return error; + } + + static int apparmor_socket_create(int family, int type, int protocol, int kern) +@@ -1315,14 +1414,30 @@ + #endif + + +-static struct aa_label *sk_peer_label(struct sock *sk) ++static struct aa_label *sk_peer_get_label(struct sock *sk) + { ++ struct sock *peer_sk; + struct aa_sk_ctx *ctx = aa_sock(sk); ++ struct aa_label *label = ERR_PTR(-ENOPROTOOPT); + + if (ctx->peer) +- return ctx->peer; ++ return aa_get_label(ctx->peer); + +- return ERR_PTR(-ENOPROTOOPT); ++ if (sk->sk_family != PF_UNIX) ++ return ERR_PTR(-ENOPROTOOPT); ++ ++ /* check for sockpair peering which does not go through ++ * security_unix_stream_connect ++ */ ++ peer_sk = unix_peer_get(sk); ++ if (peer_sk) { ++ ctx = aa_sock(peer_sk); ++ if (ctx->label) ++ label = aa_get_label(ctx->label); ++ sock_put(peer_sk); ++ } ++ ++ return label; + } + + /** +@@ -1345,7 +1460,7 @@ + struct aa_label *peer; + + label = begin_current_label_crit_section(); +- peer = sk_peer_label(sock->sk); ++ peer = sk_peer_get_label(sock->sk); + if (IS_ERR(peer)) { + error = PTR_ERR(peer); + goto done; +@@ -1356,7 +1471,7 @@ + /* don't include terminating \0 in slen, it breaks some apps */ + if (slen < 0) { + error = -ENOMEM; +- goto done; ++ goto done_put; + } + if (slen > len) { + error = -ERANGE; +@@ -1368,6 +1483,9 @@ + done_len: + if (copy_to_sockptr(optlen, &slen, sizeof(slen))) + error = -EFAULT; ++ ++done_put: ++ aa_put_label(peer); + done: + end_current_label_crit_section(label); + kfree(name); +@@ -1375,6 +1493,17 @@ + } + + /** ++ * apparmor_sock_graft - Initialize newly created socket ++ * @sk: child sock ++ * @parent: parent socket ++ * ++ * Note: could set off of SOCK_CTX(parent) but need to track inode and we can ++ * just set sk security information off of current creating process label ++ * Labeling of sk for accept case - probably should be sock based ++ * instead of task, because of the case where an implicitly labeled ++ * socket is shared by different tasks. ++ */ ++/** + * apparmor_socket_getpeersec_dgram - get security label of packet + * @sock: the peer socket + * @skb: packet data +@@ -1479,6 +1608,9 @@ + LSM_HOOK_INIT(sk_free_security, apparmor_sk_free_security), + LSM_HOOK_INIT(sk_clone_security, apparmor_sk_clone_security), + ++ LSM_HOOK_INIT(unix_stream_connect, apparmor_unix_stream_connect), ++ LSM_HOOK_INIT(unix_may_send, apparmor_unix_may_send), ++ + LSM_HOOK_INIT(socket_create, apparmor_socket_create), + LSM_HOOK_INIT(socket_post_create, apparmor_socket_post_create), + LSM_HOOK_INIT(socket_bind, apparmor_socket_bind), +--- a/security/apparmor/net.c 2026-07-04 11:43:36.000000000 +0000 ++++ b/security/apparmor/net.c 2026-08-19 04:41:42.441818159 +0000 +@@ -8,6 +8,7 @@ + * Copyright 2009-2017 Canonical Ltd. + */ + ++#include "include/af_unix.h" + #include "include/apparmor.h" + #include "include/audit.h" + #include "include/cred.h" +@@ -24,6 +25,12 @@ + { } + }; + ++struct aa_sfs_entry aa_sfs_entry_network_compat[] = { ++ AA_SFS_FILE_STRING("af_mask", AA_SFS_AF_MASK), ++ AA_SFS_FILE_BOOLEAN("af_unix", 1), ++ { } ++}; ++ + static const char * const net_mask_names[] = { + "unknown", + "send", +@@ -67,6 +74,38 @@ + }; + + ++static void audit_unix_addr(struct audit_buffer *ab, const char *str, ++ struct sockaddr_un *addr, int addrlen) ++{ ++ int len = unix_addr_len(addrlen); ++ ++ if (!addr || len <= 0) { ++ audit_log_format(ab, " %s=none", str); ++ } else if (addr->sun_path[0]) { ++ audit_log_format(ab, " %s=", str); ++ audit_log_untrustedstring(ab, addr->sun_path); ++ } else { ++ audit_log_format(ab, " %s=\"@", str); ++ if (audit_string_contains_control(&addr->sun_path[1], len - 1)) ++ audit_log_n_hex(ab, &addr->sun_path[1], len - 1); ++ else ++ audit_log_format(ab, "%.*s", len - 1, ++ &addr->sun_path[1]); ++ audit_log_format(ab, "\""); ++ } ++} ++ ++static void audit_unix_sk_addr(struct audit_buffer *ab, const char *str, ++ const struct sock *sk) ++{ ++ const struct unix_sock *u = unix_sk(sk); ++ ++ if (u && u->addr) ++ audit_unix_addr(ab, str, u->addr->name, u->addr->len); ++ else ++ audit_unix_addr(ab, str, NULL, 0); ++} ++ + /* audit callback for net specific fields */ + void audit_net_cb(struct audit_buffer *ab, void *va) + { +@@ -98,6 +137,23 @@ + net_mask_names, NET_PERMS_MASK); + } + } ++ if (ad->common.u.net->family == AF_UNIX) { ++ if ((ad->request & ~NET_PEER_MASK) && ad->net.addr) ++ audit_unix_addr(ab, "addr", ++ unix_addr(ad->net.addr), ++ ad->net.addrlen); ++ else ++ audit_unix_sk_addr(ab, "addr", ad->common.u.net->sk); ++ if (ad->request & NET_PEER_MASK) { ++ if (ad->net.addr) ++ audit_unix_addr(ab, "peer_addr", ++ unix_addr(ad->net.addr), ++ ad->net.addrlen); ++ else ++ audit_unix_sk_addr(ab, "peer_addr", ++ ad->net.peer_sk); ++ } ++ } + if (ad->peer) { + audit_log_format(ab, " peer="); + aa_label_xaudit(ab, labels_ns(ad->subj_label), ad->peer, +@@ -195,7 +251,10 @@ + if (!sock || !sock->sk) + return 0; + +- return aa_label_sk_perm(subj_cred, label, op, request, sock->sk); ++ return af_select(sock->sk->sk_family, ++ file_perm(subj_cred, label, op, request, sock), ++ aa_label_sk_perm(subj_cred, label, op, request, ++ sock->sk)); + } + + #ifdef CONFIG_NETWORK_SECMARK diff --git a/target/linux/generic/config-6.12 b/target/linux/generic/config-6.12 index 881ac6b9a05..5106b74796e 100644 --- a/target/linux/generic/config-6.12 +++ b/target/linux/generic/config-6.12 @@ -1390,6 +1390,7 @@ CONFIG_DEFAULT_MMAP_MIN_ADDR=4096 CONFIG_DEFAULT_NET_SCH="fq_codel" # CONFIG_DEFAULT_PFIFO_FAST is not set # CONFIG_DEFAULT_RENO is not set +# CONFIG_DEFAULT_SECURITY_APPARMOR is not set CONFIG_DEFAULT_SECURITY_DAC=y # CONFIG_DEFAULT_SECURITY_SELINUX is not set # CONFIG_DEFAULT_SFQ is not set @@ -5497,6 +5498,7 @@ CONFIG_SECTION_MISMATCH_WARN_ONLY=y # CONFIG_SECURITYFS is not set # CONFIG_SECURITY_APPARMOR is not set CONFIG_SECURITY_DMESG_RESTRICT=y +# CONFIG_SECURITY_IPE is not set # CONFIG_SECURITY_LANDLOCK is not set # CONFIG_SECURITY_LOADPIN is not set # CONFIG_SECURITY_LOCKDOWN_LSM is not set diff --git a/target/linux/x86/64/config-6.12 b/target/linux/x86/64/config-6.12 index 77646c0f531..2862cdc1555 100644 --- a/target/linux/x86/64/config-6.12 +++ b/target/linux/x86/64/config-6.12 @@ -531,6 +531,7 @@ CONFIG_VT_CONSOLE_SLEEP=y CONFIG_WATCHDOG_CORE=y # CONFIG_WIRELESS_HOTKEY is not set # CONFIG_X86_5LEVEL is not set +# CONFIG_X86_SGX is not set CONFIG_X86_64=y CONFIG_X86_64_SMP=y CONFIG_X86_ACPI_CPUFREQ=y From ba84ceec1c381009663be778db3586c5399aa508 Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 03:24:38 +0000 Subject: [PATCH 2/5] runtime: integrate snapd with procd --- config/Config-images.in | 1 + feeds/packages/utils/dbus/files/dbus.init | 1 + package/capos/eudev/Makefile | 72 ++++++++ package/capos/eudev/files/eudev.init | 30 ++++ package/capos/snapd/Makefile | 160 ++++++++++++++++++ package/capos/snapd/files/snapd.default | 1 + package/capos/snapd/files/snapd.init | 48 ++++++ ...10-libsnap-confine-include-sys-types.patch | 6 + ...0-snap-confine-use-libc-prctl-header.patch | 10 ++ .../030-device-cgroup-include-libgen.patch | 6 + ...nap-seccomp-use-linux-xfs-quota-uapi.patch | 19 +++ ...ng-standby-without-socket-activation.patch | 39 +++++ ...-treat-serviceunknown-as-unavailable.patch | 16 ++ package/capos/systemd-on-procd/Makefile | 46 +++++ package/utils/argosfs/Makefile | 7 +- 15 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 package/capos/eudev/Makefile create mode 100644 package/capos/eudev/files/eudev.init create mode 100644 package/capos/snapd/Makefile create mode 100644 package/capos/snapd/files/snapd.default create mode 100644 package/capos/snapd/files/snapd.init create mode 100644 package/capos/snapd/patches/010-libsnap-confine-include-sys-types.patch create mode 100644 package/capos/snapd/patches/020-snap-confine-use-libc-prctl-header.patch create mode 100644 package/capos/snapd/patches/030-device-cgroup-include-libgen.patch create mode 100644 package/capos/snapd/patches/040-snap-seccomp-use-linux-xfs-quota-uapi.patch create mode 100644 package/capos/snapd/patches/050-allow-disabling-standby-without-socket-activation.patch create mode 100644 package/capos/snapd/patches/060-cgroup-treat-serviceunknown-as-unavailable.patch create mode 100644 package/capos/systemd-on-procd/Makefile diff --git a/config/Config-images.in b/config/Config-images.in index 1b7ab5ef365..b6fe2220961 100644 --- a/config/Config-images.in +++ b/config/Config-images.in @@ -95,6 +95,7 @@ menu "Target Images" bool "argosfs" default y select TARGET_ROOTFS_INITRAMFS + select TARGET_ROOTFS_PERSIST_VAR select PACKAGE_argosfs select PACKAGE_kmod-fuse select PACKAGE_libfuse3 diff --git a/feeds/packages/utils/dbus/files/dbus.init b/feeds/packages/utils/dbus/files/dbus.init index afd59c98bd7..6d7b1aef447 100644 --- a/feeds/packages/utils/dbus/files/dbus.init +++ b/feeds/packages/utils/dbus/files/dbus.init @@ -14,6 +14,7 @@ PROG=/usr/bin/dbus-daemon start_service() { mkdir -m 0755 -p /var/lib/dbus mkdir -m 0755 -p /var/run/dbus + mkdir -m 0755 -p /etc/dbus-1/system.d chown dbus:dbus /var/lib/dbus /var/run/dbus chown -R dbus:dbus /etc/dbus-1 diff --git a/package/capos/eudev/Makefile b/package/capos/eudev/Makefile new file mode 100644 index 00000000000..410f6438937 --- /dev/null +++ b/package/capos/eudev/Makefile @@ -0,0 +1,72 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=eudev +PKG_VERSION:=3.2.14 +PKG_RELEASE:=2 + +PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz +PKG_SOURCE_URL:=https://codeload.github.com/eudev-project/eudev/tar.gz/v$(PKG_VERSION)? +PKG_HASH:=c340e6c51dfc5531ac0c0fa84a34b72162acf525f9023eb9cf4931b782c8f177 +PKG_BUILD_DIR:=$(BUILD_DIR)/eudev-$(PKG_VERSION) +PKG_LICENSE:=GPL-2.0-or-later LGPL-2.1-or-later +PKG_LICENSE_FILES:=COPYING +PKG_FIXUP:=autoreconf +PKG_BUILD_DEPENDS:=gperf/host +PKG_INSTALL:=1 + +include $(INCLUDE_DIR)/package.mk + +define Package/eudev + SECTION:=capos + CATEGORY:=CapOS + TITLE:=Standalone udev daemon and udevadm for Snap device mediation + URL:=https://github.com/eudev-project/eudev + DEPENDS:=@ARCH_64BIT + PROVIDES:=libudev-zero + CONFLICTS:=libudev-zero +endef + +define Package/eudev/description + eudev provides a systemd-independent udev daemon and udevadm. CapOS uses it + alongside procd so snapd can install and reload device-tagging rules without + replacing OpenWrt's init or hotplug infrastructure. +endef + +CONFIGURE_ARGS += \ + --prefix=/usr \ + --sbindir=/usr/sbin \ + --with-rootprefix=/usr \ + --with-rootlibdir=/usr/lib \ + --with-rootlibexecdir=/usr/lib/udev \ + --with-rootrundir=/run \ + --disable-selinux \ + --disable-blkid \ + --disable-kmod \ + --disable-hwdb \ + --disable-manpages \ + --disable-rule-generator \ + --disable-mtd_probe + +define Build/InstallDev + $(INSTALL_DIR) $(1)/usr/include $(1)/usr/lib/pkgconfig $(1)/usr/lib + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/include/libudev.h $(1)/usr/include/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/lib/pkgconfig/libudev.pc $(1)/usr/lib/pkgconfig/ + $(CP) $(PKG_INSTALL_DIR)/usr/lib/libudev.so* $(1)/usr/lib/ +endef + +define Package/eudev/install + $(INSTALL_DIR) $(1)/usr/bin $(1)/usr/sbin $(1)/usr/lib $(1)/usr/lib/udev/rules.d $(1)/etc/udev $(1)/etc/init.d + $(CP) $(PKG_INSTALL_DIR)/usr/lib/libudev.so.1* $(1)/usr/lib/ + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/udevadm $(1)/usr/bin/udevadm + $(LN) ../bin/udevadm $(1)/usr/sbin/udevadm + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/sbin/udevd $(1)/usr/sbin/udevd + # Keep OpenWrt responsible for device nodes, permissions and network naming. + # Only retain eudev classification rules that snapd-generated rules rely on. + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/lib/udev/rules.d/60-input-id.rules $(1)/usr/lib/udev/rules.d/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/lib/udev/rules.d/60-persistent-input.rules $(1)/usr/lib/udev/rules.d/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/lib/udev/rules.d/60-serial.rules $(1)/usr/lib/udev/rules.d/ + $(INSTALL_CONF) $(PKG_INSTALL_DIR)/etc/udev/udev.conf $(1)/etc/udev/udev.conf + $(INSTALL_BIN) ./files/eudev.init $(1)/etc/init.d/eudev +endef + +$(eval $(call BuildPackage,eudev)) diff --git a/package/capos/eudev/files/eudev.init b/package/capos/eudev/files/eudev.init new file mode 100644 index 00000000000..cf335e177f8 --- /dev/null +++ b/package/capos/eudev/files/eudev.init @@ -0,0 +1,30 @@ +#!/bin/sh /etc/rc.common + +START=70 +STOP=20 +USE_PROCD=1 + +start_service() { + mkdir -p /run/udev /etc/udev/rules.d + + procd_open_instance + procd_set_param command /usr/sbin/udevd + procd_set_param respawn 3600 5 5 + procd_set_param term_timeout 5 + procd_close_instance + + # Populate eudev's database for devices that existed before the daemon. + # The control socket is created during early daemon initialization. + local i=0 + while [ ! -S /run/udev/control ] && [ "$i" -lt 5 ]; do + sleep 1 + i=$((i + 1)) + done + [ -S /run/udev/control ] || return 1 + /usr/bin/udevadm trigger --action=add >/dev/null 2>&1 || true + /usr/bin/udevadm settle --timeout=10 >/dev/null 2>&1 || true +} + +reload_service() { + /usr/bin/udevadm control --reload-rules +} diff --git a/package/capos/snapd/Makefile b/package/capos/snapd/Makefile new file mode 100644 index 00000000000..59d6ed1996d --- /dev/null +++ b/package/capos/snapd/Makefile @@ -0,0 +1,160 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=snapd +PKG_VERSION:=2.76.2 +PKG_RELEASE:=6 + +PKG_SOURCE:=snapd_$(PKG_VERSION).vendor.tar.xz +PKG_SOURCE_URL:=https://github.com/canonical/snapd/releases/download/$(PKG_VERSION) +PKG_HASH:=873fedb8525057c2b276003c2f90c2e5f7b541ec1bb409a6f489c51b5c72af2b +PKG_BUILD_DIR:=$(BUILD_DIR)/snapd-$(PKG_VERSION) +PKG_BUILD_DEPENDS:=golang/host libseccomp libcap apparmor eudev +PKG_BUILD_PARALLEL:=1 +PKG_BUILD_FLAGS:=no-mips16 + +GO_PKG:=github.com/snapcore/snapd + +include $(INCLUDE_DIR)/package.mk +include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk + +# snapd is built with its upstream packaging/snapd.mk rather than the generic +# OpenWrt Go workspace. Nothing consumes snapd as a staged Go source library. +define Build/InstallDev +endef + +define Package/snapd + SECTION:=capos + CATEGORY:=CapOS + TITLE:=Snap package manager daemon + URL:=https://snapcraft.io/ + DEPENDS:=$(GO_ARCH_DEPENDS) @ARCH_64BIT +systemd-on-procd +libseccomp +libcap +libcap-bin +libapparmor +apparmor-parser +eudev +dbus +ca-bundle +openssh-keygen +kmod-loop +kmod-fs-squashfs +squashfs-tools-unsquashfs +endef + +define Package/snapd/description + The upstream Canonical snapd runtime adapted for CapOS. procd remains PID 1; + systemd-facing service and mount operations are translated by systemd-on-procd. +endef + +SNAPD_GO_BUILD_DIR:=$(PKG_BUILD_DIR)/.capos-go +SNAPD_DATA_VARS:=BINDIR=/usr/bin LIBEXECDIR=/usr/lib DATADIR=/usr/share \ + SYSTEMDSYSTEMUNITDIR=/usr/lib/systemd/system SNAPD_ENVIRONMENT_FILE=/etc/environment \ + USE_CANONICAL_SNAP_MOUNT_DIR=false USE_ALT_SNAP_MOUNT_DIR=true + +define Build/Prepare + $(call Build/Prepare/Default) + $(SED) 's|AC_CHECK_HEADERS(\[xfs/xqm.h\], \[\], \[AC_MSG_ERROR(xfs/xqm.h unavailable)\])|AC_CHECK_HEADERS([xfs/xqm.h])|' $(PKG_BUILD_DIR)/cmd/configure.ac + $(SED) 's|SNAP_MOUNT_DIR_SYSTEMD_UNIT="$$(systemd-escape -p "$$STATIC_SNAP_MOUNT_DIR")"|SNAP_MOUNT_DIR_SYSTEMD_UNIT="var-lib-snapd-snap"|' $(PKG_BUILD_DIR)/cmd/configure.ac + (cd $(PKG_BUILD_DIR) && ./mkversion.sh $(PKG_VERSION)) +endef + +define Build/Configure + (cd $(PKG_BUILD_DIR)/cmd && autoreconf -i -f) + (cd $(PKG_BUILD_DIR)/cmd && \ + $(TARGET_CONFIGURE_OPTS) \ + PKG_CONFIG_PATH="$(STAGING_DIR)/usr/lib/pkgconfig:$(STAGING_DIR)/usr/share/pkgconfig" \ + ./configure \ + --host=$(GNU_TARGET_NAME) \ + --build=$(GNU_HOST_NAME) \ + --prefix=/usr \ + --libexecdir=/usr/lib/snapd \ + --with-apparmorconfigdir=/etc/apparmor.d \ + --with-snap-mount-dir=/var/lib/snapd/snap \ + --without-unit-tests \ + --enable-apparmor \ + --disable-selinux \ + --disable-nvidia-multiarch \ + --disable-nvidia-biarch) + mkdir -p $(SNAPD_GO_BUILD_DIR) + printf '%s\n' \ + 'prefix=/usr' \ + 'bindir=/usr/bin' \ + 'sbindir=/usr/sbin' \ + 'libexecdir=/usr/lib' \ + 'mandir=/usr/share/man' \ + 'datadir=/usr/share' \ + 'localstatedir=/var/lib' \ + 'sharedstatedir=/var/lib' \ + 'unitdir=/usr/lib/systemd/system' \ + 'builddir=$(SNAPD_GO_BUILD_DIR)' \ + 'with_core_bits=0' \ + 'with_alt_snap_mount_dir=1' \ + 'with_apparmor=1' \ + 'with_static_pie=0' \ + 'with_vendor=1' \ + 'with_testkeys=0' \ + 'EXTRA_GO_BUILD_FLAGS=-buildvcs=false -trimpath' \ + > $(PKG_BUILD_DIR)/snapd.defines.mk +endef + +define Build/Compile + +$(MAKE) $(PKG_JOBS) -C $(PKG_BUILD_DIR)/cmd \ + CC="$(TARGET_CC)" CXX="$(TARGET_CXX)" \ + PKG_CONFIG="$(STAGING_DIR_HOST)/bin/pkg-config" \ + CFLAGS="$(TARGET_CFLAGS)" CPPFLAGS="$(TARGET_CPPFLAGS)" LDFLAGS="$(TARGET_LDFLAGS)" + +cd $(PKG_BUILD_DIR) && $(GO_PKG_VARS) \ + $(MAKE) -f packaging/snapd.mk SNAPD_DEFINES_DIR=$(PKG_BUILD_DIR) all + +$(MAKE) -C $(PKG_BUILD_DIR)/data $(SNAPD_DATA_VARS) all + + rm -rf $(PKG_INSTALL_DIR) + $(INSTALL_DIR) $(PKG_INSTALL_DIR) + +$(MAKE) -C $(PKG_BUILD_DIR)/cmd DESTDIR=$(PKG_INSTALL_DIR) install + +$(MAKE) -C $(PKG_BUILD_DIR)/data DESTDIR=$(PKG_INSTALL_DIR) $(SNAPD_DATA_VARS) install + + $(INSTALL_DIR) $(PKG_INSTALL_DIR)/usr/lib/snapd $(PKG_INSTALL_DIR)/usr/bin + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snap $(PKG_INSTALL_DIR)/usr/bin/snap + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snapd $(PKG_INSTALL_DIR)/usr/lib/snapd/snapd + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snapctl $(PKG_INSTALL_DIR)/usr/lib/snapd/snapctl + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snap-exec $(PKG_INSTALL_DIR)/usr/lib/snapd/snap-exec + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snap-update-ns $(PKG_INSTALL_DIR)/usr/lib/snapd/snap-update-ns + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snap-seccomp $(PKG_INSTALL_DIR)/usr/lib/snapd/snap-seccomp + $(INSTALL_BIN) $(SNAPD_GO_BUILD_DIR)/snapd-apparmor $(PKG_INSTALL_DIR)/usr/lib/snapd/snapd-apparmor + $(LN) ../lib/snapd/snapctl $(PKG_INSTALL_DIR)/usr/bin/snapctl + + $(INSTALL_DIR) \ + $(PKG_INSTALL_DIR)/var/lib/snapd/apparmor/profiles \ + $(PKG_INSTALL_DIR)/var/lib/snapd/apparmor/snap-confine \ + $(PKG_INSTALL_DIR)/var/lib/snapd/assertions \ + $(PKG_INSTALL_DIR)/var/lib/snapd/cache \ + $(PKG_INSTALL_DIR)/var/lib/snapd/cgroup \ + $(PKG_INSTALL_DIR)/var/lib/snapd/cookie \ + $(PKG_INSTALL_DIR)/var/lib/snapd/dbus-1/services \ + $(PKG_INSTALL_DIR)/var/lib/snapd/dbus-1/system-services \ + $(PKG_INSTALL_DIR)/var/lib/snapd/desktop/applications \ + $(PKG_INSTALL_DIR)/var/lib/snapd/device \ + $(PKG_INSTALL_DIR)/var/lib/snapd/environment \ + $(PKG_INSTALL_DIR)/var/lib/snapd/hostfs \ + $(PKG_INSTALL_DIR)/var/lib/snapd/inhibit \ + $(PKG_INSTALL_DIR)/var/lib/snapd/lib/gl \ + $(PKG_INSTALL_DIR)/var/lib/snapd/lib/gl32 \ + $(PKG_INSTALL_DIR)/var/lib/snapd/lib/glvnd \ + $(PKG_INSTALL_DIR)/var/lib/snapd/lib/vulkan \ + $(PKG_INSTALL_DIR)/var/lib/snapd/mount \ + $(PKG_INSTALL_DIR)/var/lib/snapd/seccomp/bpf \ + $(PKG_INSTALL_DIR)/var/lib/snapd/sequence \ + $(PKG_INSTALL_DIR)/var/lib/snapd/snaps \ + $(PKG_INSTALL_DIR)/var/lib/snapd/snap/bin + $(INSTALL_DIR) $(PKG_INSTALL_DIR)/var/cache/snapd +endef + +define Package/snapd/install + $(CP) $(PKG_INSTALL_DIR)/* $(1)/ + $(INSTALL_DIR) $(1)/etc/init.d $(1)/etc/default + $(INSTALL_BIN) ./files/snapd.init $(1)/etc/init.d/snapd + $(INSTALL_CONF) ./files/snapd.default $(1)/etc/default/snapd + $(INSTALL_DIR) $(1)/etc/systemd/system/multi-user.target.wants + $(LN) /usr/lib/systemd/system/snapd.apparmor.service $(1)/etc/systemd/system/multi-user.target.wants/snapd.apparmor.service +endef + +define Package/snapd/postinst +#!/bin/sh +[ -n "$$IPKG_INSTROOT" ] && exit 0 +if command -v setcap >/dev/null 2>&1 && [ -x /usr/lib/snapd/snap-confine ] && [ -r /usr/lib/snapd/snap-confine.v2-only.caps ]; then + setcap "$$(cat /usr/lib/snapd/snap-confine.v2-only.caps)" /usr/lib/snapd/snap-confine || true +fi +if command -v apparmor_parser >/dev/null 2>&1 && [ -r /etc/apparmor.d/usr.lib.snapd.snap-confine ]; then + apparmor_parser -r /etc/apparmor.d/usr.lib.snapd.snap-confine || true +fi +/etc/init.d/snapd enable >/dev/null 2>&1 || true +endef + +$(eval $(call BuildPackage,snapd)) diff --git a/package/capos/snapd/files/snapd.default b/package/capos/snapd/files/snapd.default new file mode 100644 index 00000000000..7e1d0f9e923 --- /dev/null +++ b/package/capos/snapd/files/snapd.default @@ -0,0 +1 @@ +SNAPD_DEBUG=0 diff --git a/package/capos/snapd/files/snapd.init b/package/capos/snapd/files/snapd.init new file mode 100644 index 00000000000..795721ece1b --- /dev/null +++ b/package/capos/snapd/files/snapd.init @@ -0,0 +1,48 @@ +#!/bin/sh /etc/rc.common + +START=80 +STOP=80 +USE_PROCD=1 + +apply_snap_confine_caps() { + local snap_confine=/usr/lib/snapd/snap-confine + local caps_file=/usr/lib/snapd/snap-confine.v2-only.caps + + [ -x "$snap_confine" ] || return 0 + [ -r "$caps_file" ] || return 0 + command -v setcap >/dev/null 2>&1 || return 0 + + setcap "$(cat "$caps_file")" "$snap_confine" || \ + logger -t snapd "failed to restore snap-confine file capabilities" +} + +load_snap_apparmor_profiles() { + [ -x /usr/lib/snapd/snapd-apparmor ] || return 0 + if command -v systemctl >/dev/null 2>&1 && [ -r /usr/lib/systemd/system/snapd.apparmor.service ]; then + systemctl start snapd.apparmor.service || \ + logger -t snapd "failed to start snapd.apparmor.service" + return + fi + /usr/lib/snapd/snapd-apparmor start || \ + logger -t snapd "failed to restore snap-managed AppArmor profiles" +} + +start_service() { + mkdir -p /run /run/netns /var/lib/snapd /var/cache/snapd /var/lib/snapd/snap/bin + rm -f /run/snapd.socket /run/snapd-snap.socket + apply_snap_confine_caps + load_snap_apparmor_profiles + + procd_open_instance + procd_set_param command /usr/lib/snapd/snapd + procd_set_param respawn 3600 5 -1 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param env PATH=/usr/sbin:/usr/bin:/sbin:/bin:/var/lib/snapd/snap/bin SNAPD_DISABLE_STANDBY=1 SNAPPY_FORCE_API_URL=https://snap.capos.top/ SNAPPY_FORCE_SAS_URL=https://snap.capos.top/ + procd_set_param file /etc/environment + procd_close_instance +} + +service_triggers() { + procd_add_reload_trigger snapd +} diff --git a/package/capos/snapd/patches/010-libsnap-confine-include-sys-types.patch b/package/capos/snapd/patches/010-libsnap-confine-include-sys-types.patch new file mode 100644 index 00000000000..92b43ce8442 --- /dev/null +++ b/package/capos/snapd/patches/010-libsnap-confine-include-sys-types.patch @@ -0,0 +1,6 @@ +--- a/cmd/libsnap-confine-private/utils.h ++++ b/cmd/libsnap-confine-private/utils.h +@@ -19,2 +19,3 @@ + #include + #include ++#include diff --git a/package/capos/snapd/patches/020-snap-confine-use-libc-prctl-header.patch b/package/capos/snapd/patches/020-snap-confine-use-libc-prctl-header.patch new file mode 100644 index 00000000000..65aa905fab3 --- /dev/null +++ b/package/capos/snapd/patches/020-snap-confine-use-libc-prctl-header.patch @@ -0,0 +1,10 @@ +--- a/cmd/snap-confine/snap-confine.c ++++ b/cmd/snap-confine/snap-confine.c +@@ -22,7 +22,6 @@ + #include + #include + #include +-#include + #include + #include + #include diff --git a/package/capos/snapd/patches/030-device-cgroup-include-libgen.patch b/package/capos/snapd/patches/030-device-cgroup-include-libgen.patch new file mode 100644 index 00000000000..9c62d48b6d0 --- /dev/null +++ b/package/capos/snapd/patches/030-device-cgroup-include-libgen.patch @@ -0,0 +1,6 @@ +--- a/cmd/libsnap-confine-private/device-cgroup-support.c ++++ b/cmd/libsnap-confine-private/device-cgroup-support.c +@@ -19,2 +19,3 @@ + #include + #include ++#include diff --git a/package/capos/snapd/patches/040-snap-seccomp-use-linux-xfs-quota-uapi.patch b/package/capos/snapd/patches/040-snap-seccomp-use-linux-xfs-quota-uapi.patch new file mode 100644 index 00000000000..ed4fac583f8 --- /dev/null +++ b/package/capos/snapd/patches/040-snap-seccomp-use-linux-xfs-quota-uapi.patch @@ -0,0 +1,19 @@ +--- a/cmd/snap-seccomp/main.go ++++ b/cmd/snap-seccomp/main.go +@@ -46,16 +46,7 @@ package main + //#include + //#include +-// //The XFS interface requires a 64 bit file system interface +-// //but we don't want to leak this anywhere else if not globally +-// //defined. +-//#ifndef _FILE_OFFSET_BITS +-//#define _FILE_OFFSET_BITS 64 +-//#include +-//#undef _FILE_OFFSET_BITS +-//#else +-//#include +-//#endif ++//#include + //#include + //#include + //#include diff --git a/package/capos/snapd/patches/050-allow-disabling-standby-without-socket-activation.patch b/package/capos/snapd/patches/050-allow-disabling-standby-without-socket-activation.patch new file mode 100644 index 00000000000..0a66cec67e0 --- /dev/null +++ b/package/capos/snapd/patches/050-allow-disabling-standby-without-socket-activation.patch @@ -0,0 +1,39 @@ +--- a/overlord/standby/standby.go ++++ b/overlord/standby/standby.go +@@ -39,6 +39,7 @@ type StandbyOpinions struct { + state *state.State + standbyWait time.Duration + startTime time.Time ++ disabled bool + opinions []Opinionator + + stoppingCh chan struct{} +@@ -82,6 +83,7 @@ func New(st *state.State) *StandbyOpinions { + return &StandbyOpinions{ + state: st, + standbyWait: w, ++ disabled: os.Getenv("SNAPD_DISABLE_STANDBY") == "1", + startTime: time.Now(), + stoppingCh: make(chan struct{}), + stoppedCh: make(chan struct{}), +@@ -90,6 +92,10 @@ func New(st *state.State) *StandbyOpinions { + } + + func (m *StandbyOpinions) Start() { ++ if m.disabled { ++ logger.Noticef("standby disabled by SNAPD_DISABLE_STANDBY") ++ return ++ } + logger.Noticef("will consider standby after: %v", m.standbyWait) + go func() { + wait := m.standbyWait +@@ -118,6 +124,9 @@ func (m *StandbyOpinions) Start() { + } + + func (m *StandbyOpinions) Stop() { ++ if m.disabled { ++ return ++ } + logger.Noticef("standby monitoring stop requested") + select { + case <-m.stoppedCh: diff --git a/package/capos/snapd/patches/060-cgroup-treat-serviceunknown-as-unavailable.patch b/package/capos/snapd/patches/060-cgroup-treat-serviceunknown-as-unavailable.patch new file mode 100644 index 00000000000..d3a711ea4c9 --- /dev/null +++ b/package/capos/snapd/patches/060-cgroup-treat-serviceunknown-as-unavailable.patch @@ -0,0 +1,16 @@ +--- a/sandbox/cgroup/tracking.go ++++ b/sandbox/cgroup/tracking.go +@@ -329,9 +329,10 @@ + logger.Debugf("StartTransientUnit failed with %q: %v", dbusErr.Name, dbusErr.Body) + // Some specific DBus errors have distinct handling. + switch dbusErr.Name { +- case "org.freedesktop.DBus.Error.NameHasNoOwner": +- // Nothing is providing systemd bus name. This is, most likely, +- // an Ubuntu 14.04 system with the special deputy systemd. ++ case "org.freedesktop.DBus.Error.NameHasNoOwner", "org.freedesktop.DBus.Error.ServiceUnknown": ++ // Nothing is providing the systemd bus name. Different D-Bus ++ // implementations report either NameHasNoOwner or ServiceUnknown ++ // when no activatable org.freedesktop.systemd1 service exists. + return "", errDBusNameHasNoOwner + case "org.freedesktop.DBus.Error.UnknownMethod": + // The DBus API is not supported on this system. This can happen on diff --git a/package/capos/systemd-on-procd/Makefile b/package/capos/systemd-on-procd/Makefile new file mode 100644 index 00000000000..c6906f66c3f --- /dev/null +++ b/package/capos/systemd-on-procd/Makefile @@ -0,0 +1,46 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=systemd-on-procd +PKG_VERSION:=0.1.7 +PKG_RELEASE:=1 + +PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz +PKG_SOURCE_URL:=https://codeload.github.com/fwerkor/systemd-on-procd/tar.gz/v$(PKG_VERSION)? +PKG_HASH:=d8e7d413869b544b0754a4b7b65dfc80cda557394e3ecfeaab74ba6997233b17 +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION) +PKG_BUILD_DEPENDS:=golang/host +PKG_BUILD_PARALLEL:=1 +PKG_BUILD_FLAGS:=no-mips16 + +GO_PKG:=github.com/fwerkor/systemd-on-procd +GO_PKG_BUILD_PKG:=$(GO_PKG)/cmd/systemd-on-procd + +include $(INCLUDE_DIR)/package.mk +include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk + +define Package/systemd-on-procd + SECTION:=capos + CATEGORY:=CapOS + TITLE:=systemd command compatibility backed by procd + URL:=https://github.com/fwerkor/systemd-on-procd + DEPENDS:=$(GO_ARCH_DEPENDS) +procd +ubus +endef + +define Package/systemd-on-procd/description + A focused systemd command and unit compatibility layer that keeps procd as + PID 1. It provides systemctl/systemd-run/systemd-mount entry points for + systemd-centric software such as snapd. +endef + +define Package/systemd-on-procd/install + $(INSTALL_DIR) $(1)/usr/libexec/systemd-on-procd + $(INSTALL_BIN) $(GO_PKG_BUILD_BIN_DIR)/systemd-on-procd $(1)/usr/libexec/systemd-on-procd/systemd-on-procd + $(INSTALL_DIR) $(1)/usr/bin + $(LN) ../libexec/systemd-on-procd/systemd-on-procd $(1)/usr/bin/systemctl + $(LN) ../libexec/systemd-on-procd/systemd-on-procd $(1)/usr/bin/systemd-run + $(LN) ../libexec/systemd-on-procd/systemd-on-procd $(1)/usr/bin/systemd-mount + $(LN) ../libexec/systemd-on-procd/systemd-on-procd $(1)/usr/bin/systemd-detect-virt +endef + +$(eval $(call GoBinPackage,systemd-on-procd)) +$(eval $(call BuildPackage,systemd-on-procd)) diff --git a/package/utils/argosfs/Makefile b/package/utils/argosfs/Makefile index 7aea03ae69a..f922726ec59 100644 --- a/package/utils/argosfs/Makefile +++ b/package/utils/argosfs/Makefile @@ -2,12 +2,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=argosfs PKG_VERSION:=0.1.0 -PKG_RELEASE:=4 +PKG_RELEASE:=6 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/fwerkor/argosfs -PKG_SOURCE_VERSION:=main -PKG_MIRROR_HASH:=skip +PKG_SOURCE_VERSION:=45e6a5c455c0068207eeeaceb945d7355487ede4 +PKG_SOURCE:=$(PKG_NAME)-$(PKG_SOURCE_VERSION).tar.zst +PKG_MIRROR_HASH:=1053038380bbb8f31fcc94d074f2128631dbd3b2d38ba561cebb06ffa1c696aa PKG_MAINTAINER:=FWERKOR PKG_LICENSE:=Apache-2.0 From 7dfbc0ab24d7cc3d6085353ae32be70c286ed4af Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 03:24:38 +0000 Subject: [PATCH 3/5] webdesktop: replace Capbox application runtime --- package/capos/capbox/Makefile | 33 - .../capbox/files/91-capbox-podman-network | 16 - package/capos/capbox/files/capbox | 1936 ----------------- package/capos/capos-core/Makefile | 6 +- .../Makefile | 20 +- .../files/90-capos-webdesktop-uhttpd} | 0 .../capos-webdesktop/htdocs/assets/app.js | 62 + .../capos-webdesktop/htdocs/assets/styles.css | 1 + .../capos/capos-webdesktop/htdocs/index.html | 70 + package/capos/capos-webdesktop/src/api.cpp | 253 +++ .../src/app.cpp | 193 +- .../src/common.hpp | 37 +- package/capos/capos-webdesktop/src/snap.hpp | 390 ++++ .../capos/capos-webpanel/htdocs/assets/app.js | 611 ------ .../capos-webpanel/htdocs/assets/styles.css | 596 ----- .../capos/capos-webpanel/htdocs/index.html | 151 -- package/capos/capos-webpanel/src/api.cpp | 635 ------ scripts/ci/qemu-port-check.sh | 6 +- tests/capbox_logic_tests.sh | 161 -- ..._smoke.sh => webdesktop_frontend_smoke.sh} | 28 +- tests/webdesktop_proxy_smoke.sh | 55 + ....sh => webdesktop_runtime_config_smoke.sh} | 4 +- tests/webpanel_proxy_smoke.sh | 198 -- website/get-capos.html | 2 +- 24 files changed, 952 insertions(+), 4512 deletions(-) delete mode 100644 package/capos/capbox/Makefile delete mode 100644 package/capos/capbox/files/91-capbox-podman-network delete mode 100644 package/capos/capbox/files/capbox rename package/capos/{capos-webpanel => capos-webdesktop}/Makefile (71%) rename package/capos/{capos-webpanel/files/90-capos-webpanel-uhttpd => capos-webdesktop/files/90-capos-webdesktop-uhttpd} (100%) create mode 100644 package/capos/capos-webdesktop/htdocs/assets/app.js create mode 100644 package/capos/capos-webdesktop/htdocs/assets/styles.css create mode 100644 package/capos/capos-webdesktop/htdocs/index.html create mode 100644 package/capos/capos-webdesktop/src/api.cpp rename package/capos/{capos-webpanel => capos-webdesktop}/src/app.cpp (84%) rename package/capos/{capos-webpanel => capos-webdesktop}/src/common.hpp (94%) create mode 100644 package/capos/capos-webdesktop/src/snap.hpp delete mode 100644 package/capos/capos-webpanel/htdocs/assets/app.js delete mode 100644 package/capos/capos-webpanel/htdocs/assets/styles.css delete mode 100644 package/capos/capos-webpanel/htdocs/index.html delete mode 100644 package/capos/capos-webpanel/src/api.cpp delete mode 100755 tests/capbox_logic_tests.sh rename tests/{webpanel_frontend_smoke.sh => webdesktop_frontend_smoke.sh} (52%) create mode 100755 tests/webdesktop_proxy_smoke.sh rename tests/{webpanel_runtime_config_smoke.sh => webdesktop_runtime_config_smoke.sh} (82%) delete mode 100755 tests/webpanel_proxy_smoke.sh diff --git a/package/capos/capbox/Makefile b/package/capos/capbox/Makefile deleted file mode 100644 index c07ee757a36..00000000000 --- a/package/capos/capbox/Makefile +++ /dev/null @@ -1,33 +0,0 @@ -include $(TOPDIR)/rules.mk - -PKG_NAME:=capbox -PKG_VERSION:=1.0.0 -PKG_RELEASE:=1 - -include $(INCLUDE_DIR)/package.mk - -define Package/capbox - SECTION:=capos - CATEGORY:=CapOS - TITLE:=Capbox Scripts - DEPENDS:=+bash +coreutils +jq +yq +podman +nsenter +socat -endef - -define Package/capbox/description - Deploy Capbox scripts to target filesystem. -endef - -CAPBOX_DST_DIR:=/usr/lib - -define Build/Compile - # scripts only, nothing to compile -endef - -define Package/capbox/install - $(INSTALL_DIR) $(1)/usr/bin - $(INSTALL_BIN) ./files/capbox $(1)/usr/bin/capbox - $(INSTALL_DIR) $(1)/etc/uci-defaults - $(INSTALL_BIN) ./files/91-capbox-podman-network $(1)/etc/uci-defaults/91-capbox-podman-network -endef - -$(eval $(call BuildPackage,capbox)) diff --git a/package/capos/capbox/files/91-capbox-podman-network b/package/capos/capbox/files/91-capbox-podman-network deleted file mode 100644 index 028db75a31b..00000000000 --- a/package/capos/capbox/files/91-capbox-podman-network +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -conf="/etc/containers/containers.conf" -[ -f "$conf" ] || exit 0 - -# CapOS uses OpenWrt firewall4/nftables. Netavark's iptables compatibility -# path is fragile on small images because xtables extensions are split out. -if grep -q '^[[:space:]]*firewall_driver[[:space:]]*=' "$conf"; then - sed -i 's/^[[:space:]]*firewall_driver[[:space:]]*=.*/firewall_driver = "nftables"/' "$conf" -elif grep -q '^[[:space:]]*#firewall_driver[[:space:]]*=' "$conf"; then - sed -i 's/^[[:space:]]*#firewall_driver[[:space:]]*=.*/firewall_driver = "nftables"/' "$conf" -else - sed -i '/^[[:space:]]*network_backend[[:space:]]*=[[:space:]]*"netavark"/a firewall_driver = "nftables"' "$conf" -fi - -exit 0 diff --git a/package/capos/capbox/files/capbox b/package/capos/capbox/files/capbox deleted file mode 100644 index b0b2eddbd20..00000000000 --- a/package/capos/capbox/files/capbox +++ /dev/null @@ -1,1936 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -CAPBOX_STATE_DIR="${CAPBOX_STATE_DIR:-/var/lib/capbox}" -CAPBOX_RUN_DIR="${CAPBOX_RUN_DIR:-/run/capbox}" -CAPBOX_HOSTEXEC_TIMEOUT="${CAPBOX_HOSTEXEC_TIMEOUT:-10}" - -die() { - echo "$*" >&2 - exit 1 -} - -need_cmd() { - local dep env_item allow_item - for dep in "$@"; do - command -v "$dep" >/dev/null 2>&1 || die "missing dependency: $dep" - done -} - -json_escape() { - jq -Rsa . <<< "${1:-}" -} - -print_json() { - jq -n "$@" -} - -require_root() { - [[ "$(id -u)" == "0" ]] || die "this command must run as root" -} - -resolve_target_user() { - local requested="${1:-}" - if [[ -n "$requested" ]]; then - id -u "$requested" >/dev/null 2>&1 || die "unknown user: $requested" - printf '%s\n' "$requested" - return 0 - fi - - if [[ -n "${SUDO_USER:-}" ]]; then - printf '%s\n' "$SUDO_USER" - return 0 - fi - - if [[ -n "${USER:-}" ]]; then - id -u "$USER" >/dev/null 2>&1 || die "unknown user: $USER" - printf '%s\n' "$USER" - return 0 - fi - - die "unable to resolve target user" -} - -user_uid() { - id -u "$1" -} - -user_gid() { - id -g "$1" -} - -user_home() { - awk -F: -v user="$1" '$1 == user { print $6; found = 1; exit } END { exit(found ? 0 : 1) }' /etc/passwd -} - -user_shell() { - awk -F: -v user="$1" '$1 == user { print $7; found = 1; exit } END { exit(found ? 0 : 1) }' /etc/passwd -} - -user_is_sudo() { - local user="$1" - local groups - - [[ "$user" == "root" ]] && return 0 - - if command -v sudo >/dev/null 2>&1; then - if sudo -l -U "$user" >/dev/null 2>&1; then - return 0 - fi - fi - - groups="$(id -Gn "$user" 2>/dev/null || true)" - [[ " $groups " == *" sudo "* || " $groups " == *" wheel "* ]] -} - -user_state_dir() { - local user="$1" - printf '%s/users/%s\n' "$CAPBOX_STATE_DIR" "$(user_uid "$user")" -} - -user_apps_dir() { - local user="$1" - printf '%s/apps\n' "$(user_state_dir "$user")" -} - -app_state_dir() { - local user="$1" - local app="$2" - printf '%s/%s\n' "$(user_apps_dir "$user")" "$app" -} - -app_manifest_json_path() { - local user="$1" - local app="$2" - printf '%s/manifest.json\n' "$(app_state_dir "$user" "$app")" -} - -app_manifest_yaml_path() { - local user="$1" - local app="$2" - printf '%s/manifest.yaml\n' "$(app_state_dir "$user" "$app")" -} - -app_meta_path() { - local user="$1" - local app="$2" - printf '%s/meta.json\n' "$(app_state_dir "$user" "$app")" -} - -app_portmaps_path() { - local user="$1" - local app="$2" - printf '%s/portmaps.json\n' "$(app_state_dir "$user" "$app")" -} - -app_hostexec_path() { - local user="$1" - local app="$2" - printf '%s/hostexec.json\n' "$(app_state_dir "$user" "$app")" -} - -app_hostexec_token_path() { - local user="$1" - local app="$2" - printf '%s/hostexec.token\n' "$(app_state_dir "$user" "$app")" -} - -app_data_dir() { - local user="$1" - local app="$2" - printf '%s/data\n' "$(app_state_dir "$user" "$app")" -} - -desktop_path() { - local user="$1" - printf '%s/desktop_app\n' "$(user_state_dir "$user")" -} - -user_runtime_dir() { - local user="$1" - printf '%s/users/%s\n' "$CAPBOX_RUN_DIR" "$(user_uid "$user")" -} - -app_runtime_dir() { - local user="$1" - local app="$2" - printf '%s/apps/%s\n' "$(user_runtime_dir "$user")" "$app" -} - -app_portmap_runtime_dir() { - local user="$1" - local app="$2" - printf '%s/portmaps\n' "$(app_runtime_dir "$user" "$app")" -} - -ensure_user_dirs() { - local user="$1" - mkdir -p "$(user_apps_dir "$user")" "$(user_runtime_dir "$user")" -} - -ensure_app_dirs() { - local user="$1" - local app="$2" - mkdir -p "$(app_state_dir "$user" "$app")" "$(app_data_dir "$user" "$app")" "$(app_portmap_runtime_dir "$user" "$app")" - [[ -f "$(app_portmaps_path "$user" "$app")" ]] || printf '[]\n' > "$(app_portmaps_path "$user" "$app")" - [[ -f "$(app_hostexec_path "$user" "$app")" ]] || printf '{"enabled":false,"allow":[]}\n' > "$(app_hostexec_path "$user" "$app")" -} - -generate_secret_token() { - if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1; then - od -An -tx1 -N24 /dev/urandom | tr -d ' \n' - return 0 - fi - printf '%s%s%s\n' "$(date +%s)" "$$" "$RANDOM" | sha256sum | awk '{print $1}' -} - -ensure_app_hostexec_token() { - local user="$1" - local app="$2" - local token_path - token_path="$(app_hostexec_token_path "$user" "$app")" - if [[ ! -s "$token_path" ]]; then - umask 077 - printf '%s\n' "$(generate_secret_token)" > "$token_path" - fi - chmod 0600 "$token_path" -} - -validate_app_name() { - local app="$1" - [[ "$app" =~ ^[a-z0-9_]+$ ]] || die "invalid app name: $app" -} - -map_machine_arch() { - case "${1:-$(uname -m)}" in - x86_64|amd64) printf 'amd64\n' ;; - aarch64|arm64) printf 'arm64\n' ;; - armv7l|armv7|armhf) printf 'armv7\n' ;; - armv6l|armv6) printf 'armv6\n' ;; - i386|i486|i586|i686) printf '386\n' ;; - *) - printf '%s\n' "${1:-$(uname -m)}" - ;; - esac -} - -network_name_for_user() { - local user="$1" - printf 'capbox_u_%s\n' "$(user_uid "$user")" -} - -container_name_for_app() { - local user="$1" - local app="$2" - printf 'capbox_u_%s_%s\n' "$(user_uid "$user")" "$app" -} - -portmap_pid_file() { - local user="$1" - local app="$2" - local proto="$3" - local listen="$4" - local target="$5" - printf '%s/%s_%s_%s.pid\n' "$(app_portmap_runtime_dir "$user" "$app")" "$proto" "$listen" "$target" -} - -app_is_installed() { - local user="$1" - local app="$2" - [[ -f "$(app_meta_path "$user" "$app")" && -f "$(app_manifest_json_path "$user" "$app")" ]] -} - -manifest_value() { - local manifest="$1" - local expr="$2" - jq -r "$expr" "$manifest" -} - -manifest_bool() { - local manifest="$1" - local expr="$2" - jq -e "$expr" "$manifest" >/dev/null 2>&1 -} - -manifest_risks_json() { - local manifest="$1" - jq -c ' - [] - + (if (.network.host // false) then ["host_network"] else [] end) - + (if ((.network.publish // []) | length) > 0 then ["publish_ports"] else [] end) - + (if (((.dependencies.apk // []) | length) > 0) then ["apk_dependencies"] else [] end) - + (if (((.dependencies.opkg // []) | length) > 0) then ["legacy_opkg_dependencies"] else [] end) - + (if (((.dependencies.capp // []) | length) > 0) then ["app_dependencies"] else [] end) - + (if ((.container.environment // []) | length) > 0 then ["environment"] else [] end) - + (if ((.container.volumes.from // []) | length) > 0 then ["shared_volumes"] else [] end) - + (if (.container.privileges.enabled // false) then ["privileged_container"] else [] end) - + (if ((.container.privileges.capabilities // []) | length) > 0 then ["extra_capabilities"] else [] end) - + (if ((.container.devices // []) | length) > 0 then ["devices"] else [] end) - + (if ((.container.volumes.extra // []) | length) > 0 then ["host_mounts"] else [] end) - + (if (.host.exec.enabled // false) then ["host_exec"] else [] end) - ' "$manifest" -} - -manifest_requires_sudo() { - local manifest="$1" - jq -e ' - (.network.host // false) - or (.container.privileges.enabled // false) - or (((.container.privileges.capabilities // []) | length) > 0) - or (((.container.devices // []) | length) > 0) - or (((.container.volumes.extra // []) | length) > 0) - ' "$manifest" >/dev/null 2>&1 -} - -read_manifest_json_from_yaml() { - local yaml_path="$1" - local out_json="$2" - need_cmd yq - yq eval -o=json '.' "$yaml_path" > "$out_json" -} - -extract_cpk() { - local cpk_file="$1" - local tmpdir="$2" - tar -xzf "$cpk_file" -C "$tmpdir" -} - -find_cpk_manifest() { - local dir="$1" - local manifest_path - manifest_path="$(find "$dir" -maxdepth 2 -type f \( -name 'config.yaml' -o -name 'config.yml' \) | sort | head -n1 || true)" - [[ -n "$manifest_path" ]] || die "config.yaml not found in CPK" - printf '%s\n' "$manifest_path" -} - -find_cpk_icon() { - local dir="$1" - find "$dir" -maxdepth 2 -type f \( -iname 'icon.png' -o -iname 'icon.jpg' -o -iname 'icon.jpeg' -o -iname 'icon.svg' -o -iname 'icon.webp' \) | sort | head -n1 || true -} - -select_cpk_image() { - local unpack_dir="$1" - local manifest_json="$2" - local arch - local image_path - - arch="$(map_machine_arch)" - if ! jq -e --arg arch "$arch" '((.build.architectures // []) | length) == 0 or ((.build.architectures // []) | index($arch) != null)' "$manifest_json" >/dev/null 2>&1; then - die "CPK does not support architecture: $arch" - fi - - image_path="$(find "$unpack_dir" -maxdepth 2 -type f \( -name "image_${arch}.tar" -o -name "${arch}.tar" -o -name "*${arch}*.tar" \) | sort | head -n1 || true)" - [[ -n "$image_path" ]] || die "image tar for architecture $arch not found" - printf '%s\n' "$image_path" -} - -ensure_manifest_shape() { - local manifest="$1" - local app_name - local version - - app_name="$(manifest_value "$manifest" '.app.name // ""')" - version="$(manifest_value "$manifest" '.version // ""')" - - [[ -n "$version" ]] || die "manifest version is required" - [[ "$version" == "1.0" || "$version" == "1.1" ]] || die "unsupported manifest version: $version" - [[ -n "$app_name" ]] || die "manifest app.name is required" - validate_app_name "$app_name" -} - -manifest_publish_rules_tsv() { - local manifest="$1" - jq -r ' - .network.publish // [] - | .[] - | if type == "string" then - { - raw: ., - proto: ( - if test("/(tcp|udp)$") then - capture("/(?tcp|udp)$").proto - else - "tcp" - end - ), - mapping: sub("/(tcp|udp)$"; "") - } - | .parts = (.mapping | split(":")) - | [.raw, .proto, (.parts[0] // ""), (.parts[1] // "")] - elif type == "object" then - [ - (tojson), - ((.proto // "tcp") | tostring), - ((.listen // .listen_port // "") | tostring), - ((.target // .target_port // "") | tostring) - ] - else - [(tojson), "tcp", "", ""] - end - | @tsv - ' "$manifest" -} - -manifest_capp_dependencies() { - local manifest="$1" - jq -r ' - if (.dependencies | type) == "object" then - .dependencies.capp // [] - else - [] - end - | .[] - | tostring - ' "$manifest" -} - -manifest_package_dependencies() { - local manifest="$1" - jq -r ' - if (.dependencies | type) == "object" and (.dependencies | has("apk")) then - .dependencies.apk // [] - elif (.dependencies | type) == "object" and (.dependencies | has("opkg")) then - .dependencies.opkg // [] - else - [] - end - | .[] - | tostring - ' "$manifest" -} - -manifest_environment_items() { - local manifest="$1" - jq -r ' - .container.environment // [] - | if type == "array" then - .[] - | if type == "string" then - . - elif type == "object" then - if has("name") then - "\(.name)=\((.value // "") | tostring)" - else - to_entries[] - | "\(.key)=\(.value | tostring)" - end - else - empty - end - elif type == "object" then - to_entries[] - | "\(.key)=\(.value | tostring)" - else - empty - end - ' "$manifest" -} - -manifest_volume_from_refs() { - local manifest="$1" - jq -r ' - .container.volumes.from // [] - | .[] - | if type == "string" then - split(":")[0] - elif type == "object" then - (.app // .name // "") - else - empty - end - ' "$manifest" -} - -manifest_hostexec_allowlist() { - local manifest="$1" - jq -r ' - .host.exec.allow // [] - | .[] - | if type == "string" then - . - elif type == "object" then - (.command // .path // "") - else - empty - end - ' "$manifest" -} - -validate_environment_item() { - local env_item="$1" - local name - - [[ "$env_item" == *=* ]] || die "invalid environment item: $env_item" - name="${env_item%%=*}" - [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "invalid environment variable name: $name" -} - -validate_hostexec_allow_item() { - local command_path="$1" - - [[ "$command_path" = /* ]] || die "hostexec allowlist entries must be absolute paths: $command_path" - [[ "$command_path" =~ ^/[A-Za-z0-9._/@+-]+$ ]] || die "hostexec allowlist entry contains unsupported characters: $command_path" -} - -validate_manifest_for_user() { - local manifest="$1" - local user="$2" - local app_name - local publish_raw publish_proto publish_listen publish_target - local dep - - ensure_manifest_shape "$manifest" - app_name="$(manifest_value "$manifest" '.app.name // ""')" - - if app_is_installed "$user" "$app_name"; then - die "app already installed for user $user: $app_name" - fi - - if manifest_requires_sudo "$manifest" && ! user_is_sudo "$user"; then - die "app requires sudo privileges to install" - fi - - if manifest_bool "$manifest" '.network.host // false' && ! user_is_sudo "$user"; then - die "host network apps may only be installed by sudo users" - fi - - while IFS=$'\t' read -r publish_raw publish_proto publish_listen publish_target; do - [[ -z "$publish_raw" ]] && continue - validate_publish_mapping "$publish_listen" "$publish_target" "$publish_proto" "$user" "$publish_raw" - done < <(manifest_publish_rules_tsv "$manifest") - - while IFS= read -r dep; do - [[ -z "$dep" ]] && continue - validate_app_name "$dep" - app_is_installed "$user" "$dep" || die "missing app dependency: $dep" - done < <(manifest_capp_dependencies "$manifest") - - while IFS= read -r dep; do - [[ -z "$dep" ]] && continue - command -v apk >/dev/null 2>&1 || die "apk is required to verify package dependencies" - apk info -e "$dep" >/dev/null 2>&1 || die "missing apk dependency: $dep" - done < <(manifest_package_dependencies "$manifest") - - while IFS= read -r dep; do - [[ -z "$dep" ]] && continue - validate_app_name "$dep" - app_is_installed "$user" "$dep" || die "missing shared-volume source app: $dep" - done < <(manifest_volume_from_refs "$manifest") - - while IFS= read -r env_item; do - [[ -z "$env_item" ]] && continue - validate_environment_item "$env_item" - done < <(manifest_environment_items "$manifest") - - while IFS= read -r allow_item; do - [[ -z "$allow_item" ]] && continue - validate_hostexec_allow_item "$allow_item" - done < <(manifest_hostexec_allowlist "$manifest") -} - -validate_publish_mapping() { - local listen="$1" - local target="$2" - local proto="$3" - local user="$4" - local raw="${5:-$listen:$target/$proto}" - - [[ "$proto" == "tcp" || "$proto" == "udp" ]] || die "unsupported publish protocol: $raw" - [[ "$listen" =~ ^[0-9]+$ && "$target" =~ ^[0-9]+$ ]] || die "invalid publish ports: $raw" - ((listen >= 1 && listen <= 65535)) || die "listen port out of range: $listen" - ((target >= 1 && target <= 65535)) || die "target port out of range: $target" - - if ((listen < 1024)) && ! user_is_sudo "$user"; then - die "low ports require sudo privileges: $listen" - fi -} - -validate_publish_rule() { - local rule="$1" - local user="$2" - local proto listen target - - proto="tcp" - if [[ "$rule" == */* ]]; then - proto="${rule##*/}" - rule="${rule%/*}" - fi - - [[ "$rule" == *:* ]] || die "invalid publish rule: $rule" - listen="${rule%%:*}" - target="${rule##*:}" - validate_publish_mapping "$listen" "$target" "$proto" "$user" "$rule" -} - -cpk_summary_json() { - local manifest="$1" - local cpk_file="$2" - local image_path="$3" - local icon_path="$4" - jq -n \ - --arg cpk "$(basename "$cpk_file")" \ - --arg arch "$(map_machine_arch)" \ - --arg image "$(basename "$image_path")" \ - --arg icon "$(basename "${icon_path:-}")" \ - --slurpfile manifest_file "$manifest" \ - --argjson risks "$(manifest_risks_json "$manifest")" ' - ($manifest_file[0]) as $manifest - | - { - ok: true, - cpk: { - file: $cpk, - arch: $arch, - image: $image, - icon: (if $icon == "" then null else $icon end) - }, - app: { - name: ($manifest.app.name // null), - version: ($manifest.app.version // null), - nickname: ($manifest.app.nickname // null), - description: ($manifest.app.description // null) - }, - permissions: { - host_network: ($manifest.network.host // false), - publish: ($manifest.network.publish // []), - privileged: ($manifest.container.privileges.enabled // false), - capabilities: ($manifest.container.privileges.capabilities // []), - devices: ($manifest.container.devices // []), - host_exec: ($manifest.host.exec.enabled // false) - }, - checks: { - dependencies: { - apk: ($manifest.dependencies.apk // []), - opkg_legacy: ($manifest.dependencies.opkg // []), - capp: ($manifest.dependencies.capp // []) - }, - publish: ($manifest.network.publish // []), - environment: ($manifest.container.environment // []), - volumes_from: ($manifest.container.volumes.from // []), - host_exec_allow: ($manifest.host.exec.allow // []) - }, - risks: $risks, - manifest: $manifest - } - ' -} - -podman_network_ensure() { - local network_name="$1" - need_cmd podman - if ! podman network inspect "$network_name" >/dev/null 2>&1; then - podman network create "$network_name" >/dev/null - fi -} - -podman_network_gateway() { - local network_name="$1" - need_cmd podman jq - podman network inspect "$network_name" 2>/dev/null | jq -r ' - .[0].subnets[0].gateway - // .[0].subnets[0].Gateway - // .[0].Subnets[0].Gateway - // empty - ' | head -n1 -} - -podman_container_running() { - local container_name="$1" - podman inspect -f '{{.State.Running}}' "$container_name" 2>/dev/null | grep -qx 'true' -} - -podman_container_exists() { - local container_name="$1" - podman inspect "$container_name" >/dev/null 2>&1 -} - -podman_container_ip() { - local container_name="$1" - podman inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_name" 2>/dev/null || true -} - -current_unix_time() { - date +%s -} - -copy_if_present() { - local src="$1" - local dst="$2" - [[ -n "$src" && -f "$src" ]] || return 0 - cp "$src" "$dst" -} - -detect_image_ref_from_load() { - local load_output="$1" - local image_ref - - image_ref="$(awk -F': ' '/Loaded image/ {print $2}' <<< "$load_output" | tail -n1)" - [[ -n "$image_ref" ]] || image_ref="$(awk -F': ' '/Loaded image\(s\)/ {print $2}' <<< "$load_output" | tail -n1)" - [[ -n "$image_ref" ]] || die "unable to detect loaded image reference" - printf '%s\n' "$image_ref" -} - -write_app_meta() { - local user="$1" - local app="$2" - local image_ref="$3" - local icon_name="$4" - local manifest="$5" - local container_name network_name installed_at - - container_name="$(container_name_for_app "$user" "$app")" - network_name="$(network_name_for_user "$user")" - installed_at="$(current_unix_time)" - - jq -n \ - --arg owner "$user" \ - --arg uid "$(user_uid "$user")" \ - --arg app "$app" \ - --arg container "$container_name" \ - --arg network "$network_name" \ - --arg image "$image_ref" \ - --arg icon "$icon_name" \ - --arg installed_at "$installed_at" \ - --slurpfile manifest_file "$manifest" ' - ($manifest_file[0]) as $manifest - | - { - owner: $owner, - owner_uid: ($uid | tonumber), - app: $app, - container_name: $container, - network_name: $network, - image_ref: $image, - icon: (if $icon == "" then null else $icon end), - installed_at: ($installed_at | tonumber), - desktop: { - management_http: ($manifest.network.management.http // null), - management_https: ($manifest.network.management.https // null), - service_http: ($manifest.network.service.http // null), - service_https: ($manifest.network.service.https // null) - } - } - ' > "$(app_meta_path "$user" "$app")" -} - -write_app_hostexec() { - local user="$1" - local app="$2" - local manifest="$3" - - jq ' - (.host.exec // {enabled: false, allow: []}) as $cfg - | { - enabled: ($cfg.enabled // false), - allow: ( - ($cfg.allow // []) - | map( - if type == "string" then - . - elif type == "object" then - (.command // .path // "") - else - "" - end - ) - | map(select(length > 0)) - | map(select(startswith("/"))) - | unique - ) - } - ' "$manifest" > "$(app_hostexec_path "$user" "$app")" -} - -hostexec_is_enabled() { - local user="$1" - local app="$2" - jq -e '.enabled // false' "$(app_hostexec_path "$user" "$app")" >/dev/null 2>&1 -} - -hostexec_token_matches() { - local user="$1" - local app="$2" - local token="$3" - [[ -n "$token" ]] || return 1 - [[ -f "$(app_hostexec_token_path "$user" "$app")" ]] || return 1 - [[ "$(tr -d '\r\n' < "$(app_hostexec_token_path "$user" "$app")")" == "$token" ]] -} - -hostexec_is_allowed() { - local user="$1" - local app="$2" - local command_path="$3" - jq -e --arg cmd "$command_path" '.allow // [] | index($cmd) != null' "$(app_hostexec_path "$user" "$app")" >/dev/null 2>&1 -} - -hostexec_validate_request() { - local user="$1" - local app="$2" - local command_path="$3" - - [[ -n "$command_path" ]] || die "missing command path" - app_is_installed "$user" "$app" || die "app is not installed: $app" - [[ "$command_path" = /* ]] || die "hostexec only allows absolute command paths" - [[ -x "$command_path" ]] || die "hostexec command is not executable: $command_path" - hostexec_is_enabled "$user" "$app" || die "hostexec is not enabled for app: $app" - hostexec_is_allowed "$user" "$app" "$command_path" || die "hostexec command is not allowed for app: $command_path" -} - -hostexec_timeout_seconds() { - if [[ "$CAPBOX_HOSTEXEC_TIMEOUT" =~ ^[0-9]+$ ]] && ((CAPBOX_HOSTEXEC_TIMEOUT >= 1 && CAPBOX_HOSTEXEC_TIMEOUT <= 300)); then - printf '%s\n' "$CAPBOX_HOSTEXEC_TIMEOUT" - else - printf '10\n' - fi -} - -run_hostexec_command() { - local user="$1" - shift - - if command -v timeout >/dev/null 2>&1; then - run_as_user "$user" timeout "$(hostexec_timeout_seconds)" "$@" - return $? - fi - - run_as_user "$user" "$@" -} - -run_as_user() { - local user="$1" - shift - - if [[ "$(id -un)" == "$user" ]]; then - "$@" - return 0 - fi - - if command -v sudo >/dev/null 2>&1; then - sudo -u "$user" -- "$@" - return 0 - fi - - local quoted="" - local arg - for arg in "$@"; do - printf -v quoted '%s %q' "$quoted" "$arg" - done - quoted="${quoted# }" - su "$user" -s /bin/sh -c "$quoted" -} - -append_hostexec_log() { - local user="$1" - local app="$2" - local line="$3" - printf '%s\n' "$line" >> "$(app_state_dir "$user" "$app")/hostexec.log" -} - -render_app_info() { - local user="$1" - local app="$2" - local manifest meta container_name network_name running ip desktop_port desktop_scheme desktop_https_skip_check target_host - local portmaps='[]' - - app_is_installed "$user" "$app" || die "app is not installed: $app" - - manifest="$(app_manifest_json_path "$user" "$app")" - meta="$(app_meta_path "$user" "$app")" - container_name="$(manifest_value "$meta" '.container_name')" - network_name="$(manifest_value "$meta" '.network_name')" - running=false - ip="" - - if command -v podman >/dev/null 2>&1 && podman inspect "$container_name" >/dev/null 2>&1; then - if podman_container_running "$container_name"; then - running=true - ip="$(podman_container_ip "$container_name")" - fi - fi - - if [[ -f "$(app_portmaps_path "$user" "$app")" ]]; then - portmaps="$(cat "$(app_portmaps_path "$user" "$app")")" - fi - - desktop_scheme="http" - desktop_port="$(jq -r ' - .network.service.http - // empty - ' "$manifest")" - if [[ -z "$desktop_port" || "$desktop_port" == "null" ]]; then - desktop_port="$(jq -r ' - .network.service.https - // empty - ' "$manifest")" - if [[ -n "$desktop_port" && "$desktop_port" != "null" ]]; then - desktop_scheme="https" - fi - fi - desktop_https_skip_check=false - if [[ "$desktop_scheme" == "https" ]]; then - desktop_https_skip_check="$(jq -r 'if (.network.service.https_skip_check // true) then "true" else "false" end' "$manifest")" - fi - - target_host="$ip" - if manifest_bool "$manifest" '.network.host // false'; then - target_host="127.0.0.1" - fi - - jq -n \ - --arg user "$user" \ - --slurpfile manifest_file "$manifest" \ - --slurpfile meta_file "$meta" \ - --argjson running "$running" \ - --arg ip "$ip" \ - --arg target_host "$target_host" \ - --arg desktop_scheme "$desktop_scheme" \ - --arg desktop_port "$desktop_port" \ - --argjson desktop_https_skip_check "$desktop_https_skip_check" \ - --argjson portmaps "$portmaps" ' - ($manifest_file[0]) as $manifest - | - ($meta_file[0]) as $meta - | - { - ok: true, - owner: $user, - app: { - name: ($manifest.app.name // null), - version: ($manifest.app.version // null), - nickname: ($manifest.app.nickname // null), - description: ($manifest.app.description // null) - }, - runtime: { - running: $running, - container_name: ($meta.container_name // null), - network_name: ($meta.network_name // null), - image_ref: ($meta.image_ref // null), - ip: (if $ip == "" then null else $ip end) - }, - desktop: { - scheme: (if $desktop_port == "" or $desktop_port == "null" then null else $desktop_scheme end), - port: (if $desktop_port == "" or $desktop_port == "null" then null else ($desktop_port | tonumber) end), - target_host: (if $target_host == "" then null else $target_host end), - https_skip_check: $desktop_https_skip_check - }, - permissions: { - host_network: ($manifest.network.host // false), - host_exec: ($manifest.host.exec.enabled // false) - }, - portmaps: $portmaps, - manifest: $manifest - } - ' -} - -create_container_from_manifest() { - local user="$1" - local manifest="$2" - local image_ref="$3" - local app_name container_name network_name cmd_value hostexec_gateway hostexec_host - local -a args - - app_name="$(manifest_value "$manifest" '.app.name')" - container_name="$(container_name_for_app "$user" "$app_name")" - network_name="$(network_name_for_user "$user")" - - podman inspect "$container_name" >/dev/null 2>&1 && die "container already exists: $container_name" - - args=(create --name "$container_name" --hostname "$app_name" --label "io.capos.owner=$(user_uid "$user")" --label "io.capos.app=$app_name") - - if manifest_bool "$manifest" '.network.host // false'; then - args+=(--network host) - else - podman_network_ensure "$network_name" - args+=(--network "$network_name" --network-alias "$app_name") - fi - - if jq -e '.container.systemd // false' "$manifest" >/dev/null 2>&1; then - args+=(--systemd always) - fi - - if jq -e '.container.privileges.enabled // false' "$manifest" >/dev/null 2>&1; then - args+=(--privileged) - fi - - while IFS= read -r cap; do - [[ -n "$cap" ]] && args+=(--cap-add "$cap") - done < <(jq -r '.container.privileges.capabilities // [] | .[]' "$manifest") - - if jq -e '(.container.privileges.allow_new_privs // false) | not' "$manifest" >/dev/null 2>&1; then - args+=(--security-opt no-new-privileges) - fi - - if jq -e '.host.exec.enabled // false' "$manifest" >/dev/null 2>&1; then - ensure_app_hostexec_token "$user" "$app_name" - if manifest_bool "$manifest" '.network.host // false'; then - hostexec_host="127.0.0.1" - else - hostexec_host="capos.host" - hostexec_gateway="$(podman_network_gateway "$network_name")" - [[ -n "$hostexec_gateway" ]] || die "unable to resolve bridge gateway for hostexec" - args+=(--add-host "${hostexec_host}:${hostexec_gateway}") - fi - args+=(-v "$(app_hostexec_token_path "$user" "$app_name"):/run/capos_hostexec.token:ro") - args+=(-e "CAPOS_HOSTEXEC_URL=http://${hostexec_host}:2000/cgi-bin/cap/api/hostexec") - args+=(-e "CAPOS_HOSTEXEC_TOKEN_FILE=/run/capos_hostexec.token") - args+=(-e "CAPOS_HOSTEXEC_APP=${app_name}") - args+=(-e "CAPOS_HOSTEXEC_USER=${user}") - fi - - while IFS= read -r env_item; do - [[ -n "$env_item" ]] && args+=(-e "$env_item") - done < <(manifest_environment_items "$manifest") - - while IFS= read -r device; do - [[ -n "$device" ]] && args+=(--device "$device") - done < <(jq -r '.container.devices // [] | .[]' "$manifest") - - while IFS= read -r mount_rule; do - [[ -n "$mount_rule" ]] && args+=(-v "$mount_rule") - done < <(jq -r '.container.volumes.extra // [] | .[]' "$manifest") - - if [[ "$(manifest_value "$manifest" '.container.volumes.data // ""')" != "" ]]; then - args+=(-v "$(app_data_dir "$user" "$app_name"):$(manifest_value "$manifest" '.container.volumes.data')") - fi - - while IFS= read -r ref_app; do - [[ -n "$ref_app" ]] && args+=(--volumes-from "$(container_name_for_app "$user" "$ref_app")") - done < <(manifest_volume_from_refs "$manifest") - - if [[ "$(manifest_value "$manifest" '.container.tmpfs // ""')" != "" ]]; then - args+=(--tmpfs "$(manifest_value "$manifest" '.container.tmpfs')") - fi - - if [[ "$(manifest_value "$manifest" '.container.resources.memory_reserved // ""')" != "" ]]; then - args+=(--memory-reservation "$(manifest_value "$manifest" '.container.resources.memory_reserved')") - fi - - if [[ "$(manifest_value "$manifest" '.container.resources.shm_size // ""')" != "" ]]; then - args+=(--shm-size "$(manifest_value "$manifest" '.container.resources.shm_size')") - fi - - if jq -e '.healthcheck.enabled // false' "$manifest" >/dev/null 2>&1; then - if [[ "$(manifest_value "$manifest" '.healthcheck.cmd // ""')" != "" ]]; then - args+=(--health-cmd "$(manifest_value "$manifest" '.healthcheck.cmd')") - fi - if [[ "$(manifest_value "$manifest" '.healthcheck.interval // ""')" != "" ]]; then - args+=(--health-interval "$(manifest_value "$manifest" '.healthcheck.interval')") - fi - if [[ "$(manifest_value "$manifest" '.healthcheck.timeout // ""')" != "" ]]; then - args+=(--health-timeout "$(manifest_value "$manifest" '.healthcheck.timeout')") - fi - if [[ "$(manifest_value "$manifest" '.healthcheck.start_period // ""')" != "" ]]; then - args+=(--health-start-period "$(manifest_value "$manifest" '.healthcheck.start_period')") - fi - if [[ "$(manifest_value "$manifest" '.healthcheck.retries // ""')" != "" ]]; then - args+=(--health-retries "$(manifest_value "$manifest" '.healthcheck.retries')") - fi - fi - - cmd_value="$(manifest_value "$manifest" '.container.cmd.exe // ""')" - if [[ -n "$cmd_value" && "$cmd_value" != "null" ]]; then - args+=("$image_ref" /bin/sh -lc "$cmd_value") - else - args+=("$image_ref") - fi - - podman "${args[@]}" >/dev/null - podman start "$container_name" >/dev/null -} - -mapping_key() { - printf '%s:%s->%s\n' "$1" "$2" "$3" -} - -port_in_use_in_state() { - local listen="$1" - local proto="$2" - local user="$3" - local app="$4" - local path - - while IFS= read -r path; do - jq -e --argjson listen "$listen" --arg proto "$proto" --arg app "$app" --arg owner "$(user_uid "$user")" ' - .[] | select(.listen == $listen and .proto == $proto) - ' "$path" >/dev/null 2>&1 || continue - if [[ "$(dirname "$path")" != "$(app_state_dir "$user" "$app")" ]]; then - return 0 - fi - done < <(find "$CAPBOX_STATE_DIR/users" -path '*/portmaps.json' -type f 2>/dev/null || true) - - return 1 -} - -portmap_rule_pid() { - local user="$1" - local app="$2" - local proto="$3" - local listen="$4" - local target="$5" - local pidfile pid - - pidfile="$(portmap_pid_file "$user" "$app" "$proto" "$listen" "$target")" - [[ -f "$pidfile" ]] || return 1 - pid="$(cat "$pidfile" 2>/dev/null || true)" - [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]] || return 1 - kill -0 "$pid" 2>/dev/null || return 1 - printf '%s\n' "$pid" -} - -socket_inodes_for_port() { - local listen="$1" - local proto="$2" - local port_hex proc_file check_state - - printf -v port_hex '%04X' "$listen" - case "$proto" in - tcp) - check_state="1" - for proc_file in /proc/net/tcp /proc/net/tcp6; do - [[ -r "$proc_file" ]] || continue - awk -v port="$port_hex" -v require_state="$check_state" ' - NR == 1 { next } - { - split($2, local, ":") - if (toupper(local[2]) != port) { - next - } - if (require_state == "1" && toupper($4) != "0A") { - next - } - print $10 - } - ' "$proc_file" - done - ;; - udp) - for proc_file in /proc/net/udp /proc/net/udp6; do - [[ -r "$proc_file" ]] || continue - awk -v port="$port_hex" ' - NR == 1 { next } - { - split($2, local, ":") - if (toupper(local[2]) == port) { - print $10 - } - } - ' "$proc_file" - done - ;; - *) - die "unsupported protocol: $proto" - ;; - esac -} - -pid_owns_socket_inode() { - local pid="$1" - local inode="$2" - local fd target - - [[ -n "$pid" && -d "/proc/$pid/fd" ]] || return 1 - for fd in /proc/"$pid"/fd/*; do - [[ -e "$fd" ]] || continue - target="$(readlink "$fd" 2>/dev/null || true)" - [[ "$target" == "socket:[$inode]" ]] && return 0 - done - return 1 -} - -host_port_in_use() { - local listen="$1" - local proto="$2" - local allowed_pid="${3:-}" - local inode - - while IFS= read -r inode; do - [[ -n "$inode" ]] || continue - if [[ -n "$allowed_pid" ]] && pid_owns_socket_inode "$allowed_pid" "$inode"; then - continue - fi - return 0 - done < <(socket_inodes_for_port "$listen" "$proto") - - return 1 -} - -stop_portmap_rule() { - local user="$1" - local app="$2" - local proto="$3" - local listen="$4" - local target="$5" - local pidfile pid - - pidfile="$(portmap_pid_file "$user" "$app" "$proto" "$listen" "$target")" - [[ -f "$pidfile" ]] || return 0 - pid="$(cat "$pidfile" 2>/dev/null || true)" - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - fi - rm -f "$pidfile" -} - -start_portmap_rule() { - local user="$1" - local app="$2" - local proto="$3" - local listen="$4" - local target="$5" - local container_name container_ip pidfile listen_arg target_arg spid - - need_cmd podman socat - container_name="$(container_name_for_app "$user" "$app")" - podman_container_running "$container_name" || return 0 - container_ip="$(podman_container_ip "$container_name")" - [[ -n "$container_ip" ]] || container_ip="127.0.0.1" - - pidfile="$(portmap_pid_file "$user" "$app" "$proto" "$listen" "$target")" - if [[ -f "$pidfile" ]]; then - local existing_pid - existing_pid="$(cat "$pidfile" 2>/dev/null || true)" - if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then - return 0 - fi - rm -f "$pidfile" - fi - - if [[ "$proto" == "tcp" ]]; then - listen_arg="TCP-LISTEN:${listen},fork,reuseaddr" - target_arg="TCP:${container_ip}:${target}" - else - listen_arg="UDP-LISTEN:${listen},fork,reuseaddr" - target_arg="UDP:${container_ip}:${target}" - fi - - socat "$listen_arg" "$target_arg" >/dev/null 2>&1 & - spid=$! - sleep 1 - if ! kill -0 "$spid" 2>/dev/null; then - wait "$spid" 2>/dev/null || true - echo "failed to start port mapping for $app on port $listen/$proto" >&2 - return 1 - fi - - printf '%s\n' "$spid" > "$pidfile" -} - -reconcile_portmaps() { - local user="$1" - local app="$2" - local state_file proto listen target pidfile - - state_file="$(app_portmaps_path "$user" "$app")" - [[ -f "$state_file" ]] || return 0 - mkdir -p "$(app_portmap_runtime_dir "$user" "$app")" - - while IFS= read -r pidfile; do - local base state_proto state_listen state_target - base="$(basename "$pidfile" .pid)" - state_proto="${base%%_*}" - base="${base#*_}" - state_listen="${base%%_*}" - state_target="${base##*_}" - if ! jq -e --arg proto "$state_proto" --argjson listen "$state_listen" --argjson target "$state_target" ' - .[] | select(.proto == $proto and .listen == $listen and .target_port == $target) - ' "$state_file" >/dev/null 2>&1; then - stop_portmap_rule "$user" "$app" "$state_proto" "$state_listen" "$state_target" - fi - done < <(find "$(app_portmap_runtime_dir "$user" "$app")" -type f -name '*.pid' 2>/dev/null || true) - - while IFS=$'\t' read -r proto listen target; do - [[ -n "$proto" ]] || continue - start_portmap_rule "$user" "$app" "$proto" "$listen" "$target" || return 1 - done < <(jq -r '.[] | [.proto, (.listen|tostring), (.target_port|tostring)] | @tsv' "$state_file") -} - -set_portmap() { - local user="$1" - local app="$2" - local listen="$3" - local target="$4" - local proto="$5" - local state_file tmp backup existing_pid reconcile_status - - app_is_installed "$user" "$app" || die "app is not installed: $app" - [[ "$listen" =~ ^[0-9]+$ && "$target" =~ ^[0-9]+$ ]] || die "port values must be numeric" - ((listen >= 1 && listen <= 65535)) || die "listen port out of range" - ((target >= 1 && target <= 65535)) || die "target port out of range" - [[ "$proto" == "tcp" || "$proto" == "udp" ]] || die "unsupported protocol: $proto" - - if ((listen < 1024)) && ! user_is_sudo "$user"; then - die "low ports require sudo privileges" - fi - - port_in_use_in_state "$listen" "$proto" "$user" "$app" && die "port already allocated in capbox state: $listen/$proto" - existing_pid="$(portmap_rule_pid "$user" "$app" "$proto" "$listen" "$target" || true)" - host_port_in_use "$listen" "$proto" "$existing_pid" && die "host port already in use: $listen/$proto" - - state_file="$(app_portmaps_path "$user" "$app")" - tmp="$(mktemp)" - backup="$(mktemp)" - cp "$state_file" "$backup" - jq \ - --arg proto "$proto" \ - --argjson listen "$listen" \ - --argjson target "$target" ' - if any(.[]; .proto == $proto and .listen == $listen and .target_port == $target) then - . - else - . + [{proto: $proto, listen: $listen, target_port: $target}] - end - ' "$state_file" > "$tmp" - mv "$tmp" "$state_file" - set +e - reconcile_portmaps "$user" "$app" - reconcile_status=$? - set -e - if (( reconcile_status != 0 )); then - cp "$backup" "$state_file" - reconcile_portmaps "$user" "$app" || true - rm -f "$backup" - die "failed to apply port mapping for $app on port $listen/$proto" - fi - rm -f "$backup" -} - -remove_portmap() { - local user="$1" - local app="$2" - local listen="$3" - local target="$4" - local proto="$5" - local state_file tmp - - app_is_installed "$user" "$app" || die "app is not installed: $app" - validate_publish_mapping "$listen" "$target" "$proto" "$user" - state_file="$(app_portmaps_path "$user" "$app")" - tmp="$(mktemp)" - jq \ - --arg proto "$proto" \ - --argjson listen "$listen" \ - --argjson target "$target" ' - map(select(.proto != $proto or .listen != $listen or .target_port != $target)) - ' "$state_file" > "$tmp" - mv "$tmp" "$state_file" - stop_portmap_rule "$user" "$app" "$proto" "$listen" "$target" -} - -install_publish_rules_from_manifest() { - local user="$1" - local app="$2" - local manifest="$3" - local raw proto listen target - - while IFS=$'\t' read -r raw proto listen target; do - [[ -z "$raw" ]] && continue - set_portmap "$user" "$app" "$listen" "$target" "$proto" - done < <(manifest_publish_rules_tsv "$manifest") -} - -cmd_user_info() { - local user - user="$(resolve_target_user "${1:-}")" - print_json \ - --arg username "$user" \ - --arg uid "$(user_uid "$user")" \ - --arg gid "$(user_gid "$user")" \ - --arg home "$(user_home "$user")" \ - --arg shell "$(user_shell "$user")" \ - --argjson is_sudo "$(if user_is_sudo "$user"; then echo true; else echo false; fi)" ' - { - ok: true, - username: $username, - uid: ($uid | tonumber), - gid: ($gid | tonumber), - home: $home, - shell: $shell, - is_sudo: $is_sudo - } - ' -} - -cmd_cpk_inspect() { - local cpk_file="$1" - local tmpdir manifest_yaml manifest_json image_path icon_path - [[ -f "$cpk_file" ]] || die "CPK file not found: $cpk_file" - need_cmd tar jq yq - tmpdir="$(mktemp -d)" - trap 'rm -rf "${tmpdir:-}"; trap - RETURN' RETURN - extract_cpk "$cpk_file" "$tmpdir" - manifest_yaml="$(find_cpk_manifest "$tmpdir")" - manifest_json="$tmpdir/manifest.json" - read_manifest_json_from_yaml "$manifest_yaml" "$manifest_json" - ensure_manifest_shape "$manifest_json" - image_path="$(select_cpk_image "$tmpdir" "$manifest_json")" - icon_path="$(find_cpk_icon "$tmpdir")" - cpk_summary_json "$manifest_json" "$cpk_file" "$image_path" "$icon_path" -} - -cmd_cpk_validate() { - local cpk_file="$1" - local user="$2" - local tmpdir manifest_yaml manifest_json image_path icon_path - [[ -f "$cpk_file" ]] || die "CPK file not found: $cpk_file" - need_cmd tar jq yq - tmpdir="$(mktemp -d)" - trap 'rm -rf "${tmpdir:-}"; trap - RETURN' RETURN - extract_cpk "$cpk_file" "$tmpdir" - manifest_yaml="$(find_cpk_manifest "$tmpdir")" - manifest_json="$tmpdir/manifest.json" - read_manifest_json_from_yaml "$manifest_yaml" "$manifest_json" - validate_manifest_for_user "$manifest_json" "$user" - image_path="$(select_cpk_image "$tmpdir" "$manifest_json")" - icon_path="$(find_cpk_icon "$tmpdir")" - cpk_summary_json "$manifest_json" "$cpk_file" "$image_path" "$icon_path" -} - -cmd_cpk_install() { - local cpk_file="$1" - local user="$2" - local tmpdir manifest_yaml manifest_json image_path icon_path app_name icon_name load_output image_ref - - require_root - [[ -f "$cpk_file" ]] || die "CPK file not found: $cpk_file" - need_cmd tar jq yq podman - ensure_user_dirs "$user" - - tmpdir="$(mktemp -d)" - trap 'rm -rf "${tmpdir:-}"; trap - RETURN' RETURN - extract_cpk "$cpk_file" "$tmpdir" - manifest_yaml="$(find_cpk_manifest "$tmpdir")" - manifest_json="$tmpdir/manifest.json" - read_manifest_json_from_yaml "$manifest_yaml" "$manifest_json" - validate_manifest_for_user "$manifest_json" "$user" - - app_name="$(manifest_value "$manifest_json" '.app.name')" - ensure_app_dirs "$user" "$app_name" - image_path="$(select_cpk_image "$tmpdir" "$manifest_json")" - icon_path="$(find_cpk_icon "$tmpdir")" - icon_name="" - if [[ -n "$icon_path" ]]; then - icon_name="$(basename "$icon_path")" - fi - - load_output="$(podman load -i "$image_path" 2>&1)" - image_ref="$(detect_image_ref_from_load "$load_output")" - - cp "$manifest_yaml" "$(app_manifest_yaml_path "$user" "$app_name")" - cp "$manifest_json" "$(app_manifest_json_path "$user" "$app_name")" - copy_if_present "$icon_path" "$(app_state_dir "$user" "$app_name")/$icon_name" - write_app_meta "$user" "$app_name" "$image_ref" "$icon_name" "$manifest_json" - write_app_hostexec "$user" "$app_name" "$manifest_json" - create_container_from_manifest "$user" "$manifest_json" "$image_ref" - install_publish_rules_from_manifest "$user" "$app_name" "$manifest_json" - render_app_info "$user" "$app_name" -} - -cmd_app_list() { - local user="$1" - local apps_dir - - ensure_user_dirs "$user" - apps_dir="$(user_apps_dir "$user")" - { - printf '[\n' - local first=1 - local app_dir app_name - for app_dir in "$apps_dir"/*; do - [[ -d "$app_dir" ]] || continue - app_name="$(basename "$app_dir")" - app_is_installed "$user" "$app_name" || continue - if ((first)); then - first=0 - else - printf ',\n' - fi - render_app_info "$user" "$app_name" - done - printf '\n]\n' - } | jq -s '.[0]' -} - -cmd_app_info() { - local user="$1" - local app="$2" - render_app_info "$user" "$app" -} - -ensure_app_container() { - local user="$1" - local app="$2" - local container_name manifest meta image_ref - - need_cmd podman - container_name="$(container_name_for_app "$user" "$app")" - if podman_container_exists "$container_name"; then - return 0 - fi - - manifest="$(app_manifest_json_path "$user" "$app")" - meta="$(app_meta_path "$user" "$app")" - image_ref="$(manifest_value "$meta" '.image_ref // ""')" - [[ -n "$image_ref" && "$image_ref" != "null" ]] || die "app image reference is missing; reinstall app: $app" - create_container_from_manifest "$user" "$manifest" "$image_ref" -} - -cmd_app_start() { - local user="$1" - local app="$2" - local container_name - require_root - need_cmd podman - app_is_installed "$user" "$app" || die "app is not installed: $app" - container_name="$(container_name_for_app "$user" "$app")" - if podman_container_exists "$container_name"; then - podman start "$container_name" >/dev/null - else - ensure_app_container "$user" "$app" - fi - reconcile_portmaps "$user" "$app" - render_app_info "$user" "$app" -} - -cmd_app_stop() { - local user="$1" - local app="$2" - local container_name - require_root - app_is_installed "$user" "$app" || die "app is not installed: $app" - container_name="$(container_name_for_app "$user" "$app")" - if command -v podman >/dev/null 2>&1 && podman_container_exists "$container_name"; then - podman stop "$container_name" >/dev/null - fi - while IFS=$'\t' read -r proto listen target; do - [[ -n "$proto" ]] || continue - stop_portmap_rule "$user" "$app" "$proto" "$listen" "$target" - done < <(jq -r '.[] | [.proto, (.listen|tostring), (.target_port|tostring)] | @tsv' "$(app_portmaps_path "$user" "$app")") - render_app_info "$user" "$app" -} - -cmd_app_restart() { - local user="$1" - local app="$2" - local container_name - require_root - need_cmd podman - app_is_installed "$user" "$app" || die "app is not installed: $app" - container_name="$(container_name_for_app "$user" "$app")" - if podman_container_exists "$container_name"; then - podman restart "$container_name" >/dev/null - else - ensure_app_container "$user" "$app" - fi - reconcile_portmaps "$user" "$app" - render_app_info "$user" "$app" -} - -remove_user_network_if_unused() { - local user="$1" - local app_dir app_name network_name - - for app_dir in "$(user_apps_dir "$user")"/*; do - [[ -d "$app_dir" ]] || continue - app_name="$(basename "$app_dir")" - app_is_installed "$user" "$app_name" && return 0 - done - - if command -v podman >/dev/null 2>&1; then - network_name="$(network_name_for_user "$user")" - podman network rm "$network_name" >/dev/null 2>&1 || true - fi -} - -cmd_app_uninstall() { - local user="$1" - local app="$2" - local container_name - require_root - app_is_installed "$user" "$app" || die "app is not installed: $app" - while IFS=$'\t' read -r proto listen target; do - [[ -n "$proto" ]] || continue - stop_portmap_rule "$user" "$app" "$proto" "$listen" "$target" - done < <(jq -r '.[] | [.proto, (.listen|tostring), (.target_port|tostring)] | @tsv' "$(app_portmaps_path "$user" "$app")") - - container_name="$(container_name_for_app "$user" "$app")" - if command -v podman >/dev/null 2>&1 && podman inspect "$container_name" >/dev/null 2>&1; then - podman rm -f "$container_name" >/dev/null - fi - rm -rf "$(app_state_dir "$user" "$app")" "$(app_runtime_dir "$user" "$app")" - if [[ -f "$(desktop_path "$user")" ]] && [[ "$(cat "$(desktop_path "$user")")" == "$app" ]]; then - rm -f "$(desktop_path "$user")" - fi - remove_user_network_if_unused "$user" - print_json --arg message "uninstalled $app" '{ok: true, message: $message}' -} - -cmd_app_logs() { - local user="$1" - local app="$2" - local container_name container_output="" hostexec_output="" hostexec_log - - app_is_installed "$user" "$app" || die "app is not installed: $app" - container_name="$(container_name_for_app "$user" "$app")" - if command -v podman >/dev/null 2>&1 && podman_container_exists "$container_name"; then - container_output="$(podman logs --tail 120 "$container_name" 2>&1 || true)" - fi - hostexec_log="$(app_state_dir "$user" "$app")/hostexec.log" - if [[ -f "$hostexec_log" ]]; then - hostexec_output="$(tail -n 120 "$hostexec_log" 2>/dev/null || true)" - fi - jq -n \ - --arg app "$app" \ - --arg container "$container_output" \ - --arg hostexec "$hostexec_output" ' - { - ok: true, - app: $app, - logs: { - container: $container, - hostexec: $hostexec - } - } - ' -} - -cmd_portmap_list() { - local user="$1" - local app="$2" - app_is_installed "$user" "$app" || die "app is not installed: $app" - cat "$(app_portmaps_path "$user" "$app")" -} - -cmd_portmap_set() { - local user="$1" - local app="$2" - local listen="$3" - local target="$4" - local proto="$5" - require_root - set_portmap "$user" "$app" "$listen" "$target" "$proto" - cmd_portmap_list "$user" "$app" -} - -cmd_portmap_remove() { - local user="$1" - local app="$2" - local listen="$3" - local target="$4" - local proto="$5" - require_root - remove_portmap "$user" "$app" "$listen" "$target" "$proto" - cmd_portmap_list "$user" "$app" -} - -cmd_desktop_get() { - local user="$1" - local app="" - if [[ -f "$(desktop_path "$user")" ]]; then - app="$(cat "$(desktop_path "$user")")" - fi - jq -n --arg app "$app" '{ok: true, app: (if $app == "" then null else $app end)}' -} - -cmd_desktop_set() { - local user="$1" - local app="$2" - app_is_installed "$user" "$app" || die "app is not installed: $app" - ensure_user_dirs "$user" - printf '%s\n' "$app" > "$(desktop_path "$user")" - cmd_desktop_get "$user" -} - -cmd_reconcile() { - local user="$1" - local app="$2" - require_root - if [[ -n "$app" ]]; then - app_is_installed "$user" "$app" || die "app is not installed: $app" - if command -v podman >/dev/null 2>&1; then - ensure_app_container "$user" "$app" - fi - reconcile_portmaps "$user" "$app" - render_app_info "$user" "$app" - return 0 - fi - - local app_dir app_name - for app_dir in "$(user_apps_dir "$user")"/*; do - [[ -d "$app_dir" ]] || continue - app_name="$(basename "$app_dir")" - app_is_installed "$user" "$app_name" || continue - reconcile_portmaps "$user" "$app_name" - done - print_json --arg message "reconciled user $user" '{ok: true, message: $message}' -} - -cmd_hostexec_run() { - local user="$1" - local app="$2" - shift 2 - local command_path="${1:-}" - - hostexec_validate_request "$user" "$app" "$command_path" - - local started ended exit_code - started="$(current_unix_time)" - if run_hostexec_command "$user" "$@"; then - exit_code=0 - else - exit_code=$? - fi - ended="$(current_unix_time)" - append_hostexec_log "$user" "$app" "$(printf '%s app=%s user=%s cmd=%q exit=%s duration=%s' "$started" "$app" "$user" "$*" "$exit_code" "$((ended - started))")" - return "$exit_code" -} - -cmd_hostexec_invoke() { - local user="$1" - local app="$2" - local token="$3" - shift 3 - local command_path="${1:-}" - local started ended exit_code tmp output - - hostexec_token_matches "$user" "$app" "$token" || die "hostexec token is invalid" - hostexec_validate_request "$user" "$app" "$command_path" - - tmp="$(mktemp)" - started="$(current_unix_time)" - if run_hostexec_command "$user" "$@" >"$tmp" 2>&1; then - exit_code=0 - else - exit_code=$? - fi - ended="$(current_unix_time)" - output="$(cat "$tmp")" - rm -f "$tmp" - - append_hostexec_log "$user" "$app" "$(printf '%s app=%s user=%s cmd=%q exit=%s duration=%s source=api' "$started" "$app" "$user" "$*" "$exit_code" "$((ended - started))")" - jq -n \ - --argjson exit_code "$exit_code" \ - --arg output "$output" ' - { - ok: true, - exit_code: $exit_code, - success: ($exit_code == 0), - output: $output - } - ' -} - -usage() { - cat <<'EOF' -Usage: - capbox user info [--user USER] - capbox cpk inspect FILE - capbox cpk validate FILE [--user USER] - capbox cpk install FILE [--user USER] - capbox app list [--user USER] - capbox app info APP [--user USER] - capbox app start APP [--user USER] - capbox app stop APP [--user USER] - capbox app restart APP [--user USER] - capbox app uninstall APP [--user USER] - capbox app logs APP [--user USER] - capbox portmap list APP [--user USER] - capbox portmap set APP --listen PORT --target PORT [--proto tcp|udp] [--user USER] - capbox portmap remove APP --listen PORT --target PORT [--proto tcp|udp] [--user USER] - capbox desktop get [--user USER] - capbox desktop set APP [--user USER] - capbox hostexec run APP --user USER -- /absolute/command [args...] - capbox hostexec invoke APP --user USER --token TOKEN -- /absolute/command [args...] - capbox reconcile [APP] [--user USER] -EOF -} - -main() { - local group="${1:-}" - local action="${2:-}" - local user="" - - [[ -n "$group" ]] || { - usage - exit 1 - } - - shift $(( $# > 0 ? 1 : 0 )) - case "$group" in - user) - [[ "${1:-}" == "info" ]] || die "unknown user command" - shift - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - *) die "unexpected argument: $1" ;; - esac - done - cmd_user_info "$user" - ;; - cpk) - [[ -n "${1:-}" ]] || die "missing cpk action" - action="$1" - shift - local file="" - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - *) - [[ -z "$file" ]] || die "unexpected argument: $1" - file="$1" - shift - ;; - esac - done - [[ -n "$file" ]] || die "missing CPK file" - case "$action" in - inspect) cmd_cpk_inspect "$file" ;; - validate) cmd_cpk_validate "$file" "$(resolve_target_user "$user")" ;; - install) cmd_cpk_install "$file" "$(resolve_target_user "$user")" ;; - *) die "unknown cpk action: $action" ;; - esac - ;; - app) - [[ -n "${1:-}" ]] || die "missing app action" - action="$1" - shift - local app="" - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - *) - [[ -z "$app" ]] || die "unexpected argument: $1" - app="$1" - shift - ;; - esac - done - user="$(resolve_target_user "$user")" - case "$action" in - list) cmd_app_list "$user" ;; - info) [[ -n "$app" ]] || die "missing app name"; cmd_app_info "$user" "$app" ;; - start) [[ -n "$app" ]] || die "missing app name"; cmd_app_start "$user" "$app" ;; - stop) [[ -n "$app" ]] || die "missing app name"; cmd_app_stop "$user" "$app" ;; - restart) [[ -n "$app" ]] || die "missing app name"; cmd_app_restart "$user" "$app" ;; - uninstall) [[ -n "$app" ]] || die "missing app name"; cmd_app_uninstall "$user" "$app" ;; - logs) [[ -n "$app" ]] || die "missing app name"; cmd_app_logs "$user" "$app" ;; - *) die "unknown app action: $action" ;; - esac - ;; - portmap) - [[ -n "${1:-}" ]] || die "missing portmap action" - action="$1" - shift - local app="" listen="" target="" proto="tcp" - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - --listen) listen="$2"; shift 2 ;; - --target) target="$2"; shift 2 ;; - --proto) proto="$2"; shift 2 ;; - *) - [[ -z "$app" ]] || die "unexpected argument: $1" - app="$1" - shift - ;; - esac - done - [[ -n "$app" ]] || die "missing app name" - user="$(resolve_target_user "$user")" - case "$action" in - list) cmd_portmap_list "$user" "$app" ;; - set) - [[ -n "$listen" && -n "$target" ]] || die "listen and target ports are required" - cmd_portmap_set "$user" "$app" "$listen" "$target" "$proto" - ;; - remove) - [[ -n "$listen" && -n "$target" ]] || die "listen and target ports are required" - cmd_portmap_remove "$user" "$app" "$listen" "$target" "$proto" - ;; - *) die "unknown portmap action: $action" ;; - esac - ;; - desktop) - [[ -n "${1:-}" ]] || die "missing desktop action" - action="$1" - shift - local app="" - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - *) - [[ -z "$app" ]] || die "unexpected argument: $1" - app="$1" - shift - ;; - esac - done - user="$(resolve_target_user "$user")" - case "$action" in - get) cmd_desktop_get "$user" ;; - set) [[ -n "$app" ]] || die "missing app name"; cmd_desktop_set "$user" "$app" ;; - *) die "unknown desktop action: $action" ;; - esac - ;; - hostexec) - [[ -n "${1:-}" ]] || die "missing hostexec action" - action="$1" - shift - local app="" token="" - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - --token) token="$2"; shift 2 ;; - --) shift; break ;; - *) - [[ -z "$app" ]] || die "unexpected argument: $1" - app="$1" - shift - ;; - esac - done - [[ -n "$app" ]] || die "missing app name" - user="$(resolve_target_user "$user")" - case "$action" in - run) - cmd_hostexec_run "$user" "$app" "$@" - ;; - invoke) - [[ -n "$token" ]] || die "missing hostexec token" - cmd_hostexec_invoke "$user" "$app" "$token" "$@" - ;; - *) - die "unknown hostexec action: $action" - ;; - esac - ;; - reconcile) - local app="" - shift 0 - while (($#)); do - case "$1" in - --user) user="$2"; shift 2 ;; - *) - [[ -z "$app" ]] || die "unexpected argument: $1" - app="$1" - shift - ;; - esac - done - cmd_reconcile "$(resolve_target_user "$user")" "$app" - ;; - -h|--help|help) - usage - ;; - *) - die "unknown command group: $group" - ;; - esac -} - -if [[ "${CAPBOX_LIB_ONLY:-0}" != "1" ]]; then - main "$@" -fi diff --git a/package/capos/capos-core/Makefile b/package/capos/capos-core/Makefile index 9196a39c448..305365d6b57 100644 --- a/package/capos/capos-core/Makefile +++ b/package/capos/capos-core/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=capos-core PKG_VERSION:=0.1.0 -PKG_RELEASE:=1 +PKG_RELEASE:=2 include $(INCLUDE_DIR)/package.mk @@ -10,7 +10,7 @@ define Package/capos-core SECTION:=capos CATEGORY:=CapOS TITLE:=CapOS Core Meta Package - DEPENDS:=@ARCH_64BIT @!SMALL_FLASH +capbox +capos-webpanel +sudo + DEPENDS:=@ARCH_64BIT @!SMALL_FLASH +snapd +capos-webdesktop +sudo endef define Package/capos-core/description @@ -22,7 +22,7 @@ define Build/Compile endef define Package/capos-core/install - # meta package, nothing to install + $(INSTALL_DIR) $(1)/home $(1)/usr/src $(1)/media endef $(eval $(call BuildPackage,capos-core)) diff --git a/package/capos/capos-webpanel/Makefile b/package/capos/capos-webdesktop/Makefile similarity index 71% rename from package/capos/capos-webpanel/Makefile rename to package/capos/capos-webdesktop/Makefile index c4602a533de..ae1f0aee78f 100644 --- a/package/capos/capos-webpanel/Makefile +++ b/package/capos/capos-webdesktop/Makefile @@ -1,6 +1,6 @@ include $(TOPDIR)/rules.mk -PKG_NAME:=capos-webpanel +PKG_NAME:=capos-webdesktop PKG_VERSION:=1.0.0 PKG_RELEASE:=1 @@ -9,15 +9,15 @@ $(shell mkdir -p $(PKG_BUILD_DIR)) include $(INCLUDE_DIR)/package.mk -define Package/capos-webpanel +define Package/capos-webdesktop SECTION:=capos CATEGORY:=CapOS - TITLE:=CapOS Web Panel - DEPENDS:=+libstdcpp +libopenssl +luci +uhttpd +libustream-mbedtls +px5g-mbedtls +capbox + TITLE:=CapOS WebDesktop + DEPENDS:=+libstdcpp +libopenssl +luci +uhttpd +libustream-mbedtls +px5g-mbedtls +snapd endef -define Package/capos-webpanel/description - Build and install CapOS webpanel CGI binaries and static web assets. +define Package/capos-webdesktop/description + Build and install CapOS WebDesktop CGI binaries and static web assets. endef CAP_CGI_DIR:=/www/cgi-bin/cap @@ -33,14 +33,14 @@ define Build/Compile $(TARGET_CXX) $(TARGET_CXXFLAGS) $(TARGET_CPPFLAGS) -std=gnu++17 -I$(PKG_BUILD_DIR)/src \ -o $(PKG_BUILD_DIR)/bin/api $(PKG_BUILD_DIR)/src/api.cpp \ - $(TARGET_LDFLAGS) -lcrypt + $(TARGET_LDFLAGS) -lcrypt -lssl -lcrypto $(TARGET_CXX) $(TARGET_CXXFLAGS) $(TARGET_CPPFLAGS) -std=gnu++17 -I$(PKG_BUILD_DIR)/src \ -o $(PKG_BUILD_DIR)/bin/app $(PKG_BUILD_DIR)/src/app.cpp \ $(TARGET_LDFLAGS) -lcrypt -lssl -lcrypto endef -define Package/capos-webpanel/install +define Package/capos-webdesktop/install $(INSTALL_DIR) $(1)$(CAP_CGI_DIR) $(INSTALL_BIN) $(PKG_BUILD_DIR)/bin/api $(1)$(CAP_CGI_DIR)/ $(INSTALL_BIN) $(PKG_BUILD_DIR)/bin/app $(1)$(CAP_CGI_DIR)/ @@ -49,7 +49,7 @@ define Package/capos-webpanel/install $(CP) -a ./htdocs/* $(1)$(CAP_WWW_DIR)/ $(INSTALL_DIR) $(1)/etc/uci-defaults - $(INSTALL_BIN) ./files/90-capos-webpanel-uhttpd $(1)/etc/uci-defaults/90-capos-webpanel-uhttpd + $(INSTALL_BIN) ./files/90-capos-webdesktop-uhttpd $(1)/etc/uci-defaults/90-capos-webdesktop-uhttpd endef -$(eval $(call BuildPackage,capos-webpanel)) +$(eval $(call BuildPackage,capos-webdesktop)) diff --git a/package/capos/capos-webpanel/files/90-capos-webpanel-uhttpd b/package/capos/capos-webdesktop/files/90-capos-webdesktop-uhttpd similarity index 100% rename from package/capos/capos-webpanel/files/90-capos-webpanel-uhttpd rename to package/capos/capos-webdesktop/files/90-capos-webdesktop-uhttpd diff --git a/package/capos/capos-webdesktop/htdocs/assets/app.js b/package/capos/capos-webdesktop/htdocs/assets/app.js new file mode 100644 index 00000000000..a1160ec1eb7 --- /dev/null +++ b/package/capos/capos-webdesktop/htdocs/assets/app.js @@ -0,0 +1,62 @@ +const API="/cgi-bin/cap/api"; +const STORE_URL="https://snap.capos.top/embed"; +const STORE_ORIGIN=new URL(STORE_URL).origin; +const $=(id)=>document.getElementById(id); +const state={session:null,installed:[],endpoints:new Map(),selected:null,installing:new Map()}; + +function esc(v){return String(v??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));} +function toast(message,error=false){const el=document.createElement("div");el.className=`toast${error?" error":""}`;el.textContent=message;$("toasts").append(el);setTimeout(()=>el.remove(),4200);} +async function request(path,options={}){const r=await fetch(API+path,{credentials:"same-origin",...options});let data;try{data=await r.json();}catch{throw new Error(`HTTP ${r.status}`)}if(!r.ok||data?.type==="error"||data?.ok===false)throw new Error(data?.message||data?.result?.message||`HTTP ${r.status}`);return data;} +function snapResult(data){return data?.result??[];} +function setDrawer(open){$("drawer").classList.toggle("open",open);} +function setTab(tab){document.querySelectorAll(".tab[data-tab]").forEach(b=>b.classList.toggle("active",b.dataset.tab===tab));$("installedPane").classList.toggle("active",tab==="installed");} + +async function loadSession(){const data=await request("/session");if(!data.authenticated)return false;state.session=data.session;$("userBadge").textContent=`${state.session.username}${state.session.is_sudo?" · sudo":""}`;$("luciLink").style.display=state.session.is_sudo?"inline-flex":"none";return true;} +function showApp(){$("loginView").classList.add("hidden");$("appShell").classList.add("ready");} +function showLogin(){$("loginView").classList.remove("hidden");$("appShell").classList.remove("ready");} + +async function login(e){e.preventDefault();try{await request("/login",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({username:$("username").value,password:$("password").value})});$("password").value="";await boot();}catch(e){toast(e.message,true)}} +async function logout(){try{await request("/logout",{method:"POST"})}finally{state.session=null;showLogin();}} + +async function endpointFor(name,force=false){if(!force&&state.endpoints.has(name))return state.endpoints.get(name);try{const data=await request(`/snapd/snaps/${encodeURIComponent(name)}/endpoints`);state.endpoints.set(name,data.endpoints||[]);return data.endpoints||[];}catch{state.endpoints.set(name,[]);return[]}} +async function hydrateEndpoints(){const candidates=state.installed.slice(0,20);await Promise.all(candidates.map(s=>endpointFor(s.name)));renderInstalled();} +function primaryEndpoint(name){return (state.endpoints.get(name)||[]).find(e=>e.primary)||null;} + +function installedCard(snap){const ep=primaryEndpoint(snap.name);const opened=state.selected===snap.name;const summary=snap.summary||snap.description||"已安装 Snap";const channel=snap["tracking-channel"]||snap.channel||"";const management=state.session?.is_sudo?``:"";return `

${esc(snap.name)}

${esc(summary)}

${esc(snap.version||"")}
${esc(channel||"installed")}${ep?`${esc(ep.protocol)}:${ep.port} · 自动发现`:`未发现 Web 入口`}
${ep?``:``}${management}
`} +function renderInstalled(){const q=$("installedSearch").value.trim().toLowerCase();const items=state.installed.filter(s=>!q||`${s.name} ${s.summary||""} ${s.version||""}`.toLowerCase().includes(q));$("installedCount").textContent=`${state.installed.length} 个`;$("installedList").innerHTML=items.length?items.map(installedCard).join(""):`
没有匹配的已安装 Snap。
`;} +async function loadInstalled(){const data=await request("/snapd/snaps");state.installed=Array.isArray(snapResult(data))?snapResult(data):[];renderInstalled();postStoreState();void hydrateEndpoints();} + +function changeProgress(result){if(result?.ready||result?.status==="Done")return 100;const tasks=Array.isArray(result?.tasks)?result.tasks:[];if(!tasks.length)return 3;let completed=0;let partial=0;for(const task of tasks){if(task?.status==="Done"){completed+=1;continue}const done=Number(task?.progress?.done);const total=Number(task?.progress?.total);if(Number.isFinite(done)&&Number.isFinite(total)&&total>0)partial+=Math.max(0,Math.min(1,done/total));else if(task?.status==="Doing")partial+=0.15;}return Math.max(2,Math.min(99,Math.round((completed+partial)/tasks.length*100)));} +async function waitChange(data,label,onProgress){const id=data?.change;if(!id){onProgress?.(100);toast(`${label}请求已提交`);return}toast(`${label}已提交 · change ${id}`);onProgress?.(2);for(let i=0;i<180;i++){await new Promise(r=>setTimeout(r,1000));const c=await request(`/snapd/changes/${encodeURIComponent(id)}`);const result=c.result||{};onProgress?.(changeProgress(result));if(result.ready||result.status==="Done"||result.status==="Error"){if(result.status==="Error")throw new Error(result.err||`${label}失败`);onProgress?.(100);toast(`${label}完成`);return}}throw new Error(`${label}仍在进行,请稍后刷新`)} +async function snapAction(name,action,label,body="",onProgress){const data=await request(`/snapd/snaps/${encodeURIComponent(name)}/${action}`,{method:"POST",headers:body?{"Content-Type":"application/x-www-form-urlencoded"}:{},body});await waitChange(data,label,onProgress);state.endpoints.delete(name);await loadInstalled();} +async function serviceAction(name,action){const data=await request(`/snapd/snaps/${encodeURIComponent(name)}/service/${action}`,{method:"POST"});await waitChange(data,`${action} ${name}`);state.endpoints.delete(name);setTimeout(async()=>{await endpointFor(name,true);renderInstalled()},800)} + +async function openSnap(name){let ep=primaryEndpoint(name);if(!ep){await endpointFor(name,true);ep=primaryEndpoint(name)}if(!ep){toast("没有检测到 HTTP/HTTPS Web 入口",true);return}state.selected=name;$("activeApp").textContent=`${name} · ${ep.protocol}:${ep.port}`;$("welcome").classList.add("hidden");$("desktopFrame").classList.add("active");$("desktopFrame").src=`/cgi-bin/cap/app/${encodeURIComponent(name)}/`;setDrawer(false);renderInstalled();} +function openStore(){state.selected="@store";$("activeApp").textContent="CapOS App Store";$("welcome").classList.add("hidden");const frame=$("desktopFrame");frame.classList.add("active");frame.src=STORE_URL;setDrawer(false);} +function ask(title,text){return new Promise(resolve=>{$("confirmTitle").textContent=title;$("confirmText").textContent=text;const d=$("confirmDialog");const handler=()=>{d.removeEventListener("close",handler);resolve(d.returnValue==="ok")};d.addEventListener("close",handler);d.showModal()})} + +function storeFrame(){return state.selected==="@store"?$("desktopFrame"):null;} +function postStore(message){const frame=storeFrame();if(frame?.contentWindow)frame.contentWindow.postMessage(message,STORE_ORIGIN);} +function postStoreState(){postStore({type:"capos-webdesktop:state",version:1,installed:state.installed.map(s=>s.name),installing:Object.fromEntries(state.installing),canInstall:state.session?.is_sudo===true});} +function validSnapName(name){return typeof name==="string"&&name.length<=64&&/^(?=.*[a-z])[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name);} +async function installFromStore(name,channel){if(!state.session?.is_sudo)throw new Error("安装 Snap 需要 sudo 权限");if(state.installed.some(s=>s.name===name)){postStoreState();return}if(state.installing.has(name))return;state.installing.set(name,1);postStore({type:"capos-webdesktop:progress",version:1,name,progress:1});try{const body=new URLSearchParams({channel:channel||"stable"}).toString();await snapAction(name,"install",`安装 ${name}`,body,progress=>{state.installing.set(name,progress);postStore({type:"capos-webdesktop:progress",version:1,name,progress});});state.installing.delete(name);postStoreState();}catch(error){state.installing.delete(name);postStoreState();const message=error instanceof Error?error.message:String(error);postStore({type:"capos-webdesktop:error",version:1,name,message});toast(message,true);}} +function onStoreMessage(event){const frame=storeFrame();if(!frame||event.origin!==STORE_ORIGIN||event.source!==frame.contentWindow||!event.data||typeof event.data!=="object")return;const message=event.data;if(message.type==="capos-store:hello"){postStoreState();return}if(message.type==="capos-store:install"){if(!validSnapName(message.name)){postStore({type:"capos-webdesktop:error",version:1,message:"无效的 Snap 名称"});return}void installFromStore(message.name,typeof message.channel==="string"?message.channel:"stable");return}if(message.type==="capos-store:open"){if(!validSnapName(message.name)||!state.installed.some(s=>s.name===message.name)){postStore({type:"capos-webdesktop:error",version:1,name:message.name,message:"该 Snap 尚未安装"});return}void openSnap(message.name);}} + +async function onInstalledClick(e){const b=e.target.closest("button[data-act]");if(!b)return;const {act,name}=b.dataset;try{if(act==="open")return openSnap(name);if(act==="detect"){await endpointFor(name,true);renderInstalled();return}if(act==="start"||act==="stop")return serviceAction(name,act);if(act==="refresh")return snapAction(name,"refresh",`更新 ${name}`);if(act==="remove"){if(await ask("卸载 Snap",`确认卸载 ${name}?`))await snapAction(name,"remove",`卸载 ${name}`)}}catch(e){toast(e.message,true)}} + +async function boot(){try{if(!await loadSession()){showLogin();return}showApp();await loadInstalled();}catch(e){showLogin();toast(e.message,true)}} + +document.addEventListener("DOMContentLoaded",()=>{ + $("loginForm").addEventListener("submit",login); + $("logoutBtn").addEventListener("click",logout); + $("toggleDrawerBtn").addEventListener("click",()=>setDrawer(!$("drawer").classList.contains("open"))); + $("closeDrawerBtn").addEventListener("click",()=>setDrawer(false)); + $("welcomeAppsBtn").addEventListener("click",()=>setDrawer(true)); + $("refreshBtn").addEventListener("click",loadInstalled); + $("installedSearch").addEventListener("input",renderInstalled); + $("installedList").addEventListener("click",onInstalledClick); + $("storeTab").addEventListener("click",openStore); + document.querySelectorAll(".tab[data-tab]").forEach(b=>b.addEventListener("click",()=>setTab(b.dataset.tab))); + window.addEventListener("message",onStoreMessage); + boot(); +}); diff --git a/package/capos/capos-webdesktop/htdocs/assets/styles.css b/package/capos/capos-webdesktop/htdocs/assets/styles.css new file mode 100644 index 00000000000..272cc00e706 --- /dev/null +++ b/package/capos/capos-webdesktop/htdocs/assets/styles.css @@ -0,0 +1 @@ +:root{color-scheme:light;--bg:#e8edf1;--panel:rgba(255,255,255,.95);--ink:#18222c;--muted:#647483;--line:#d5dde4;--accent:#226b91;--accent2:#164e70;--danger:#ae3e38;--ok:#287653;--shadow:0 18px 48px rgba(20,35,48,.16)}*{box-sizing:border-box}html,body{margin:0;height:100%;overflow:hidden;font-family:"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif;color:var(--ink);background:var(--bg)}button,input,a{font:inherit}button,.link-button{border:0;border-radius:9px;padding:8px 12px;cursor:pointer;text-decoration:none;white-space:nowrap}.primary{background:var(--accent);color:#fff}.primary:hover{background:var(--accent2)}.secondary{background:#fff;color:var(--ink);border:1px solid var(--line)}.danger{background:rgba(174,62,56,.1);color:var(--danger)}button:disabled{opacity:.45;cursor:not-allowed}.wide{width:100%}.login{height:100%;display:grid;place-items:center;background:linear-gradient(145deg,#f8fafb,#dfe7ed);padding:20px}.login.hidden{display:none}.login-card{width:min(420px,100%);display:grid;gap:15px;padding:26px;background:var(--panel);border:1px solid rgba(255,255,255,.75);border-radius:18px;box-shadow:var(--shadow)}.login-card h1,.login-card p{margin:0}.login-card p{color:var(--muted);margin-top:5px}.login-card label{display:grid;gap:6px;color:var(--muted);font-size:12px;font-weight:700}.login-card input,.pane-tools input,.store-search input{min-width:0;width:100%;border:1px solid var(--line);border-radius:9px;padding:10px 11px;background:#fff;color:var(--ink)}.brand-mark{width:max-content;padding:7px 10px;border:1px solid var(--line);border-radius:8px;color:var(--accent);font-weight:800;background:#fff}.shell{display:none;height:100%;position:relative}.shell.ready{display:block}.topbar{position:absolute;z-index:30;top:12px;left:12px;right:12px;height:62px;padding:9px 12px;display:flex;align-items:center;justify-content:space-between;gap:14px;background:var(--panel);border:1px solid var(--line);border-radius:13px;box-shadow:var(--shadow);backdrop-filter:blur(12px)}.identity{display:grid;min-width:0}.identity strong{font-size:14px}.identity span{font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.top-actions{display:flex;gap:7px;align-items:center}.pill{padding:7px 10px;border-radius:999px;background:rgba(34,107,145,.1);color:var(--accent);font-size:12px;font-weight:700}.desktop{position:absolute;inset:86px 0 0;background:#f6f8f9}.desktop-frame{display:none;width:100%;height:100%;border:0;background:#fff}.desktop-frame.active{display:block}.desktop-welcome{height:100%;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 40% 30%,rgba(34,107,145,.11),transparent 42%),linear-gradient(160deg,#f8fafb,#e6ecef)}.desktop-welcome.hidden{display:none}.welcome-card{max-width:620px;background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:28px;box-shadow:var(--shadow)}.welcome-card h2{font-size:30px;margin:8px 0}.welcome-card p{color:var(--muted);line-height:1.6;margin:0 0 18px}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-size:11px;color:var(--accent);font-weight:800}.desktop-status{display:none;position:absolute;left:14px;bottom:14px;z-index:12;max-width:560px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);box-shadow:var(--shadow);font-size:12px;color:var(--muted)}.desktop-status.visible{display:block}.drawer{position:absolute;z-index:25;top:86px;right:12px;bottom:12px;width:min(560px,calc(100vw - 24px));display:grid;grid-template-rows:auto auto 1fr;gap:10px;padding:14px;background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);transform:translateX(calc(100% + 20px));transition:transform .18s ease}.drawer.open{transform:translateX(0)}.drawer-head{display:flex;justify-content:space-between;gap:12px}.drawer-head h2,.drawer-head p{margin:0}.drawer-head h2{font-size:18px}.drawer-head p{font-size:12px;color:var(--muted);margin-top:3px}.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);padding-bottom:8px}.tab{background:transparent;color:var(--muted)}.tab.active{background:rgba(34,107,145,.1);color:var(--accent);font-weight:700}.tab-pane{display:none;min-height:0;overflow:auto}.tab-pane.active{display:grid;grid-template-rows:auto 1fr;gap:10px}.pane-tools,.store-search{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;align-items:center}.pane-tools span,.store-hint{font-size:12px;color:var(--muted)}.cards{display:grid;gap:9px;align-content:start}.app-card{display:grid;gap:9px;padding:12px;background:#fff;border:1px solid var(--line);border-radius:11px}.app-card.opened{box-shadow:inset 3px 0 var(--accent)}.app-card-head{display:flex;justify-content:space-between;gap:10px}.app-card h3,.app-card p{margin:0}.app-card h3{font-size:15px}.app-card p{font-size:12px;color:var(--muted);line-height:1.45;margin-top:3px}.meta{display:flex;gap:6px;flex-wrap:wrap}.chip{font-size:11px;padding:4px 7px;border-radius:999px;border:1px solid var(--line);color:var(--muted);background:#fafcfd}.chip.web{color:var(--ok);border-color:rgba(40,118,83,.25)}.actions{display:flex;gap:6px;flex-wrap:wrap}.actions button{font-size:12px;padding:6px 9px}.empty{border:1px dashed var(--line);border-radius:10px;padding:16px;color:var(--muted);font-size:13px}.store-hint{padding:3px 0 7px}.publisher{font-size:11px;color:var(--muted)}dialog{border:0;border-radius:14px;padding:0;box-shadow:var(--shadow)}dialog::backdrop{background:rgba(17,29,39,.3)}.dialog-card{width:min(420px,calc(100vw - 30px));padding:18px;display:grid;gap:10px}.dialog-card h3,.dialog-card p{margin:0}.dialog-card p{color:var(--muted);line-height:1.5}.dialog-actions{display:flex;justify-content:flex-end;gap:8px}.toasts{position:fixed;z-index:80;right:16px;bottom:16px;display:grid;gap:8px}.toast{max-width:360px;padding:10px 12px;border-radius:10px;background:#17232d;color:#fff;box-shadow:var(--shadow);font-size:12px}.toast.error{background:#7b2925}@media(max-width:760px){.topbar{height:auto;align-items:flex-start}.top-actions{flex-wrap:wrap;justify-content:flex-end}.desktop{inset:112px 0 0}.drawer{top:112px}.identity span{max-width:180px}.welcome-card h2{font-size:24px}} \ No newline at end of file diff --git a/package/capos/capos-webdesktop/htdocs/index.html b/package/capos/capos-webdesktop/htdocs/index.html new file mode 100644 index 00000000000..0fa3ea8655c --- /dev/null +++ b/package/capos/capos-webdesktop/htdocs/index.html @@ -0,0 +1,70 @@ + + + + + + CapOS WebDesktop + + + + + +
+
+
CapOS WebDesktop桌面就绪
+
+ 访客 + LuCI + + + +
+
+ +
+
+
+ Official Snap ecosystem +

选择一个 Web 应用

+

CapOS 会从 Snap 的实际运行进程自动发现监听端口并检测 HTTP/HTTPS 入口,不需要单独维护应用适配表。

+ +
+
+ +
+
+ + +
+ + +
+

确认操作

+
+
+
+
+ + + diff --git a/package/capos/capos-webdesktop/src/api.cpp b/package/capos/capos-webdesktop/src/api.cpp new file mode 100644 index 00000000000..0daa0a64044 --- /dev/null +++ b/package/capos/capos-webdesktop/src/api.cpp @@ -0,0 +1,253 @@ +#include "common.hpp" +#include "snap.hpp" + +#include + +using namespace capos; + +namespace { + +std::string effectivePathInfo() { + auto path = getenvOrEmpty("PATH_INFO"); + if (!path.empty()) return path; + auto uri = getenvOrEmpty("REQUEST_URI"); + const auto question = uri.find('?'); + if (question != std::string::npos) uri.resize(question); + const std::string prefix = "/cgi-bin/cap/api"; + if (uri.rfind(prefix, 0) == 0) return uri.substr(prefix.size()); + return ""; +} + +std::string sessionJson(const Session& session) { + std::ostringstream out; + out << "{\"authenticated\":true" + << ",\"username\":\"" << jsonEscape(session.username) << "\"" + << ",\"uid\":" << static_cast(session.uid) + << ",\"is_sudo\":" << (session.is_sudo ? "true" : "false") << '}'; + return out.str(); +} + +std::optional requireSession() { + const auto session = currentSession(); + if (!session.has_value()) { + sendJson(401, jsonError("authentication required", "UNAUTHENTICATED")); + return std::nullopt; + } + return session; +} + +void handleRoot() { + sendJson(200, "{\"ok\":true,\"service\":\"capos-webdesktop-api\",\"app_backend\":\"snapd\"}"); +} + +void handleLogin() { + if (getenvOrEmpty("REQUEST_METHOD") != "POST") { + sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); + return; + } + const auto form = parseKv(readRequestBody()); + const auto username = form.find("username"); + const auto password = form.find("password"); + if (username == form.end() || password == form.end()) { + sendJson(400, jsonError("username and password are required", "INVALID_REQUEST")); + return; + } + + uid_t uid = 0; + std::string error; + if (!verifyPassword(username->second, password->second, uid, error)) { + sendJson(401, jsonError(error, "INVALID_CREDENTIALS")); + return; + } + + Session session; + session.id = randomHex(24); + session.username = username->second; + session.uid = uid; + session.is_sudo = userIsSudo(session.username); + session.created_at = std::time(nullptr); + session.expires_at = session.created_at + kSessionTtl; + if (!saveSession(session)) { + sendJson(500, jsonError("failed to persist session")); + return; + } + cleanupExpiredSessions(); + sendJson(200, "{\"ok\":true,\"session\":" + sessionJson(session) + "}", + {{"Set-Cookie", sessionCookieHeader(session.id, kSessionTtl)}}); +} + +void handleLogout() { + if (const auto session = currentSession(); session.has_value()) deleteSession(session->id); + sendJson(200, "{\"ok\":true}", {{"Set-Cookie", sessionCookieHeader("", 0)}}); +} + +void handleSessionInfo() { + const auto session = currentSession(); + if (!session.has_value()) { + sendJson(200, "{\"ok\":true,\"authenticated\":false}"); + return; + } + sendJson(200, "{\"ok\":true,\"authenticated\":true,\"session\":" + sessionJson(*session) + "}"); +} + +void sendSnapdResponse(const SnapdHttpResponse& response) { + if (response.body.empty()) { + sendJson(response.status, jsonError("empty response from snapd", "SNAPD_PROTOCOL")); + return; + } + sendJson(response.status, response.body); +} + +void handleSystemInfo() { + sendSnapdResponse(snapdRequest("GET", "/v2/system-info")); +} + +void handleSnapFind(const std::map& query) { + const auto it = query.find("q"); + if (it == query.end() || trim(it->second).empty()) { + sendJson(400, jsonError("q is required", "INVALID_REQUEST")); + return; + } + sendSnapdResponse(snapdRequest("GET", "/v2/find?q=" + percentEncode(trim(it->second)))); +} + +void handleSnapList() { + sendSnapdResponse(snapdRequest("GET", "/v2/snaps")); +} + +void handleSnapApps(const std::map& query) { + std::string path = "/v2/apps"; + std::vector params; + if (const auto it = query.find("names"); it != query.end() && !it->second.empty()) + params.push_back("names=" + percentEncode(it->second)); + if (const auto it = query.find("select"); it != query.end() && !it->second.empty()) + params.push_back("select=" + percentEncode(it->second)); + if (!params.empty()) { + path += '?'; + for (size_t i = 0; i < params.size(); ++i) { + if (i) path += '&'; + path += params[i]; + } + } + sendSnapdResponse(snapdRequest("GET", path)); +} + +void handleSnapAction(const Session& session, const std::string& snapName, const std::string& action) { + if (getenvOrEmpty("REQUEST_METHOD") != "POST") { + sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); + return; + } + if (!session.is_sudo) { + sendJson(403, jsonError("Snap installation and updates require sudo access", "SUDO_REQUIRED")); + return; + } + if (!validSnapName(snapName)) { + sendJson(400, jsonError("invalid snap name", "INVALID_SNAP_NAME")); + return; + } + static const std::set allowed = {"install", "remove", "refresh", "revert", "enable", "disable"}; + if (!allowed.count(action)) { + sendJson(400, jsonError("unsupported snap action", "INVALID_ACTION")); + return; + } + + std::string payload = "{\"action\":\"" + jsonEscape(action) + "\""; + if (action == "install") { + const auto form = parseKv(readRequestBody()); + if (const auto channel = form.find("channel"); channel != form.end() && !channel->second.empty()) + payload += ",\"channel\":\"" + jsonEscape(channel->second) + "\""; + } + payload += '}'; + sendSnapdResponse(snapdRequest("POST", "/v2/snaps/" + percentEncode(snapName), payload)); +} + +void handleSnapServiceAction(const Session& session, const std::string& snapName, const std::string& action) { + if (getenvOrEmpty("REQUEST_METHOD") != "POST") { + sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); + return; + } + if (!session.is_sudo) { + sendJson(403, jsonError("Service control requires sudo access", "SUDO_REQUIRED")); + return; + } + if (!validSnapName(snapName)) { + sendJson(400, jsonError("invalid snap name", "INVALID_SNAP_NAME")); + return; + } + static const std::set allowed = {"start", "stop", "restart"}; + if (!allowed.count(action)) { + sendJson(400, jsonError("unsupported service action", "INVALID_ACTION")); + return; + } + const auto payload = "{\"action\":\"" + jsonEscape(action) + + "\",\"names\":[\"" + jsonEscape(snapName) + "\"],\"scope\":[\"system\"]}"; + sendSnapdResponse(snapdRequest("POST", "/v2/apps", payload)); +} + +void handleSnapChange(const std::string& changeId) { + if (changeId.empty() || !std::all_of(changeId.begin(), changeId.end(), [](unsigned char ch) { return std::isdigit(ch); })) { + sendJson(400, jsonError("invalid change id", "INVALID_CHANGE_ID")); + return; + } + sendSnapdResponse(snapdRequest("GET", "/v2/changes/" + changeId)); +} + +void handleSnapEndpoints(const std::string& snapName) { + if (!validSnapName(snapName)) { + sendJson(400, jsonError("invalid snap name", "INVALID_SNAP_NAME")); + return; + } + sendJson(200, endpointsJson(snapName, discoverSnapEndpoints(snapName))); +} + +} // namespace + +int main() { + cleanupExpiredSessions(); + const auto path = effectivePathInfo(); + const auto segments = splitPath(path); + const auto query = parseKv(getenvOrEmpty("QUERY_STRING")); + + if (segments.empty()) { + handleRoot(); + return 0; + } + if (segments[0] == "login") { + handleLogin(); + return 0; + } + if (segments[0] == "logout") { + handleLogout(); + return 0; + } + if (segments[0] == "session") { + handleSessionInfo(); + return 0; + } + + const auto session = requireSession(); + if (!session.has_value()) return 0; + + if (segments[0] == "me") { + sendJson(200, "{\"ok\":true,\"session\":" + sessionJson(*session) + "}"); + return 0; + } + if (segments[0] == "system") { + handleSystemInfo(); + return 0; + } + if (segments[0] == "snapd") { + if (segments.size() == 2 && segments[1] == "find") handleSnapFind(query); + else if (segments.size() == 2 && segments[1] == "snaps") handleSnapList(); + else if (segments.size() == 2 && segments[1] == "apps") handleSnapApps(query); + else if (segments.size() == 3 && segments[1] == "changes") handleSnapChange(segments[2]); + else if (segments.size() == 4 && segments[1] == "snaps" && segments[3] == "endpoints") handleSnapEndpoints(segments[2]); + else if (segments.size() == 4 && segments[1] == "snaps") handleSnapAction(*session, segments[2], segments[3]); + else if (segments.size() == 5 && segments[1] == "snaps" && segments[3] == "service") handleSnapServiceAction(*session, segments[2], segments[4]); + else sendJson(404, jsonError("not found", "NOT_FOUND")); + return 0; + } + + sendJson(404, jsonError("not found", "NOT_FOUND")); + return 0; +} diff --git a/package/capos/capos-webpanel/src/app.cpp b/package/capos/capos-webdesktop/src/app.cpp similarity index 84% rename from package/capos/capos-webpanel/src/app.cpp rename to package/capos/capos-webdesktop/src/app.cpp index 68376f93ed5..6f57024d361 100644 --- a/package/capos/capos-webpanel/src/app.cpp +++ b/package/capos/capos-webdesktop/src/app.cpp @@ -1,4 +1,5 @@ #include "common.hpp" +#include "snap.hpp" #include #include @@ -14,6 +15,8 @@ extern char** environ; namespace { +std::string gProxyPrefix = "/cgi-bin/cap/app"; + struct UpstreamResponse { int status = 502; std::string reason = "Bad Gateway"; @@ -91,7 +94,7 @@ std::string effectivePathInfo() { } std::string proxyPrefix() { - return "/cgi-bin/cap/app"; + return gProxyPrefix; } std::string lowerAscii(std::string value) { @@ -204,22 +207,6 @@ std::string urlEncode(const std::string& input) { return out.str(); } -std::string joinQuery(const std::map& params, const std::vector& exclude = {}) { - std::ostringstream out; - bool first = true; - for (const auto& [key, value] : params) { - if (std::find(exclude.begin(), exclude.end(), key) != exclude.end()) { - continue; - } - if (!first) { - out << '&'; - } - first = false; - out << urlEncode(key) << '=' << urlEncode(value); - } - return out.str(); -} - std::string htmlShell(const std::string& title, const std::string& body) { std::ostringstream out; out << "" @@ -252,31 +239,6 @@ std::string renderEmpty(const std::string& heading, const std::string& message, ); } -std::optional findMappedListenPort(const std::string& json, long long targetPort) { - const std::regex pattern("\\{\\s*\"proto\"\\s*:\\s*\"([^\"]+)\"\\s*,\\s*\"listen\"\\s*:\\s*([0-9]+)\\s*,\\s*\"target_port\"\\s*:\\s*([0-9]+)\\s*\\}"); - for (std::sregex_iterator it(json.begin(), json.end(), pattern), end; it != end; ++it) { - const auto proto = (*it)[1].str(); - const auto listen = std::stoi((*it)[2].str()); - const auto target = std::stoll((*it)[3].str()); - if (proto == "tcp" && target == targetPort) { - return listen; - } - } - return std::nullopt; -} - -std::string replaceAll(std::string text, const std::string& from, const std::string& to) { - if (from.empty()) { - return text; - } - size_t pos = 0; - while ((pos = text.find(from, pos)) != std::string::npos) { - text.replace(pos, from.size(), to); - pos += to.size(); - } - return text; -} - std::string rewriteHtmlBody(std::string body) { const auto prefix = proxyPrefix(); const std::vector markers = { @@ -868,12 +830,12 @@ std::string renderStatusPage(const std::string& selectedApp, const std::string& << "
" << "
应用名" << htmlEscape(selectedApp) << "
" << "
版本" << htmlEscape(version) << "
" - << "
桌面端口" << (desktopPort.has_value() ? std::to_string(*desktopPort) : std::string("-")) << "
" - << "
访问方式" << (hostNetwork ? "Host Network" : "Container Proxy") << "
" + << "
自动发现端口" << (desktopPort.has_value() ? std::to_string(*desktopPort) : std::string("-")) << "
" + << "
访问方式" << (hostNetwork ? "Host Socket" : "Host Socket") << "
" << "
目标" << htmlEscape(target) << "
" << "
" << "
" - << "" + << "" << "刷新" << "返回面板" << "
" @@ -889,64 +851,63 @@ int main() { const auto session = currentSession(); if (!session.has_value()) { - sendHtml(401, renderEmpty("需要登录", "请先返回 CapOS 面板完成登录,然后再查看桌面应用。")); + sendHtml(401, renderEmpty("需要登录", "请先返回 CapOS WebDesktop 完成登录。")); return 0; } const auto rawQuery = getenvOrEmpty("QUERY_STRING"); const auto query = parseKv(rawQuery); + auto rawPath = effectivePathInfo(); + const auto pathSegments = splitPath(rawPath); + std::string selectedApp; - if (const auto it = query.find("app"); it != query.end()) { + size_t appPathOffset = 0; + if (!pathSegments.empty() && validSnapName(pathSegments[0])) { + selectedApp = pathSegments[0]; + const auto marker = "/" + selectedApp; + if (rawPath.rfind(marker, 0) == 0) appPathOffset = marker.size(); + } else if (const auto it = query.find("snap"); it != query.end() && validSnapName(it->second)) { selectedApp = it->second; } if (selectedApp.empty()) { - const auto desktop = runCapbox({"desktop", "get", "--user", session->username}); - if (desktop.exit_code != 0) { - sendHtml(500, renderEmpty("桌面应用加载失败", trim(desktop.output))); - return 0; - } - if (const auto app = findJsonString(desktop.output, "app"); app.has_value()) { - selectedApp = *app; - } - } - - if (selectedApp.empty()) { - sendHtml(200, renderEmpty("还没有桌面应用", "先在面板里从你已安装的应用中选择一个桌面应用,桌面区就会在这里显示。")); + sendHtml(400, renderEmpty("未选择应用", "请从 WebDesktop 的已安装 Snap 列表中打开应用。")); return 0; } - const auto info = runCapbox({"app", "info", selectedApp, "--user", session->username}); - if (info.exit_code != 0) { - sendHtml(404, renderEmpty("应用不存在", trim(info.output))); + gProxyPrefix = "/cgi-bin/cap/app/" + selectedApp; + auto endpoint = primarySnapWebEndpoint(selectedApp); + if (!endpoint.has_value()) { + const auto emptyInfo = std::string("{\"nickname\":\"") + jsonEscape(selectedApp) + + "\",\"description\":\"CapOS 尚未从该 Snap 的运行进程中发现 HTTP/HTTPS 入口。\"," + "\"version\":\"-\",\"running\":false,\"host_network\":true}"; + sendHtml(200, renderStatusPage(selectedApp, emptyInfo, "应用可能尚未启动、没有 Web UI,或正在等待首次配置。")); return 0; } - const auto running = findJsonBool(info.output, "running").value_or(false); - const auto scheme = findJsonString(info.output, "scheme").value_or("http"); - const auto host = findJsonString(info.output, "target_host"); - const auto port = findJsonInt(info.output, "port"); - const auto httpsSkipCheck = findJsonBool(info.output, "https_skip_check").value_or(true); - - if (!running || !host.has_value() || !port.has_value() || (scheme != "http" && scheme != "https")) { - std::string why = "当前桌面应用还没有可代理的 HTTP/HTTPS 入口。"; - if (!running) { - why = "当前桌面应用还没有启动。"; - } else if (!port.has_value()) { - why = "当前桌面应用在元数据里没有声明 service 端口。"; - } else if (scheme != "http" && scheme != "https") { - why = "当前桌面应用声明了暂不支持的 service scheme。"; - } - sendHtml(200, renderStatusPage(selectedApp, info.output, why)); - return 0; - } + const std::string host = "127.0.0.1"; + int port = endpoint->port; + std::string scheme = endpoint->protocol; + const bool httpsSkipCheck = true; + const auto buildInfo = [&]() { + std::ostringstream value; + value << "{\"nickname\":\"" << jsonEscape(selectedApp) + << "\",\"description\":\"Web endpoint discovered automatically from Snap-owned listening sockets.\"" + << ",\"version\":\"-\",\"running\":true,\"host_network\":true" + << ",\"target_host\":\"127.0.0.1\",\"port\":" << port + << ",\"scheme\":\"" << jsonEscape(scheme) << "\",\"https_skip_check\":true}"; + return value.str(); + }; + std::string info = buildInfo(); - auto path = effectivePathInfo(); - if (path.empty() || path == "/") { - path = "/"; + std::string path = "/"; + if (appPathOffset > 0 && appPathOffset < rawPath.size()) { + path = rawPath.substr(appPathOffset); + if (path.empty()) path = "/"; } - const auto upstreamQuery = stripQueryParam(rawQuery, "app"); + auto upstreamQuery = stripQueryParam(rawQuery, "snap"); + upstreamQuery = stripQueryParam(upstreamQuery, "app"); std::string upstreamPath = path; if (!upstreamQuery.empty()) { upstreamPath += (upstreamPath.find('?') == std::string::npos ? "?" : "&"); @@ -954,64 +915,60 @@ int main() { } if (isWebSocketRequest()) { - proxyWebSocket(*session, selectedApp, info.output, *host, static_cast(*port), scheme, !httpsSkipCheck, upstreamPath); + proxyWebSocket(*session, selectedApp, info, host, port, scheme, !httpsSkipCheck, upstreamPath); return 0; } const auto requestBody = readRequestBody(); - const auto requestText = buildForwardRequest(*host, static_cast(*port), upstreamPath, requestBody, session->id); - auto response = fetchHttp(*host, static_cast(*port), scheme, !httpsSkipCheck, requestText); + auto requestText = buildForwardRequest(host, port, upstreamPath, requestBody, session->id); + auto response = fetchHttp(host, port, scheme, !httpsSkipCheck, requestText); + if (!response.has_value()) { + // A Snap may switch ports after refresh/restart. Expire the short-lived + // cache immediately and retry once using a fresh process/socket scan. + invalidateSnapEndpointCache(selectedApp); + const auto refreshed = discoverSnapEndpoints(selectedApp); + const auto replacement = std::find_if(refreshed.begin(), refreshed.end(), [](const SnapEndpoint& candidate) { + return candidate.web; + }); + if (replacement != refreshed.end()) { + port = replacement->port; + scheme = replacement->protocol; + info = buildInfo(); + requestText = buildForwardRequest(host, port, upstreamPath, requestBody, session->id); + response = fetchHttp(host, port, scheme, !httpsSkipCheck, requestText); + } + } if (!response.has_value()) { - sendHtml(502, renderStatusPage(selectedApp, info.output, "连接桌面应用失败,可能容器尚未完全启动,或目标服务没有监听声明的端口。")); + sendHtml(502, renderStatusPage(selectedApp, info, "自动发现了入口,但当前无法连接该服务。")); return 0; } std::string contentType; bool chunkedEncoding = false; for (const auto& [name, value] : response->headers) { - std::string lowered = name; - std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); - if (lowered == "content-type") { - contentType = value; - } else if (lowered == "transfer-encoding") { - std::string loweredValue = value; - std::transform(loweredValue.begin(), loweredValue.end(), loweredValue.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); - if (loweredValue.find("chunked") != std::string::npos) { - chunkedEncoding = true; - } - } + const auto lowered = lowerAscii(name); + if (lowered == "content-type") contentType = value; + else if (lowered == "transfer-encoding" && lowerAscii(value).find("chunked") != std::string::npos) chunkedEncoding = true; } - if (chunkedEncoding) { const auto decoded = decodeChunkedBody(response->body); if (!decoded.has_value()) { - sendHtml(502, renderStatusPage(selectedApp, info.output, "桌面应用返回了无法解析的 chunked 响应,代理暂时无法继续。")); + sendHtml(502, renderStatusPage(selectedApp, info, "应用返回了无法解析的 chunked 响应。")); return 0; } response->body = *decoded; } - - if (lowerAscii(contentType).find("text/html") != std::string::npos) { - response->body = rewriteHtmlBody(response->body); - } + if (lowerAscii(contentType).find("text/html") != std::string::npos) response->body = rewriteHtmlBody(response->body); std::vector> outHeaders; for (const auto& [name, value] : response->headers) { - std::string lowered = name; - lowered = lowerAscii(lowered); - if (isHopByHop(lowered) || lowered == "content-type") { - continue; - } - if (lowered == "location") { - outHeaders.emplace_back(name, rewriteResponseLocation(value, scheme, *host, static_cast(*port))); - } else if (lowered == "set-cookie") { - outHeaders.emplace_back(name, rewriteSetCookiePath(value)); - } else { - outHeaders.emplace_back(name, value); - } + const auto lowered = lowerAscii(name); + if (isHopByHop(lowered) || lowered == "content-type") continue; + if (lowered == "location") outHeaders.emplace_back(name, rewriteResponseLocation(value, scheme, host, port)); + else if (lowered == "set-cookie") outHeaders.emplace_back(name, rewriteSetCookiePath(value)); + else outHeaders.emplace_back(name, value); } outHeaders.emplace_back("Content-Length", std::to_string(response->body.size())); - writeHeaders(response->status, contentType.empty() ? "text/plain; charset=utf-8" : contentType, outHeaders); std::cout << response->body; return 0; diff --git a/package/capos/capos-webpanel/src/common.hpp b/package/capos/capos-webdesktop/src/common.hpp similarity index 94% rename from package/capos/capos-webpanel/src/common.hpp rename to package/capos/capos-webdesktop/src/common.hpp index 3b527a897de..87186f0b51f 100644 --- a/package/capos/capos-webpanel/src/common.hpp +++ b/package/capos/capos-webdesktop/src/common.hpp @@ -1,5 +1,5 @@ -#ifndef CAPOS_WEBPANEL_COMMON_HPP -#define CAPOS_WEBPANEL_COMMON_HPP +#ifndef CAPOS_WEBDESKTOP_COMMON_HPP +#define CAPOS_WEBDESKTOP_COMMON_HPP #include #include @@ -52,8 +52,7 @@ struct Session { }; inline constexpr const char* kSessionCookieName = "capos_session"; -inline constexpr const char* kSessionDir = "/tmp/capos-webpanel/sessions"; -inline constexpr const char* kUploadDir = "/tmp/capos-webpanel/uploads"; +inline constexpr const char* kSessionDir = "/tmp/capos-webdesktop/sessions"; inline constexpr std::time_t kSessionTtl = 60 * 60 * 12; inline std::string getenvOrEmpty(const char* key) { @@ -380,10 +379,6 @@ inline std::string sessionCookieHeader(const std::string& value, std::time_t max return header.str(); } -inline std::string uploadPathFor(const std::string& sessionId, const std::string& uploadId, const std::string& name) { - return std::string(kUploadDir) + "/" + sessionId + "/" + uploadId + "_" + name; -} - inline bool saveSession(const Session& session) { if (!isSafeSessionId(session.id)) { return false; @@ -541,32 +536,6 @@ inline std::string jsonOk(const std::string& dataJson) { return "{\"ok\":true,\"data\":" + dataJson + "}"; } -inline std::string sanitizeUploadFilename(std::string filename) { - for (char& ch : filename) { - const bool safe = - (ch >= 'a' && ch <= 'z') || - (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || - ch == '.' || ch == '_' || ch == '-'; - if (!safe) { - ch = '_'; - } - } - if (filename.empty()) { - filename = "upload.cpk"; - } - return filename; -} - -inline ExecResult runCapbox(const std::vector& args) { - std::ostringstream command; - command << "capbox"; - for (const auto& arg : args) { - command << ' ' << shellQuote(arg); - } - return execCommand(command.str()); -} - inline std::optional regexFirst(const std::string& text, const std::regex& pattern) { std::smatch match; if (std::regex_search(text, match, pattern) && match.size() > 1) { diff --git a/package/capos/capos-webdesktop/src/snap.hpp b/package/capos/capos-webdesktop/src/snap.hpp new file mode 100644 index 00000000000..3b9ecc22e5b --- /dev/null +++ b/package/capos/capos-webdesktop/src/snap.hpp @@ -0,0 +1,390 @@ +#ifndef CAPOS_WEBDESKTOP_SNAP_HPP +#define CAPOS_WEBDESKTOP_SNAP_HPP + +#include "common.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace capos { + +struct SnapdHttpResponse { + int status = 502; + std::string body; + std::map headers; +}; + +inline std::string lowerAsciiCopy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; +} + +inline std::string percentEncode(const std::string& input) { + std::ostringstream out; + for (unsigned char ch : input) { + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') { + out << static_cast(ch); + } else { + out << '%' << std::uppercase << std::hex << std::setw(2) << std::setfill('0') + << static_cast(ch) << std::nouppercase << std::dec << std::setfill(' '); + } + } + return out.str(); +} + +inline std::optional decodeChunkedPayload(const std::string& input) { + std::string out; + size_t pos = 0; + while (pos < input.size()) { + auto lineEnd = input.find("\r\n", pos); + size_t sep = 2; + if (lineEnd == std::string::npos) { + lineEnd = input.find('\n', pos); + sep = 1; + } + if (lineEnd == std::string::npos) return std::nullopt; + auto token = trim(input.substr(pos, lineEnd - pos)); + const auto semicolon = token.find(';'); + if (semicolon != std::string::npos) token.resize(semicolon); + char* end = nullptr; + const auto len = std::strtoull(token.c_str(), &end, 16); + if (end == nullptr || *end != '\0') return std::nullopt; + pos = lineEnd + sep; + if (len == 0) return out; + if (pos + len > input.size()) return std::nullopt; + out.append(input, pos, static_cast(len)); + pos += static_cast(len); + if (input.compare(pos, 2, "\r\n") == 0) pos += 2; + else if (input.compare(pos, 1, "\n") == 0) pos += 1; + else return std::nullopt; + } + return std::nullopt; +} + +inline SnapdHttpResponse snapdRequest(const std::string& method, const std::string& path, + const std::string& body = {}) { + SnapdHttpResponse response; + const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + response.body = jsonError("cannot create snapd socket", "SNAPD_UNAVAILABLE"); + return response; + } + + timeval timeout{}; + timeout.tv_sec = 8; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", "/run/snapd.socket"); + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { + ::close(fd); + response.body = jsonError("snapd is not running", "SNAPD_UNAVAILABLE"); + return response; + } + + std::ostringstream request; + request << method << ' ' << path << " HTTP/1.1\r\n" + << "Host: localhost\r\n" + << "Connection: close\r\n" + << "Accept: application/json\r\n"; + if (!body.empty()) { + request << "Content-Type: application/json\r\n" + << "Content-Length: " << body.size() << "\r\n"; + } + request << "\r\n" << body; + const auto wire = request.str(); + size_t sent = 0; + while (sent < wire.size()) { + const auto n = ::send(fd, wire.data() + sent, wire.size() - sent, MSG_NOSIGNAL); + if (n <= 0) { + ::close(fd); + response.body = jsonError("failed to send request to snapd", "SNAPD_IO"); + return response; + } + sent += static_cast(n); + } + + std::string raw; + std::array buffer{}; + while (true) { + const auto n = ::recv(fd, buffer.data(), buffer.size(), 0); + if (n == 0) break; + if (n < 0) { + if (errno == EINTR) continue; + break; + } + raw.append(buffer.data(), static_cast(n)); + } + ::close(fd); + + const auto headerEnd = raw.find("\r\n\r\n"); + if (headerEnd == std::string::npos) { + response.body = jsonError("invalid response from snapd", "SNAPD_PROTOCOL"); + return response; + } + std::stringstream headers(raw.substr(0, headerEnd)); + std::string line; + if (std::getline(headers, line)) { + std::smatch match; + if (std::regex_search(line, match, std::regex(R"(^HTTP/\d+\.\d+\s+(\d+))"))) { + response.status = std::stoi(match[1].str()); + } + } + while (std::getline(headers, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + const auto colon = line.find(':'); + if (colon == std::string::npos) continue; + response.headers[lowerAsciiCopy(trim(line.substr(0, colon)))] = trim(line.substr(colon + 1)); + } + response.body = raw.substr(headerEnd + 4); + const auto transfer = response.headers.find("transfer-encoding"); + if (transfer != response.headers.end() && lowerAsciiCopy(transfer->second).find("chunked") != std::string::npos) { + if (auto decoded = decodeChunkedPayload(response.body); decoded.has_value()) response.body = *decoded; + } + return response; +} + +inline bool validSnapName(const std::string& name) { + if (name.empty() || name.size() > 64 || name.front() == '-' || name.back() == '-') return false; + bool hasLetter = false; + for (const auto ch : name) { + if (ch >= 'a' && ch <= 'z') hasLetter = true; + else if (!(ch >= '0' && ch <= '9') && ch != '-') return false; + } + return hasLetter; +} + +struct SnapEndpoint { + int port = 0; + std::string protocol = "tcp"; + bool web = false; + int score = 0; + std::string evidence; +}; + +inline std::optional snapNameForPid(const std::string& pid) { + const auto env = readFile("/proc/" + pid + "/environ"); + if (!env.has_value()) return std::nullopt; + size_t pos = 0; + while (pos < env->size()) { + const auto end = env->find('\0', pos); + const auto item = env->substr(pos, end == std::string::npos ? std::string::npos : end - pos); + for (const auto* key : {"SNAP_INSTANCE_NAME=", "SNAP_NAME="}) { + if (item.rfind(key, 0) == 0) { + const auto value = item.substr(std::strlen(key)); + if (validSnapName(value)) return value; + } + } + if (end == std::string::npos) break; + pos = end + 1; + } + return std::nullopt; +} + +inline std::set socketInodesForSnap(const std::string& snapName) { + std::set inodes; + DIR* proc = ::opendir("/proc"); + if (proc == nullptr) return inodes; + while (auto* entry = ::readdir(proc)) { + const std::string pid = entry->d_name; + if (pid.empty() || !std::all_of(pid.begin(), pid.end(), [](unsigned char ch) { return std::isdigit(ch); })) continue; + const auto owner = snapNameForPid(pid); + if (!owner.has_value() || *owner != snapName) continue; + const auto fdDirPath = "/proc/" + pid + "/fd"; + DIR* fds = ::opendir(fdDirPath.c_str()); + if (fds == nullptr) continue; + while (auto* fdEntry = ::readdir(fds)) { + if (fdEntry->d_name[0] == '.') continue; + std::array target{}; + const auto fdPath = fdDirPath + "/" + fdEntry->d_name; + const auto n = ::readlink(fdPath.c_str(), target.data(), target.size() - 1); + if (n <= 0) continue; + target[static_cast(n)] = '\0'; + std::string link(target.data()); + if (link.rfind("socket:[", 0) == 0 && link.back() == ']') { + inodes.insert(link.substr(8, link.size() - 9)); + } + } + ::closedir(fds); + } + ::closedir(proc); + return inodes; +} + +inline void collectListeningPorts(const std::string& tablePath, const std::set& inodes, std::set& ports) { + std::ifstream in(tablePath); + std::string line; + std::getline(in, line); + while (std::getline(in, line)) { + std::stringstream row(line); + std::vector fields; + std::string field; + while (row >> field) fields.push_back(field); + if (fields.size() < 10 || fields[3] != "0A" || inodes.find(fields[9]) == inodes.end()) continue; + const auto colon = fields[1].find(':'); + if (colon == std::string::npos) continue; + char* end = nullptr; + const auto value = std::strtol(fields[1].substr(colon + 1).c_str(), &end, 16); + if (end != nullptr && *end == '\0' && value > 0 && value <= 65535) ports.insert(static_cast(value)); + } +} + +inline std::optional probePlainHttp(int port) { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return std::nullopt; + timeval timeout{}; timeout.tv_sec = 1; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast(port)); addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { ::close(fd); return std::nullopt; } + const std::string req = "GET / HTTP/1.0\r\nHost: localhost\r\nAccept: text/html,*/*\r\nConnection: close\r\n\r\n"; + if (::send(fd, req.data(), req.size(), MSG_NOSIGNAL) <= 0) { ::close(fd); return std::nullopt; } + std::array buf{}; const auto n = ::recv(fd, buf.data(), buf.size(), 0); ::close(fd); + if (n <= 0) return std::nullopt; + std::string data(buf.data(), static_cast(n)); + if (data.rfind("HTTP/", 0) != 0) return std::nullopt; + return data; +} + +inline std::optional probeTlsHttp(int port) { + SSL_CTX* ctx = SSL_CTX_new(TLS_client_method()); + if (ctx == nullptr) return std::nullopt; + SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, nullptr); + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { SSL_CTX_free(ctx); return std::nullopt; } + timeval timeout{}; timeout.tv_sec = 1; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast(port)); addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) != 0) { ::close(fd); SSL_CTX_free(ctx); return std::nullopt; } + SSL* ssl = SSL_new(ctx); + SSL_set_fd(ssl, fd); SSL_set_tlsext_host_name(ssl, "localhost"); + if (SSL_connect(ssl) != 1) { SSL_free(ssl); ::close(fd); SSL_CTX_free(ctx); return std::nullopt; } + const std::string req = "GET / HTTP/1.0\r\nHost: localhost\r\nAccept: text/html,*/*\r\nConnection: close\r\n\r\n"; + if (SSL_write(ssl, req.data(), static_cast(req.size())) <= 0) { SSL_free(ssl); ::close(fd); SSL_CTX_free(ctx); return std::nullopt; } + std::array buf{}; const auto n = SSL_read(ssl, buf.data(), static_cast(buf.size())); + SSL_free(ssl); ::close(fd); SSL_CTX_free(ctx); + if (n <= 0) return std::nullopt; + std::string data(buf.data(), static_cast(n)); + if (data.rfind("HTTP/", 0) != 0) return std::nullopt; + return data; +} + +inline SnapEndpoint scoreWebProbe(int port, const std::string& scheme, const std::string& response) { + SnapEndpoint endpoint; endpoint.port = port; endpoint.protocol = scheme; endpoint.web = true; endpoint.score = scheme == "https" ? 85 : 80; + const auto lower = lowerAsciiCopy(response); + if (lower.find("content-type: text/html") != std::string::npos || lower.find("& endpoints) { + std::ostringstream data; + data << std::time(nullptr) << '\n'; + for (const auto& ep : endpoints) { + data << ep.port << '\t' << ep.protocol << '\t' << (ep.web ? 1 : 0) << '\t' + << ep.score << '\t' << ep.evidence << '\n'; + } + writeFile(endpointCachePath(snapName), data.str()); +} + +inline std::optional> readSnapEndpointCache(const std::string& snapName, std::time_t ttlSeconds) { + const auto content = readFile(endpointCachePath(snapName)); + if (!content.has_value()) return std::nullopt; + std::stringstream stream(*content); + std::string line; + if (!std::getline(stream, line)) return std::nullopt; + const auto storedAt = static_cast(std::strtoll(line.c_str(), nullptr, 10)); + const auto now = std::time(nullptr); + if (storedAt <= 0 || now < storedAt || now - storedAt > ttlSeconds) return std::nullopt; + + std::vector endpoints; + while (std::getline(stream, line)) { + std::stringstream row(line); + std::string port, protocol, web, score, evidence; + if (!std::getline(row, port, '\t') || !std::getline(row, protocol, '\t') || + !std::getline(row, web, '\t') || !std::getline(row, score, '\t') || + !std::getline(row, evidence)) continue; + SnapEndpoint ep; + ep.port = std::atoi(port.c_str()); + ep.protocol = protocol; + ep.web = web == "1"; + ep.score = std::atoi(score.c_str()); + ep.evidence = evidence; + if (ep.port > 0 && ep.port <= 65535) endpoints.push_back(ep); + } + return endpoints; +} + +inline void invalidateSnapEndpointCache(const std::string& snapName) { + std::remove(endpointCachePath(snapName).c_str()); +} + +inline std::vector discoverSnapEndpoints(const std::string& snapName) { + std::vector result; + if (!validSnapName(snapName)) return result; + const auto inodes = socketInodesForSnap(snapName); + std::set ports; + collectListeningPorts("/proc/net/tcp", inodes, ports); + collectListeningPorts("/proc/net/tcp6", inodes, ports); + for (const auto port : ports) { + if (auto http = probePlainHttp(port); http.has_value()) result.push_back(scoreWebProbe(port, "http", *http)); + else if (auto https = probeTlsHttp(port); https.has_value()) result.push_back(scoreWebProbe(port, "https", *https)); + else result.push_back(SnapEndpoint{port, "tcp", false, 10, "listen"}); + } + std::stable_sort(result.begin(), result.end(), [](const SnapEndpoint& a, const SnapEndpoint& b) { + if (a.web != b.web) return a.web > b.web; + if (a.score != b.score) return a.score > b.score; + return a.port < b.port; + }); + cacheSnapEndpoints(snapName, result); + return result; +} + +inline std::string endpointsJson(const std::string& snapName, const std::vector& endpoints) { + std::ostringstream out; + out << "{\"ok\":true,\"snap\":\"" << jsonEscape(snapName) << "\",\"endpoints\":["; + for (size_t i = 0; i < endpoints.size(); ++i) { + if (i) out << ','; + const auto& ep = endpoints[i]; + out << "{\"port\":" << ep.port + << ",\"protocol\":\"" << jsonEscape(ep.protocol) << "\"" + << ",\"web\":" << (ep.web ? "true" : "false") + << ",\"score\":" << ep.score + << ",\"evidence\":\"" << jsonEscape(ep.evidence) << "\"" + << ",\"primary\":" << (i == 0 && ep.web ? "true" : "false") << '}'; + } + out << "]}"; + return out.str(); +} + +inline std::optional primarySnapWebEndpoint(const std::string& snapName) { + auto cached = readSnapEndpointCache(snapName, 30); + const auto endpoints = cached.has_value() ? *cached : discoverSnapEndpoints(snapName); + for (const auto& endpoint : endpoints) if (endpoint.web) return endpoint; + return std::nullopt; +} + +} // namespace capos + +#endif diff --git a/package/capos/capos-webpanel/htdocs/assets/app.js b/package/capos/capos-webpanel/htdocs/assets/app.js deleted file mode 100644 index 3ec86f3e221..00000000000 --- a/package/capos/capos-webpanel/htdocs/assets/app.js +++ /dev/null @@ -1,611 +0,0 @@ -"use strict"; - -const state = { - session: null, - apps: [], - desktop: null, - upload: null, - pending: new Set(), - portApp: null, -}; - -const $ = (id) => document.getElementById(id); - -const nodes = { - loginView: $("loginView"), - loginForm: $("loginForm"), - loginBtn: $("loginBtn"), - username: $("username"), - password: $("password"), - appShell: $("appShell"), - topbar: document.querySelector(".topbar"), - controlDrawer: $("controlDrawer"), - appsList: $("appsList"), - appsCount: $("appsCount"), - appSearch: $("appSearch"), - appFilter: $("appFilter"), - sessionSummary: $("sessionSummary"), - userBadge: $("userBadge"), - luciLink: $("luciLink"), - inspectionBox: $("inspectionBox"), - desktopHint: $("desktopHint"), - desktopStatus: $("desktopStatus"), - desktopFrame: $("desktopFrame"), - toastStack: $("toastStack"), - cpkFile: $("cpkFile"), - uploadBtn: $("uploadBtn"), - installBtn: $("installBtn"), - portModal: $("portModal"), - portForm: $("portForm"), - portModalApp: $("portModalApp"), - portListen: $("portListen"), - portTarget: $("portTarget"), - portProto: $("portProto"), - savePortBtn: $("savePortBtn"), - confirmModal: $("confirmModal"), - confirmTitle: $("confirmTitle"), - confirmMessage: $("confirmMessage"), - confirmOkBtn: $("confirmOkBtn"), -}; - -class ApiError extends Error { - constructor(message, status, code) { - super(message); - this.status = status; - this.code = code; - } -} - -function h(value) { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function showToast(message, kind = "info") { - const toast = document.createElement("div"); - toast.className = `toast ${kind}`; - toast.textContent = message; - nodes.toastStack.appendChild(toast); - setTimeout(() => toast.remove(), 3800); -} - -function resetSessionUi(message) { - state.session = null; - state.apps = []; - state.desktop = null; - state.upload = null; - nodes.password.value = ""; - nodes.controlDrawer.classList.remove("open"); - nodes.loginView.style.display = "grid"; - nodes.appShell.classList.remove("ready"); - nodes.desktopFrame.src = "about:blank"; - nodes.installBtn.disabled = true; - nodes.inspectionBox.innerHTML = "

等待上传 CPK。

"; - if (message) { - showToast(message, "error"); - } -} - -async function api(path, options = {}) { - const res = await fetch(`/cgi-bin/cap/api${path}`, { - credentials: "same-origin", - ...options, - }); - const text = await res.text(); - let data = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch (error) { - throw new ApiError(text || `HTTP ${res.status}`, res.status, "INVALID_JSON"); - } - if (res.status === 401) { - resetSessionUi(data.message || "登录状态已过期,请重新登录。"); - throw new ApiError(data.message || "unauthenticated", res.status, data.error_code || "UNAUTHENTICATED"); - } - if (!res.ok || data.ok === false) { - throw new ApiError(data.message || `HTTP ${res.status}`, res.status, data.error_code || "ERROR"); - } - return data; -} - -function setPending(key, pending) { - if (pending) { - state.pending.add(key); - } else { - state.pending.delete(key); - } - renderApps(); - nodes.loginBtn.disabled = state.pending.has("login"); - nodes.uploadBtn.disabled = state.pending.has("upload"); - nodes.installBtn.disabled = !state.upload || state.pending.has("install"); - nodes.savePortBtn.disabled = state.pending.has("port"); -} - -function isDesktopApp(appName) { - return Boolean(state.desktop && state.desktop.app === appName); -} - -function risksForApp(item) { - const manifest = item.manifest || {}; - const container = manifest.container || {}; - const privileges = container.privileges || {}; - const network = manifest.network || {}; - const host = manifest.host || {}; - const exec = host.exec || {}; - const risks = []; - if (network.host || item.permissions?.host_network) risks.push("host network"); - if (exec.enabled || item.permissions?.host_exec) risks.push("host.exec"); - if (privileges.enabled) risks.push("privileged"); - if ((privileges.capabilities || []).length) risks.push("capabilities"); - if ((container.devices || []).length) risks.push("devices"); - if ((container.volumes?.extra || []).length) risks.push("host mounts"); - if ((network.publish || []).length) risks.push("publish"); - return risks; -} - -function serviceLabel(item) { - const desktop = item.desktop || {}; - if (!desktop.port) { - return "未声明"; - } - return `${desktop.scheme || "http"}://${desktop.target_host || "-"}:${desktop.port}`; -} - -function portmapText(map) { - return `${map.proto || "tcp"} ${map.listen}->${map.target_port}`; -} - -function appMatches(item, query, filter) { - const app = item.app || {}; - const running = Boolean(item.runtime?.running); - const desktop = isDesktopApp(app.name); - const risks = risksForApp(item); - if (filter === "running" && !running) return false; - if (filter === "stopped" && running) return false; - if (filter === "desktop" && !desktop) return false; - if (filter === "risk" && risks.length === 0) return false; - if (!query) return true; - const haystack = [ - app.name, - app.nickname, - app.version, - app.description, - item.runtime?.container_name, - item.runtime?.ip, - serviceLabel(item), - ...(item.portmaps || []).map(portmapText), - ...risks, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return haystack.includes(query); -} - -function emptyState(title, message) { - return `

${h(title)}

${h(message)}

`; -} - -function renderApps() { - const query = nodes.appSearch.value.trim().toLowerCase(); - const filter = nodes.appFilter.value; - const apps = state.apps.filter((item) => appMatches(item, query, filter)); - nodes.appsCount.textContent = `${state.apps.length} 个应用`; - - if (!state.apps.length) { - nodes.appsList.innerHTML = emptyState("还没有已安装应用", "上传 CPK 后,应用会显示在这里。"); - return; - } - if (!apps.length) { - nodes.appsList.innerHTML = emptyState("没有匹配的应用", "调整搜索词或过滤条件。"); - return; - } - - nodes.appsList.innerHTML = apps - .map((item) => { - const app = item.app || {}; - const runtime = item.runtime || {}; - const running = Boolean(runtime.running); - const desktop = isDesktopApp(app.name); - const risks = risksForApp(item); - const actionPending = (name) => state.pending.has(`${name}:${app.name}`); - const disabled = (name) => (actionPending(name) ? "disabled" : ""); - const portmaps = item.portmaps || []; - return ` -
-
-
-

${h(app.nickname || app.name)}

-

${h(app.description || "这个应用还没有提供描述。")}

-
- ${running ? "Running" : "Stopped"} -
-
- Version ${h(app.version || "-")} - ${desktop ? '当前桌面' : ""} - ${risks.map((risk) => `${h(risk)}`).join("")} -
-
-
容器${h(runtime.container_name || "-")}
-
IP${h(runtime.ip || "-")}
-
Service${h(serviceLabel(item))}
-
Image${h(runtime.image_ref || "-")}
-
-
- ${ - portmaps.length - ? portmaps - .map( - (map) => - `` - ) - .join("") - : '无端口映射' - } -
-
- - - - - - - -
-
- `; - }) - .join(""); -} - -function renderDesktopStatus() { - const appName = state.desktop?.app; - nodes.desktopHint.textContent = appName ? `当前桌面应用:${appName}` : "请选择一个已安装应用作为桌面应用。"; - - if (!appName) { - nodes.desktopStatus.className = "desktop-status visible"; - nodes.desktopStatus.textContent = "还没有设置桌面应用。打开右侧应用面板,选择一个已安装应用作为桌面。"; - return; - } - - const item = state.apps.find((candidate) => candidate.app?.name === appName); - if (!item) { - nodes.desktopStatus.className = "desktop-status visible"; - nodes.desktopStatus.textContent = "当前桌面应用记录指向一个不存在的应用,请重新选择桌面应用。"; - return; - } - if (!item.runtime?.running) { - nodes.desktopStatus.className = "desktop-status visible"; - nodes.desktopStatus.textContent = `${item.app?.nickname || appName} 尚未启动,启动后桌面区会自动代理它的 HTTP 服务。`; - return; - } - if (!item.desktop?.port) { - nodes.desktopStatus.className = "desktop-status visible"; - nodes.desktopStatus.textContent = `${item.app?.nickname || appName} 没有声明 network.service.http,无法作为桌面 iframe 代理。`; - return; - } - nodes.desktopStatus.className = "desktop-status"; - nodes.desktopStatus.textContent = ""; -} - -function updateViewportOffsets() { - const topbarHeight = nodes.topbar ? nodes.topbar.offsetHeight : 62; - document.documentElement.style.setProperty("--workspace-top", `${topbarHeight + 24}px`); -} - -function refreshDesktopFrame() { - const appName = state.desktop?.app; - const item = state.apps.find((candidate) => candidate.app?.name === appName); - if (!appName || !item?.runtime?.running || !item.desktop?.port) { - nodes.desktopFrame.src = "about:blank"; - return; - } - nodes.desktopFrame.src = `/cgi-bin/cap/app?ts=${Date.now()}`; -} - -async function loadSession() { - const data = await api("/session"); - if (!data.authenticated) { - resetSessionUi(); - return false; - } - state.session = data.session; - nodes.loginView.style.display = "none"; - nodes.appShell.classList.add("ready"); - nodes.userBadge.textContent = `${data.session.username}${data.session.is_sudo ? " · sudo" : ""}`; - nodes.sessionSummary.textContent = `当前用户:${data.session.username} · ${data.session.is_sudo ? "具备 sudo 权限" : "普通用户权限"}`; - nodes.luciLink.style.display = data.session.is_sudo ? "inline-flex" : "none"; - updateViewportOffsets(); - return true; -} - -async function loadApps() { - const data = await api("/apps"); - state.apps = data.apps || []; - renderApps(); -} - -async function loadDesktop() { - state.desktop = await api("/desktop"); -} - -async function refreshAll() { - if (!(await loadSession())) { - return; - } - await Promise.all([loadApps(), loadDesktop()]); - renderApps(); - renderDesktopStatus(); - refreshDesktopFrame(); -} - -function formBody(values) { - return { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" }, - body: new URLSearchParams(values), - }; -} - -function confirmDialog(title, message, danger = false) { - nodes.confirmTitle.textContent = title; - nodes.confirmMessage.textContent = message; - nodes.confirmOkBtn.className = danger ? "danger" : "primary"; - return new Promise((resolve) => { - const handler = () => { - nodes.confirmModal.removeEventListener("close", handler); - resolve(nodes.confirmModal.returnValue === "ok"); - }; - nodes.confirmModal.addEventListener("close", handler); - nodes.confirmModal.showModal(); - }); -} - -async function appAction(app, action) { - const labels = { - start: "启动", - stop: "停止", - restart: "重启", - reconcile: "修复状态", - uninstall: "卸载", - }; - const danger = action === "uninstall" || action === "stop"; - if (["stop", "restart", "uninstall"].includes(action)) { - const ok = await confirmDialog(`${labels[action]} ${app}`, `确认${labels[action]}应用 ${app}?`, danger); - if (!ok) return; - } - const key = `${action}:${app}`; - if (state.pending.has(key)) return; - setPending(key, true); - try { - const path = action === "reconcile" ? `/apps/${encodeURIComponent(app)}/reconcile` : `/apps/${encodeURIComponent(app)}/${action}`; - await api(path, { method: "POST" }); - showToast(`${labels[action]}完成:${app}`, "success"); - await refreshAll(); - } finally { - setPending(key, false); - } -} - -function openPortModal(app) { - state.portApp = app; - nodes.portModalApp.textContent = `应用:${app}`; - nodes.portListen.value = ""; - nodes.portTarget.value = ""; - nodes.portProto.value = "tcp"; - nodes.portModal.showModal(); - nodes.portListen.focus(); -} - -function validPort(value) { - return /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535; -} - -async function savePortMapping() { - const app = state.portApp; - if (!app) return; - const listen = nodes.portListen.value.trim(); - const target = nodes.portTarget.value.trim(); - const proto = nodes.portProto.value; - if (!validPort(listen) || !validPort(target)) { - showToast("端口必须是 1-65535。", "error"); - return; - } - setPending("port", true); - try { - await api(`/apps/${encodeURIComponent(app)}/ports`, formBody({ listen, target, proto })); - showToast(`已保存 ${app} 的 ${proto} ${listen}->${target}`, "success"); - nodes.portModal.close(); - await refreshAll(); - } finally { - setPending("port", false); - } -} - -async function removePortMapping(button) { - const app = button.dataset.app; - const listen = button.dataset.listen; - const target = button.dataset.target; - const proto = button.dataset.proto || "tcp"; - const ok = await confirmDialog("删除端口映射", `删除 ${app} 的 ${proto} ${listen}->${target} 映射?`, true); - if (!ok) return; - await api(`/apps/${encodeURIComponent(app)}/ports`, formBody({ action: "remove", listen, target, proto })); - showToast("端口映射已删除", "success"); - await refreshAll(); -} - -function reviewRiskItems(inspection) { - const manifest = inspection.manifest || {}; - const container = manifest.container || {}; - const privileges = container.privileges || {}; - const network = manifest.network || {}; - const hostExec = manifest.host?.exec || {}; - const items = []; - if (network.host || inspection.permissions?.host_network) items.push("使用 host network,容器与宿主机共享网络命名空间。"); - if (privileges.enabled || inspection.permissions?.privileged) items.push("请求 privileged 容器权限。"); - if ((container.devices || inspection.permissions?.devices || []).length) items.push(`请求设备访问:${(container.devices || inspection.permissions?.devices || []).join(", ")}`); - if (hostExec.enabled || inspection.permissions?.host_exec) items.push("启用 host.exec,可按 token 调用宿主机白名单命令。"); - if ((container.volumes?.extra || []).length) items.push(`请求宿主机目录挂载:${container.volumes.extra.join(", ")}`); - if ((network.publish || inspection.permissions?.publish || []).length) items.push("安装时会创建宿主机端口映射。"); - if ((privileges.capabilities || inspection.permissions?.capabilities || []).length) items.push(`额外 capabilities:${(privileges.capabilities || inspection.permissions?.capabilities || []).join(", ")}`); - return items; -} - -function renderInspection(inspection) { - const app = inspection.app || {}; - const cpk = inspection.cpk || {}; - const manifest = inspection.manifest || {}; - const network = manifest.network || {}; - const env = manifest.container?.environment || []; - const dependencies = manifest.dependencies || {}; - const risks = reviewRiskItems(inspection); - nodes.inspectionBox.innerHTML = ` -
-

${h(app.nickname || app.name || "未命名应用")}

-

${h(app.description || "这个 CPK 没有提供描述。")}

-
-
-
应用名${h(app.name || "-")}
-
版本${h(app.version || "-")}
-
架构${h(cpk.arch || "-")}
-
镜像${h(cpk.image || "-")}
-
Service${h(network.service?.http ? `http:${network.service.http}` : network.service?.https ? `https:${network.service.https}` : "未声明")}
-
Publish${h(JSON.stringify(network.publish || []))}
-
依赖${h(JSON.stringify(dependencies))}
-
环境变量${h(JSON.stringify(env))}
-
- ${ - risks.length - ? `
${risks.map((risk) => `
${h(risk)}
`).join("")}
` - : '

未发现 privileged、devices、host.exec、host network 等高风险能力。

' - } - `; -} - -async function uploadCpk() { - const file = nodes.cpkFile.files[0]; - if (!file) { - showToast("请先选择一个 CPK 文件。", "error"); - return; - } - setPending("upload", true); - try { - const data = await api(`/upload?filename=${encodeURIComponent(file.name)}`, { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body: await file.arrayBuffer(), - }); - state.upload = data.upload; - renderInspection(data.inspection || {}); - showToast("上传完成,预检通过。", "success"); - } catch (error) { - state.upload = null; - nodes.inspectionBox.innerHTML = `
${h(error.message)}
`; - throw error; - } finally { - setPending("upload", false); - } -} - -async function installUpload() { - if (!state.upload) { - showToast("还没有可安装的上传结果。", "error"); - return; - } - const ok = await confirmDialog("确认安装 CPK", "安装会创建应用状态、载入镜像,并可能按 manifest 创建容器、端口映射和 hostexec token。确认继续?", true); - if (!ok) return; - setPending("install", true); - try { - await api("/install", formBody({ upload_id: state.upload.id })); - nodes.inspectionBox.innerHTML = "

安装成功,可以在应用库中查看。

"; - nodes.cpkFile.value = ""; - state.upload = null; - showToast("应用已安装。", "success"); - await refreshAll(); - } finally { - setPending("install", false); - } -} - -nodes.loginForm.addEventListener("submit", async (event) => { - event.preventDefault(); - setPending("login", true); - try { - await api("/login", formBody({ username: nodes.username.value, password: nodes.password.value })); - nodes.password.value = ""; - showToast("登录成功。", "success"); - await refreshAll(); - } catch (error) { - showToast(error.message, "error"); - } finally { - setPending("login", false); - } -}); - -$("logoutBtn").addEventListener("click", async () => { - try { - await api("/logout", { method: "POST" }); - } catch (error) { - showToast(error.message, "error"); - } finally { - resetSessionUi(); - } -}); - -$("toggleDrawerBtn").addEventListener("click", () => nodes.controlDrawer.classList.toggle("open")); -$("closeDrawerBtn").addEventListener("click", () => nodes.controlDrawer.classList.remove("open")); -$("refreshBtn").addEventListener("click", () => refreshAll().catch((error) => showToast(error.message, "error"))); -$("appsRefreshBtn").addEventListener("click", () => refreshAll().catch((error) => showToast(error.message, "error"))); -nodes.appSearch.addEventListener("input", renderApps); -nodes.appFilter.addEventListener("change", renderApps); -window.addEventListener("resize", updateViewportOffsets); - -nodes.appsList.addEventListener("click", async (event) => { - const button = event.target.closest("button[data-action]"); - if (!button) return; - const { action, app } = button.dataset; - try { - if (action === "remove-port") { - await removePortMapping(button); - return; - } - if (action === "portmap") { - openPortModal(app); - return; - } - if (action === "desktop") { - await api("/desktop", formBody({ app })); - showToast(`已将 ${app} 设为桌面应用。`, "success"); - await refreshAll(); - return; - } - await appAction(app, action); - } catch (error) { - showToast(error.message, "error"); - } -}); - -nodes.portForm.addEventListener("submit", async (event) => { - if (event.submitter?.value === "cancel") { - return; - } - event.preventDefault(); - try { - await savePortMapping(); - } catch (error) { - showToast(error.message, "error"); - } -}); - -nodes.uploadBtn.addEventListener("click", () => uploadCpk().catch((error) => showToast(error.message, "error"))); -nodes.installBtn.addEventListener("click", () => installUpload().catch((error) => showToast(error.message, "error"))); - -refreshAll().catch((error) => { - resetSessionUi(); - showToast(error.message, "error"); -}); -updateViewportOffsets(); diff --git a/package/capos/capos-webpanel/htdocs/assets/styles.css b/package/capos/capos-webpanel/htdocs/assets/styles.css deleted file mode 100644 index 339561d4da2..00000000000 --- a/package/capos/capos-webpanel/htdocs/assets/styles.css +++ /dev/null @@ -1,596 +0,0 @@ -:root { - color-scheme: light; - --bg: #e8ebef; - --surface: rgba(255, 255, 255, 0.94); - --surface-strong: #ffffff; - --ink: #18222c; - --muted: #62717f; - --line: #d7dde4; - --accent: #276a8f; - --accent-strong: #164f72; - --danger: #b23b36; - --warning: #9a6517; - --success: #1f7a54; - --shadow: 0 16px 38px rgba(19, 31, 43, 0.16); -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - min-height: 100%; - font-family: "Segoe UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; - color: var(--ink); - background: - linear-gradient(135deg, rgba(39, 106, 143, 0.12), transparent 38%), - linear-gradient(180deg, #f6f7f8 0%, var(--bg) 100%); -} - -button, -.link-button, -input, -select { - font: inherit; -} - -button, -.link-button { - border: none; - border-radius: 8px; - padding: 8px 12px; - cursor: pointer; - text-decoration: none; - transition: transform 0.14s ease, opacity 0.14s ease, background 0.14s ease; - white-space: nowrap; -} - -button:hover, -.link-button:hover { - transform: translateY(-1px); -} - -button:disabled { - cursor: not-allowed; - opacity: 0.52; - transform: none; -} - -.primary { - background: var(--accent); - color: #fff; -} - -.primary:hover { - background: var(--accent-strong); -} - -.secondary { - background: #fff; - color: var(--ink); - border: 1px solid var(--line); -} - -.danger { - background: rgba(178, 59, 54, 0.12); - color: var(--danger); -} - -.small { - padding: 6px 9px; - font-size: 12px; -} - -.wide { - width: 100%; -} - -.icon-button { - width: 34px; - height: 34px; - padding: 0; -} - -label { - display: grid; - gap: 6px; - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -input, -select { - width: 100%; - min-width: 0; - padding: 10px 11px; - border-radius: 8px; - border: 1px solid var(--line); - background: #fff; - color: var(--ink); -} - -.login { - display: grid; - place-items: center; - min-height: 100vh; - padding: 20px; -} - -.login-card { - width: min(430px, 100%); - border: 1px solid rgba(255, 255, 255, 0.62); - border-radius: 16px; - background: var(--surface); - box-shadow: var(--shadow); - padding: 24px; - display: grid; - gap: 14px; -} - -.brand-mark { - justify-self: start; - border: 1px solid var(--line); - border-radius: 8px; - padding: 7px 10px; - color: var(--accent); - font-weight: 800; - background: #fff; -} - -.login-card h1, -.login-card p, -.panel h3, -.panel p, -.modal h3, -.modal p, -.empty-state h4, -.empty-state p { - margin: 0; -} - -.login-card h1 { - font-size: 28px; -} - -.login-card p, -.panel p, -.modal p, -.empty-state p { - color: var(--muted); - line-height: 1.45; -} - -.app { - display: none; - min-height: 100vh; -} - -.app.ready { - display: block; -} - -.workspace { - position: relative; - min-height: 100vh; - padding-top: var(--workspace-top, 86px); -} - -.desktop { - position: relative; - height: calc(100vh - var(--workspace-top, 86px)); - min-height: 360px; - background: #f7f8f8; -} - -.desktop-frame { - width: 100%; - height: 100%; - border: none; - display: block; - background: #f7f8f8; -} - -.desktop-status { - position: absolute; - top: 12px; - left: 12px; - z-index: 5; - display: none; - max-width: min(560px, calc(100% - 24px)); - border: 1px solid var(--line); - border-radius: 10px; - background: rgba(255, 255, 255, 0.93); - padding: 10px 12px; - box-shadow: 0 12px 26px rgba(19, 31, 43, 0.1); - color: var(--muted); - font-size: 13px; -} - -.desktop-status.visible { - display: block; -} - -.topbar { - position: fixed; - z-index: 20; - top: 12px; - left: 12px; - right: 12px; - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - padding: 10px 12px; - border-radius: 12px; - border: 1px solid var(--line); - background: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(10px); - box-shadow: var(--shadow); -} - -.topbar-main { - display: grid; - min-width: 0; -} - -.topbar-main strong { - font-size: 14px; -} - -.topbar-main span { - color: var(--muted); - font-size: 12px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topbar-actions { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.pill, -.chip { - display: inline-flex; - align-items: center; - border-radius: 999px; - font-weight: 700; -} - -.pill { - padding: 7px 11px; - font-size: 13px; - background: rgba(39, 106, 143, 0.12); - color: var(--accent); -} - -.control-drawer { - position: fixed; - top: var(--workspace-top, 86px); - right: 12px; - bottom: 12px; - width: min(520px, calc(100vw - 24px)); - border: 1px solid var(--line); - border-radius: 14px; - background: var(--surface); - box-shadow: var(--shadow); - padding: 14px; - display: grid; - grid-template-rows: auto 1fr; - gap: 12px; - z-index: 18; - transform: translateX(calc(100% + 18px)); - transition: transform 0.2s ease; -} - -.control-drawer.open { - transform: translateX(0); -} - -.drawer-head, -.panel-head, -.modal-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.drawer-head h2, -.drawer-head p { - margin: 0; -} - -.drawer-head h2 { - font-size: 18px; -} - -.drawer-head p { - margin-top: 3px; - color: var(--muted); - font-size: 12px; -} - -.drawer-content { - overflow: auto; - display: grid; - gap: 12px; - padding-right: 4px; -} - -.panel { - border: 1px solid var(--line); - border-radius: 12px; - background: rgba(255, 255, 255, 0.78); - padding: 12px; - display: grid; - gap: 10px; -} - -.panel h3 { - font-size: 15px; -} - -.filters { - display: grid; - grid-template-columns: minmax(0, 1fr) 132px; - gap: 8px; -} - -.apps { - display: grid; - gap: 10px; -} - -.app-card { - border: 1px solid var(--line); - border-radius: 8px; - background: #fff; - padding: 12px; - display: grid; - gap: 10px; -} - -.app-card.is-desktop { - border-color: rgba(39, 106, 143, 0.45); - box-shadow: inset 3px 0 0 var(--accent); -} - -.app-title { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; -} - -.app-title h4, -.app-title p { - margin: 0; -} - -.app-title h4 { - font-size: 15px; -} - -.app-title p { - margin-top: 3px; - font-size: 12px; - color: var(--muted); - line-height: 1.4; -} - -.app-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.fact { - border: 1px solid var(--line); - border-radius: 8px; - padding: 7px 8px; - background: #fbfcfd; - min-width: 0; -} - -.fact strong { - display: block; - color: var(--muted); - font-size: 11px; - margin-bottom: 3px; -} - -.fact span { - display: block; - overflow-wrap: anywhere; - font-size: 12px; -} - -.chips, -.actions, -.port-list { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.chip { - border: 1px solid var(--line); - padding: 4px 8px; - background: #fff; - color: var(--muted); - font-size: 11px; -} - -.chip.running { - color: var(--success); -} - -.chip.stopped, -.chip.risk { - color: var(--danger); -} - -.chip.warning { - color: var(--warning); -} - -.chip.desktop-chip { - color: var(--accent); - border-color: rgba(39, 106, 143, 0.42); - background: rgba(39, 106, 143, 0.08); -} - -.port-pill { - border: 1px solid var(--line); - border-radius: 999px; - padding: 5px 8px; - background: #fff; - color: var(--ink); - font-size: 11px; - cursor: pointer; -} - -.actions button { - font-size: 12px; - padding: 6px 9px; -} - -.upload-box { - display: grid; - gap: 9px; -} - -.review { - border: 1px solid var(--line); - border-radius: 8px; - background: #fbfcfd; - padding: 10px; - display: grid; - gap: 9px; - max-height: 300px; - overflow: auto; -} - -.review h4 { - margin: 0; - font-size: 14px; -} - -.review p { - margin: 0; - color: var(--muted); - font-size: 12px; - line-height: 1.4; -} - -.review-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.risk-list { - display: grid; - gap: 6px; -} - -.risk-item { - border: 1px solid rgba(178, 59, 54, 0.24); - border-radius: 8px; - background: rgba(178, 59, 54, 0.06); - padding: 8px; - color: var(--danger); - font-size: 12px; -} - -.empty-state { - border: 1px dashed var(--line); - border-radius: 8px; - background: rgba(255, 255, 255, 0.68); - padding: 14px; - display: grid; - gap: 5px; -} - -.toast-stack { - position: fixed; - right: 14px; - bottom: 14px; - z-index: 50; - display: grid; - gap: 8px; - width: min(360px, calc(100vw - 28px)); -} - -.toast { - padding: 11px 12px; - border-radius: 10px; - background: rgba(22, 32, 40, 0.94); - color: #fff; - box-shadow: var(--shadow); - font-size: 13px; -} - -.toast.error { - background: rgba(178, 59, 54, 0.96); -} - -.toast.success { - background: rgba(31, 122, 84, 0.96); -} - -.modal { - border: none; - padding: 0; - background: transparent; -} - -.modal::backdrop { - background: rgba(24, 34, 44, 0.36); -} - -.modal-card { - width: min(420px, calc(100vw - 28px)); - border: 1px solid var(--line); - border-radius: 12px; - background: #fff; - box-shadow: var(--shadow); - padding: 14px; - display: grid; - gap: 12px; -} - -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 8px; -} - -@media (max-width: 760px) { - .topbar { - align-items: flex-start; - flex-wrap: wrap; - } - - .topbar-main { - width: 100%; - } - - .topbar-actions { - width: 100%; - } - - .filters, - .app-grid, - .review-grid { - grid-template-columns: 1fr; - } -} diff --git a/package/capos/capos-webpanel/htdocs/index.html b/package/capos/capos-webpanel/htdocs/index.html deleted file mode 100644 index e0cc399221c..00000000000 --- a/package/capos/capos-webpanel/htdocs/index.html +++ /dev/null @@ -1,151 +0,0 @@ - - - - - - CapOS WebDesktop - - - - - -
-
-
-
- CapOS WebDesktop - 正在同步你的应用环境... - 选择一个已安装应用作为你的桌面应用。 -
-
- 访客 - LuCI - - - -
-
- -
-
- -
- - -
-
- - - - - - - - - -
- - - - diff --git a/package/capos/capos-webpanel/src/api.cpp b/package/capos/capos-webpanel/src/api.cpp deleted file mode 100644 index 37fc3e938a5..00000000000 --- a/package/capos/capos-webpanel/src/api.cpp +++ /dev/null @@ -1,635 +0,0 @@ -#include "common.hpp" - -#include - -using namespace capos; - -namespace { - -std::string effectivePathInfo() { - auto path = getenvOrEmpty("PATH_INFO"); - if (!path.empty()) { - return path; - } - - auto uri = getenvOrEmpty("REQUEST_URI"); - const auto question = uri.find('?'); - if (question != std::string::npos) { - uri = uri.substr(0, question); - } - const std::string prefix = "/cgi-bin/cap/api"; - if (uri.rfind(prefix, 0) == 0) { - return uri.substr(prefix.size()); - } - return ""; -} - -std::string sessionJson(const Session& session) { - std::ostringstream out; - out << "{" - << "\"authenticated\":true," - << "\"username\":\"" << jsonEscape(session.username) << "\"," - << "\"uid\":" << static_cast(session.uid) << "," - << "\"is_sudo\":" << (session.is_sudo ? "true" : "false") - << "}"; - return out.str(); -} - -bool isValidAppName(const std::string& app) { - if (app.empty() || app.size() > 64) { - return false; - } - return std::all_of(app.begin(), app.end(), [](unsigned char ch) { - return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_'; - }); -} - -bool isValidUsername(const std::string& username) { - if (username.empty() || username.size() > 64) { - return false; - } - return std::all_of(username.begin(), username.end(), [](unsigned char ch) { - return std::isalnum(ch) != 0 || ch == '_' || ch == '-' || ch == '.'; - }); -} - -bool validateAppOrSend(const std::string& app) { - if (isValidAppName(app)) { - return true; - } - sendJson(400, jsonError("invalid app name", "INVALID_APP_NAME")); - return false; -} - -bool parsePortValue(const std::string& value, std::string& normalized) { - if (value.empty() || value.size() > 5) { - return false; - } - if (!std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch) != 0; })) { - return false; - } - const auto port = std::strtol(value.c_str(), nullptr, 10); - if (port < 1 || port > 65535) { - return false; - } - normalized = std::to_string(port); - return true; -} - -bool normalizeProto(const std::string& value, std::string& proto) { - proto = value.empty() ? "tcp" : value; - std::transform(proto.begin(), proto.end(), proto.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); - return proto == "tcp" || proto == "udp"; -} - -bool isValidUploadId(const std::string& uploadId) { - return isHexToken(uploadId, 16, 16); -} - -bool hasSuffix(const std::string& value, const std::string& suffix) { - return value.size() >= suffix.size() && - value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; -} - -bool isValidUploadFilename(const std::string& filename) { - if (filename.empty() || filename.size() > 120 || filename == "." || filename == "..") { - return false; - } - if (filename.find('/') != std::string::npos || filename.find('\\') != std::string::npos) { - return false; - } - if (!std::all_of(filename.begin(), filename.end(), [](unsigned char ch) { - return std::isalnum(ch) != 0 || ch == '.' || ch == '_' || ch == '-'; - })) { - return false; - } - return filename.size() >= 4 && - (hasSuffix(filename, ".cpk") || hasSuffix(filename, ".tgz") || hasSuffix(filename, ".tar.gz")); -} - -bool isAllowedAppAction(const std::string& action) { - static const std::set allowed = {"start", "stop", "restart", "uninstall"}; - return allowed.count(action) != 0; -} - -int capboxFailureStatus(const std::string& output, int fallback = 400) { - const auto lowered = [&output]() { - std::string value = output; - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); - return value; - }(); - if (lowered.find("already installed") != std::string::npos || - lowered.find("already allocated") != std::string::npos || - lowered.find("already in use") != std::string::npos) { - return 409; - } - if (lowered.find("sudo") != std::string::npos || - lowered.find("permission") != std::string::npos || - lowered.find("not enabled") != std::string::npos || - lowered.find("not allowed") != std::string::npos) { - return 403; - } - if (lowered.find("not installed") != std::string::npos || - lowered.find("not found") != std::string::npos) { - return 404; - } - return fallback; -} - -std::optional requireSession() { - const auto session = currentSession(); - if (!session.has_value()) { - sendJson(401, jsonError("authentication required", "UNAUTHENTICATED")); - return std::nullopt; - } - return session; -} - -std::string uploadMetaPath(const std::string& sessionId, const std::string& uploadId) { - return std::string(kUploadDir) + "/" + sessionId + "/" + uploadId + ".meta"; -} - -std::optional lookupUploadPath(const std::string& sessionId, const std::string& uploadId) { - const auto content = readFile(uploadMetaPath(sessionId, uploadId)); - if (!content.has_value()) { - return std::nullopt; - } - return trim(*content); -} - -std::vector splitBodyLines(const std::string& body) { - std::vector lines; - std::stringstream stream(body); - std::string line; - while (std::getline(stream, line)) { - if (!line.empty() && line.back() == '\r') { - line.pop_back(); - } - lines.push_back(line); - } - return lines; -} - -void handleRoot() { - sendJson(200, "{\"ok\":true,\"service\":\"capos-webpanel-api\"}"); -} - -void handleHostexec() { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - - const auto app = trim(getenvOrEmpty("HTTP_X_CAPOS_APP")); - const auto user = trim(getenvOrEmpty("HTTP_X_CAPOS_USER")); - const auto token = trim(getenvOrEmpty("HTTP_X_CAPOS_TOKEN")); - if (app.empty() || user.empty() || token.empty()) { - sendJson(400, jsonError("X-CapOS-App, X-CapOS-User and X-CapOS-Token are required", "INVALID_REQUEST")); - return; - } - if (!isValidAppName(app) || !isValidUsername(user) || !isHexToken(token, 32, 128)) { - sendJson(400, jsonError("invalid hostexec headers", "INVALID_REQUEST")); - return; - } - - const auto lines = splitBodyLines(readRequestBody()); - if (lines.empty() || trim(lines.front()).empty()) { - sendJson(400, jsonError("request body must contain command path on the first line", "INVALID_REQUEST")); - return; - } - - std::vector args = {"hostexec", "invoke", app, "--user", user, "--token", token, "--"}; - for (const auto& line : lines) { - args.push_back(line); - } - - const auto result = runCapbox(args); - if (result.exit_code != 0) { - sendJson(403, jsonError(trim(result.output), "HOSTEXEC_DENIED")); - return; - } - sendJson(200, trim(result.output)); -} - -void handleLogin() { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - - const auto form = parseKv(readRequestBody()); - const auto usernameIt = form.find("username"); - const auto passwordIt = form.find("password"); - if (usernameIt == form.end() || passwordIt == form.end()) { - sendJson(400, jsonError("username and password are required", "INVALID_REQUEST")); - return; - } - - uid_t uid = 0; - std::string error; - if (!verifyPassword(usernameIt->second, passwordIt->second, uid, error)) { - sendJson(401, jsonError(error, "INVALID_CREDENTIALS")); - return; - } - - Session session; - session.id = randomHex(24); - session.username = usernameIt->second; - session.uid = uid; - session.is_sudo = userIsSudo(session.username); - session.created_at = std::time(nullptr); - session.expires_at = session.created_at + kSessionTtl; - - if (!saveSession(session)) { - sendJson(500, jsonError("failed to persist session")); - return; - } - cleanupExpiredSessions(); - - sendJson( - 200, - "{\"ok\":true,\"session\":" + sessionJson(session) + "}", - {{"Set-Cookie", sessionCookieHeader(session.id, kSessionTtl)}} - ); -} - -void handleLogout() { - if (const auto session = currentSession(); session.has_value()) { - deleteSession(session->id); - } - sendJson( - 200, - "{\"ok\":true}", - {{"Set-Cookie", sessionCookieHeader("", 0)}} - ); -} - -void handleSessionInfo() { - const auto session = currentSession(); - if (!session.has_value()) { - sendJson(200, "{\"ok\":true,\"authenticated\":false}"); - return; - } - sendJson(200, "{\"ok\":true,\"authenticated\":true,\"session\":" + sessionJson(*session) + "}"); -} - -void handleMe(const Session& session) { - sendJson(200, "{\"ok\":true,\"session\":" + sessionJson(session) + "}"); -} - -void handleSystem(const Session& session) { - const auto result = runCapbox({"user", "info", "--user", session.username}); - if (result.exit_code != 0) { - sendJson(500, jsonError(trim(result.output), "SYSTEM_INFO_FAILED")); - return; - } - sendJson(200, "{\"ok\":true,\"service\":\"capos-webpanel\",\"user\":" + trim(result.output) + "}"); -} - -void handleAppsList(const Session& session) { - const auto result = runCapbox({"app", "list", "--user", session.username}); - if (result.exit_code != 0) { - sendJson(500, jsonError(trim(result.output))); - return; - } - sendJson(200, "{\"ok\":true,\"apps\":" + trim(result.output) + "}"); -} - -void handleAppInfo(const Session& session, const std::string& app) { - if (!validateAppOrSend(app)) { - return; - } - const auto result = runCapbox({"app", "info", app, "--user", session.username}); - if (result.exit_code != 0) { - sendJson(404, jsonError(trim(result.output), "APP_NOT_FOUND")); - return; - } - sendJson(200, trim(result.output)); -} - -void handleAppAction(const Session& session, const std::string& app, const std::string& action) { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - if (!validateAppOrSend(app)) { - return; - } - if (!isAllowedAppAction(action)) { - sendJson(400, jsonError("unsupported app action", "INVALID_ACTION")); - return; - } - - const auto result = runCapbox({"app", action, app, "--user", session.username}); - if (result.exit_code != 0) { - const auto output = trim(result.output); - sendJson(capboxFailureStatus(output), jsonError(output, "APP_ACTION_FAILED")); - return; - } - sendJson(200, trim(result.output)); -} - -void handleAppReconcile(const Session& session, const std::string& app) { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - if (!validateAppOrSend(app)) { - return; - } - const auto result = runCapbox({"reconcile", app, "--user", session.username}); - if (result.exit_code != 0) { - const auto output = trim(result.output); - sendJson(capboxFailureStatus(output), jsonError(output, "RECONCILE_FAILED")); - return; - } - sendJson(200, trim(result.output)); -} - -void handleAppLogs(const Session& session, const std::string& app) { - if (getenvOrEmpty("REQUEST_METHOD") != "GET") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - if (!validateAppOrSend(app)) { - return; - } - const auto result = runCapbox({"app", "logs", app, "--user", session.username}); - if (result.exit_code != 0) { - const auto output = trim(result.output); - sendJson(capboxFailureStatus(output), jsonError(output, "APP_LOGS_FAILED")); - return; - } - sendJson(200, trim(result.output)); -} - -void handleAppPorts(const Session& session, const std::string& app) { - if (!validateAppOrSend(app)) { - return; - } - const auto method = getenvOrEmpty("REQUEST_METHOD"); - if (method == "GET") { - const auto result = runCapbox({"portmap", "list", app, "--user", session.username}); - if (result.exit_code != 0) { - sendJson(400, jsonError(trim(result.output), "PORTMAP_LIST_FAILED")); - return; - } - sendJson(200, "{\"ok\":true,\"portmaps\":" + trim(result.output) + "}"); - return; - } - - if (method != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - - const auto form = parseKv(readRequestBody()); - const auto action = form.count("action") ? form.at("action") : "set"; - const auto listenIt = form.find("listen"); - const auto targetIt = form.find("target"); - std::string proto; - if (listenIt == form.end() || targetIt == form.end()) { - sendJson(400, jsonError("listen and target are required", "INVALID_REQUEST")); - return; - } - std::string listen; - std::string target; - if (!parsePortValue(listenIt->second, listen) || !parsePortValue(targetIt->second, target)) { - sendJson(400, jsonError("ports must be between 1 and 65535", "INVALID_PORT")); - return; - } - if (!normalizeProto(form.count("proto") ? form.at("proto") : "tcp", proto)) { - sendJson(400, jsonError("proto must be tcp or udp", "INVALID_PROTO")); - return; - } - if (action != "set" && action != "remove") { - sendJson(400, jsonError("unsupported port action", "INVALID_ACTION")); - return; - } - - ExecResult result; - if (action == "remove") { - result = runCapbox({"portmap", "remove", app, "--listen", listen, "--target", target, "--proto", proto, "--user", session.username}); - } else { - result = runCapbox({"portmap", "set", app, "--listen", listen, "--target", target, "--proto", proto, "--user", session.username}); - } - - if (result.exit_code != 0) { - const auto output = trim(result.output); - sendJson(capboxFailureStatus(output), jsonError(output, "PORTMAP_UPDATE_FAILED")); - return; - } - sendJson(200, "{\"ok\":true,\"portmaps\":" + trim(result.output) + "}"); -} - -void handleDesktopGet(const Session& session) { - const auto result = runCapbox({"desktop", "get", "--user", session.username}); - if (result.exit_code != 0) { - sendJson(500, jsonError(trim(result.output))); - return; - } - sendJson(200, trim(result.output)); -} - -void handleDesktopSet(const Session& session) { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - - const auto form = parseKv(readRequestBody()); - const auto appIt = form.find("app"); - if (appIt == form.end() || appIt->second.empty()) { - sendJson(400, jsonError("app is required", "INVALID_REQUEST")); - return; - } - if (!validateAppOrSend(appIt->second)) { - return; - } - - const auto result = runCapbox({"desktop", "set", appIt->second, "--user", session.username}); - if (result.exit_code != 0) { - sendJson(400, jsonError(trim(result.output), "DESKTOP_SET_FAILED")); - return; - } - sendJson(200, trim(result.output)); -} - -void handleUpload(const Session& session, const std::map& query) { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - - std::string filename = "upload.cpk"; - if (const auto it = query.find("filename"); it != query.end() && !it->second.empty()) { - filename = sanitizeUploadFilename(it->second); - if (filename != it->second || !isValidUploadFilename(filename)) { - sendJson(400, jsonError("upload filename may only contain letters, digits, dot, dash and underscore and must end with .cpk, .tgz or .tar.gz", "INVALID_UPLOAD_FILENAME")); - return; - } - } else if (!isValidUploadFilename(filename)) { - sendJson(400, jsonError("invalid upload filename", "INVALID_UPLOAD_FILENAME")); - return; - } - - const auto uploadId = randomHex(8); - const auto targetPath = uploadPathFor(session.id, uploadId, filename); - const auto body = readRequestBody(); - if (body.empty()) { - sendJson(400, jsonError("empty upload body", "EMPTY_UPLOAD")); - return; - } - - if (!writeFile(targetPath, body) || !writeFile(uploadMetaPath(session.id, uploadId), targetPath)) { - sendJson(500, jsonError("failed to save upload")); - return; - } - - const auto inspect = runCapbox({"cpk", "validate", targetPath, "--user", session.username}); - if (inspect.exit_code != 0) { - std::remove(targetPath.c_str()); - std::remove(uploadMetaPath(session.id, uploadId).c_str()); - const auto output = trim(inspect.output); - sendJson(capboxFailureStatus(output), jsonError(output, "INVALID_CPK")); - return; - } - - std::ostringstream out; - out << "{" - << "\"ok\":true," - << "\"upload\":{" - << "\"id\":\"" << jsonEscape(uploadId) << "\"," - << "\"filename\":\"" << jsonEscape(filename) << "\"" - << "}," - << "\"inspection\":" << trim(inspect.output) - << "}"; - sendJson(200, out.str()); -} - -void handleInstall(const Session& session) { - if (getenvOrEmpty("REQUEST_METHOD") != "POST") { - sendJson(405, jsonError("method not allowed", "METHOD_NOT_ALLOWED")); - return; - } - - const auto form = parseKv(readRequestBody()); - const auto uploadIt = form.find("upload_id"); - if (uploadIt == form.end() || !isValidUploadId(uploadIt->second)) { - sendJson(400, jsonError("upload_id is required", "INVALID_REQUEST")); - return; - } - - const auto path = lookupUploadPath(session.id, uploadIt->second); - if (!path.has_value()) { - sendJson(404, jsonError("uploaded CPK not found", "UPLOAD_NOT_FOUND")); - return; - } - - const auto install = runCapbox({"cpk", "install", *path, "--user", session.username}); - if (install.exit_code != 0) { - const auto output = trim(install.output); - sendJson(capboxFailureStatus(output), jsonError(output, "INSTALL_FAILED")); - return; - } - - std::remove(path->c_str()); - std::remove(uploadMetaPath(session.id, uploadIt->second).c_str()); - sendJson(200, trim(install.output)); -} - -} // namespace - -int main() { - cleanupExpiredSessions(); - - const auto path = effectivePathInfo(); - const auto segments = splitPath(path); - const auto query = parseKv(getenvOrEmpty("QUERY_STRING")); - - if (segments.empty()) { - handleRoot(); - return 0; - } - - if (segments[0] == "login") { - handleLogin(); - return 0; - } - if (segments[0] == "logout") { - handleLogout(); - return 0; - } - if (segments[0] == "session") { - handleSessionInfo(); - return 0; - } - if (segments[0] == "hostexec") { - handleHostexec(); - return 0; - } - - const auto session = requireSession(); - if (!session.has_value()) { - return 0; - } - - if (segments[0] == "me") { - handleMe(*session); - return 0; - } - - if (segments[0] == "system") { - handleSystem(*session); - return 0; - } - - if (segments[0] == "apps") { - if (segments.size() == 1) { - handleAppsList(*session); - return 0; - } - if (segments.size() == 2) { - handleAppInfo(*session, segments[1]); - return 0; - } - if (segments.size() == 3) { - if (segments[2] == "ports") { - handleAppPorts(*session, segments[1]); - return 0; - } - if (segments[2] == "reconcile") { - handleAppReconcile(*session, segments[1]); - return 0; - } - if (segments[2] == "logs") { - handleAppLogs(*session, segments[1]); - return 0; - } - handleAppAction(*session, segments[1], segments[2]); - return 0; - } - } - - if (segments[0] == "desktop") { - if (getenvOrEmpty("REQUEST_METHOD") == "GET") { - handleDesktopGet(*session); - } else { - handleDesktopSet(*session); - } - return 0; - } - - if (segments[0] == "upload") { - handleUpload(*session, query); - return 0; - } - - if (segments[0] == "install") { - handleInstall(*session); - return 0; - } - - sendJson(404, jsonError("not found", "NOT_FOUND")); - return 0; -} diff --git a/scripts/ci/qemu-port-check.sh b/scripts/ci/qemu-port-check.sh index f560da24682..eddef5e3afa 100755 --- a/scripts/ci/qemu-port-check.sh +++ b/scripts/ci/qemu-port-check.sh @@ -173,8 +173,8 @@ probe_service() { return 1 fi if curl -fsS --connect-timeout 2 --max-time 5 "$probe_url" -o "$response"; then - if grep -q '"service"[[:space:]]*:[[:space:]]*"capos-webpanel-api"' "$response"; then - echo "CapOS webpanel responded on $probe_url" + if grep -q '"service"[[:space:]]*:[[:space:]]*"capos-webdesktop-api"' "$response"; then + echo "CapOS WebDesktop responded on $probe_url" return 0 fi last_status=1 @@ -184,7 +184,7 @@ probe_service() { sleep 3 done - echo "timed out waiting for CapOS webpanel on $probe_url (last curl status: $last_status)" >&2 + echo "timed out waiting for CapOS WebDesktop on $probe_url (last curl status: $last_status)" >&2 tail -n 240 "$log" >&2 || true return 1 } diff --git a/tests/capbox_logic_tests.sh b/tests/capbox_logic_tests.sh deleted file mode 100755 index 9f3a5a356b5..00000000000 --- a/tests/capbox_logic_tests.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CAPBOX_LIB_ONLY=1 -source "$ROOT_DIR/package/capos/capbox/files/capbox" - -TMPDIR="$(mktemp -d)" -trap 'rm -rf "$TMPDIR"' EXIT - -export CAPBOX_STATE_DIR="$TMPDIR/state" -export CAPBOX_RUN_DIR="$TMPDIR/run" -export PATH="$TMPDIR/bin:$PATH" - -mkdir -p "$TMPDIR/bin" -cat >"$TMPDIR/bin/apk" <<'APK' -#!/usr/bin/env sh -if [ "$1" = "info" ] && [ "$2" = "-e" ] && [ "$3" = "busybox" ]; then - exit 0 -fi -exit 1 -APK -chmod +x "$TMPDIR/bin/apk" - -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -assert_ok() { - ( "$@" ) || fail "expected success: $*" -} - -assert_fail() { - if ( "$@" ) >/dev/null 2>&1; then - fail "expected failure: $*" - fi -} - -assert_contains() { - local haystack="$1" - local needle="$2" - [[ "$haystack" == *"$needle"* ]] || fail "expected [$haystack] to contain [$needle]" -} - -current_user="$(id -un)" -ensure_app_dirs "$current_user" sharedapp -cat >"$(app_manifest_json_path "$current_user" sharedapp)" <<'JSON' -{"version":"1.1","app":{"name":"sharedapp","version":"1.0"},"network":{},"container":{}} -JSON -cat >"$(app_meta_path "$current_user" sharedapp)" <"$valid_manifest" <<'JSON' -{ - "version": "1.1", - "app": { - "name": "demo_app", - "version": "2.3.4", - "nickname": "Demo", - "description": "Demo application" - }, - "dependencies": { - "apk": ["busybox"], - "opkg": ["legacy-should-not-be-checked"], - "capp": ["sharedapp"] - }, - "network": { - "publish": [ - "18080:80", - {"listen": 18443, "target": 443, "proto": "tcp"} - ], - "service": {"http": 80} - }, - "container": { - "environment": { - "PUID": "1000", - "PGID": "1000" - }, - "volumes": { - "from": ["sharedapp:/data"] - } - }, - "host": { - "exec": { - "enabled": true, - "allow": ["/bin/echo"] - } - } -} -JSON - -invalid_env_manifest="$TMPDIR/invalid-env.json" -cat >"$invalid_env_manifest" <<'JSON' -{ - "version": "1.1", - "app": {"name": "badenv", "version": "1.0"}, - "container": {"environment": ["BAD-NAME=value"]} -} -JSON - -invalid_hostexec_manifest="$TMPDIR/invalid-hostexec.json" -cat >"$invalid_hostexec_manifest" <<'JSON' -{ - "version": "1.1", - "app": {"name": "badexec", "version": "1.0"}, - "host": {"exec": {"enabled": true, "allow": ["echo unsafe"]}} -} -JSON - -assert_ok validate_app_name "demo_app" -assert_fail validate_app_name "Demo-App" - -publish_rows="$(manifest_publish_rules_tsv "$valid_manifest")" -assert_contains "$publish_rows" $'18080:80\ttcp\t18080\t80' -assert_contains "$publish_rows" $'{"listen":18443,"target":443,"proto":"tcp"}\ttcp\t18443\t443' - -env_rows="$(manifest_environment_items "$valid_manifest")" -assert_contains "$env_rows" "PUID=1000" -assert_contains "$env_rows" "PGID=1000" - -risks="$(manifest_risks_json "$valid_manifest")" -assert_contains "$risks" "apk_dependencies" -assert_contains "$risks" "app_dependencies" -assert_contains "$risks" "environment" -assert_contains "$risks" "shared_volumes" -assert_contains "$risks" "host_exec" - -assert_ok validate_manifest_for_user "$valid_manifest" "$current_user" -assert_fail validate_manifest_for_user "$invalid_env_manifest" "$current_user" -assert_fail validate_manifest_for_user "$invalid_hostexec_manifest" "$current_user" - -assert_ok validate_publish_mapping 18080 80 tcp "$current_user" -assert_ok validate_publish_mapping 5353 53 udp "$current_user" -assert_fail validate_publish_mapping 0 80 tcp "$current_user" -assert_fail validate_publish_mapping 18080 70000 tcp "$current_user" -assert_fail validate_publish_mapping 18080 80 sctp "$current_user" - -ensure_app_dirs "$current_user" hostapp -cat >"$(app_manifest_json_path "$current_user" hostapp)" <<'JSON' -{"version":"1.1","app":{"name":"hostapp","version":"1.0"},"host":{"exec":{"enabled":true,"allow":["/bin/echo","relative"]}}} -JSON -cat >"$(app_meta_path "$current_user" hostapp)" <[[:space:]]*[^<]' "$INDEX"; then exit 1 fi -if grep -RInE 'https?://|cdn|telemetry|window\.prompt|window\.confirm' "$HTDOCS"; then - echo "frontend should stay local-only and avoid prompt/confirm" >&2 +if grep -RInE 'cdn|telemetry|window\.prompt|window\.confirm' "$HTDOCS"; then + echo "frontend should avoid remote dependencies, telemetry, and prompt/confirm" >&2 + exit 1 +fi + +unexpected_urls="$(grep -RhoE 'https?://[^"[:space:]]+' "$HTDOCS" | sort -u | grep -vFx 'https://snap.capos.top/embed' || true)" +if [[ -n "$unexpected_urls" ]]; then + echo "unexpected remote frontend URL(s):" >&2 + echo "$unexpected_urls" >&2 exit 1 fi @@ -40,6 +47,19 @@ if (missing.length) { console.error(`missing DOM ids referenced by app.js: ${[...new Set(missing)].join(", ")}`); process.exit(1); } +for (const expected of [ + 'event.origin!==STORE_ORIGIN', + 'event.source!==frame.contentWindow', + 'capos-webdesktop:state', + 'capos-webdesktop:progress', + 'capos-store:install', + 'capos-store:open', +]) { + if (!app.includes(expected)) { + console.error(`missing Snap Store bridge guard/protocol marker: ${expected}`); + process.exit(1); + } +} NODE -echo "webpanel frontend smoke passed" +echo "webdesktop frontend smoke passed" diff --git a/tests/webdesktop_proxy_smoke.sh b/tests/webdesktop_proxy_smoke.sh new file mode 100755 index 00000000000..6db98f16fe4 --- /dev/null +++ b/tests/webdesktop_proxy_smoke.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_BIN="${APP_BIN:-/tmp/capos-webdesktop-app-smoke}" +TMPDIR="$(mktemp -d)" +SESSION_ID="0123456789abcdef0123456789abcdef0123456789abcdef" +SESSION_DIR="/tmp/capos-webdesktop/sessions" +SESSION_PATH="$SESSION_DIR/$SESSION_ID.session" +SERVER_PID="" +cleanup(){ [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true; rm -f "$SESSION_PATH"; rm -rf "$TMPDIR"; } +trap cleanup EXIT + +if [[ ! -x "$APP_BIN" ]]; then + g++ -std=gnu++17 -I"$ROOT_DIR/package/capos/capos-webdesktop/src" \ + -o "$APP_BIN" "$ROOT_DIR/package/capos/capos-webdesktop/src/app.cpp" -lcrypt -lssl -lcrypto +fi +mkdir -p "$SESSION_DIR" +cat >"$SESSION_PATH" <next' + c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nSet-Cookie: upstream=1; Path=/\r\nLocation: /next\r\nContent-Length: "+str(len(body)).encode()+b"\r\n\r\n"+body) +PY +SERVER_PID=$! +for _ in $(seq 1 50); do [[ -s "$TMPDIR/port" ]] && break; sleep .1; done +test -s "$TMPDIR/port" +PORT="$(cat "$TMPDIR/port")" + +output="$(env -i PATH="$PATH" REQUEST_METHOD=GET PATH_INFO=/demo/ QUERY_STRING= HTTP_COOKIE="capos_session=$SESSION_ID" "$APP_BIN" /dev/null || true - [[ -n "$WS_PID" ]] && kill "$WS_PID" 2>/dev/null || true - rm -f "$SESSION_PATH" - rm -rf "$TMPDIR" -} -trap cleanup EXIT - -if [[ ! -x "$APP_BIN" ]]; then - g++ -std=gnu++17 -I"$ROOT_DIR/package/capos/capos-webpanel/src" \ - -o "$APP_BIN" "$ROOT_DIR/package/capos/capos-webpanel/src/app.cpp" \ - -lcrypt -lssl -lcrypto -fi - -mkdir -p "$SESSION_DIR" "$TMPDIR/bin" -cat >"$SESSION_PATH" <"$TMPDIR/bin/capbox" <<'CAPBOX' -#!/usr/bin/env sh -if [ "$1 $2" = "desktop get" ]; then - printf '{"ok":true,"app":"demo"}\n' - exit 0 -fi -if [ "$1 $2 $3" = "app info demo" ]; then - cat "$CAPBOX_INFO_JSON" - exit 0 -fi -echo "unexpected capbox call: $*" >&2 -exit 1 -CAPBOX -chmod +x "$TMPDIR/bin/capbox" - -wait_for_port_file() { - local path="$1" - for _ in $(seq 1 50); do - [[ -s "$path" ]] && return 0 - sleep 0.1 - done - echo "timed out waiting for $path" >&2 - exit 1 -} - -openssl req -x509 -newkey rsa:2048 -nodes \ - -keyout "$TMPDIR/key.pem" \ - -out "$TMPDIR/cert.pem" \ - -subj "/CN=127.0.0.1" \ - -days 1 >/dev/null 2>&1 - -python3 - "$TMPDIR/https.port" "$TMPDIR/cert.pem" "$TMPDIR/key.pem" <<'PY' & -import socket -import ssl -import sys - -port_file, cert_file, key_file = sys.argv[1:4] -server = socket.socket() -server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -server.bind(("127.0.0.1", 0)) -server.listen(1) -with open(port_file, "w", encoding="utf-8") as handle: - handle.write(str(server.getsockname()[1])) -context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -context.load_cert_chain(cert_file, key_file) -conn, _ = server.accept() -with context.wrap_socket(conn, server_side=True) as tls: - data = b"" - while b"\r\n\r\n" not in data: - chunk = tls.recv(4096) - if not chunk: - break - data += chunk - body = b'next' - tls.sendall( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/html; charset=utf-8\r\n" - b"Set-Cookie: upstream=1; Path=/\r\n" - b"Location: /next\r\n" - + b"Content-Length: " + str(len(body)).encode() + b"\r\n" - b"\r\n" + body - ) -server.close() -PY -HTTPS_PID=$! -wait_for_port_file "$TMPDIR/https.port" -HTTPS_PORT="$(cat "$TMPDIR/https.port")" - -cat >"$TMPDIR/info-https.json" <"$TMPDIR/info-ws.json" <Deployment targets

Default access points

-
Web PanelHTTP 2000/tcp · HTTPS 2020/tcp
+
WebDesktopHTTP 2000/tcp · HTTPS 2020/tcp
SSH22/tcp (after root password is set)
Telnet23/tcp (initial bootstrap stage)
Network defaultsDHCP + DHCPv6
From 291f984835af285966bbaaa633baaa6e5f502790 Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 03:24:38 +0000 Subject: [PATCH 4/5] docs: align CapOS with Snap ecosystem --- .github/workflows/os-sanity.yml | 20 +- README.md | 23 +- TODO.md | 1098 ++----------------------------- 3 files changed, 99 insertions(+), 1042 deletions(-) diff --git a/.github/workflows/os-sanity.yml b/.github/workflows/os-sanity.yml index 341579080d9..84d2e369544 100644 --- a/.github/workflows/os-sanity.yml +++ b/.github/workflows/os-sanity.yml @@ -57,21 +57,19 @@ jobs: # 只做可读性检查,不 update/install(不触网) ./scripts/feeds list >/dev/null - - name: CapOS webdesktop and capbox smoke tests + - name: CapOS WebDesktop smoke tests shell: bash run: | set -euxo pipefail - bash -n package/capos/capbox/files/capbox - g++ -std=gnu++17 -Ipackage/capos/capos-webpanel/src \ - -o /tmp/capos-api-smoke package/capos/capos-webpanel/src/api.cpp -lcrypt - g++ -std=gnu++17 -Ipackage/capos/capos-webpanel/src \ - -o /tmp/capos-app-smoke package/capos/capos-webpanel/src/app.cpp -lcrypt -lssl -lcrypto + g++ -std=gnu++17 -Ipackage/capos/capos-webdesktop/src \ + -o /tmp/capos-api-smoke package/capos/capos-webdesktop/src/api.cpp -lcrypt -lssl -lcrypto + g++ -std=gnu++17 -Ipackage/capos/capos-webdesktop/src \ + -o /tmp/capos-app-smoke package/capos/capos-webdesktop/src/app.cpp -lcrypt -lssl -lcrypto - tests/capbox_logic_tests.sh - tests/webpanel_frontend_smoke.sh - tests/webpanel_proxy_smoke.sh - tests/webpanel_runtime_config_smoke.sh + tests/webdesktop_frontend_smoke.sh + tests/webdesktop_proxy_smoke.sh + tests/webdesktop_runtime_config_smoke.sh - name: Kconfig sanity (defconfig) shell: bash @@ -79,6 +77,8 @@ jobs: set -euxo pipefail # 不跑交互 menuconfig,只验证 Kconfig/配置生成链路 make defconfig + grep -q '^CONFIG_TARGET_ROOTFS_ARGOSFS=y' .config + grep -q '^CONFIG_TARGET_ROOTFS_PERSIST_VAR=y' .config - name: Package metadata sanity (prepare-tmpinfo) shell: bash diff --git a/README.md b/README.md index 6d53f5e7eb8..b43e6ed2512 100755 --- a/README.md +++ b/README.md @@ -46,8 +46,8 @@ Instead of feeling like a stripped-down build environment, CapOS aims to feel li

Runs with a small footprint and works well across a broad range of hardware, from compact devices to full PCs.

-

Webdesktop Management

-

Manage the system from a browser through a more visual, approachable interface for settings, packages, and system status.

+

WebDesktop + Snap Store

+

Manage the system and install applications from the official Snap ecosystem directly in CapOS WebDesktop. Web services are discovered and integrated automatically.

@@ -76,13 +76,24 @@ Instead of feeling like a stripped-down build environment, CapOS aims to feel li | Area | Default | | --- | --- | -| Web panel | `2000/tcp` (HTTP), `2020/tcp` (HTTPS) | +| WebDesktop | `2000/tcp` (HTTP), `2020/tcp` (HTTPS) | | Remote access | `23/tcp` for Telnet before a root password is set, `22/tcp` for SSH | | Network mode | Uses `DHCP` and `DHCPv6` by default | | Firewall | Accepts inbound traffic from `LAN`; rejects inbound traffic from `WAN` except `2000/tcp` and `2020/tcp` | More usage details are available in the [User Guide](https://github.com/fwerkor/capos/wiki/User-guide). +## Application Architecture + +CapOS separates system packages from user applications: + +- `apk` manages CapOS itself: the kernel, drivers, WebDesktop, snapd, networking, storage, and other system components. +- `snapd` installs user applications directly from the official Snap Store. +- `systemd-on-procd` implements the systemd command/unit boundary needed by systemd-centric software while keeping procd as PID 1. +- `capos-webdesktop` talks to snapd over `/run/snapd.socket`, manages install/update/remove operations, and discovers Web entrypoints from Snap-owned listening sockets. It probes HTTP/HTTPS and proxies the best Web endpoint without a curated CapOS application catalog. + +This means CapOS does not repackage third-party Snap applications or maintain a parallel application ecosystem. + ## Downloads | Resource | Link | @@ -93,13 +104,13 @@ More usage details are available in the [User Guide](https://github.com/fwerkor/ ## Development -CapOS welcomes contributors, maintainers, and application developers. If you want to improve the platform or build apps around it, the best starting points are below. +CapOS welcomes contributors and maintainers. System components continue to use CapOS/OpenWrt packages, while user applications are installed from the official Snap Store. Application authors do not need a CapOS-specific package format. | Topic | Link | | --- | --- | | Developer guide | [CapOS Developer Guide](https://github.com/fwerkor/capos/wiki/Developer-guide) | -| App development | [CAPP Development Guide](https://blog.fwerkor.com/archives/1123) | -| Example project | [capp-helloworld](https://github.com/fwerkor/capp-helloworld) | +| Application packaging | [Snapcraft documentation](https://snapcraft.io/docs) | +| systemd compatibility | [systemd-on-procd](https://github.com/fwerkor/systemd-on-procd) | ### Source Code Access diff --git a/TODO.md b/TODO.md index eaa20bafeef..1e0857b3844 100644 --- a/TODO.md +++ b/TODO.md @@ -1,1053 +1,99 @@ -# CapOS `capbox` / `capos-webpanel` 实现方案 +# CapOS application platform roadmap -## 1. 文档目的 +CapOS uses the upstream Snap ecosystem for user applications. CapOS does not maintain a second application package format or a curated mirror of Snap applications. -本文件用于记录 `package/capos/capbox` 与 `package/capos/capos-webpanel` 的完整实现方案,作为后续开发的统一基线。 +## Architecture -当前目标不是一次性做出“应用商店生态”,而是先把 CapOS 自带的应用运行与管理能力做扎实,包括: +- `apk`: CapOS system packages, kernel, drivers, WebDesktop, snapd, networking, storage and recovery components. +- `snapd`: upstream application installation, assertions, channels, refresh, rollback and Snap Store access. +- `systemd-on-procd`: systemd command/unit compatibility for software that assumes systemd; procd remains PID 1. +- `capos-webdesktop`: browser control plane, Snap Store frontend, lifecycle UI, automatic Web endpoint discovery and reverse proxy. -- 基于 Podman 的多用户应用隔离运行环境 -- 基于 CPK 的本地上传安装 -- 基于 Linux 用户的鉴权和权限控制 -- 基于 Web 面板的应用管理与桌面应用展示 +Dependency chain: -## 2. 已确认的产品边界 - -### 2.1 已确认事实 - -- CapOS 基于 OpenWrt。 -- Web UI 运行在 `2000/tcp` 和 `2020/tcp`。 -- `/www/index.html` 已跳转到 `/cap/`。 -- `uhttpd` 已启用 `/cgi-bin` 前缀,适合部署 CGI 程序。 -- “用户”概念完全等价于 Linux 用户。 -- 应用本质是 Podman 容器。 -- 不同用户之间的应用库必须隔离。 -- 同一用户不能重复安装同一个应用。 -- 同一个应用名可以被不同用户分别安装。 -- 每个用户的所有应用处于同一个虚拟网络中。 -- 同一用户的应用之间可以通过“应用名”直接互访。 -- 不同用户安装同名应用时,网络和运行时不能互相冲突。 -- 应用默认不绑定宿主机端口,除非元数据声明或用户后续手动配置。 -- 只有 sudo 用户能配置宿主机 `1024` 以下端口。 -- 只有 sudo 用户能在线安装需要 `host network` 的应用。 -- sudo 用户登录后能看到 LuCI 链接,普通用户不能。 -- 每个用户都可以从自己的已安装应用中选一个“桌面应用”。 - -### 2.2 本轮新增确认 - -- “在线安装”不是软件源,不做内建应用商店。 -- Web 面板中的“在线安装”实际含义是“用户上传 `.cpk` 文件并安装”。 -- 更复杂的应用商店能力应由一个独立的“应用”来实现,而不是做进系统面板。 -- 某些应用需要具备“在宿主机上以当前用户身份执行命令”的能力。 - -### 2.3 本轮不做的内容 - -- 不做完整应用商店。 -- 不做远程仓库索引、搜索、推荐、评分、自动更新。 -- 不做跨用户应用统一管理后台。 -- 不做复杂的多节点或集群能力。 - -## 3. 仓库现状 - -### 3.1 现有文件 - -- `package/capos/capbox/Makefile` -- `package/capos/capbox/files/capbox` -- `package/capos/capos-webpanel/Makefile` -- `package/capos/capos-webpanel/src/api.cpp` -- `package/capos/capos-webpanel/src/app.cpp` -- `package/capos/capos-webpanel/htdocs/index.html` -- `files/www/index.html` - -### 3.2 当前状态 - -- `capbox` 目前只有一个 `reload_portmap()` 的 bash 原型函数。 -- `capos-webpanel` 目前仍是 Hello World 占位。 -- `uhttpd` 默认监听 `2000/2020`,文档根目录是 `/www`,CGI 前缀为 `/cgi-bin`。 -- `files/www/index.html` 已将根路径跳转到 `/cap/`。 - -### 3.3 现状判断 - -- 现有代码足以作为包骨架。 -- `capbox` 和 `capos-webpanel` 的主体实现基本需要从头设计。 -- 当前是适合统一定义架构、状态布局、权限模型和 API 边界的阶段。 - -## 4. 总体架构 - -### 4.1 设计原则 - -- 高权限操作只收敛在 `capbox`。 -- `capos-webpanel` 不直接控制 Podman,而是通过 `capbox` 间接执行。 -- 用户权限判断统一基于 Linux 用户与 sudo 能力。 -- 容器运行时和 Web 鉴权状态分离。 -- 所有涉及多用户隔离、安全边界、端口控制、宿主机命令执行的逻辑,必须在后端强制校验,不能只依赖前端限制。 - -### 4.2 模块分工 - -#### `capbox` - -负责应用安装、校验、运行、停止、删除、端口映射、网络管理、桌面应用配置、宿主机受控命令执行等“系统级能力”。 - -#### `capos-webpanel` - -负责用户登录、会话管理、权限判断、上传 CPK、展示应用列表、触发安装与管理动作、显示桌面应用、为 sudo 用户提供 LuCI 入口。 - -### 4.3 调用关系 - -用户浏览器 -> `uhttpd` -> `capos-webpanel` CGI -> `capbox` CLI -> Podman / 系统命令 - -### 4.4 为什么这样拆分 - -- 便于把高权限逻辑集中审计。 -- 便于未来独立“应用商店”应用复用 `capbox` 能力。 -- 便于在 CLI 和 Web 两侧共享同一套应用管理语义。 - -## 5. CPK 与元数据方案 - -## 5.1 CPK 格式 - -- CPK 本质是 `.tar.gz`。 -- 包内核心文件是 `config.yaml`。 -- 包内包含按架构区分的镜像 tar 文件,如 `image_amd64.tar`、`image_arm64.tar`。 -- 可包含图标等附加文件,例如 `icon.jpg`。 - -### 5.2 已知元数据字段 - -根据当前参考格式,`config.yaml` 包含以下主要结构: - -- `version` -- `app` -- `dependencies` -- `network` -- `container` -- `healthcheck` -- `build` - -### 5.3 本轮必须支持的字段 - -#### `app` - -- `name` -- `version` -- `nickname` -- `description` -- `source` -- `author` -- `vendor` -- `license` -- `docs` - -#### `dependencies` - -- `opkg` -- `capp` - -#### `network` - -- `host` -- `publish` -- `service.http` -- `service.https` -- `service.https_skip_check` -- `management.http` -- `management.https` -- `management.https_skip_check` - -#### `container` - -- `cmd.exe` -- `cmd.terminal` -- `volumes.data` -- `volumes.from` -- `volumes.extra` -- `tmpfs` -- `privileges.enabled` -- `privileges.capabilities` -- `privileges.allow_new_privs` -- `systemd` -- `resources.memory_reserved` -- `resources.shm_size` -- `environment` -- `devices` - -#### `healthcheck` - -- `enabled` -- `cmd` -- `interval` -- `retries` -- `start_period` -- `timeout` - -#### `build` - -- `architectures` -- `extra_files` - -### 5.4 推荐的 1.1 增强字段与兼容策略 - -原则: - -- `1.0` 旧格式继续兼容 -- `1.1` 在不破坏旧包的前提下引入更结构化的写法 -- `capbox` 内部统一做规范化,再进入安装和运行逻辑 - -建议从以下几项开始增强: - -#### `network.publish` - -兼容旧写法: - -```yaml -network: - publish: - - "8080:80" - - "5353:53/udp" -``` - -推荐新写法: - -```yaml -network: - publish: - - listen: 8080 - target: 80 - proto: tcp - - listen: 5353 - target: 53 - proto: udp +```text +capos-core +├── snapd +│ └── systemd-on-procd +└── capos-webdesktop + └── snapd ``` -这样更容易做权限检查、冲突检查和未来扩展描述字段。 - -#### `container.environment` - -兼容旧写法: - -```yaml -container: - environment: - - PUID=1000 - - PGID=1000 -``` - -推荐新写法: - -```yaml -container: - environment: - PUID: "1000" - PGID: "1000" -``` - -或者: - -```yaml -container: - environment: - - name: PUID - value: "1000" - - name: PGID - value: "1000" -``` - -#### `container.volumes.from` - -旧设计里写成 `container-name:/path` 不够贴合 CapOS 的“应用”语义,而且 `podman --volumes-from` 实际也是面向整个容器。 - -推荐改成直接引用应用名: - -```yaml -container: - volumes: - from: - - postgres -``` - -或者: - -```yaml -container: - volumes: - from: - - app: postgres -``` - -#### `host.exec` - -为了支持“应用可在宿主机上以当前用户身份执行命令”,建议在 manifest 中扩展一段自定义能力声明: - -```yaml -host: - exec: - enabled: true - allow: - - /usr/bin/du - - /usr/bin/rsync -``` - -也推荐支持对象化写法,便于以后补描述、参数策略或提示文案: - -```yaml -host: - exec: - enabled: true - allow: - - command: /usr/bin/du - - command: /usr/bin/rsync -``` - -#### 桌面入口规则 - -- 桌面应用始终使用 `network.service` 作为显示入口 -- `network.management` 保留给应用自己的管理界面语义,不参与 WebPanel 的桌面显示选择 - -说明: - -- 这不是容器直接拿宿主机 shell。 -- 这是通过 `capbox` 提供的受控执行能力。 -- 需要白名单、参数校验、超时与权限约束。 - -### 5.5 manifest 校验规则 - -- `app.name` 必须匹配 `^[a-z0-9_]+$` -- `app.name` 作为同一用户范围内唯一应用标识 -- `version` 必须存在,当前接受 `1.0` 和 `1.1` -- 目标架构必须存在对应镜像 tar -- 同一用户已安装同名应用时拒绝安装 -- `network.host=true` 时必须判定当前用户是否有权限 -- `network.publish` 同时兼容字符串写法与对象写法,并逐项校验 -- `dependencies.capp` 必须校验是否已在同一用户下安装 -- `container.environment` 同时兼容字符串数组、键值对象和 `{name,value}` 对象数组 -- `container.volumes.from` 只能引用当前用户下已存在应用,并推荐直接使用应用名 -- `host.exec.enabled=true` 时安装页必须标记为高风险能力 - -## 6. 多用户隔离与命名策略 - -### 6.1 用户等价于 Linux 用户 - -- 登录用户名就是系统用户名。 -- 所有应用归属某个 Linux 用户。 -- Web 面板权限直接从当前登录用户的系统身份推导。 - -### 6.2 容器真实命名 - -同名应用允许被不同用户安装,因此容器真实名字不能直接等于应用名。 - -建议命名: - -- 容器:`capbox_u__` -- 网络:`capbox_u_` -- 数据目录:`/var/lib/capbox/users//apps//...` - -### 6.3 网络别名与主机名 - -- 容器加入所属用户的独立网络。 -- 在该网络中将应用名 `app.name` 作为 `hostname` / network alias。 -- 这样同一用户内可以直接使用应用名互访。 -- 不同用户由于处于不同网络,互不冲突。 - -### 6.4 隔离效果 - -示例: - -- A 用户安装 `jellyfin` -- B 用户安装 `jellyfin` -- A 用户的 `nginx_proxy_manager` 可以通过 `http://jellyfin` 访问 A 用户的 `jellyfin` -- B 用户的应用不会访问到 A 用户的 `jellyfin` - -## 7. 本地状态与目录布局 - -### 7.1 持久化状态目录 - -建议使用: - -- `/var/lib/capbox/users//profile.json` -- `/var/lib/capbox/users//desktop_app` -- `/var/lib/capbox/users//apps//manifest.yaml` -- `/var/lib/capbox/users//apps//manifest.json` -- `/var/lib/capbox/users//apps//meta.json` -- `/var/lib/capbox/users//apps//icon.*` -- `/var/lib/capbox/users//apps//portmaps.json` -- `/var/lib/capbox/users//apps//hostexec.json` -- `/var/lib/capbox/users//apps//data/` - -### 7.2 运行时目录 - -建议使用: - -- `/run/capbox/portmap/` -- `/run/capbox/sessions/` 或交由 webpanel 管理 -- `/run/capbox/hostexec/` - -### 7.3 状态原则 - -- `capbox` 必须以磁盘状态为准,而不是进程内存。 -- 当前 `reload_portmap()` 中的 bash 关联数组只能作为原型,不适合正式持久化。 -- 任何重启后的恢复逻辑都应来自磁盘状态 + Podman 实际状态。 - -## 8. `capbox` 详细方案 - -### 8.1 定位 - -`capbox` 是 CapOS 应用运行时的唯一系统级后端入口。 - -### 8.2 建议子命令 - -- `capbox app inspect ` -- `capbox app validate [--user ]` -- `capbox app install [--user ]` -- `capbox app list [--user ]` -- `capbox app info [--user ]` -- `capbox app start [--user ]` -- `capbox app stop [--user ]` -- `capbox app restart [--user ]` -- `capbox app uninstall [--user ]` -- `capbox portmap list [--user ]` -- `capbox portmap set ...` -- `capbox portmap remove ...` -- `capbox desktop get [--user ]` -- `capbox desktop set [--user ]` -- `capbox hostexec run ...` -- `capbox reconcile [--user ]` - -### 8.3 语言选择 - -第一阶段建议继续使用 shell 脚本实现 `capbox`,依赖: - -- `bash` -- `jq` -- `yq` -- `podman` -- `nsenter` -- `socat` - -原因: - -- 与现有代码连续性最好。 -- OpenWrt 打包简单。 -- 先把能力做通,再决定是否迁移到更强类型的实现语言。 - -### 8.4 `capbox` 的核心职责 - -#### CPK 预检 - -- 解压到临时目录 -- 读取并校验 `config.yaml` -- 检查架构匹配 -- 输出标准化的 manifest JSON -- 输出风险摘要 - -#### 安装 - -- 检查当前用户是否已装同名应用 -- 检查 opkg 依赖 -- 检查 CAPP 依赖 -- 导入容器镜像 -- 初始化用户网络 -- 创建应用数据目录 -- 按 manifest 创建容器 -- 保存本地状态 - -#### 生命周期管理 - -- 列表 -- 查询详情 -- 启动 -- 停止 -- 重启 -- 卸载 - -#### 网络与端口 - -- 维护每用户独立网络 -- 管理宿主机端口映射 -- 处理容器间卷引用 - -#### 桌面应用状态 - -- 读写用户当前桌面应用配置 - -#### 宿主机受控命令执行 - -- 按应用声明开放能力 -- 以应用所属 Linux 用户身份执行 -- 限制命令范围与参数范围 - -### 8.5 容器创建策略 - -默认策略: - -- 默认桥接到用户专属网络 -- 默认不暴露宿主机端口 -- `network.host=true` 时改为 host 网络 -- 若声明数据目录,则映射到应用专属数据路径 -- 若声明终端命令,则保存以供后续使用 - -### 8.6 风险项分级 - -以下能力建议在安装前明确标记: - -- `network.host=true` -- 申请宿主机低位端口 -- `container.privileges.enabled=true` -- 请求 `devices` -- 请求宿主机额外挂载 -- 启用 `host.exec` - -其中: - -- `network.host=true` 的本地上传安装仅允许 sudo 用户继续 -- 低位端口映射仅允许 sudo 用户设置 -- 对于高风险 manifest,安装界面必须显示风险说明 - -### 8.7 `reconcile` 机制 - -引入 `capbox reconcile`,用于: - -- 重建端口映射 -- 修复应用状态文件与 Podman 实际状态不一致的问题 -- 系统重启后恢复运行所需辅助进程 - -## 9. 端口映射方案 - -### 9.1 现有基础 - -当前 `capbox` 已有一个 `reload_portmap()` 原型,底层思路是: - -- `podman inspect` 找到容器 PID -- 使用 `nsenter -n` -- 使用 `socat` 将宿主机端口转发到容器网络命名空间内的 `127.0.0.1:` - -### 9.2 正式方案 - -- 将端口映射声明持久化到应用状态目录中 -- 由 `capbox reconcile` 负责根据状态重建映射 -- 不再依赖单一 bash 进程内的 PID 关联数组作为真实状态源 - -### 9.3 规则 - -- 默认无端口映射 -- 用户可为自己的应用配置额外端口映射 -- 普通用户只能使用宿主机 `>=1024` 端口 -- sudo 用户可使用 `1-65535` 全部端口 -- 端口冲突必须检测并报错 -- TCP/UDP 分开管理 - -### 9.4 元数据中的 `network.publish` - -- 安装时读取 -- 逐项做权限判断和冲突判断 -- 通过后转成正式状态 -- 与后续用户手动添加的映射统一进入同一数据结构 - -## 10. 宿主机命令执行能力方案 - -### 10.1 目标 - -允许某些应用代表“当前应用所属用户”在宿主机上执行有限的命令。 - -### 10.2 严格限制 - -- 不直接把宿主机 shell 或 `podman.sock` 暴露给容器 -- 不默认授予 root -- 仅以应用所属 Linux 用户身份执行 -- 必须有 manifest 显式声明 -- 建议必须有命令白名单 -- 必须支持超时 -- 必须记录日志 - -### 10.3 推荐实现 - -第一阶段先落地 HTTP token bridge,后续再考虑收敛到 Unix socket。 - -#### 第一阶段:HTTP token bridge - -- 安装启用 `host.exec` 的应用时,为该应用生成独立 token -- 容器启动时注入: - - `CAPOS_HOSTEXEC_URL` - - `CAPOS_HOSTEXEC_TOKEN_FILE` - - `CAPOS_HOSTEXEC_APP` - - `CAPOS_HOSTEXEC_USER` -- 应用向 `/cgi-bin/cap/api/hostexec` 发起 `POST` -- 通过 `X-CapOS-App`、`X-CapOS-User`、`X-CapOS-Token` 鉴权 -- 请求体第一行是绝对路径命令,后续每行一个参数 -- `capos-webpanel` 调用 `capbox hostexec invoke` -- `capbox` 校验 token、应用归属、命令白名单,再以所属 Linux 用户身份执行 -- 返回 JSON:`ok / exit_code / success / output` - -#### 第二阶段:per-user 或 per-app socket - -- 为每个用户暴露一个受控 Unix socket -- 应用通过 socket 请求执行宿主机命令 -- socket 后面仍然由 `capbox` 统一处理 - -HTTP bridge 先把能力跑通,socket 方案后续再优化安全性和体验。 - -### 10.4 建议限制项 - -- 只允许执行白名单中的绝对路径命令 -- 参数个数和参数格式可限制 -- 指定工作目录白名单 -- 指定环境变量白名单 -- 默认禁止 shell 拼接 -- 记录发起应用、用户、命令、退出码、耗时 - -## 11. `capos-webpanel` 详细方案 - -### 11.1 定位 - -`capos-webpanel` 是 CapOS 自带的轻量 WebDesktop 面板,负责用户认证和应用管理,不承担“应用商店”角色。 - -### 11.2 组成 - -- `src/api.cpp`:JSON API CGI -- `src/app.cpp`:桌面应用代理 CGI -- `htdocs/`:静态前端 - -### 11.3 功能范围 - -- 登录 / 登出 -- 当前用户信息 -- sudo 权限判断 -- 应用列表与详情 -- 上传 CPK -- CPK 预检 -- 安装应用 -- 启停重启卸载 -- 端口映射管理 -- 桌面应用选择 -- 桌面应用显示 -- sudo 用户显示 LuCI 链接 - -### 11.4 不承担的职责 - -- 不做 Podman 直接调用 -- 不做远程仓库管理 -- 不做复杂商店逻辑 -- 不做跨用户统一总控台 - -## 12. 鉴权与会话方案 - -### 12.1 用户来源 - -- 直接使用系统 Linux 用户 -- 不单独维护 Web 用户数据库 - -### 12.2 登录方式 - -建议第一阶段采用表单登录: - -- 用户名 -- 密码 - -服务端校验系统身份,成功后发 session cookie。 - -### 12.3 session 内容 - -建议包含: - -- `session_id` -- `uid` -- `username` -- `is_sudo` -- `issued_at` -- `expires_at` - -### 12.4 session 存储 - -建议存放在: - -- `/run/capos-webpanel/sessions/.json` - -### 12.5 sudo 判断 - -需要服务端可靠判断用户是否具备 sudo 权限。 - -建议: - -- 不只看前端传参 -- 不只依赖前端界面隐藏 -- 在服务端启动安装、端口绑定、host network 等敏感动作前重新检查 - -### 12.6 鉴权原则 - -- 前端所有权限显示仅作为体验优化 -- 最终授权必须由 `api.cpp` 和 `capbox` 双重检查 - -## 13. Web API 设计 - -### 13.1 路径建议 - -- `/cgi-bin/cap/api` -- `/cgi-bin/cap/app` - -### 13.2 API 风格 - -建议采用 JSON over CGI,按动作分发。 - -例如: - -- `POST /cgi-bin/cap/api/login` -- `POST /cgi-bin/cap/api/logout` -- `GET /cgi-bin/cap/api/me` -- `GET /cgi-bin/cap/api/apps` -- `GET /cgi-bin/cap/api/apps/` -- `POST /cgi-bin/cap/api/apps/upload` -- `POST /cgi-bin/cap/api/apps/install` -- `POST /cgi-bin/cap/api/apps//start` -- `POST /cgi-bin/cap/api/apps//stop` -- `POST /cgi-bin/cap/api/apps//restart` -- `DELETE /cgi-bin/cap/api/apps/` -- `GET /cgi-bin/cap/api/apps//ports` -- `POST /cgi-bin/cap/api/apps//ports` -- `DELETE /cgi-bin/cap/api/apps//ports` -- `GET /cgi-bin/cap/api/desktop` -- `POST /cgi-bin/cap/api/desktop` - -### 13.3 上传安装流程 - -建议拆成两步: - -#### 第一步:上传并预检 - -- 用户上传 `.cpk` -- 服务端写入临时目录 -- 调用 `capbox cpk inspect/validate` -- 返回 manifest 摘要和风险提示 - -#### 第二步:用户确认安装 - -- 前端展示关键信息 -- 用户确认后执行安装 -- 服务端调用 `capbox cpk install` - -### 13.4 返回内容建议 - -统一返回: - -- `ok` -- `message` -- `data` -- `error_code` - -### 13.5 错误码建议 - -- `UNAUTHENTICATED` -- `FORBIDDEN` -- `INVALID_MANIFEST` -- `APP_ALREADY_INSTALLED` -- `PORT_CONFLICT` -- `HOST_NETWORK_NOT_ALLOWED` -- `LOW_PORT_NOT_ALLOWED` -- `DEPENDENCY_MISSING` -- `ARCH_NOT_SUPPORTED` - -## 14. 前端 UI 方案 - -### 14.1 总体布局 - -- 顶部导航栏 -- 左侧或顶部应用入口区 -- 主显示区作为桌面应用承载区 - -### 14.2 顶部导航内容 - -- CapOS 标识 -- 当前用户 -- 应用列表 -- 上传安装 -- 端口映射管理 -- 桌面应用选择 -- LuCI 链接,仅 sudo 可见 -- 退出登录 - -### 14.3 主显示区 - -- 若已设置桌面应用,则显示该应用内容 -- 若未设置,则显示引导页 -- 若应用未运行或无可展示入口,则显示状态说明 - -### 14.4 桌面应用选择逻辑 - -每个用户可以从自己的已安装应用中选一个桌面应用。 - -显示入口优先级: - -- `service.http` -- `service.https` - -### 14.5 安装界面展示内容 - -- 应用名 -- 显示名 -- 版本 -- 作者 -- 描述 -- 风险项 -- 是否请求 host network -- 是否请求宿主机命令执行 -- 是否请求特权能力 - -## 15. 桌面应用代理方案 - -### 15.1 `app.cpp` 的作用 - -`app.cpp` 负责把用户当前选中的桌面应用接到 Web 面板内显示。 - -### 15.2 工作方式 - -- 读取当前 session 对应用户 -- 读取该用户的桌面应用配置 -- 解析应用目标端口 -- 找到目标容器 -- 代理请求到该容器服务 - -### 15.3 代理目标的选择 - -优先级: - -- `service.http` -- `service.https` - -### 15.4 需要注意的问题 - -- WebSocket 支持 -- 绝对路径资源 -- 反向代理头 -- HTTPS 证书跳过策略 -- 应用自身是否假设运行在根路径 - -第一阶段先保证基础 HTTP/HTTPS 管理页可代理。 - -## 16. 安装流程细化 - -### 16.1 上传 - -- 用户通过 Web 页面上传 `.cpk` -- 写入临时目录 - -### 16.2 预检 - -- 解包 -- 校验 manifest -- 校验架构 -- 校验依赖 -- 校验权限要求 -- 输出风险摘要 - -### 16.3 确认 - -- 用户确认安装 - -### 16.4 导入与创建 - -- `podman load` 导入镜像 -- 创建用户网络 -- 创建应用数据目录 -- 创建容器 -- 保存状态 - -### 16.5 启动 - -- 若策略允许,直接启动 -- 写入桌面候选列表 - -## 17. 卸载流程细化 - -- 停止容器 -- 删除容器 -- 删除端口映射 -- 删除应用状态目录 -- 若当前桌面应用就是它,则清空桌面配置 -- 保留或删除数据目录需要明确策略 - -建议第一阶段: - -- 卸载时默认同时删除应用运行状态 -- 对数据目录行为做明确提示 - -## 18. 安全策略 - -### 18.1 核心原则 - -- 不信任前端 -- 不信任上传 manifest 的所有声明 -- 不把 root 级控制面直接暴露给容器 - -### 18.2 必须后端校验的内容 - -- 当前用户身份 -- sudo 权限 -- 应用归属 -- 应用名合法性 -- 同名安装冲突 -- 低位端口权限 -- host network 权限 -- `devices` 和危险挂载 -- `host.exec` 是否允许 - -### 18.3 容器高危能力 - -以下项目应被视为高风险: - -- `privileged` -- `host network` -- 挂宿主机系统目录 -- 设备直通 -- 宿主机命令执行 - -### 18.4 审计建议 - -建议记录: - -- 登录 -- 上传 CPK -- 安装确认 -- 卸载 -- 端口变更 -- 桌面应用切换 -- hostexec 请求 - -## 19. OpenWrt 打包与依赖 - -### 19.1 `capbox` 依赖建议 - -当前已有: - -- `bash` -- `coreutils` -- `jq` -- `podman` -- `nsenter` -- `socat` - -建议新增: - -- `yq` - -### 19.2 `capos-webpanel` 依赖建议 - -当前已有: - -- `libstdcpp` -- `luci` -- `uhttpd` - -视最终实现需要再补: - -- 可能需要支持 multipart 上传解析的辅助实现 -- 如需更稳健 JSON 处理,可考虑引入轻量依赖或自写最小解析 - -## 20. 开发分阶段计划 - -## Phase 1: `capbox` 核心框架 - -- [ ] 重构 `capbox` 为多子命令 CLI -- [ ] 引入 manifest 解析与校验 -- [ ] 建立用户状态目录 -- [ ] 实现用户网络创建与查询 -- [ ] 实现应用列表、安装、卸载、启停 -- [ ] 将现有端口映射原型纳入正式状态管理 - -## Phase 2: 上传安装闭环 - -- [ ] `api.cpp` 实现登录与 session -- [ ] 实现当前用户与 sudo 状态查询 -- [ ] 实现 CPK 上传 -- [ ] 实现预检接口 -- [ ] 实现确认安装接口 -- [ ] 实现错误码和统一 JSON 返回 - -## Phase 3: 应用管理 UI - -- [ ] 前端应用列表 -- [ ] 前端上传安装页面 -- [ ] 风险项展示 -- [ ] 应用启停卸载 -- [ ] 端口映射管理 -- [ ] sudo 用户显示 LuCI - -## Phase 4: 桌面应用体验 - -- [ ] 用户可选择桌面应用 -- [ ] `app.cpp` 代理桌面应用 -- [ ] 未设置桌面应用时显示引导页 -- [ ] 应用不可达时显示状态信息 - -## Phase 5: 宿主机受控命令执行 +## WebDesktop application discovery -- [ ] 定义 manifest 扩展字段 -- [ ] 在安装预检中展示该能力 -- [ ] 实现 `capbox hostexec` -- [ ] 增加白名单和超时机制 -- [ ] 增加日志记录 +CapOS should not require application authors or CapOS maintainers to declare service ports manually. -## Phase 6: 收尾与验证 +The current discovery path is: -- [ ] 完整错误处理 -- [ ] 权限边界回归测试 -- [ ] 多用户网络隔离测试 -- [ ] 同名应用跨用户安装测试 -- [ ] 低位端口权限测试 -- [ ] host network 权限测试 +1. Ask snapd for installed Snap/application metadata. +2. Find processes carrying `SNAP_INSTANCE_NAME` or `SNAP_NAME` for the selected Snap. +3. Resolve process file descriptors to listening TCP socket inodes. +4. Resolve those inodes through `/proc/net/tcp` and `/proc/net/tcp6`. +5. Probe candidate ports using HTTP, then HTTPS. +6. Prefer HTML/redirecting Web endpoints and expose the highest-scoring result through the WebDesktop reverse proxy. +7. Cache discovered endpoints briefly and rediscover after lifecycle changes or failures. -## 21. 验收标准 +A Snap that has no detected Web endpoint remains installable and manageable. It simply has no WebDesktop `Open` action. -### 21.1 应用与用户隔离 +## systemd-on-procd compatibility targets -- A/B 用户可分别安装同名应用 -- 同一用户不能重复安装同名应用 -- 同一用户应用间可通过应用名互访 -- 不同用户的同名应用不会串网 +Implemented in the standalone `fwerkor/systemd-on-procd` repository: -### 21.2 权限 +- system service lifecycle through procd/ubus; +- `systemctl` start/stop/restart/enable/disable/mask/unmask/status primitives; +- systemd unit parsing for the directives used by snapd-generated services; +- mount units; +- `systemd-run`, `systemd-mount`, and `systemd-detect-virt` compatibility entry points. -- 普通用户看不到 LuCI -- sudo 用户能看到 LuCI -- 普通用户不能绑定低位端口 -- sudo 用户可以绑定低位端口 -- 普通用户不能安装 `host network` 应用 -- sudo 用户可以安装 `host network` 应用 +Remaining compatibility work: -### 21.3 安装闭环 +- native `.socket` activation with systemd-compatible file-descriptor passing; +- `.timer` activation; +- systemd user-manager (`systemctl --user`) semantics; +- journal-compatible log queries where callers require `journalctl` output; +- broader cgroup/slice quota semantics beyond the subset required for initial Snap support. -- 可上传 `.cpk` -- 可显示预检结果 -- 可确认安装 -- 可启动、停止、卸载 +Unsupported semantics must fail explicitly rather than report false success. -### 21.4 桌面应用 +## snapd integration targets -- 用户可以从自己的应用中选择桌面应用 -- Web 面板可显示该应用 -- 切换桌面应用后即时生效 +- Build the stable upstream snapd release against musl/OpenWrt. +- Keep snapd itself under procd. +- Use `systemd-on-procd` for systemd-facing unit operations generated by snapd. +- Enable kernel and userspace requirements for strict confinement: AppArmor, seccomp, namespaces, cgroups, SquashFS XATTR and loop mounts. +- Keep the normal snapd Unix API at `/run/snapd.socket` so WebDesktop and standard Snap tooling use the same control plane. +- Avoid carrying a long-lived CapOS fork of snapd. CapOS-specific changes should stay in packaging and compatibility layers whenever possible. -### 21.5 宿主机命令执行 +## WebDesktop targets -- 支持声明式开启 -- 默认关闭 -- 仅以应用所属用户身份执行 -- 支持白名单和审计 +Implemented/current direction: -## 22. 后续演进方向 +- native CapOS system package (`capos-webdesktop`), not a Snap; +- system-user login and sudo-aware privileged actions; +- direct official Snap Store search through snapd; +- install, update, remove and service lifecycle actions; +- async snapd change tracking; +- automatic endpoint discovery; +- application-scoped HTTP/HTTPS/WebSocket reverse proxy; +- no CPK upload/install flow in the main UI. -- 独立“应用商店”应用复用 `capbox` -- 更细粒度的 manifest policy -- 更强的代理兼容性 -- 更完善的应用图标、描述和分类展示 -- 应用健康状态可视化 -- 应用日志查看 -- 应用升级与迁移流程 +Next UI/backend work after the runtime is fully validated: -## 23. 当前结论 +- expose Snap channel switching, revert and refresh scheduling; +- show confinement, publisher verification and permission/interface information before install; +- show snapd change/task progress rather than only aggregate status; +- expose endpoint alternatives when more than one Web UI is detected; +- surface recovery diagnostics when confinement or systemd compatibility blocks a Snap. -本轮实施基线如下: +## Validation gates -- 先把 `capbox` 做成真正可用的系统级应用运行时后端。 -- `capos-webpanel` 做成轻量但完整的用户面板。 -- 面板内“在线安装”只表示“上传 CPK 并安装”。 -- 复杂商店能力不做进系统面板。 -- “应用可在宿主机上以当前用户身份执行命令”作为显式、受控、高风险能力接入。 +Before this application platform is considered production-ready: -后续开发均以本文件为准,若产品边界变化,再同步更新本文件。 +- cross-build `systemd-on-procd`, snapd and WebDesktop for every supported 64-bit CapOS target; +- boot an image with AppArmor enabled and confirm parser/profile loading; +- install at least one strict server Snap from the official Store; +- verify refresh and revert; +- verify service restart across reboot; +- verify automatic Web endpoint discovery and WebSocket proxying; +- test a Snap using socket/timer units once those compatibility paths are implemented; +- document any remaining classes of Snap that CapOS intentionally does not support. From 384434876df5c487beee222cbabbe1519f5ebe9c Mon Sep 17 00:00:00 2001 From: Cao Yuhang Date: Thu, 20 Aug 2026 03:24:38 +0000 Subject: [PATCH 5/5] store: proxy Snap federation through CapOS --- website/snap/README.md | 14 +- website/snap/package.json | 3 +- website/snap/worker/index.ts | 238 ++++++++++++++++++++++- website/snap/worker/store-proxy.test.mjs | 139 +++++++++++++ website/snap/wrangler.toml | 1 + 5 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 website/snap/worker/store-proxy.test.mjs diff --git a/website/snap/README.md b/website/snap/README.md index 618b7d57f86..1fa075c0c8a 100644 --- a/website/snap/README.md +++ b/website/snap/README.md @@ -1,20 +1,22 @@ # CapOS Snap Store -`snap.capos.top` combines three surfaces in one Cloudflare Worker deployment: +`snap.capos.top` is the complete client-facing Snap source for CapOS and combines three surfaces in one Cloudflare Worker deployment: - a public, App Store-style catalog for CapOS users; -- Snap Store compatible `/v2/*` federation endpoints and proxied upstream downloads; +- Snap Store compatible `/v2/*` and legacy `/api/v1/*` endpoints, assertion federation and Snap payload delivery; - a Cloudflare Access-protected `/admin` console for repository versions, upstream order, local packages and CapOS metadata. ## Architecture -- **Cloudflare Worker**: API, Access JWT verification, upstream federation, streaming proxy and authenticated package upload controller. +- **Cloudflare Worker**: the Store API presented to `snapd`, Access JWT verification, upstream federation, URL rewriting, streaming proxy and authenticated package upload controller. - **D1**: repository versions, ordered upstreams, local Snap metadata and audit log. -- **Cloudflare R2 / `repo.capos.top`**: the existing `capos` bucket stores local artifacts at `//snaps/*.snap`. -- **Canonical/other upstreams**: queried in priority order. Their Snap downloads are proxied in real time and are never mirrored into R2. +- **Cloudflare R2 / `repo.capos.top`**: the existing `capos` bucket stores local artifacts at `//snaps/*.snap` and lazily caches federated upstream Snap payloads under `upstream-cache/`. +- **Canonical/other upstreams**: server-side federation sources queried in priority order. CapOS clients do not need to contact those Store or CDN hosts directly. Resolution order is always `local > upstream[0] > upstream[1] > ...` for a repository version. +CapOS starts `snapd` with both its Store API and assertion service rooted at `https://snap.capos.top/`. Federated Store responses are rewritten before they leave the Worker: Store API links stay on `snap.capos.top`, while payload URLs point to `/download/upstream`. The payload endpoint supports `HEAD` and byte ranges and serves cached R2 objects when available. Canonical assertions and Store-provided SHA3-384 values are still verified by `snapd`; changing the transport endpoint does not bypass Snap's trust checks. + ## Local development Use Node.js 22 or newer. @@ -48,6 +50,8 @@ Large Snap files are uploaded as a Cloudflare R2 multipart upload. The browser s Public catalog reads use two cache layers. Canonical catalog responses are cached at Cloudflare for 10 minutes (5 minutes for the featured feed and 2 minutes for searches), while the final aggregated `/api/storefront` and `/api/search` responses use a short Worker Cache API layer of 120 and 60 seconds respectively. Browser caching remains deliberately short at 15 seconds for the storefront and 10 seconds for search results. Responses expose `X-CapOS-Cache: HIT|MISS` for diagnostics. +Federated Snap payloads use a separate R2 cache. A full upstream GET is streamed to the client and R2 simultaneously; later full or ranged downloads are served from the `ARTIFACTS` binding and expose `X-CapOS-Store: payload-cache`. Store metadata and assertion responses expose `X-CapOS-Store: federated`. + Administrative endpoints and mutations remain `no-store`; credentials and admin state are never written to the public cache. After every part is accepted, `/api/admin/packages/finalize` completes the multipart upload, verifies the final object size, and records the local package in D1. Failed uploads are explicitly aborted. diff --git a/website/snap/package.json b/website/snap/package.json index 95aeee80c35..52ef38a1204 100644 --- a/website/snap/package.json +++ b/website/snap/package.json @@ -11,7 +11,8 @@ "deploy": "npm run build && wrangler deploy", "setup:production": "bash scripts/setup-production.sh", "db:migrate:local": "wrangler d1 migrations apply capos-snap-store --local", - "db:migrate:remote": "wrangler d1 migrations apply capos-snap-store --remote" + "db:migrate:remote": "wrangler d1 migrations apply capos-snap-store --remote", + "test:worker": "node --no-warnings --experimental-strip-types worker/store-proxy.test.mjs" }, "dependencies": { "@vitejs/plugin-react": "latest", diff --git a/website/snap/worker/index.ts b/website/snap/worker/index.ts index 07258690928..3eff4caa358 100644 --- a/website/snap/worker/index.ts +++ b/website/snap/worker/index.ts @@ -3,6 +3,7 @@ interface Env { ASSETS: Fetcher; DEFAULT_VERSION: string; REPO_PUBLIC_BASE: string; + STORE_PUBLIC_BASE: string; ARTIFACTS: R2Bucket; ACCESS_TEAM_DOMAIN: string; ACCESS_AUD: string; @@ -393,7 +394,238 @@ async function finalizePackage(request: Request, env: Env) { return json({ok:true,revision:rev,downloadUrl:`${env.REPO_PUBLIC_BASE.replace(/\/$/,'')}/${objectPath}`}); } -async function proxyDownload(request:Request){const u=new URL(request.url);const target=u.searchParams.get('url');if(!target)return json({error:'Missing download URL.'},400);let upstream:URL;try{upstream=new URL(target)}catch{return json({error:'Invalid download URL.'},400)}const allowed=upstream.protocol==='https:'&&(upstream.hostname.endsWith('.snapcraftcontent.com')||upstream.hostname.endsWith('.canonical.com')||upstream.hostname.endsWith('.ubuntu.com'));if(!allowed)return json({error:'Download host is not an allowed upstream.'},403);const headers=new Headers(request.headers);headers.delete('cookie');headers.delete('authorization');const response=await fetch(new Request(upstream,{method:'GET',headers,redirect:'follow'}));const out=new Headers(response.headers);out.set('cache-control','public, max-age=3600');out.delete('set-cookie');return new Response(response.body,{status:response.status,headers:out})} +function sameOrigin(a: URL, b: URL) { + return a.protocol === b.protocol && a.host === b.host; +} + +function upstreamApiOrigins(sources: { apiUrl: string }[]) { + const origins = new Set(); + for (const source of sources) { + try { origins.add(new URL(source.apiUrl).origin); } catch { /* validated when configured */ } + } + return origins; +} + +function upstreamApiHosts(sources: { apiUrl: string }[]) { + const hosts = new Set(); + for (const source of sources) { + try { hosts.add(new URL(source.apiUrl).hostname); } catch { /* validated when configured */ } + } + return hosts; +} + +function canonicalPayloadHost(hostname: string) { + return hostname === 'api.snapcraft.io' || + hostname === 'api.staging.snapcraft.io' || + hostname.endsWith('.snapcraft.io') || + hostname.endsWith('.snapcraftcontent.com') || + hostname.endsWith('.canonical.com') || + hostname.endsWith('.ubuntu.com'); +} + +function payloadDownloadPath(pathname: string) { + return pathname.includes('/api/v1/snaps/download/') || + pathname.includes('/api/v1/snaps/dm-verity/download/'); +} + +function storePublicOrigin(request: Request, env: Env) { + const configured = env.STORE_PUBLIC_BASE || new URL(request.url).origin; + return new URL(configured).origin; +} + +function downloadProxyUrl(origin: string, target: URL, version: string) { + const out = new URL('/download/upstream', origin); + out.searchParams.set('url', target.toString()); + out.searchParams.set('version', version); + return out.toString(); +} + +function rewriteStoreUrl(raw: string, requestOrigin: string, version: string, sources: { apiUrl: string }[], path: string[] = []) { + let value: URL; + try { value = new URL(raw); } catch { return raw; } + + const sourceOrigins = upstreamApiOrigins(sources); + const sourceHosts = upstreamApiHosts(sources); + const isStoreApiPath = value.pathname.startsWith('/v2/') || + (value.pathname.startsWith('/api/v1/') && !payloadDownloadPath(value.pathname)); + if ((sourceOrigins.has(value.origin) || sourceHosts.has(value.hostname)) && isStoreApiPath) { + const local = new URL(value.pathname + value.search + value.hash, requestOrigin); + return local.toString(); + } + + const inDownloadField = path.some(part => part.toLowerCase().includes('download')); + const canonicalDownload = canonicalPayloadHost(value.hostname) && + (payloadDownloadPath(value.pathname) || value.hostname.endsWith('.snapcraftcontent.com')); + if (inDownloadField || canonicalDownload) return downloadProxyUrl(requestOrigin, value, version); + + return raw; +} + +function rewriteStoreJson(value: unknown, requestOrigin: string, version: string, sources: { apiUrl: string }[], path: string[] = []): unknown { + if (typeof value === 'string') return rewriteStoreUrl(value, requestOrigin, version, sources, path); + if (Array.isArray(value)) return value.map((item, index) => rewriteStoreJson(item, requestOrigin, version, sources, [...path, String(index)])); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [key, rewriteStoreJson(item, requestOrigin, version, sources, [...path, key])])); + } + return value; +} + +function rewriteStoreLocation(location: string, requestOrigin: string, version: string, sources: { apiUrl: string }[]) { + return rewriteStoreUrl(location, requestOrigin, version, sources, ['location']); +} + +async function rewriteStoreResponse(response: Response, request: Request, env: Env, version: string, sources: { apiUrl: string }[]) { + const headers = new Headers(response.headers); + headers.delete('set-cookie'); + headers.set('x-capos-store', 'federated'); + const publicOrigin = storePublicOrigin(request, env); + + const location = headers.get('location'); + if (location) headers.set('location', rewriteStoreLocation(location, publicOrigin, version, sources)); + + const contentType = headers.get('content-type') || ''; + if (!contentType.toLowerCase().includes('application/json')) { + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } + + const raw = await response.text(); + if (!raw) { + headers.delete('content-length'); + headers.delete('content-encoding'); + headers.delete('etag'); + return new Response(null, { status: response.status, statusText: response.statusText, headers }); + } + const payload = JSON.parse(raw) as unknown; + const rewritten = rewriteStoreJson(payload, publicOrigin, version, sources); + headers.delete('content-length'); + headers.delete('content-encoding'); + headers.delete('etag'); + return new Response(JSON.stringify(rewritten), { status: response.status, statusText: response.statusText, headers }); +} + +async function proxyStore(request: Request, env: Env, version: string) { + const sources = (await upstreams(env, version)).filter(source => source.enabled); + if (!sources.length) return json({'error-list':[{'code':'no-upstream','message':'No enabled Snap upstream.'}]},503); + + const incoming = new URL(request.url); + const bodyBytes = ['GET', 'HEAD'].includes(request.method) ? undefined : await request.arrayBuffer(); + let lastError: unknown; + + for (const source of sources) { + const target = new URL(source.apiUrl); + if (sameOrigin(target, incoming)) continue; // Never recursively proxy ourselves. + target.pathname = incoming.pathname; + target.search = incoming.search; + + const headers = new Headers(request.headers); + headers.set('Snap-Device-Series', headers.get('Snap-Device-Series') || '16'); + if (!headers.get('User-Agent')) headers.set('User-Agent', 'CapOS-snapd/1'); + headers.delete('host'); + headers.delete('cookie'); + + try { + const response = await fetch(new Request(target, { + method: request.method, + headers, + body: bodyBytes, + redirect: 'manual', + })); + if (response.status >= 500 && sources.length > 1) { + lastError = new Error(`${source.name} returned ${response.status}`); + continue; + } + return rewriteStoreResponse(response, request, env, version, sources); + } catch (error) { + lastError = error; + } + } + + console.error('all Snap Store upstreams failed', lastError); + return json({'error-list':[{'code':'upstream-unavailable','message':'All configured Snap upstreams are unavailable.'}]},502); +} + +async function sha256Hex(value: string) { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +async function upstreamPayloadCacheKey(upstream: URL) { + return `upstream-cache/${await sha256Hex(upstream.toString())}.snap`; +} + +function r2RangeHeaders(object: R2ObjectBody, headers: Headers) { + if (!object.range) return; + const range = object.range as { offset?: number; length?: number; suffix?: number }; + let offset = 0; + let length = object.size; + if (typeof range.suffix === 'number') { + length = Math.min(range.suffix, object.size); + offset = object.size - length; + } else { + offset = typeof range.offset === 'number' ? range.offset : 0; + length = typeof range.length === 'number' ? range.length : Math.max(0, object.size - offset); + } + headers.set('content-range', `bytes ${offset}-${offset + Math.max(0, length - 1)}/${object.size}`); + headers.set('content-length', String(length)); +} + +async function cachedPayload(request: Request, env: Env, cacheKey: string) { + const object = await env.ARTIFACTS.get(cacheKey, request.headers.has('range') ? { range: request.headers } : undefined); + if (!object) return null; + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('accept-ranges', 'bytes'); + headers.set('cache-control', 'public, max-age=86400, stale-while-revalidate=604800'); + headers.set('x-capos-store', 'payload-cache'); + r2RangeHeaders(object, headers); + return new Response(request.method === 'HEAD' ? null : object.body, { + status: object.range ? 206 : 200, + headers, + }); +} + +async function proxyDownload(request: Request, env: Env, ctx: ExecutionContext) { + const u = new URL(request.url); + const version = safeVersion(u.searchParams.get('version'), env); + const target = u.searchParams.get('url'); + if (!target) return json({error:'Missing download URL.'},400); + + let upstream: URL; + try { upstream = new URL(target); } catch { return json({error:'Invalid download URL.'},400); } + const sources = (await upstreams(env, version)).filter(source => source.enabled); + const allowedOrigins = upstreamApiOrigins(sources); + const allowed = upstream.protocol === 'https:' && (canonicalPayloadHost(upstream.hostname) || allowedOrigins.has(upstream.origin)); + if (!allowed) return json({error:'Download host is not an allowed upstream.'},403); + + const cacheKey = await upstreamPayloadCacheKey(upstream); + const cached = await cachedPayload(request, env, cacheKey); + if (cached) return cached; + + const headers = new Headers(request.headers); + headers.delete('cookie'); + headers.delete('authorization'); + headers.delete('host'); + const response = await fetch(new Request(upstream, { + method: request.method, + headers, + redirect: 'follow', + }), { cf: { cacheEverything: request.method === 'GET', cacheTtl: 86400 } }); + const out = new Headers(response.headers); + out.set('cache-control','public, max-age=3600, stale-while-revalidate=86400'); + out.set('x-capos-store','payload'); + out.delete('set-cookie'); + if (request.method === 'GET' && !request.headers.has('range') && response.status === 200 && response.body) { + const [clientBody, cacheBody] = response.body.tee(); + ctx.waitUntil(env.ARTIFACTS.put(cacheKey, cacheBody, { + httpMetadata: response.headers, + customMetadata: { source: upstream.toString() }, + }).catch(error => console.error('unable to cache upstream Snap payload', error))); + return new Response(clientBody,{status:response.status,statusText:response.statusText,headers:out}); + } + return new Response(request.method === 'HEAD' ? null : response.body,{status:response.status,statusText:response.statusText,headers:out}); +} + async function embeddedStore(request: Request, env: Env) { const url = new URL(request.url); @@ -414,9 +646,9 @@ async function api(request:Request,env:Env,ctx:ExecutionContext){const url=new U if(p==='/api/catalog'&&request.method==='GET')return edgeCached(request,env,ctx,300,30,()=>richCatalog(request,env,ctx)); if(p==='/api/search'&&request.method==='GET')return edgeCached(request,env,ctx,60,10,()=>searchStore(request,env)); if(p==='/api/app'&&request.method==='GET')return edgeCached(request,env,ctx,1800,60,()=>appDetail(request,env)); - if(p==='/download/upstream'&&request.method==='GET')return proxyDownload(request); + if(p==='/download/upstream'&&(request.method==='GET'||request.method==='HEAD'))return proxyDownload(request,env,ctx); if(p==='/api/admin'||p.startsWith('/api/admin/')){if(!(await verifyAccess(request,env)))return json({error:'Cloudflare Access authentication required.'},401);if(p==='/api/admin/state'&&request.method==='GET')return adminState(request,env);if(p==='/api/admin/versions'&&request.method==='POST')return postVersion(request,env);if(p==='/api/admin/upstreams'&&request.method==='PUT')return putUpstreams(request,env);if(p==='/api/admin/upstreams'&&request.method==='POST')return postUpstream(request,env);if(p==='/api/admin/packages/uploads'&&request.method==='POST')return startPackageUpload(request,env);if(p==='/api/admin/packages/upload-part'&&request.method==='PUT')return uploadPackagePart(request,env);if(p==='/api/admin/packages/abort'&&request.method==='POST')return abortPackageUpload(request,env);if(p==='/api/admin/packages/finalize'&&request.method==='POST')return finalizePackage(request,env);} - if(p.startsWith('/v2/')){const version=safeVersion(request.headers.get('X-CapOS-Version'),env);const sources=await upstreams(env,version);const first=sources.find(s=>s.enabled);if(!first)return json({'error-list':[{'code':'no-upstream','message':'No enabled Snap upstream.'}]},503);const target=new URL(first.apiUrl);target.pathname=p;target.search=url.search;const headers=new Headers(request.headers);headers.set('Snap-Device-Series',headers.get('Snap-Device-Series')||'16');headers.set('User-Agent','CapOS-snapd/1');headers.delete('host');const response=await fetch(new Request(target,{method:request.method,headers,body:['GET','HEAD'].includes(request.method)?undefined:request.body,redirect:'manual'}));return response;} + if(p.startsWith('/api/v1/')||p.startsWith('/v2/')){const version=safeVersion(request.headers.get('X-CapOS-Version'),env);return proxyStore(request,env,version);} return json({error:'Not found.'},404); } diff --git a/website/snap/worker/store-proxy.test.mjs b/website/snap/worker/store-proxy.test.mjs new file mode 100644 index 00000000000..82a9269a34b --- /dev/null +++ b/website/snap/worker/store-proxy.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import worker from './index.ts'; + +const upstreamRows = [{ + id: 1, + name: 'Canonical Snap Store', + api_url: 'https://api.snapcraft.io', + kind: 'canonical', + priority: 10, + enabled: 1, +}]; + +const env = { + DEFAULT_VERSION: 'rolling', + REPO_PUBLIC_BASE: 'https://repo.capos.top', + STORE_PUBLIC_BASE: 'https://snap.capos.top', + DB: { + prepare(sql) { + assert.match(sql, /version_upstreams/); + return { + bind() { + return { all: async () => ({ results: upstreamRows }) }; + }, + }; + }, + }, + ASSETS: { fetch: async () => new Response('asset') }, + ARTIFACTS: { + get: async () => null, + put: async () => ({ key: 'test-cache' }), + }, +}; + +const pending = []; +const ctx = { waitUntil(promise) { pending.push(Promise.resolve(promise)); } }; +const realFetch = globalThis.fetch; +const upstreamRequests = []; + +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + upstreamRequests.push(request.clone()); + const url = new URL(request.url); + + if (url.pathname === '/v2/snaps/refresh') { + return new Response(JSON.stringify({ + results: [{ + result: 'install', + 'instance-key': 'hello-world', + 'snap-id': 'buPKUD3TKqCOgLEjjHx5kSiCpIs5cMuQ', + name: 'hello-world', + snap: { + name: 'hello-world', + revision: 29, + version: '6.4', + download: { + url: 'https://api.snapcraft.io/api/v1/snaps/download/test.snap', + size: 20480, + 'sha3-384': 'deadbeef', + }, + }, + 'assertion-stream-urls': [ + 'https://api.snapcraft.io/v2/assertions/snap-declaration/16/test-id', + ], + }], + }), { headers: { 'content-type': 'application/json' } }); + } + + if (url.pathname === '/v2/redirect-test') { + return new Response(null, { + status: 302, + headers: { location: 'https://api.snapcraft.io/v2/assertions/account/canonical' }, + }); + } + + if (url.pathname === '/api/v1/snaps/sections') { + return new Response(JSON.stringify({ + _links: { self: { href: 'http://api.snapcraft.io/api/v1/snaps/sections' } }, + _embedded: { 'clickindex:sections': [] }, + }), { headers: { 'content-type': 'application/json' } }); + } + + if (url.pathname === '/api/v1/snaps/download/test.snap') { + assert.equal(request.headers.get('range'), 'bytes=0-3'); + return new Response(new Uint8Array([1, 2, 3, 4]), { + status: 206, + headers: { + 'content-type': 'application/vnd.snap', + 'content-range': 'bytes 0-3/20480', + }, + }); + } + + throw new Error(`unexpected upstream request: ${request.method} ${request.url}`); +}; + +try { + const actionResponse = await worker.fetch(new Request('https://snap.capos.top/v2/snaps/refresh', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'Snap-Device-Series': '16', + 'Snap-Device-Architecture': 'amd64', + }, + body: JSON.stringify({ context: [], actions: [{ action: 'install', name: 'hello-world', 'instance-key': 'hello-world' }] }), + }), env, ctx); + + assert.equal(actionResponse.status, 200); + assert.equal(actionResponse.headers.get('x-capos-store'), 'federated'); + const action = await actionResponse.json(); + const downloadUrl = action.results[0].snap.download.url; + const assertionUrl = action.results[0]['assertion-stream-urls'][0]; + assert.match(downloadUrl, /^https:\/\/snap\.capos\.top\/download\/upstream\?/); + assert.match(downloadUrl, /version=rolling/); + assert.equal(assertionUrl, 'https://snap.capos.top/v2/assertions/snap-declaration/16/test-id'); + assert.ok(!JSON.stringify(action).includes('https://api.snapcraft.io/v2/')); + + const payloadResponse = await worker.fetch(new Request(downloadUrl, { + headers: { range: 'bytes=0-3' }, + }), env, ctx); + assert.equal(payloadResponse.status, 206); + assert.equal(payloadResponse.headers.get('x-capos-store'), 'payload'); + assert.deepEqual([...new Uint8Array(await payloadResponse.arrayBuffer())], [1, 2, 3, 4]); + await Promise.all(pending); + + const redirectResponse = await worker.fetch(new Request('https://snap.capos.top/v2/redirect-test'), env, ctx); + assert.equal(redirectResponse.status, 302); + assert.equal(redirectResponse.headers.get('location'), 'https://snap.capos.top/v2/assertions/account/canonical'); + + const sectionsResponse = await worker.fetch(new Request('https://snap.capos.top/api/v1/snaps/sections'), env, ctx); + assert.equal(sectionsResponse.status, 200); + const sections = await sectionsResponse.json(); + assert.equal(sections._links.self.href, 'https://snap.capos.top/api/v1/snaps/sections'); + + assert.equal(new URL(upstreamRequests[0].url).origin, 'https://api.snapcraft.io'); + assert.equal(upstreamRequests[0].headers.get('Snap-Device-Series'), '16'); + console.log('snap store proxy tests: PASS'); +} finally { + globalThis.fetch = realFetch; +} diff --git a/website/snap/wrangler.toml b/website/snap/wrangler.toml index 5886df01655..845b1d4a9d6 100644 --- a/website/snap/wrangler.toml +++ b/website/snap/wrangler.toml @@ -32,3 +32,4 @@ DEFAULT_VERSION = "rolling" REPO_PUBLIC_BASE = "https://repo.capos.top" ACCESS_TEAM_DOMAIN = "fwerkor.cloudflareaccess.com" ACCESS_AUD = "c35348d6e3f59bc8857131672c0c59096083cdaaed23536cecee3d9921c2e2a5" +STORE_PUBLIC_BASE = "https://snap.capos.top"