From e03c0f4ad6ace426a1b4a8d00720b9b8a03b4b1c Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Tue, 19 Aug 2025 11:01:16 +0200 Subject: [PATCH 1/9] Experimental integration of CLI support This commit is squashed from the repo https://github.com/AnatolyJacobs/LSPosed_CLI. Further work are required to improve / polish it. Refer to https://github.com/mywalkb/LSPosed_mod/wiki/CLI for current CLI interface. Co-authored-by: Anatoly Jacobs <50045621+AnatolyJacobs@users.noreply.github.com> Co-authored-by: mywalk <66966897+mywalkb@users.noreply.github.com> --- .gitignore | 1 + .../org/lsposed/manager/ConfigManager.java | 37 + .../manager/ui/fragment/SettingsFragment.java | 16 + .../drawable/ic_baseline_hourglass_top_24.xml | 29 + .../main/res/drawable/ic_outline_cmd_24.xml | 29 + app/src/main/res/values/arrays.xml | 23 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/prefs.xml | 18 + .../lspd/core/ApplicationServiceClient.java | 8 + daemon/build.gradle.kts | 4 + daemon/proguard-rules.pro | 13 + .../main/java/org/lsposed/lspd/cli/Main.java | 669 ++++++++++++++++++ .../main/java/org/lsposed/lspd/cli/Utils.java | 68 ++ .../org/lsposed/lspd/service/CLIService.java | 343 +++++++++ .../lsposed/lspd/service/ConfigManager.java | 38 +- .../lspd/service/LSPApplicationService.java | 13 + .../lspd/service/LSPManagerService.java | 19 + .../lspd/service/LSPNotificationManager.java | 6 +- .../lsposed/lspd/service/LSPosedService.java | 7 + .../lsposed/lspd/service/ServiceManager.java | 7 + daemon/src/main/res/values/strings.xml | 2 + magisk-loader/magisk_module/cli | 22 + magisk-loader/magisk_module/customize.sh | 5 + .../lspd/service/ILSPApplicationService.aidl | 3 + .../aidl/org/lsposed/lspd/ICLIService.aidl | 44 ++ .../org/lsposed/lspd/ILSPManagerService.aidl | 9 + 26 files changed, 1436 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml create mode 100644 app/src/main/res/drawable/ic_outline_cmd_24.xml create mode 100644 daemon/src/main/java/org/lsposed/lspd/cli/Main.java create mode 100644 daemon/src/main/java/org/lsposed/lspd/cli/Utils.java create mode 100644 daemon/src/main/java/org/lsposed/lspd/service/CLIService.java create mode 100644 magisk-loader/magisk_module/cli create mode 100644 services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl diff --git a/.gitignore b/.gitignore index 9be6d25cf..7e383cb7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ apache/local/generated +apache/build .project .settings .cache diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java index 79a1236a7..ab61f6bc7 100644 --- a/app/src/main/java/org/lsposed/manager/ConfigManager.java +++ b/app/src/main/java/org/lsposed/manager/ConfigManager.java @@ -382,6 +382,43 @@ public static boolean setDexObfuscateEnabled(boolean enabled) { } } + public static boolean isEnableCli() { + try { + return LSPManagerServiceHolder.getService().isEnableCli(); + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + return false; + } + } + + public static boolean setEnableCli(boolean enabled) { + try { + LSPManagerServiceHolder.getService().setEnableCli(enabled); + return true; + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + return false; + } + } + + public static int getSessionTimeout() { + try { + return LSPManagerServiceHolder.getService().getSessionTimeout(); + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + return -2; + } + } + + public static boolean setSessionTimeout(int iTimeout) { + try { + LSPManagerServiceHolder.getService().setSessionTimeout(iTimeout); + return true; + } catch (RemoteException e) { + Log.e(App.TAG, Log.getStackTraceString(e)); + return false; + } + } public static int getDex2OatWrapperCompatibility() { try { return LSPManagerServiceHolder.getService().getDex2OatWrapperCompatibility(); diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java index ef8c5f728..a348f662f 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java @@ -371,6 +371,22 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { translation_contributors.setSummary(translators); } } + + MaterialSwitchPreference prefCli = findPreference("enable_cli"); + Preference prefSessionTimeout = findPreference("cli_session_timeout"); + if (prefCli != null && prefSessionTimeout != null) { + prefCli.setEnabled(installed); + prefCli.setChecked(!installed || ConfigManager.isEnableCli()); + prefCli.setOnPreferenceChangeListener((preference, newValue) -> { + ConfigManager.setEnableCli((boolean) newValue); + prefSessionTimeout.setEnabled((boolean) newValue); + return true; + }); + prefSessionTimeout.setEnabled(!installed ? false : prefCli.isChecked()); + ((SimpleMenuPreference) prefSessionTimeout).setValue(!installed ? "-1" : "" + ConfigManager.getSessionTimeout()); + prefSessionTimeout.setOnPreferenceChangeListener((preference, newValue) -> ConfigManager.setSessionTimeout(Integer.parseInt((String) newValue))); + } + SimpleMenuPreference channel = findPreference("update_channel"); if (channel != null) { channel.setOnPreferenceChangeListener((preference, newValue) -> { diff --git a/app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml b/app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml new file mode 100644 index 000000000..a6d86557d --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_outline_cmd_24.xml b/app/src/main/res/drawable/ic_outline_cmd_24.xml new file mode 100644 index 000000000..e8ec8459b --- /dev/null +++ b/app/src/main/res/drawable/ic_outline_cmd_24.xml @@ -0,0 +1,29 @@ + + + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 2ddb9495a..949570121 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -88,4 +88,27 @@ CHANNEL_NIGHTLY + + @string/disabled + @string/one_command + 1 + 3 + 5 + 15 + 30 + 60 + + + + -2 + -1 + 1 + 3 + 5 + 15 + 30 + 60 + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b4b2694e..07b0df533 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,6 +209,13 @@ Nightly build Xposed API call protection Block dynamically loaded module code to use Xposed API, this may break some modules but benefit security + CLI + Enable CLI + (experimental feature) + Session timeout in minutes + One command + Disabled + Readme Releases diff --git a/app/src/main/res/xml/prefs.xml b/app/src/main/res/xml/prefs.xml index 6b9d8691c..5e503fc50 100644 --- a/app/src/main/res/xml/prefs.xml +++ b/app/src/main/res/xml/prefs.xml @@ -160,4 +160,22 @@ android:summary="@string/settings_show_hidden_icon_apps_enabled_summary" android:title="@string/settings_show_hidden_icon_apps_enabled" /> + + + + + diff --git a/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java b/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java index 6eb01d0b3..bc6402edc 100644 --- a/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java +++ b/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java @@ -102,6 +102,14 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) { return null; } + @Override + public int requestCLIBinder(String sPin, List binder) { + try { + return service.requestCLIBinder(sPin, binder); + } catch (RemoteException | NullPointerException ignored) { + } + return -1; + } @Override public IBinder asBinder() { return service.asBinder(); diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index 3514b4f5a..190be32b7 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -20,6 +20,7 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.ide.common.signing.KeystoreHelper import java.io.PrintStream +import java.util.UUID plugins { alias(libs.plugins.agp.app) @@ -99,6 +100,7 @@ android.applicationVariants.all { sign?.keyPassword, sign?.keyAlias ) + val uuid = UUID.randomUUID().toString(); PrintStream(outSrc).print( """ |package org.lsposed.lspd.util; @@ -106,6 +108,7 @@ android.applicationVariants.all { | public static final byte[] CERTIFICATE = {${ certificateInfo.certificate.encoded.joinToString(",") }}; + | public static final String CLI_UUID = "$uuid"; |}""".trimMargin() ) } @@ -120,6 +123,7 @@ dependencies { implementation(projects.hiddenapi.bridge) implementation(projects.services.daemonService) implementation(projects.services.managerService) + implementation("info.picocli:picocli:4.7.6") compileOnly(libs.androidx.annotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/daemon/proguard-rules.pro b/daemon/proguard-rules.pro index 74c73f81e..1d4f63f2d 100644 --- a/daemon/proguard-rules.pro +++ b/daemon/proguard-rules.pro @@ -1,6 +1,19 @@ -keepclasseswithmembers,includedescriptorclasses class * { native ; } +-keepattributes *Annotation*, Signature, Exception + +-keep class picocli.CommandLine { *; } +-keep class picocli.CommandLine$* { *; } + +-keepclassmembers class * extends java.util.concurrent.Callable { + public java.lang.Integer call(); +} +-keep class org.lsposed.lspd.cli.* {*;} +-keepclasseswithmembers class org.lsposed.lspd.cli.Main { + public static void main(java.lang.String[]); +} + -keepclasseswithmembers class org.lsposed.lspd.Main { public static void main(java.lang.String[]); } diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java new file mode 100644 index 000000000..93accb808 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -0,0 +1,669 @@ +package org.lsposed.lspd.cli; + +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.lsposed.lspd.models.Application; +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.service.ILSPApplicationService; +import org.lsposed.lspd.util.SignInfo; + +import static org.lsposed.lspd.cli.Utils.CMDNAME; +import static org.lsposed.lspd.cli.Utils.ERRCODES; + +import picocli.CommandLine; +import picocli.CommandLine.IExecutionExceptionHandler; +import picocli.CommandLine.IExitCodeExceptionMapper; +import picocli.CommandLine.ParseResult; + +import java.io.ByteArrayOutputStream; +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.FileInputStream; +import java.io.FileOutputStream; + +import java.time.LocalDateTime; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +@CommandLine.Command(name = "log") +class LogCommand implements Callable { + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + @CommandLine.Option(names = {"-f", "--follow", "follow"}, description = "Follow update of log, as tail -f") + boolean bFollow; + @CommandLine.Option(names = {"-v", "--verbose", "verbose"}, description = "Get verbose log") + boolean bVerboseLog; + @CommandLine.Option(names = {"-c", "--clear", "clear"}, description = "Clear log") + boolean bClear; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + if (bClear) { + manager.clearLogs(bVerboseLog); + if (!bFollow) { // we can clear old logs and follow new + return 0; + } + } + ParcelFileDescriptor pfdLog = bVerboseLog ? manager.getVerboseLog() : manager.getModulesLog(); + printLog(pfdLog); + + return ERRCODES.NOERROR.ordinal(); + } + + private void printLog(ParcelFileDescriptor parcelFileDescriptor) { + var br = new BufferedReader(new InputStreamReader(new FileInputStream(parcelFileDescriptor.getFileDescriptor()))); + // TODO handle sigint when in follow mode for clean exit + while (true) { + String sLine; + try { + sLine = br.readLine(); + } catch (IOException ioe) { break; } + if (sLine == null) { + if (bFollow) { + try { + Thread.sleep(500); + } catch (InterruptedException ie) {} + } else { + break; + } + } else { + System.out.println(sLine); + } + } + } +} + +@CommandLine.Command(name = "ls") +class ListModulesCommand implements Callable { + @CommandLine.ArgGroup(exclusive = true) + LSModuleOpts objArgs = new LSModuleOpts(); + + static class LSModuleOpts { + @CommandLine.Option(names = {"-e", "--enabled"}, description = "list only enabled modules", required = true) + boolean bEnable; + @CommandLine.Option(names = {"-d", "--disabled"}, description = "list only disabled modules", required = true) + boolean bDisable; + } + + private static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER + private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | + PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + + var lstEnabledModules = Arrays.asList(manager.enabledModules()); + var lstPackages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); + for (var packageInfo : lstPackages.getList()) { + var metaData = packageInfo.applicationInfo.metaData; + + if (metaData != null && metaData.containsKey("xposedmodule")) { + var bPkgEnabled = lstEnabledModules.contains(packageInfo.packageName); + + if ((objArgs.bEnable && bPkgEnabled) || (objArgs.bDisable && !bPkgEnabled) || (!objArgs.bEnable && !objArgs.bDisable)) { + var sFmt = "%-40s %10d %-8s"; + System.out.println(String.format(sFmt, packageInfo.packageName, packageInfo.applicationInfo.uid, bPkgEnabled ? "enable" : "disable")); + } + } + } + + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "set") +class SetModulesCommand implements Callable { + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + SetModuleOpts objArgs = new SetModuleOpts(); + + static class SetModuleOpts { + @CommandLine.Option(names = {"-e", "--enable"}, description = "enable modules", required = true) + boolean bEnable; + @CommandLine.Option(names = {"-d", "--disable"}, description = "disable modules", required = true) + boolean bDisable; + } + + @CommandLine.Option(names = {"-i", "--ignore"}, description = "ignore not installed packages") + boolean bIgnore; + @CommandLine.Parameters(index = "0..*", description = "packages name", paramLabel="", arity = "1") + List lstModules; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + boolean bMsgReboot = false; + + for (var module : lstModules) { + var lstScope = manager.getModuleScope(module); + if (lstScope == null) + { + System.err.println(manager.getLastErrorMsg()); + continue; + } + if (objArgs.bEnable) { + if (lstScope.size() < 2) { + System.err.println("Scope list is empty " + module + " not enabled"); + return Utils.ERRCODES.EMPTY_SCOPE.ordinal(); + } + } + if (objArgs.bEnable) { + if (!manager.enableModule(module)) { + System.err.println("Failed to enable"); + return ERRCODES.ENABLE_DISABLE.ordinal(); + } + } else { + if (!manager.disableModule(module)) { + System.err.println("Failed to disable"); + return ERRCODES.ENABLE_DISABLE.ordinal(); + } + } + if (Utils.checkPackageInScope("android", lstScope)) { + bMsgReboot = true; + } + } + if (bMsgReboot) { + System.err.println("Reboot is required"); + } + + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "modules", subcommands = {ListModulesCommand.class, SetModulesCommand.class}) +class ModulesCommand implements Runnable { + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + public void run() { + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); + } +} + +class Scope extends Application { + public static class Converter implements CommandLine.ITypeConverter { + @Override + public Scope convert(String value) { + var s = value.split("/", 2); + return new Scope(s[0], Integer.parseInt(s[1])); + } + } + + public Scope(String packageName, int userId) { + this.packageName = packageName; + this.userId = userId; + } + + @Override + public String toString() { + return "Scope{" + + "packageName='" + packageName + '\'' + + ", userId=" + userId + + '}'; + } +} + +@CommandLine.Command(name = "ls") +class ListScopeCommand implements Callable { + @CommandLine.Parameters(index = "0", description = "module's name", paramLabel="") + String moduleName; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + + var lstScope = manager.getModuleScope(moduleName); + if (lstScope == null) + { + System.err.println(manager.getLastErrorMsg()); + return ERRCODES.LS_SCOPE.ordinal(); + } + + for (var scope : lstScope) { + System.out.println(scope.packageName + "/" + scope.userId); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "set", exitCodeOnExecutionException = 4 /* ERRCODES.SET_SCOPE */) +class SetScopeCommand implements Callable { + /*, multiplicity = "0..1"*/ + @CommandLine.ArgGroup(exclusive = true) + ScopeOpts objArgs = new ScopeOpts(); + + static class ScopeOpts { + @CommandLine.Option(names = {"-s", "--set"}, description = "set a new scope (default)", required = true) + boolean bSet; + @CommandLine.Option(names = {"-a", "--append"}, description = "append packages to scope", required = true) + boolean bAppend; + @CommandLine.Option(names = {"-d", "--remove"}, description = "remove packages to scope", required = true) + boolean bDel; + } + @CommandLine.Option(names = {"-i", "--ignore"}, description = "ignore not installed packages") + boolean bIgnore; + + @CommandLine.Parameters(index = "0", description = "module's name", paramLabel="", arity = "1") + String moduleName; + + @CommandLine.Parameters(index = "1..*", description = "package name/uid", arity = "1") + Scope[] scopes; + + @Override + public Integer call() throws RemoteException { + boolean bMsgReboot = false; + ICLIService manager = Main.getManager(); + + // default operation set + // TODO find a mode for manage in picocli + if (!objArgs.bSet && !objArgs.bAppend && !objArgs.bDel) { + objArgs.bSet = true; + } + + boolean bAndroidExist = false; + if (objArgs.bSet) { + var lstScope = manager.getModuleScope(moduleName); + if (lstScope == null) { + System.err.println(manager.getLastErrorMsg()); + return ERRCODES.SET_SCOPE.ordinal(); + } + bAndroidExist = Utils.checkPackageInScope("android", lstScope); + } + + for(var scope : scopes) { + if (Utils.validPackageNameAndUserId(manager, scope.packageName, scope.userId)) { + if (scope.packageName.equals("android")) { + bMsgReboot = true; + } + } else if (!bIgnore) { + throw new RuntimeException("Error: " + scope.packageName + (scope.userId < 0? "" : ("/" + scope.userId)) + " is not a valid package name"); + } + } + if (bAndroidExist && !bMsgReboot) { // if android is removed with setcommand reboot is required + bMsgReboot = true; + } + if (bMsgReboot) { + System.err.println("Reboot is required"); + } + if (objArgs.bSet) { + List lstScope = new ArrayList<>(Arrays.asList(scopes)); // Arrays.asList return a read-only list and we require a changeable list + if (Utils.checkPackageModule(moduleName, lstScope)) { + System.err.println("Added package of module into scope!"); + } + if (!manager.setModuleScope(moduleName, lstScope)) { + throw new RuntimeException("Failed to set scope for " + moduleName); + } + if (lstScope.size() < 2) { + manager.disableModule(moduleName); + } + } else { + var lstScope = manager.getModuleScope(moduleName); + if (lstScope == null) { + System.err.println(manager.getLastErrorMsg()); + return ERRCODES.SET_SCOPE.ordinal(); + } + for (var scope : scopes) { + if (objArgs.bAppend) { + Application app = new Application(); + app.packageName = scope.packageName; + app.userId = scope.userId; + lstScope.add(app); + } else { + lstScope.removeIf(app -> scope.packageName.equals(app.packageName) && scope.userId == app.userId); + } + } + if (Utils.checkPackageModule(moduleName, lstScope)) { + System.err.println("Added package of module into scope!"); + } + if (!manager.setModuleScope(moduleName, lstScope)) { + throw new RuntimeException("Failed to set scope for " + moduleName); + } + if (lstScope.size() < 2) { + manager.disableModule(moduleName); + } + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "scope", subcommands = {ListScopeCommand.class, SetScopeCommand.class}) +class ScopeCommand implements Runnable { + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + public void run() { + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); + } +} + +@CommandLine.Command(name = "status") +class StatusCommand implements Callable { + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + String sSysVer; + if (Build.VERSION.PREVIEW_SDK_INT != 0) { + sSysVer = String.format("%1$s Preview (API %2$d)", Build.VERSION.CODENAME, Build.VERSION.SDK_INT); + } else { + sSysVer = String.format("%1$s (API %2$d)", Build.VERSION.RELEASE, Build.VERSION.SDK_INT); + } + + var sPrint = "API version: " + manager.getXposedApiVersion() + '\n' + + "Injection Interface: " + manager.getApi() + '\n' + + "Framework version: " + manager.getXposedVersionName() + '(' + manager.getXposedVersionCode() + ")\n" + + "System version: " + sSysVer + '\n' + + "Device: " + getDevice() + '\n' + + "System ABI: " + Build.SUPPORTED_ABIS[0]; + System.out.println(sPrint); + return ERRCODES.NOERROR.ordinal(); + } + + private String getDevice() { + String manufacturer = Character.toUpperCase(Build.MANUFACTURER.charAt(0)) + Build.MANUFACTURER.substring(1); + if (!Build.BRAND.equals(Build.MANUFACTURER)) { + manufacturer += " " + Character.toUpperCase(Build.BRAND.charAt(0)) + Build.BRAND.substring(1); + } + manufacturer += " " + Build.MODEL + " "; + return manufacturer; + } +} + +@CommandLine.Command(name = "backup") +class BackupCommand implements Callable { + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + @CommandLine.Parameters(index = "0..*", description = "module's name default all", paramLabel="") + String[] modulesName; + @CommandLine.Option(names = {"-f", "--file"}, description = "output file") + String file; + + private static final int VERSION = 2; + private static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER + private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | + PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + + if (modulesName == null) { + List modules = new ArrayList<>(); + var packages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); + for (var packageInfo : packages.getList()) { + var metaData = packageInfo.applicationInfo.metaData; + + if (metaData != null && metaData.containsKey("xposedmodule")) { + modules.add(packageInfo.packageName); + } + } + modulesName = modules.toArray(new String[0]); + } + if (file == null) { + file = String.format("LSPosed_%s.lsp", LocalDateTime.now().toString()); + } + + var enabledModules = Arrays.asList(manager.enabledModules()); + JSONObject rootObject = new JSONObject(); + try { + rootObject.put("version", VERSION); + JSONArray modulesArray = new JSONArray(); + + for (var module : modulesName) { + JSONObject moduleObject = new JSONObject(); + moduleObject.put("enable", enabledModules.contains(module)); + moduleObject.put("package", module); + moduleObject.put("autoInclude", manager.getAutoInclude(module)); + + var scopes = manager.getModuleScope(module); + JSONArray scopeArray = new JSONArray(); + for (var s : scopes) { + JSONObject app = new JSONObject(); + app.put("package", s.packageName); + app.put("userId", s.userId); + scopeArray.put(app); + } + moduleObject.put("scope", scopeArray); + modulesArray.put(moduleObject); + } + rootObject.put("modules", modulesArray); + + FileOutputStream fos = new FileOutputStream(file + ".gz"); + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(fos); + gzipOutputStream.write(rootObject.toString().getBytes()); + gzipOutputStream.close(); + fos.close(); + } catch(Exception ex) { + throw new RemoteException(ex.getMessage()); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = "restore") +class RestoreCommand implements Callable { + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + @CommandLine.Parameters(index = "0..*", description = "module's name default all", paramLabel="") + String[] modulesName; + @CommandLine.Option(names = {"-f", "--file"}, description = "input file", required = true) + String file; + + private static final int VERSION = 2; + private static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER + private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | + PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = Main.getManager(); + + StringBuilder json = new StringBuilder(); + try { + FileInputStream fis = new FileInputStream(file); + GZIPInputStream gzipInputStream = new GZIPInputStream(fis, 64); + var os = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int length; + while ((length = gzipInputStream.read(buf)) > 0) { + os.write(buf, 0, length); + } + json.append(os); + gzipInputStream.close(); + fis.close(); + os.close(); + } catch(Exception ex) { + throw new RemoteException(ex.getMessage()); + } + + List modules; + if (modulesName == null) { + modules = new ArrayList<>(); + var packages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); + for (var packageInfo : packages.getList()) { + var metaData = packageInfo.applicationInfo.metaData; + + if (metaData != null && metaData.containsKey("xposedmodule")) { + modules.add(packageInfo.packageName); + } + } + } else { + modules = Arrays.asList(modulesName); + } + + try { + JSONObject rootObject = new JSONObject(json.toString()); + int version = rootObject.getInt("version"); + if (version == VERSION || version == 1) { + JSONArray jsmodules = rootObject.getJSONArray("modules"); + int len = jsmodules.length(); + for (int i = 0; i < len; i++) { + JSONObject moduleObject = jsmodules.getJSONObject(i); + String name = moduleObject.getString("package"); + if (!modules.contains(name)) { + continue; + } + var enabled = moduleObject.getBoolean("enable"); + if (enabled) { + if (!manager.enableModule(name)) { + System.err.println(manager.getLastErrorMsg()); + throw new RuntimeException("Failed to enable " + name); + } + } else { + if (!manager.disableModule(name)) { + System.err.println(manager.getLastErrorMsg()); + throw new RuntimeException("Failed to disable " + name); + } + } + var autoInclude = false; + try { + autoInclude = moduleObject.getBoolean("autoInclude"); + } catch (JSONException ignore) { } + manager.setAutoInclude(name, autoInclude); + JSONArray scopeArray = moduleObject.getJSONArray("scope"); + List scopes = new ArrayList<>(); + for (int j = 0; j < scopeArray.length(); j++) { + if (version == VERSION) { + JSONObject app = scopeArray.getJSONObject(j); + scopes.add(new Scope(app.getString("package"), app.getInt("userId"))); + } else { + scopes.add(new Scope(scopeArray.getString(j), 0)); + } + } + if (!manager.setModuleScope(name, scopes)) { + System.err.println(manager.getLastErrorMsg()); + throw new RuntimeException("Failed to set scope for " + name); + } + } + } else { + throw new RemoteException("Unknown backup file version"); + } + }catch(JSONException je) { + throw new RemoteException(je.getMessage()); + } + + return ERRCODES.NOERROR.ordinal(); + } +} + +@CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class}, version = "0.2") +public class Main implements Runnable { + @CommandLine.Option(names = {"-V", "--version", "version"}, versionHelp = true, description = "display version info") + boolean versionInfoRequested; + + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") + boolean usageHelpRequested; + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + private static ICLIService objManager; + + public Main() { + } + + public static void main(String[] args) { + System.exit(exec(args)); + } + + public void run() { + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); + } + + private static int exec(String[] args) { + IExecutionExceptionHandler errorHandler = new IExecutionExceptionHandler() { + public int handleExecutionException(Exception ex, CommandLine commandLine, ParseResult parseResult) { + commandLine.getErr().println(ex.getMessage()); + if (ex instanceof RemoteException) { + return ERRCODES.REMOTE_ERROR.ordinal(); + } + return commandLine.getCommandSpec().exitCodeOnExecutionException(); + } + }; + int rc = new CommandLine(new Main()) + .registerConverter(Scope.class, new Scope.Converter()) + .setExecutionExceptionHandler(errorHandler) + .execute(args); + return rc; + } + + public static final ICLIService getManager() { + if (objManager == null) { + try { + objManager = getCLIService(); + if (objManager == null) throw new RemoteException(); + } catch (RemoteException re) { + System.err.println("Get manager binder fail, maybe the daemon hasn't started yet or you have not enabled cli"); + System.exit(ERRCODES.NO_DAEMON.ordinal()); + } + } + return objManager; + } + + private static ICLIService getCLIService() throws RemoteException { + var activityService = ServiceManager.getService("activity"); + var binder = new Binder(); + + Parcel data = Parcel.obtain(); + data.writeInterfaceToken("LSPosed"); + data.writeInt(2); + data.writeString("lsp-cli:" + SignInfo.CLI_UUID); + data.writeStrongBinder(binder); + + Parcel reply = Parcel.obtain(); + + if(activityService.transact(1598837584, data, reply, 0)) { + reply.readException(); + var serviceBinder = reply.readStrongBinder(); + if (serviceBinder == null) { + System.err.println("ERROR: binder null"); + return null; + } + var service = ILSPApplicationService.Stub.asInterface(serviceBinder); + var lstBinder = new ArrayList(1); + String sPin = null; + + while (true) { + int iStatus = service.requestCLIBinder(sPin, lstBinder); + if (iStatus == 0) { + return ICLIService.Stub.asInterface(lstBinder.get(0)); + } else if (iStatus == 1) { // request pin to user + sPin = new String(System.console().readPassword("Enter pin code: ")); + } else { + System.err.println("ERROR: cli request failed"); + return null; + } + } + } else { + System.err.println("ERROR: transact failed"); + } + return null; + } +} diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java new file mode 100644 index 000000000..cee26d4df --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java @@ -0,0 +1,68 @@ +package org.lsposed.lspd.cli; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.RemoteException; + +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.models.Application; + +import java.util.HashMap; +import java.util.List; +import java.util.Objects; + +public class Utils { + + public static final String CMDNAME = "cli"; + + public enum ERRCODES { + NOERROR, + USAGE, + EMPTY_SCOPE, + ENABLE_DISABLE, + SET_SCOPE, + LS_SCOPE, + NO_DAEMON, + REMOTE_ERROR + } + + private static HashMap packagesMap; + + private static void initPackagesMap(ICLIService managerService) throws RemoteException { + var packages = + managerService.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | PackageManager.MATCH_UNINSTALLED_PACKAGES, true).getList(); + packagesMap = new HashMap<>(); + for (var packageInfo: packages) { + int userid = packageInfo.applicationInfo.uid / 100000; + packagesMap.put(packageInfo.packageName + "|" + userid, packageInfo); + } + } + + public static boolean validPackageNameAndUserId(ICLIService managerService, String packageName, int userId) throws RemoteException { + if (packagesMap == null) { + initPackagesMap(managerService); + } + + return packagesMap.containsKey(packageName + "|" + userId); + } + + public static boolean checkPackageInScope(String sPackageName, List lstScope) { + for (var app : lstScope) { + if (app.packageName.equals(sPackageName)) { + return true; + } + } + return false; + } + + public static boolean checkPackageModule(String moduleName, List lstScope) { + if (!checkPackageInScope(moduleName, lstScope)) { + Application app = new Application(); + app.packageName = moduleName; + app.userId = 0; + lstScope.add(app); + return true; + } + return false; + } +} diff --git a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java new file mode 100644 index 000000000..b93d33390 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java @@ -0,0 +1,343 @@ +/* + * This file is part of LSPosed. + * + * LSPosed 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, either version 3 of the License, or + * (at your option) any later version. + * + * LSPosed is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LSPosed. If not, see . + * + * Copyright (C) 2022 LSPosed Contributors + */ + +package org.lsposed.lspd.service; + +import static org.lsposed.lspd.service.ServiceManager.TAG; + +import android.app.INotificationManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Intent; +import android.content.pm.ParceledListSlice; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.Color; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.util.Log; + +import org.lsposed.daemon.BuildConfig; +import org.lsposed.daemon.R; +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.models.Application; +import org.lsposed.lspd.util.FakeContext; +import org.lsposed.lspd.util.SignInfo; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.time.LocalDateTime; + +import io.github.libxposed.service.IXposedService; +import rikka.parcelablelist.ParcelableListSlice; + +public class CLIService extends ICLIService.Stub { + + private final static Map sessions = new ConcurrentHashMap<>(3); + private static final HandlerThread worker = new HandlerThread("cli worker"); + private static final Handler workerHandler; + + private static final String CHANNEL_ID = "lsposedpin"; + private static final String CHANNEL_NAME = "Pin code"; + private static final int CHANNEL_IMP = NotificationManager.IMPORTANCE_HIGH; + private static final int NOTIFICATION_ID = 2000; + private static final String opPkg = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? + "android" : "com.android.settings"; + + // E/JavaBinder ] *** Uncaught remote exception! (Exceptions are not yet supported across processes.) + private String sLastMsg; + + static { + worker.start(); + workerHandler = new Handler(worker.getLooper()); + } + + CLIService() { + } + + private int getParentPid(int pid) { + try { + Path path = Paths.get("/proc/" + pid + "/status"); + for (var sLine : Files.readAllLines(path)) { + if (sLine.startsWith("PPid:")) { + return Integer.parseInt(sLine.split(":", 2)[1].trim()); + } + } + } catch (IOException io) { + return -1; + } + return -1; + } + + private static class Session { + boolean bValid; + String sPIN; + LocalDateTime ldtStartSession; + } + + public boolean isValidSession(int iPid, String sPin) { + var iPPid = getParentPid(iPid); + Log.d(TAG, "cli validating session pid=" + iPid + " ppid=" + iPPid); + if (iPPid != -1) { + int timeout = ConfigManager.getInstance().getSessionTimeout(); + if (timeout == -2) { + return true; + } + Session session = sessions.get(iPPid); + if (session != null) { + if (!session.bValid) { + if (sPin != null && sPin.equals(session.sPIN)) { + session.bValid = true; + session.ldtStartSession = LocalDateTime.now(); + Log.d(TAG, "cli valid session ppid=" + iPPid); + return true; + } else { + return false; + } + } + + LocalDateTime ldtExpire = LocalDateTime.now().minusMinutes(timeout); + + if (session.ldtStartSession.isAfter(ldtExpire)) { + return true; + } else { + sessions.remove(iPPid); + } + } + } + return false; + } + + public void requestSession(int iPid) { + var iPPid = getParentPid(iPid); + Log.d(TAG, "cli request new session pid=" + iPid + " parent pid=" + iPPid); + if (iPPid != -1) { + Session session = new Session(); + session.sPIN = String.format("%06d", ThreadLocalRandom.current().nextInt(0, 999999)); + session.bValid = false; + sessions.put(iPPid, session); + showNotification(session.sPIN); + Log.d(TAG, "cli request pin " + session.sPIN); + } + } + + private void showNotification(String sPin) { + var context = new FakeContext(); + String title = context.getString(R.string.pin_request_notification_title); + String content = sPin; + + var style = new Notification.BigTextStyle(); + style.bigText(content); + + var notification = new Notification.Builder(context, CHANNEL_ID) + .setContentTitle(title) + .setContentText(content) + .setColor(Color.BLUE) + .setSmallIcon(LSPNotificationManager.getNotificationIcon()) + .setAutoCancel(false) + .setStyle(style) + .build(); + notification.extras.putString("android.substName", "LSPosed"); + try { + var nm = LSPNotificationManager.getNotificationManager(); + var list = new ArrayList(); + + final NotificationChannel channel = + new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, CHANNEL_IMP); + channel.setShowBadge(false); + if (LSPNotificationManager.hasNotificationChannelForSystem(nm, CHANNEL_ID)) { + nm.updateNotificationChannelForPackage("android", 1000, channel); + } else { + list.add(channel); + } + + nm.createNotificationChannelsForPackage("android", 1000, new ParceledListSlice<>(list)); + nm.enqueueNotificationWithTag("android", opPkg, null, NOTIFICATION_ID, notification, 0); + } catch (RemoteException e) { + Log.e(TAG, "notifyStatusNotification: ", e); + } + } + + public static boolean basicCheck(int uid) { + if (!ConfigManager.getInstance().isEnableCli()) { + return false; + } + if (uid != 0) { + return false; + } + return true; + } + + public static boolean applicationStageNameValid(int pid, String processName) { + var infoArr = processName.split(":"); + if (infoArr.length != 2 || !infoArr[0].equals("lsp-cli")) { + return false; + } + + if(infoArr[1].equals(SignInfo.CLI_UUID)) { + return true; + } + return false; + } + + private static boolean isValidXposedModule(String sPackageName) throws RemoteException { + var appInfo = PackageService.getApplicationInfo(sPackageName, PackageManager.GET_META_DATA | PackageService.MATCH_ALL_FLAGS, 0); + + return appInfo != null && appInfo.metaData != null && appInfo.metaData.containsKey("xposedmodule"); + } + + @Override + public IBinder asBinder() { + return this; + } + + @Override + public int getXposedApiVersion() { + return IXposedService.API; + } + + @Override + public int getXposedVersionCode() { + return BuildConfig.VERSION_CODE; + } + + @Override + public String getXposedVersionName() { + return BuildConfig.VERSION_NAME; + } + + @Override + public String getApi() { + return ConfigManager.getInstance().getApi(); + } + + @Override + public ParcelableListSlice getInstalledPackagesFromAllUsers(int flags, boolean filterNoProcess) throws RemoteException { + return PackageService.getInstalledPackagesFromAllUsers(flags, filterNoProcess); + } + + @Override + public String[] enabledModules() { + return ConfigManager.getInstance().enabledModules(); + } + + @Override + public boolean enableModule(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().enableModule(packageName); + } + + @Override + public boolean setModuleScope(String packageName, List scope) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().setModuleScope(packageName, scope); + } + + @Override + public List getModuleScope(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return null; + } + List list = ConfigManager.getInstance().getModuleScope(packageName); + if (list == null) return null; + else return list; + } + + @Override + public boolean disableModule(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().disableModule(packageName); + } + + @Override + public boolean isVerboseLog() { + return ConfigManager.getInstance().verboseLog(); + } + + @Override + public void setVerboseLog(boolean enabled) { + ConfigManager.getInstance().setVerboseLog(enabled); + } + + @Override + public ParcelFileDescriptor getVerboseLog() { + return ConfigManager.getInstance().getVerboseLog(); + } + + @Override + public ParcelFileDescriptor getModulesLog() { + workerHandler.post(() -> ServiceManager.getLogcatService().checkLogFile()); + return ConfigManager.getInstance().getModulesLog(); + } + + @Override + public boolean clearLogs(boolean verbose) { + return ConfigManager.getInstance().clearLogs(verbose); + } + + @Override + public void getLogs(ParcelFileDescriptor zipFd) throws RemoteException { + ConfigFileManager.getLogs(zipFd); + } + + @Override + public String getLastErrorMsg() { + return sLastMsg; + } + + @Override + public boolean getAutoInclude(String packageName) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return false; + } + return ConfigManager.getInstance().getAutoInclude(packageName); + } + + @Override + public void setAutoInclude(String packageName, boolean add) throws RemoteException { + if (!isValidXposedModule(packageName)) { + sLastMsg = "Module " + packageName + " is not a valid xposed module"; + return; + } + ConfigManager.getInstance().setAutoInclude(packageName, add); + } +} diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java index e058e68a3..6a8e5cf34 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java @@ -99,7 +99,9 @@ public class ConfigManager { private boolean logWatchdog = true; private boolean dexObfuscate = true; private boolean enableStatusNotification = true; + private boolean bEnableCli = false; private Path miscPath = null; + private int iSessionTimeout = -1; private int managerUid = -1; @@ -282,6 +284,22 @@ private synchronized void updateConfig() { updateModulePrefs("lspd", 0, "config", "enable_auto_add_shortcut", null); } + bool = config.get("enable_cli"); + if (bool == null && BuildConfig.VERSION_NAME.contains("_cli_auto")) { + bEnableCli = true; + updateModulePrefs("lspd", 0, "config", "enable_cli", bEnableCli); + } else { + bEnableCli = bool != null && (boolean) bool; + } + + var value = config.get("cli_session_timeout"); + if (value == null && BuildConfig.VERSION_NAME.contains("_cli_auto")) { + iSessionTimeout = -2; + updateModulePrefs("lspd", 0, "config", "cli_session_timeout", iSessionTimeout); + } else { + iSessionTimeout = value == null ? -1 : (int) value; + } + bool = config.get("enable_status_notification"); enableStatusNotification = bool == null || (boolean) bool; @@ -1073,6 +1091,24 @@ public void setEnableStatusNotification(boolean enable) { enableStatusNotification = enable; } + public boolean isEnableCli() { + return bEnableCli; + } + + public void setEnableCli(boolean on) { + updateModulePrefs("lspd", 0, "config", "enable_cli", on); + bEnableCli = on; + } + + public int getSessionTimeout() { + return iSessionTimeout; + } + + public void setSessionTimeout(int iTimeout) { + updateModulePrefs("lspd", 0, "config", "cli_session_timeout", iTimeout); + iSessionTimeout = iTimeout; + } + public ParcelFileDescriptor getManagerApk() { try { return ConfigFileManager.getManagerApk(); @@ -1230,7 +1266,7 @@ synchronized SharedMemory getPreloadDex() { public boolean getAutoInclude(String packageName) { try (Cursor cursor = db.query("modules", new String[]{"auto_include"}, - "module_pkg_name = ? and auto_include = 1", new String[]{packageName}, null, null, null, null)) { + "module_pkg_name = ? and auto_include = 1", new String[]{packageName}, null, null, null, null)) { return cursor == null || cursor.moveToNext(); } } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java index 259980267..e5f5aa0e1 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java @@ -157,6 +157,19 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) t return ConfigManager.getInstance().getManagerApk(); } + @Override + public int requestCLIBinder(String sPin, List binder) throws RemoteException { + var processInfo = ensureRegistered(); + + CLIService cliService = ServiceManager.getCLIService(); + if (cliService.isValidSession(processInfo.pid, sPin)) { + binder.add(cliService); + return 0; + } else { + cliService.requestSession(processInfo.pid); + return 1; + } + } public boolean hasRegister(int uid, int pid) { return processes.containsKey(new Pair<>(uid, pid)); } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java index 46a3cd542..c8cc456b3 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java @@ -557,6 +557,25 @@ public boolean isLogWatchdogEnabled() { return ConfigManager.getInstance().isLogWatchdogEnabled(); } + @Override + public boolean isEnableCli() { + return ConfigManager.getInstance().isEnableCli(); + } + + @Override + public void setEnableCli(boolean enabled) { + ConfigManager.getInstance().setEnableCli(enabled); + } + + @Override + public int getSessionTimeout() { + return ConfigManager.getInstance().getSessionTimeout(); + } + + @Override + public void setSessionTimeout(int iTimeout) { + ConfigManager.getInstance().setSessionTimeout(iTimeout); + } @Override public boolean setAutoInclude(String packageName, boolean enabled) { return ConfigManager.getInstance().setAutoInclude(packageName, enabled); diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java index 878837eee..feeea9873 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java @@ -61,7 +61,7 @@ public void binderDied() { } }; - private static INotificationManager getNotificationManager() throws RemoteException { + static INotificationManager getNotificationManager() throws RemoteException { if (binder == null || notificationManager == null) { binder = android.os.ServiceManager.getService(Context.NOTIFICATION_SERVICE); binder.linkToDeath(recipient, 0); @@ -90,11 +90,11 @@ private static Bitmap getBitmap(int id) { } } - private static Icon getNotificationIcon() { + static Icon getNotificationIcon() { return Icon.createWithBitmap(getBitmap(R.drawable.ic_notification)); } - private static boolean hasNotificationChannelForSystem( + static boolean hasNotificationChannelForSystem( INotificationManager nm, String channelId) throws RemoteException { NotificationChannel channel; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java index b2e0e81a2..4c41cecec 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java @@ -94,6 +94,13 @@ public ILSPApplicationService requestApplicationService(int uid, int pid, String Log.d(TAG, "Skipped duplicated request for uid " + uid + " pid " + pid); return null; } + + // for cli + if (CLIService.basicCheck(uid) && CLIService.applicationStageNameValid(pid, processName)) { + Log.d(TAG, "CLI start, pid: " + pid); + return ServiceManager.requestApplicationService(uid, pid, processName, heartBeat); + } + if (!ServiceManager.getManagerService().shouldStartManager(pid, uid, processName) && ConfigManager.getInstance().shouldSkipProcess(new ConfigManager.ProcessScope(processName, uid))) { Log.d(TAG, "Skipped " + processName + "/" + uid); return null; diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java index 1e890081a..26a22129b 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ServiceManager.java @@ -65,6 +65,7 @@ public class ServiceManager { private static LSPSystemServerService systemServerService = null; private static LogcatService logcatService = null; private static Dex2OatService dex2OatService = null; + private static CLIService cliService = null; private static final ExecutorService executorService = Executors.newSingleThreadExecutor(); @@ -137,6 +138,8 @@ public static void start(String[] args) { applicationService = new LSPApplicationService(); managerService = new LSPManagerService(); systemServerService = new LSPSystemServerService(systemServerMaxRetry); + cliService = new CLIService(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { dex2OatService = new Dex2OatService(); dex2OatService.start(); @@ -208,6 +211,10 @@ public static LogcatService getLogcatService() { return logcatService; } + public static CLIService getCLIService() { + return cliService; + } + public static boolean systemServerRequested() { return systemServerService.systemServerRequested(); } diff --git a/daemon/src/main/res/values/strings.xml b/daemon/src/main/res/values/strings.xml index 0d195d2dd..70a1a8dd5 100644 --- a/daemon/src/main/res/values/strings.xml +++ b/daemon/src/main/res/values/strings.xml @@ -11,6 +11,8 @@ LSPosed status LSPosed loaded Tap the notification to open manager + + PIN Code requested Scope Request %1$s on user %2$s requests to add %3$s to its scope. Scope request diff --git a/magisk-loader/magisk_module/cli b/magisk-loader/magisk_module/cli new file mode 100644 index 000000000..a69193ed2 --- /dev/null +++ b/magisk-loader/magisk_module/cli @@ -0,0 +1,22 @@ +#!/system/bin/sh + +dex_path="" +for DEXDIR in /data/adb/modules $(magisk --path 2>/dev/null)/.magisk/modules +do + if [ -d "$DEXDIR/zygisk_lsposed" ]; then + dex_path="$DEXDIR/zygisk_lsposed" + break + fi +done + +if [ -z "$dex_path" ] +then + echo "No lsposed module path found" + exit 1 +fi + +dex_path="$dex_path/daemon.apk" + +java_potions="-Djava.class.path=$dex_path" + +exec app_process $java_potions /system/bin org.lsposed.lspd.cli.Main "$@" diff --git a/magisk-loader/magisk_module/customize.sh b/magisk-loader/magisk_module/customize.sh index 010a94324..050e60b13 100644 --- a/magisk-loader/magisk_module/customize.sh +++ b/magisk-loader/magisk_module/customize.sh @@ -82,6 +82,8 @@ extract "$ZIPFILE" 'daemon.apk' "$MODPATH" extract "$ZIPFILE" 'daemon' "$MODPATH" rm -f /data/adb/lspd/manager.apk extract "$ZIPFILE" 'manager.apk' "$MODPATH" +mkdir '/data/adb/lspd' +extract "$ZIPFILE" 'cli' '/data/adb/lspd/bin' if [ "$FLAVOR" == "zygisk" ]; then mkdir -p "$MODPATH/zygisk" @@ -148,7 +150,10 @@ fi set_perm_recursive "$MODPATH" 0 0 0755 0644 set_perm_recursive "$MODPATH/bin" 0 2000 0755 0755 u:object_r:xposed_file:s0 +set_perm_recursive "/data/adb/lspd/" 0 0 0755 0644 +set_perm_recursive "/data/adb/lspd/bin" 0 0 0755 0755 u:object_r:xposed_file:s0 chmod 0744 "$MODPATH/daemon" +chmod 0700 "/data/adb/lspd/bin/cli" if [ "$(grep_prop ro.maple.enable)" == "1" ] && [ "$FLAVOR" == "zygisk" ]; then ui_print "- Add ro.maple.enable=0" diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl index b85b6ed21..2fab0f999 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl @@ -12,4 +12,7 @@ interface ILSPApplicationService { String getPrefsPath(String packageName); ParcelFileDescriptor requestInjectedManagerBinder(out List binder); + + int requestCLIBinder(String sPid, out List binder); + } diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl new file mode 100644 index 000000000..bafaa0b00 --- /dev/null +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl @@ -0,0 +1,44 @@ +package org.lsposed.lspd; + +import rikka.parcelablelist.ParcelableListSlice; +import org.lsposed.lspd.models.Application; + +interface ICLIService { + String getApi() = 1; + + ParcelableListSlice getInstalledPackagesFromAllUsers(int flags, boolean filterNoProcess) = 2; + + String[] enabledModules() = 3; + + boolean enableModule(String packageName) = 4; + + boolean disableModule(String packageName) = 5; + + boolean setModuleScope(String packageName, in List scope) = 6; + + List getModuleScope(String packageName) = 7; + + boolean isVerboseLog() = 8; + + void setVerboseLog(boolean enabled) = 9; + + ParcelFileDescriptor getVerboseLog() = 10; + + ParcelFileDescriptor getModulesLog() = 11; + + int getXposedVersionCode() = 12; + + String getXposedVersionName() = 13; + + int getXposedApiVersion() = 14; + + boolean clearLogs(boolean verbose) = 15; + + void getLogs(in ParcelFileDescriptor zipFd) = 16; + + String getLastErrorMsg() = 17; + + boolean getAutoInclude(String packageName) = 18; + + void setAutoInclude(String packageName, boolean add) = 19; +} diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl index aeded3c62..9b161dcfe 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl @@ -95,4 +95,13 @@ interface ILSPManagerService { boolean getAutoInclude(String packageName) = 51; boolean setAutoInclude(String packageName, boolean enable) = 52; + + boolean isEnableCli() = 64; + + void setEnableCli(boolean enable) = 65; + + int getSessionTimeout() = 66; + + void setSessionTimeout(int iTimeout) = 67; + } From a9afab65020a07ac312a6df40b5be47c3bec1858 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Thu, 21 Aug 2025 22:17:53 +0200 Subject: [PATCH 2/9] Implement persistent, root-only PIN authentication This commit refactors the experimental CLI to establish a robust and secure foundation for command-line interaction. The previous session-based model was unintuitive, unsuitable for scripting, and has been entirely replaced. The new design introduces a persistent, file-based PIN and restricts execution to the root user, laying the groundwork for all future CLI development. Key Changes: - **Persistent PIN Authentication:** - The CLI is now protected by a randomly generated PIN that is stored in LSPosed's configuration, surviving reboots. - The complex session timeout and notification-based PIN system has been completely removed. - **Script-Friendly Credentials:** - Authentication is now standard and predictable, using the `--pin` command-line argument or the `LSPOSED_CLI_PIN` environment variable for convenience in scripts. - **User-Friendly Helper Command:** - A `login` command has been added. It verifies the user's PIN and provides a convenient `export` command to set the environment variable for the current shell session. --- .../org/lsposed/manager/ConfigManager.java | 45 ++-- .../manager/ui/fragment/SettingsFragment.java | 63 ++++- .../drawable/ic_baseline_hourglass_top_24.xml | 29 --- app/src/main/res/values/arrays.xml | 23 -- app/src/main/res/values/strings.xml | 13 +- app/src/main/res/xml/prefs.xml | 8 - .../lspd/core/ApplicationServiceClient.java | 6 +- .../main/java/org/lsposed/lspd/cli/Main.java | 238 +++++++++++++----- .../main/java/org/lsposed/lspd/cli/Utils.java | 1 + .../org/lsposed/lspd/service/CLIService.java | 121 +-------- .../lsposed/lspd/service/ConfigManager.java | 35 +-- .../lspd/service/LSPApplicationService.java | 40 ++- .../lspd/service/LSPManagerService.java | 24 +- .../lspd/service/ILSPApplicationService.aidl | 3 +- .../aidl/org/lsposed/lspd/ICLIService.aidl | 2 + .../org/lsposed/lspd/ILSPManagerService.aidl | 9 +- 16 files changed, 321 insertions(+), 339 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java index ab61f6bc7..f19b9b691 100644 --- a/app/src/main/java/org/lsposed/manager/ConfigManager.java +++ b/app/src/main/java/org/lsposed/manager/ConfigManager.java @@ -382,68 +382,57 @@ public static boolean setDexObfuscateEnabled(boolean enabled) { } } - public static boolean isEnableCli() { + public static int getDex2OatWrapperCompatibility() { try { - return LSPManagerServiceHolder.getService().isEnableCli(); - } catch (RemoteException e) { + return LSPManagerServiceHolder.getService().getDex2OatWrapperCompatibility(); + } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); - return false; + return ILSPManagerService.DEX2OAT_CRASHED; } } - public static boolean setEnableCli(boolean enabled) { + public static boolean getAutoInclude(String packageName) { try { - LSPManagerServiceHolder.getService().setEnableCli(enabled); - return true; + return LSPManagerServiceHolder.getService().getAutoInclude(packageName); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); return false; } } - public static int getSessionTimeout() { - try { - return LSPManagerServiceHolder.getService().getSessionTimeout(); - } catch (RemoteException e) { - Log.e(App.TAG, Log.getStackTraceString(e)); - return -2; - } - } - - public static boolean setSessionTimeout(int iTimeout) { + public static boolean setAutoInclude(String packageName, boolean enable) { try { - LSPManagerServiceHolder.getService().setSessionTimeout(iTimeout); + LSPManagerServiceHolder.getService().setAutoInclude(packageName, enable); return true; } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); return false; } } - public static int getDex2OatWrapperCompatibility() { + + public static boolean isCliEnabled() { try { - return LSPManagerServiceHolder.getService().getDex2OatWrapperCompatibility(); + return LSPManagerServiceHolder.getService().isCliEnabled(); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); - return ILSPManagerService.DEX2OAT_CRASHED; + return false; } } - public static boolean getAutoInclude(String packageName) { + public static void setCliPin(String pin) { try { - return LSPManagerServiceHolder.getService().getAutoInclude(packageName); + LSPManagerServiceHolder.getService().setCliPin(pin); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); - return false; } } - public static boolean setAutoInclude(String packageName, boolean enable) { + public static String getCliPin() { try { - LSPManagerServiceHolder.getService().setAutoInclude(packageName, enable); - return true; + return LSPManagerServiceHolder.getService().getCliPin(); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); - return false; + return null; } } } diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java index a348f662f..8889aaea6 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java @@ -24,6 +24,7 @@ import android.os.Build; import android.os.Bundle; import android.provider.Settings; +import android.text.Html; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -58,6 +59,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Locale; +import java.util.UUID; import rikka.core.util.ResourceUtils; import rikka.material.app.LocaleDelegate; @@ -373,18 +375,38 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { } MaterialSwitchPreference prefCli = findPreference("enable_cli"); - Preference prefSessionTimeout = findPreference("cli_session_timeout"); - if (prefCli != null && prefSessionTimeout != null) { + if (prefCli != null) { prefCli.setEnabled(installed); - prefCli.setChecked(!installed || ConfigManager.isEnableCli()); - prefCli.setOnPreferenceChangeListener((preference, newValue) -> { - ConfigManager.setEnableCli((boolean) newValue); - prefSessionTimeout.setEnabled((boolean) newValue); - return true; - }); - prefSessionTimeout.setEnabled(!installed ? false : prefCli.isChecked()); - ((SimpleMenuPreference) prefSessionTimeout).setValue(!installed ? "-1" : "" + ConfigManager.getSessionTimeout()); - prefSessionTimeout.setOnPreferenceChangeListener((preference, newValue) -> ConfigManager.setSessionTimeout(Integer.parseInt((String) newValue))); + if (!installed) { + prefCli.setChecked(false); + } else { + // Set the initial state of the switch and summary + boolean isEnabled = ConfigManager.isCliEnabled(); + prefCli.setChecked(isEnabled); + updateCliSummary(prefCli, isEnabled); + + // Set the listener for when the user toggles the switch + prefCli.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enabled = (boolean) newValue; + if (enabled) { + // User is TURNING ON the CLI + if (BuildConfig.DEBUG) { + // For debug builds, set a special non-null value + ConfigManager.setCliPin("DEBUG_MODE_NO_AUTH"); + } else { + // For release builds, generate and set a new random PIN + String newPin = UUID.randomUUID().toString().substring(0, 8); + ConfigManager.setCliPin(newPin); + } + } else { + // User is TURNING OFF the CLI, invalidate the PIN + ConfigManager.setCliPin(null); + } + // Update the summary to reflect the new state + updateCliSummary(preference, enabled); + return true; + }); + } } SimpleMenuPreference channel = findPreference("update_channel"); @@ -414,5 +436,24 @@ public RecyclerView onCreateRecyclerView(@NonNull LayoutInflater inflater, @NonN } return recyclerView; } + + private void updateCliSummary(Preference prefCli, boolean isEnabled) { + if (!isEnabled) { + prefCli.setSummary(R.string.pref_summary_enable_cli); + } else { + if (BuildConfig.DEBUG) { + prefCli.setSummary(R.string.pref_summary_cli_debug); + } else { + String pin = ConfigManager.getCliPin(); + if (pin != null) { + String summary = getString(R.string.pref_summary_cli_pin, pin); + prefCli.setSummary(Html.fromHtml(summary, Html.FROM_HTML_MODE_COMPACT)); + } else { + // Fallback in case PIN is null while switch is on + prefCli.setSummary(R.string.pref_summary_enable_cli); + } + } + } + } } } diff --git a/app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml b/app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml deleted file mode 100644 index a6d86557d..000000000 --- a/app/src/main/res/drawable/ic_baseline_hourglass_top_24.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 949570121..2ddb9495a 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -88,27 +88,4 @@ CHANNEL_NIGHTLY - - @string/disabled - @string/one_command - 1 - 3 - 5 - 15 - 30 - 60 - - - - -2 - -1 - 1 - 3 - 5 - 15 - 30 - 60 - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07b0df533..a7a576819 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,11 +209,14 @@ Nightly build Xposed API call protection Block dynamically loaded module code to use Xposed API, this may break some modules but benefit security - CLI - Enable CLI - (experimental feature) - Session timeout in minutes - One command + Command line interface + Enable CLI feature + + Automate LSPosed with shell scripts via PIN authentication + + Your CLI auth PIN is: %1$s
+ + Enabled for debugging. No authentication required. Disabled diff --git a/app/src/main/res/xml/prefs.xml b/app/src/main/res/xml/prefs.xml index 5e503fc50..f97483657 100644 --- a/app/src/main/res/xml/prefs.xml +++ b/app/src/main/res/xml/prefs.xml @@ -169,13 +169,5 @@ android:defaultValue="false" android:icon="@drawable/ic_outline_cmd_24" android:persistent="false"/> - diff --git a/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java b/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java index bc6402edc..3c5f80340 100644 --- a/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java +++ b/core/src/main/java/org/lsposed/lspd/core/ApplicationServiceClient.java @@ -103,13 +103,13 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) { } @Override - public int requestCLIBinder(String sPin, List binder) { + public void requestCLIBinder(String sPin, List binder) { try { - return service.requestCLIBinder(sPin, binder); + service.requestCLIBinder(sPin, binder); } catch (RemoteException | NullPointerException ignored) { } - return -1; } + @Override public IBinder asBinder() { return service.asBinder(); diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index 93accb808..c006e7526 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -45,6 +45,9 @@ @CommandLine.Command(name = "log") class LogCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") boolean usageHelpRequested; @CommandLine.Option(names = {"-f", "--follow", "follow"}, description = "Follow update of log, as tail -f") @@ -56,7 +59,7 @@ class LogCommand implements Callable { @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.getManager(); if (bClear) { manager.clearLogs(bVerboseLog); if (!bFollow) { // we can clear old logs and follow new @@ -92,8 +95,57 @@ private void printLog(ParcelFileDescriptor parcelFileDescriptor) { } } +@CommandLine.Command(name = "login", + description = "Verifies the PIN and prints the shell command to set the session environment variable.") +class LoginCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Option(names = "--for-eval", + description = "Output only the export command for use with eval().") + private boolean forEval; + + @Override + public Integer call() throws Exception { + // Step 1: Authenticate by requesting the manager. + // If the PIN is wrong, parent.getManager() will throw a SecurityException, + // which our main exception handler will catch and report to the user. + // We don't need any try-catch block here. + parent.getManager(); + + // Step 2: If we reach here, authentication was successful. + String pin = parent.pin; + if (pin == null) { + // This case should ideally not be hit if auth succeeded, but as a safeguard: + System.err.println("Error: Could not retrieve the PIN used for authentication."); + return 1; + } + + String exportCommand = "export LSPOSED_CLI_PIN=\"" + pin + "\""; + + if (forEval) { + // For power-users using `eval $(...)` + System.out.println(exportCommand); + } else { + // For regular interactive users + System.out.println("✅ Authentication successful."); + System.out.println(); + System.out.println("To avoid typing the PIN for every command in this shell session, run the following command:"); + System.out.println(); + System.out.println(" " + exportCommand); + System.out.println(); + System.out.println("You will then be able to run commands like 'lsposed-cli status' without the --pin argument."); + } + + return 0; // Success + } +} + @CommandLine.Command(name = "ls") class ListModulesCommand implements Callable { + @CommandLine.ParentCommand + private ModulesCommand parent; + @CommandLine.ArgGroup(exclusive = true) LSModuleOpts objArgs = new LSModuleOpts(); @@ -110,7 +162,7 @@ static class LSModuleOpts { @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.parent.getManager(); var lstEnabledModules = Arrays.asList(manager.enabledModules()); var lstPackages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); @@ -131,8 +183,25 @@ public Integer call() throws RemoteException { } } +@CommandLine.Command(name = "revoke-pin", description = "Revokes the current CLI PIN, disabling CLI access.") +class RevokePinCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @Override + public Integer call() throws Exception { + System.out.println("Revoking current CLI PIN..."); + parent.getManager().revokeCurrentPin(); + System.out.println("PIN has been revoked. You must re-enable the CLI from the Manager app."); + return 0; + } +} + @CommandLine.Command(name = "set") class SetModulesCommand implements Callable { + @CommandLine.ParentCommand + private ModulesCommand parent; + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") SetModuleOpts objArgs = new SetModuleOpts(); @@ -150,7 +219,7 @@ static class SetModuleOpts { @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.parent.getManager(); boolean bMsgReboot = false; for (var module : lstModules) { @@ -191,6 +260,9 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = "modules", subcommands = {ListModulesCommand.class, SetModulesCommand.class}) class ModulesCommand implements Runnable { + @CommandLine.ParentCommand + Main parent; + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") boolean usageHelpRequested; @@ -227,12 +299,15 @@ public String toString() { @CommandLine.Command(name = "ls") class ListScopeCommand implements Callable { + @CommandLine.ParentCommand + private ScopeCommand parent; + @CommandLine.Parameters(index = "0", description = "module's name", paramLabel="") String moduleName; @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.parent.getManager(); var lstScope = manager.getModuleScope(moduleName); if (lstScope == null) @@ -251,6 +326,9 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = "set", exitCodeOnExecutionException = 4 /* ERRCODES.SET_SCOPE */) class SetScopeCommand implements Callable { /*, multiplicity = "0..1"*/ + @CommandLine.ParentCommand + private ScopeCommand parent; + @CommandLine.ArgGroup(exclusive = true) ScopeOpts objArgs = new ScopeOpts(); @@ -274,7 +352,7 @@ static class ScopeOpts { @Override public Integer call() throws RemoteException { boolean bMsgReboot = false; - ICLIService manager = Main.getManager(); + ICLIService manager = parent.parent.getManager(); // default operation set // TODO find a mode for manage in picocli @@ -350,6 +428,9 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = "scope", subcommands = {ListScopeCommand.class, SetScopeCommand.class}) class ScopeCommand implements Runnable { + @CommandLine.ParentCommand + Main parent; + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") boolean usageHelpRequested; @@ -363,12 +444,15 @@ public void run() { @CommandLine.Command(name = "status") class StatusCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") boolean usageHelpRequested; @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.getManager(); String sSysVer; if (Build.VERSION.PREVIEW_SDK_INT != 0) { sSysVer = String.format("%1$s Preview (API %2$d)", Build.VERSION.CODENAME, Build.VERSION.SDK_INT); @@ -398,6 +482,9 @@ private String getDevice() { @CommandLine.Command(name = "backup") class BackupCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") boolean usageHelpRequested; @CommandLine.Parameters(index = "0..*", description = "module's name default all", paramLabel="") @@ -412,7 +499,7 @@ class BackupCommand implements Callable { @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.getManager(); if (modulesName == null) { List modules = new ArrayList<>(); @@ -469,6 +556,9 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = "restore") class RestoreCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") boolean usageHelpRequested; @CommandLine.Parameters(index = "0..*", description = "module's name default all", paramLabel="") @@ -483,7 +573,7 @@ class RestoreCommand implements Callable { @Override public Integer call() throws RemoteException { - ICLIService manager = Main.getManager(); + ICLIService manager = parent.getManager(); StringBuilder json = new StringBuilder(); try { @@ -573,8 +663,11 @@ public Integer call() throws RemoteException { } } -@CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class}, version = "0.2") +@CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class, RevokePinCommand.class}, version = "0.3") public class Main implements Runnable { + @CommandLine.Option(names = {"-p", "--pin"}, description = "Authentication PIN for the CLI.", scope = CommandLine.ScopeType.INHERIT) + String pin; + @CommandLine.Option(names = {"-V", "--version", "version"}, versionHelp = true, description = "display version info") boolean versionInfoRequested; @@ -590,8 +683,13 @@ public Main() { } public static void main(String[] args) { - System.exit(exec(args)); - } + System.exit(new CommandLine(new Main()) + .setExecutionExceptionHandler((ex, commandLine, parseResult) -> { + commandLine.getErr().println(ex.getMessage()); + return ex instanceof SecurityException ? ERRCODES.AUTH_FAILED.ordinal() : ERRCODES.REMOTE_ERROR.ordinal(); + }) + .execute(args)); + } public void run() { throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); @@ -614,56 +712,84 @@ public int handleExecutionException(Exception ex, CommandLine commandLine, Parse return rc; } - public static final ICLIService getManager() { - if (objManager == null) { + public final ICLIService getManager() { + if (objManager == null) { try { - objManager = getCLIService(); - if (objManager == null) throw new RemoteException(); - } catch (RemoteException re) { - System.err.println("Get manager binder fail, maybe the daemon hasn't started yet or you have not enabled cli"); + objManager = connectToService(); + if (objManager == null) { + // connectToService will throw, but as a fallback: + throw new SecurityException("Authentication failed or daemon service not available."); + } + } catch (RemoteException | SecurityException e) { + System.err.println("Error: " + e.getMessage()); System.exit(ERRCODES.NO_DAEMON.ordinal()); } } return objManager; } - private static ICLIService getCLIService() throws RemoteException { - var activityService = ServiceManager.getService("activity"); - var binder = new Binder(); - - Parcel data = Parcel.obtain(); - data.writeInterfaceToken("LSPosed"); - data.writeInt(2); - data.writeString("lsp-cli:" + SignInfo.CLI_UUID); - data.writeStrongBinder(binder); - - Parcel reply = Parcel.obtain(); - - if(activityService.transact(1598837584, data, reply, 0)) { - reply.readException(); - var serviceBinder = reply.readStrongBinder(); - if (serviceBinder == null) { - System.err.println("ERROR: binder null"); - return null; - } - var service = ILSPApplicationService.Stub.asInterface(serviceBinder); - var lstBinder = new ArrayList(1); - String sPin = null; - - while (true) { - int iStatus = service.requestCLIBinder(sPin, lstBinder); - if (iStatus == 0) { - return ICLIService.Stub.asInterface(lstBinder.get(0)); - } else if (iStatus == 1) { // request pin to user - sPin = new String(System.console().readPassword("Enter pin code: ")); - } else { - System.err.println("ERROR: cli request failed"); - return null; - } - } - } else { - System.err.println("ERROR: transact failed"); - } - return null; - } + private ICLIService connectToService() throws RemoteException { + // 1. Check for credentials provided by the user via arguments or environment. + // We store this in a separate variable to remember if the user even tried to provide a PIN. + String initialPin = this.pin; // `this.pin` is populated by picocli from the --pin arg + if (initialPin == null) { + initialPin = System.getenv("LSPOSED_CLI_PIN"); + } + // `this.pin` will be used for the actual connection attempts. + this.pin = initialPin; + + // 2. Connect to the basic application service binder (boilerplate). + var activityService = ServiceManager.getService("activity"); + if (activityService == null) throw new RemoteException("Could not get activity service."); + + var binder = new Binder(); + Parcel data = Parcel.obtain(); + data.writeInterfaceToken("LSPosed"); + data.writeInt(2); + data.writeString("lsp-cli:" + org.lsposed.lspd.util.SignInfo.CLI_UUID); + data.writeStrongBinder(binder); + Parcel reply = Parcel.obtain(); + + if (!activityService.transact(1598837584, data, reply, 0)) { + throw new RemoteException("Transaction to activity service failed."); + } + + reply.readException(); + var serviceBinder = reply.readStrongBinder(); + if (serviceBinder == null) throw new RemoteException("Daemon did not return a service binder."); + + var service = ILSPApplicationService.Stub.asInterface(serviceBinder); + var lstBinder = new ArrayList(1); + + // 3. First attempt: Authenticate with the credentials we have (which could be null). + service.requestCLIBinder(this.pin, lstBinder); + + // 4. Recovery step: If the first attempt failed, we have no PIN, AND we're in an + // interactive shell, then prompt the user as a last resort. + if (lstBinder.isEmpty() && this.pin == null && System.console() != null) { + System.err.println("Authentication required."); + char[] pinChars = System.console().readPassword("Enter CLI PIN: "); + if (pinChars != null) { + this.pin = new String(pinChars); + // Second attempt: Retry with the PIN from the interactive prompt. + service.requestCLIBinder(this.pin, lstBinder); + } + } + + // 5. Final check and smart error reporting. + if (lstBinder.isEmpty()) { + String errorMessage; + if (initialPin == null) { + // The user never provided a PIN. The daemon requires one. Guide the user. + errorMessage = "Authentication required. Use --pin, set LSPOSED_CLI_PIN, or use an interactive shell."; + } else { + // The user provided a PIN, but it was rejected by the daemon. + errorMessage = "Authentication failed. The provided PIN is incorrect or CLI is disabled in the Manager app."; + } + throw new SecurityException(errorMessage); + } + + // If we reach here, we are successful. + return ICLIService.Stub.asInterface(lstBinder.get(0)); + } } diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java index cee26d4df..0fb17a484 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java @@ -22,6 +22,7 @@ public enum ERRCODES { ENABLE_DISABLE, SET_SCOPE, LS_SCOPE, + AUTH_FAILED, NO_DAEMON, REMOTE_ERROR } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java index b93d33390..1c31d4115 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java @@ -61,18 +61,9 @@ public class CLIService extends ICLIService.Stub { - private final static Map sessions = new ConcurrentHashMap<>(3); private static final HandlerThread worker = new HandlerThread("cli worker"); private static final Handler workerHandler; - private static final String CHANNEL_ID = "lsposedpin"; - private static final String CHANNEL_NAME = "Pin code"; - private static final int CHANNEL_IMP = NotificationManager.IMPORTANCE_HIGH; - private static final int NOTIFICATION_ID = 2000; - private static final String opPkg = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? - "android" : "com.android.settings"; - - // E/JavaBinder ] *** Uncaught remote exception! (Exceptions are not yet supported across processes.) private String sLastMsg; static { @@ -83,117 +74,13 @@ public class CLIService extends ICLIService.Stub { CLIService() { } - private int getParentPid(int pid) { - try { - Path path = Paths.get("/proc/" + pid + "/status"); - for (var sLine : Files.readAllLines(path)) { - if (sLine.startsWith("PPid:")) { - return Integer.parseInt(sLine.split(":", 2)[1].trim()); - } - } - } catch (IOException io) { - return -1; - } - return -1; - } - - private static class Session { - boolean bValid; - String sPIN; - LocalDateTime ldtStartSession; - } - - public boolean isValidSession(int iPid, String sPin) { - var iPPid = getParentPid(iPid); - Log.d(TAG, "cli validating session pid=" + iPid + " ppid=" + iPPid); - if (iPPid != -1) { - int timeout = ConfigManager.getInstance().getSessionTimeout(); - if (timeout == -2) { - return true; - } - Session session = sessions.get(iPPid); - if (session != null) { - if (!session.bValid) { - if (sPin != null && sPin.equals(session.sPIN)) { - session.bValid = true; - session.ldtStartSession = LocalDateTime.now(); - Log.d(TAG, "cli valid session ppid=" + iPPid); - return true; - } else { - return false; - } - } - - LocalDateTime ldtExpire = LocalDateTime.now().minusMinutes(timeout); - - if (session.ldtStartSession.isAfter(ldtExpire)) { - return true; - } else { - sessions.remove(iPPid); - } - } - } - return false; - } - - public void requestSession(int iPid) { - var iPPid = getParentPid(iPid); - Log.d(TAG, "cli request new session pid=" + iPid + " parent pid=" + iPPid); - if (iPPid != -1) { - Session session = new Session(); - session.sPIN = String.format("%06d", ThreadLocalRandom.current().nextInt(0, 999999)); - session.bValid = false; - sessions.put(iPPid, session); - showNotification(session.sPIN); - Log.d(TAG, "cli request pin " + session.sPIN); - } - } - - private void showNotification(String sPin) { - var context = new FakeContext(); - String title = context.getString(R.string.pin_request_notification_title); - String content = sPin; - - var style = new Notification.BigTextStyle(); - style.bigText(content); - - var notification = new Notification.Builder(context, CHANNEL_ID) - .setContentTitle(title) - .setContentText(content) - .setColor(Color.BLUE) - .setSmallIcon(LSPNotificationManager.getNotificationIcon()) - .setAutoCancel(false) - .setStyle(style) - .build(); - notification.extras.putString("android.substName", "LSPosed"); - try { - var nm = LSPNotificationManager.getNotificationManager(); - var list = new ArrayList(); - - final NotificationChannel channel = - new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, CHANNEL_IMP); - channel.setShowBadge(false); - if (LSPNotificationManager.hasNotificationChannelForSystem(nm, CHANNEL_ID)) { - nm.updateNotificationChannelForPackage("android", 1000, channel); - } else { - list.add(channel); - } - - nm.createNotificationChannelsForPackage("android", 1000, new ParceledListSlice<>(list)); - nm.enqueueNotificationWithTag("android", opPkg, null, NOTIFICATION_ID, notification, 0); - } catch (RemoteException e) { - Log.e(TAG, "notifyStatusNotification: ", e); - } + @Override + public void revokeCurrentPin() { + ConfigManager.getInstance().setCliPin(null); } public static boolean basicCheck(int uid) { - if (!ConfigManager.getInstance().isEnableCli()) { - return false; - } - if (uid != 0) { - return false; - } - return true; + return uid == 0; } public static boolean applicationStageNameValid(int pid, String processName) { diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java index 6a8e5cf34..5db19fa23 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java @@ -99,9 +99,8 @@ public class ConfigManager { private boolean logWatchdog = true; private boolean dexObfuscate = true; private boolean enableStatusNotification = true; - private boolean bEnableCli = false; private Path miscPath = null; - private int iSessionTimeout = -1; + private String cliPin = null; private int managerUid = -1; @@ -284,21 +283,8 @@ private synchronized void updateConfig() { updateModulePrefs("lspd", 0, "config", "enable_auto_add_shortcut", null); } - bool = config.get("enable_cli"); - if (bool == null && BuildConfig.VERSION_NAME.contains("_cli_auto")) { - bEnableCli = true; - updateModulePrefs("lspd", 0, "config", "enable_cli", bEnableCli); - } else { - bEnableCli = bool != null && (boolean) bool; - } - - var value = config.get("cli_session_timeout"); - if (value == null && BuildConfig.VERSION_NAME.contains("_cli_auto")) { - iSessionTimeout = -2; - updateModulePrefs("lspd", 0, "config", "cli_session_timeout", iSessionTimeout); - } else { - iSessionTimeout = value == null ? -1 : (int) value; - } + var pin = config.get("cli_pin"); + cliPin = pin instanceof String ? (String) pin : null; bool = config.get("enable_status_notification"); enableStatusNotification = bool == null || (boolean) bool; @@ -1091,22 +1077,21 @@ public void setEnableStatusNotification(boolean enable) { enableStatusNotification = enable; } - public boolean isEnableCli() { - return bEnableCli; + public boolean isCliEnabled() { + return BuildConfig.DEBUG || cliPin != null; } public void setEnableCli(boolean on) { updateModulePrefs("lspd", 0, "config", "enable_cli", on); - bEnableCli = on; } - public int getSessionTimeout() { - return iSessionTimeout; + public String getCliPin() { + return cliPin; } - public void setSessionTimeout(int iTimeout) { - updateModulePrefs("lspd", 0, "config", "cli_session_timeout", iTimeout); - iSessionTimeout = iTimeout; + public void setCliPin(String pin) { + updateModulePrefs("lspd", 0, "config", "cli_pin", pin); + cliPin = pin; } public ParcelFileDescriptor getManagerApk() { diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java index e5f5aa0e1..e89f70091 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java @@ -31,6 +31,7 @@ import androidx.annotation.NonNull; +import org.lsposed.daemon.BuildConfig; import org.lsposed.lspd.models.Module; import java.util.Collections; @@ -157,19 +158,34 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) t return ConfigManager.getInstance().getManagerApk(); } - @Override - public int requestCLIBinder(String sPin, List binder) throws RemoteException { - var processInfo = ensureRegistered(); - - CLIService cliService = ServiceManager.getCLIService(); - if (cliService.isValidSession(processInfo.pid, sPin)) { - binder.add(cliService); - return 0; - } else { - cliService.requestSession(processInfo.pid); - return 1; - } + @Override + public void requestCLIBinder(String pin, List binder) throws RemoteException { + ensureRegistered(); // Ensures caller is a valid process + + ConfigManager config = ConfigManager.getInstance(); + String storedPin = config.getCliPin(); + + boolean allowAccess = false; + if (BuildConfig.DEBUG) { + // For DEBUG builds, access is granted if either: + // 1. The user hasn't set a PIN yet (default on). + // 2. The special "no auth" value is set. + if (storedPin == null || "DEBUG_MODE_NO_AUTH".equals(storedPin)) { + allowAccess = true; + } + } + + // For ALL builds (including debug if the above conditions were not met), + // check if the provided PIN matches the stored one. + if (!allowAccess && storedPin != null && storedPin.equals(pin)) { + allowAccess = true; + } + + if (allowAccess) { + binder.add(ServiceManager.getCLIService()); + } } + public boolean hasRegister(int uid, int pid) { return processes.containsKey(new Pair<>(uid, pid)); } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java index c8cc456b3..cac2f2f2b 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java @@ -558,31 +558,27 @@ public boolean isLogWatchdogEnabled() { } @Override - public boolean isEnableCli() { - return ConfigManager.getInstance().isEnableCli(); + public boolean setAutoInclude(String packageName, boolean enabled) { + return ConfigManager.getInstance().setAutoInclude(packageName, enabled); } @Override - public void setEnableCli(boolean enabled) { - ConfigManager.getInstance().setEnableCli(enabled); + public boolean isCliEnabled() { + return ConfigManager.getInstance().isCliEnabled(); } @Override - public int getSessionTimeout() { - return ConfigManager.getInstance().getSessionTimeout(); + public boolean getAutoInclude(String packageName) { + return ConfigManager.getInstance().getAutoInclude(packageName); } @Override - public void setSessionTimeout(int iTimeout) { - ConfigManager.getInstance().setSessionTimeout(iTimeout); - } - @Override - public boolean setAutoInclude(String packageName, boolean enabled) { - return ConfigManager.getInstance().setAutoInclude(packageName, enabled); + public String getCliPin() { + return ConfigManager.getInstance().getCliPin(); } @Override - public boolean getAutoInclude(String packageName) { - return ConfigManager.getInstance().getAutoInclude(packageName); + public void setCliPin(String pin) { + ConfigManager.getInstance().setCliPin(pin); } } diff --git a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl index 2fab0f999..34e78f2f8 100644 --- a/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl +++ b/services/daemon-service/src/main/aidl/org/lsposed/lspd/service/ILSPApplicationService.aidl @@ -13,6 +13,5 @@ interface ILSPApplicationService { ParcelFileDescriptor requestInjectedManagerBinder(out List binder); - int requestCLIBinder(String sPid, out List binder); - + void requestCLIBinder(String pin, out List binder); } diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl index bafaa0b00..a55d63dea 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ICLIService.aidl @@ -41,4 +41,6 @@ interface ICLIService { boolean getAutoInclude(String packageName) = 18; void setAutoInclude(String packageName, boolean add) = 19; + + void revokeCurrentPin() = 20; } diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl index 9b161dcfe..169961a13 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl @@ -96,12 +96,9 @@ interface ILSPManagerService { boolean setAutoInclude(String packageName, boolean enable) = 52; - boolean isEnableCli() = 64; + boolean isCliEnabled() = 53; - void setEnableCli(boolean enable) = 65; - - int getSessionTimeout() = 66; - - void setSessionTimeout(int iTimeout) = 67; + void setCliPin(String pin) = 54; + String getCliPin() = 55; } From b7a112f00ac687a09ec4e2499caa188dfe71cc97 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 22 Aug 2025 00:15:15 +0200 Subject: [PATCH 3/9] Enhance security with volatile in-memory PIN A file-based PIN can be easily abused by root users. In the future, we plan to allow shell users to run the CLI tool directly. --- .../org/lsposed/manager/ConfigManager.java | 16 ++-- .../manager/ui/fragment/SettingsFragment.java | 84 +++++++++---------- .../org/lsposed/lspd/service/CLIService.java | 2 +- .../lsposed/lspd/service/ConfigManager.java | 28 ++++--- .../lspd/service/LSPApplicationService.java | 17 ++-- .../lspd/service/LSPManagerService.java | 16 ++-- .../org/lsposed/lspd/ILSPManagerService.aidl | 6 +- 7 files changed, 83 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java index f19b9b691..f642d837b 100644 --- a/app/src/main/java/org/lsposed/manager/ConfigManager.java +++ b/app/src/main/java/org/lsposed/manager/ConfigManager.java @@ -410,29 +410,29 @@ public static boolean setAutoInclude(String packageName, boolean enable) { } } - public static boolean isCliEnabled() { + public static String getCurrentCliPin() { try { - return LSPManagerServiceHolder.getService().isCliEnabled(); + return LSPManagerServiceHolder.getService().getCurrentCliPin(); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); - return false; + return null; } } - public static void setCliPin(String pin) { + public static String resetCliPin() { try { - LSPManagerServiceHolder.getService().setCliPin(pin); + return LSPManagerServiceHolder.getService().resetCliPin(); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); + return null; } } - public static String getCliPin() { + public static void disableCli() { try { - return LSPManagerServiceHolder.getService().getCliPin(); + LSPManagerServiceHolder.getService().disableCli(); } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); - return null; } } } diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java index 8889aaea6..65097ce8b 100644 --- a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java @@ -376,37 +376,7 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { MaterialSwitchPreference prefCli = findPreference("enable_cli"); if (prefCli != null) { - prefCli.setEnabled(installed); - if (!installed) { - prefCli.setChecked(false); - } else { - // Set the initial state of the switch and summary - boolean isEnabled = ConfigManager.isCliEnabled(); - prefCli.setChecked(isEnabled); - updateCliSummary(prefCli, isEnabled); - - // Set the listener for when the user toggles the switch - prefCli.setOnPreferenceChangeListener((preference, newValue) -> { - boolean enabled = (boolean) newValue; - if (enabled) { - // User is TURNING ON the CLI - if (BuildConfig.DEBUG) { - // For debug builds, set a special non-null value - ConfigManager.setCliPin("DEBUG_MODE_NO_AUTH"); - } else { - // For release builds, generate and set a new random PIN - String newPin = UUID.randomUUID().toString().substring(0, 8); - ConfigManager.setCliPin(newPin); - } - } else { - // User is TURNING OFF the CLI, invalidate the PIN - ConfigManager.setCliPin(null); - } - // Update the summary to reflect the new state - updateCliSummary(preference, enabled); - return true; - }); - } + setupCliPreference(prefCli); } SimpleMenuPreference channel = findPreference("update_channel"); @@ -437,22 +407,48 @@ public RecyclerView onCreateRecyclerView(@NonNull LayoutInflater inflater, @NonN return recyclerView; } - private void updateCliSummary(Preference prefCli, boolean isEnabled) { - if (!isEnabled) { - prefCli.setSummary(R.string.pref_summary_enable_cli); + private void setupCliPreference(MaterialSwitchPreference prefCli) { + boolean installed = ConfigManager.isBinderAlive(); + if (!installed) { + prefCli.setEnabled(false); + return; + } + prefCli.setEnabled(true); + + // On load, check the daemon's memory for the current state. + String currentPin = ConfigManager.getCurrentCliPin(); + boolean isEnabled; + if (BuildConfig.DEBUG) { + // On DEBUG, the feature is considered enabled even if PIN is null (default-on state) + isEnabled = true; + prefCli.setEnabled(false); } else { - if (BuildConfig.DEBUG) { - prefCli.setSummary(R.string.pref_summary_cli_debug); + isEnabled = currentPin != null; + } + prefCli.setChecked(isEnabled); + updateCliSummary(prefCli, currentPin); + + prefCli.setOnPreferenceChangeListener((preference, newValue) -> { + boolean enable = (boolean) newValue; + String newPin = null; + if (enable) { + newPin = ConfigManager.resetCliPin(); } else { - String pin = ConfigManager.getCliPin(); - if (pin != null) { - String summary = getString(R.string.pref_summary_cli_pin, pin); - prefCli.setSummary(Html.fromHtml(summary, Html.FROM_HTML_MODE_COMPACT)); - } else { - // Fallback in case PIN is null while switch is on - prefCli.setSummary(R.string.pref_summary_enable_cli); - } + ConfigManager.disableCli(); } + updateCliSummary(preference, newPin); + return true; + }); + } + + private void updateCliSummary(Preference pref, String pin) { + if (BuildConfig.DEBUG && pin == null) { + pref.setSummary(R.string.pref_summary_cli_debug); + } else if (pin != null) { + String summary = getString(R.string.pref_summary_cli_pin, pin); + pref.setSummary(Html.fromHtml(summary, Html.FROM_HTML_MODE_COMPACT)); + } else { + pref.setSummary(R.string.pref_summary_enable_cli); } } } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java index 1c31d4115..1afab1f3f 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java @@ -76,7 +76,7 @@ public class CLIService extends ICLIService.Stub { @Override public void revokeCurrentPin() { - ConfigManager.getInstance().setCliPin(null); + ConfigManager.getInstance().disableCli(); } public static boolean basicCheck(int uid) { diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java index 5db19fa23..c5983ddee 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java @@ -100,7 +100,6 @@ public class ConfigManager { private boolean dexObfuscate = true; private boolean enableStatusNotification = true; private Path miscPath = null; - private String cliPin = null; private int managerUid = -1; @@ -114,6 +113,8 @@ public class ConfigManager { private String api = "(???)"; + private String volatileCliPin = null; + static class ProcessScope { final String processName; final int uid; @@ -283,9 +284,6 @@ private synchronized void updateConfig() { updateModulePrefs("lspd", 0, "config", "enable_auto_add_shortcut", null); } - var pin = config.get("cli_pin"); - cliPin = pin instanceof String ? (String) pin : null; - bool = config.get("enable_status_notification"); enableStatusNotification = bool == null || (boolean) bool; @@ -1077,21 +1075,25 @@ public void setEnableStatusNotification(boolean enable) { enableStatusNotification = enable; } - public boolean isCliEnabled() { - return BuildConfig.DEBUG || cliPin != null; + public String getCurrentCliPin() { + return volatileCliPin; } - public void setEnableCli(boolean on) { - updateModulePrefs("lspd", 0, "config", "enable_cli", on); + public String resetCliPin() { + // Generate a new, secure random PIN + this.volatileCliPin = java.util.UUID.randomUUID().toString().substring(0, 8); + return this.volatileCliPin; } - public String getCliPin() { - return cliPin; + public void disableCli() { + this.volatileCliPin = null; } - public void setCliPin(String pin) { - updateModulePrefs("lspd", 0, "config", "cli_pin", pin); - cliPin = pin; + public boolean isCliPinValid(String providedPin) { + if (volatileCliPin == null || providedPin == null) { + return false; // CLI is disabled or no PIN was provided + } + return volatileCliPin.equals(providedPin); } public ParcelFileDescriptor getManagerApk() { diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java index e89f70091..f8b48593c 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java @@ -163,28 +163,27 @@ public void requestCLIBinder(String pin, List binder) throws RemoteExce ensureRegistered(); // Ensures caller is a valid process ConfigManager config = ConfigManager.getInstance(); - String storedPin = config.getCliPin(); boolean allowAccess = false; + // Rule 1: Special case for DEBUG builds. if (BuildConfig.DEBUG) { - // For DEBUG builds, access is granted if either: - // 1. The user hasn't set a PIN yet (default on). - // 2. The special "no auth" value is set. - if (storedPin == null || "DEBUG_MODE_NO_AUTH".equals(storedPin)) { + // If the daemon is a debug build AND no PIN has been set in memory yet, + // we allow access by default without a PIN. + if (config.getCurrentCliPin() == null && pin == null) { allowAccess = true; } } - // For ALL builds (including debug if the above conditions were not met), - // check if the provided PIN matches the stored one. - if (!allowAccess && storedPin != null && storedPin.equals(pin)) { + // Rule 2: Standard PIN validation for ALL builds. + // If access wasn't already granted by the debug rule, we perform the normal check. + if (!allowAccess && config.isCliPinValid(pin)) { allowAccess = true; } if (allowAccess) { binder.add(ServiceManager.getCLIService()); } - } + } public boolean hasRegister(int uid, int pid) { return processes.containsKey(new Pair<>(uid, pid)); diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java index cac2f2f2b..d69796282 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPManagerService.java @@ -563,22 +563,22 @@ public boolean setAutoInclude(String packageName, boolean enabled) { } @Override - public boolean isCliEnabled() { - return ConfigManager.getInstance().isCliEnabled(); + public boolean getAutoInclude(String packageName) { + return ConfigManager.getInstance().getAutoInclude(packageName); } @Override - public boolean getAutoInclude(String packageName) { - return ConfigManager.getInstance().getAutoInclude(packageName); + public String getCurrentCliPin() { + return ConfigManager.getInstance().getCurrentCliPin(); } @Override - public String getCliPin() { - return ConfigManager.getInstance().getCliPin(); + public String resetCliPin() { + return ConfigManager.getInstance().resetCliPin(); } @Override - public void setCliPin(String pin) { - ConfigManager.getInstance().setCliPin(pin); + public void disableCli() { + ConfigManager.getInstance().disableCli(); } } diff --git a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl index 169961a13..dca4480e1 100644 --- a/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl +++ b/services/manager-service/src/main/aidl/org/lsposed/lspd/ILSPManagerService.aidl @@ -96,9 +96,9 @@ interface ILSPManagerService { boolean setAutoInclude(String packageName, boolean enable) = 52; - boolean isCliEnabled() = 53; + String getCurrentCliPin() = 53; - void setCliPin(String pin) = 54; + String resetCliPin() = 54; - String getCliPin() = 55; + void disableCli() = 55; } From 46a9674214d2a24247fe16a7e30683dc734f24ab Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 22 Aug 2025 00:37:08 +0200 Subject: [PATCH 4/9] Fix login command --- daemon/src/main/java/org/lsposed/lspd/cli/Main.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index c006e7526..0392c3d77 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -663,7 +663,7 @@ public Integer call() throws RemoteException { } } -@CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class, RevokePinCommand.class}, version = "0.3") +@CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, LoginCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class, RevokePinCommand.class}, version = "0.3") public class Main implements Runnable { @CommandLine.Option(names = {"-p", "--pin"}, description = "Authentication PIN for the CLI.", scope = CommandLine.ScopeType.INHERIT) String pin; From 97dd064d591e46fe81b151822affbb2d6925e988 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 22 Aug 2025 07:34:47 +0200 Subject: [PATCH 5/9] Fix formatting --- .gitignore | 1 - .../org/lsposed/manager/ConfigManager.java | 2 +- .../main/res/drawable/ic_outline_cmd_24.xml | 21 +-- app/src/main/res/values/strings.xml | 2 +- daemon/build.gradle.kts | 2 +- daemon/proguard-rules.pro | 4 +- .../main/java/org/lsposed/lspd/cli/Main.java | 150 +++++++++--------- .../org/lsposed/lspd/service/CLIService.java | 23 +-- .../lsposed/lspd/service/ConfigManager.java | 2 +- .../lspd/service/LSPApplicationService.java | 45 +++--- .../lspd/service/LSPNotificationManager.java | 6 +- daemon/src/main/res/values/strings.xml | 2 - gradle/libs.versions.toml | 1 + 13 files changed, 110 insertions(+), 151 deletions(-) diff --git a/.gitignore b/.gitignore index 7e383cb7c..9be6d25cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ apache/local/generated -apache/build .project .settings .cache diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java index f642d837b..e5377bbf1 100644 --- a/app/src/main/java/org/lsposed/manager/ConfigManager.java +++ b/app/src/main/java/org/lsposed/manager/ConfigManager.java @@ -385,7 +385,7 @@ public static boolean setDexObfuscateEnabled(boolean enabled) { public static int getDex2OatWrapperCompatibility() { try { return LSPManagerServiceHolder.getService().getDex2OatWrapperCompatibility(); - } catch (RemoteException e) { + } catch (RemoteException e) { Log.e(App.TAG, Log.getStackTraceString(e)); return ILSPManagerService.DEX2OAT_CRASHED; } diff --git a/app/src/main/res/drawable/ic_outline_cmd_24.xml b/app/src/main/res/drawable/ic_outline_cmd_24.xml index e8ec8459b..a52ab0836 100644 --- a/app/src/main/res/drawable/ic_outline_cmd_24.xml +++ b/app/src/main/res/drawable/ic_outline_cmd_24.xml @@ -1,29 +1,10 @@ - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7a576819..8b7c2a37e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -211,7 +211,7 @@ Block dynamically loaded module code to use Xposed API, this may break some modules but benefit security Command line interface Enable CLI feature - + Automate LSPosed with shell scripts via PIN authentication Your CLI auth PIN is: %1$s
diff --git a/daemon/build.gradle.kts b/daemon/build.gradle.kts index 190be32b7..21935e03e 100644 --- a/daemon/build.gradle.kts +++ b/daemon/build.gradle.kts @@ -119,11 +119,11 @@ android.applicationVariants.all { dependencies { implementation(libs.libxposed.`interface`) implementation(libs.agp.apksig) + implementation(libs.picocli) implementation(projects.apache) implementation(projects.hiddenapi.bridge) implementation(projects.services.daemonService) implementation(projects.services.managerService) - implementation("info.picocli:picocli:4.7.6") compileOnly(libs.androidx.annotation) compileOnly(projects.hiddenapi.stubs) } diff --git a/daemon/proguard-rules.pro b/daemon/proguard-rules.pro index 1d4f63f2d..e8d2d4e63 100644 --- a/daemon/proguard-rules.pro +++ b/daemon/proguard-rules.pro @@ -5,15 +5,15 @@ -keep class picocli.CommandLine { *; } -keep class picocli.CommandLine$* { *; } +-keep class org.lsposed.lspd.cli.* {*;} -keepclassmembers class * extends java.util.concurrent.Callable { public java.lang.Integer call(); } --keep class org.lsposed.lspd.cli.* {*;} + -keepclasseswithmembers class org.lsposed.lspd.cli.Main { public static void main(java.lang.String[]); } - -keepclasseswithmembers class org.lsposed.lspd.Main { public static void main(java.lang.String[]); } diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index 0392c3d77..692ba4c43 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -260,7 +260,7 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = "modules", subcommands = {ListModulesCommand.class, SetModulesCommand.class}) class ModulesCommand implements Runnable { - @CommandLine.ParentCommand + @CommandLine.ParentCommand Main parent; @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") @@ -428,7 +428,7 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = "scope", subcommands = {ListScopeCommand.class, SetScopeCommand.class}) class ScopeCommand implements Runnable { - @CommandLine.ParentCommand + @CommandLine.ParentCommand Main parent; @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") @@ -665,7 +665,7 @@ public Integer call() throws RemoteException { @CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, LoginCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class, RevokePinCommand.class}, version = "0.3") public class Main implements Runnable { - @CommandLine.Option(names = {"-p", "--pin"}, description = "Authentication PIN for the CLI.", scope = CommandLine.ScopeType.INHERIT) + @CommandLine.Option(names = {"-p", "--pin"}, description = "Authentication PIN for the CLI.", scope = CommandLine.ScopeType.INHERIT) String pin; @CommandLine.Option(names = {"-V", "--version", "version"}, versionHelp = true, description = "display version info") @@ -683,13 +683,13 @@ public Main() { } public static void main(String[] args) { - System.exit(new CommandLine(new Main()) - .setExecutionExceptionHandler((ex, commandLine, parseResult) -> { - commandLine.getErr().println(ex.getMessage()); - return ex instanceof SecurityException ? ERRCODES.AUTH_FAILED.ordinal() : ERRCODES.REMOTE_ERROR.ordinal(); - }) - .execute(args)); - } + System.exit(new CommandLine(new Main()) + .setExecutionExceptionHandler((ex, commandLine, parseResult) -> { + commandLine.getErr().println(ex.getMessage()); + return ex instanceof SecurityException ? ERRCODES.AUTH_FAILED.ordinal() : ERRCODES.REMOTE_ERROR.ordinal(); + }) + .execute(args)); + } public void run() { throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); @@ -713,7 +713,7 @@ public int handleExecutionException(Exception ex, CommandLine commandLine, Parse } public final ICLIService getManager() { - if (objManager == null) { + if (objManager == null) { try { objManager = connectToService(); if (objManager == null) { @@ -728,68 +728,68 @@ public final ICLIService getManager() { return objManager; } - private ICLIService connectToService() throws RemoteException { - // 1. Check for credentials provided by the user via arguments or environment. - // We store this in a separate variable to remember if the user even tried to provide a PIN. - String initialPin = this.pin; // `this.pin` is populated by picocli from the --pin arg - if (initialPin == null) { - initialPin = System.getenv("LSPOSED_CLI_PIN"); - } - // `this.pin` will be used for the actual connection attempts. - this.pin = initialPin; - - // 2. Connect to the basic application service binder (boilerplate). - var activityService = ServiceManager.getService("activity"); - if (activityService == null) throw new RemoteException("Could not get activity service."); - - var binder = new Binder(); - Parcel data = Parcel.obtain(); - data.writeInterfaceToken("LSPosed"); - data.writeInt(2); - data.writeString("lsp-cli:" + org.lsposed.lspd.util.SignInfo.CLI_UUID); - data.writeStrongBinder(binder); - Parcel reply = Parcel.obtain(); - - if (!activityService.transact(1598837584, data, reply, 0)) { - throw new RemoteException("Transaction to activity service failed."); - } - - reply.readException(); - var serviceBinder = reply.readStrongBinder(); - if (serviceBinder == null) throw new RemoteException("Daemon did not return a service binder."); - - var service = ILSPApplicationService.Stub.asInterface(serviceBinder); - var lstBinder = new ArrayList(1); - - // 3. First attempt: Authenticate with the credentials we have (which could be null). - service.requestCLIBinder(this.pin, lstBinder); - - // 4. Recovery step: If the first attempt failed, we have no PIN, AND we're in an - // interactive shell, then prompt the user as a last resort. - if (lstBinder.isEmpty() && this.pin == null && System.console() != null) { - System.err.println("Authentication required."); - char[] pinChars = System.console().readPassword("Enter CLI PIN: "); - if (pinChars != null) { - this.pin = new String(pinChars); - // Second attempt: Retry with the PIN from the interactive prompt. - service.requestCLIBinder(this.pin, lstBinder); - } - } - - // 5. Final check and smart error reporting. - if (lstBinder.isEmpty()) { - String errorMessage; - if (initialPin == null) { - // The user never provided a PIN. The daemon requires one. Guide the user. - errorMessage = "Authentication required. Use --pin, set LSPOSED_CLI_PIN, or use an interactive shell."; - } else { - // The user provided a PIN, but it was rejected by the daemon. - errorMessage = "Authentication failed. The provided PIN is incorrect or CLI is disabled in the Manager app."; - } - throw new SecurityException(errorMessage); - } - - // If we reach here, we are successful. - return ICLIService.Stub.asInterface(lstBinder.get(0)); - } + private ICLIService connectToService() throws RemoteException { + // 1. Check for credentials provided by the user via arguments or environment. + // We store this in a separate variable to remember if the user even tried to provide a PIN. + String initialPin = this.pin; // `this.pin` is populated by picocli from the --pin arg + if (initialPin == null) { + initialPin = System.getenv("LSPOSED_CLI_PIN"); + } + // `this.pin` will be used for the actual connection attempts. + this.pin = initialPin; + + // 2. Connect to the basic application service binder (boilerplate). + var activityService = ServiceManager.getService("activity"); + if (activityService == null) throw new RemoteException("Could not get activity service."); + + var binder = new Binder(); + Parcel data = Parcel.obtain(); + data.writeInterfaceToken("LSPosed"); + data.writeInt(2); + data.writeString("lsp-cli:" + org.lsposed.lspd.util.SignInfo.CLI_UUID); + data.writeStrongBinder(binder); + Parcel reply = Parcel.obtain(); + + if (!activityService.transact(1598837584, data, reply, 0)) { + throw new RemoteException("Transaction to activity service failed."); + } + + reply.readException(); + var serviceBinder = reply.readStrongBinder(); + if (serviceBinder == null) throw new RemoteException("Daemon did not return a service binder."); + + var service = ILSPApplicationService.Stub.asInterface(serviceBinder); + var lstBinder = new ArrayList(1); + + // 3. First attempt: Authenticate with the credentials we have (which could be null). + service.requestCLIBinder(this.pin, lstBinder); + + // 4. Recovery step: If the first attempt failed, we have no PIN, AND we're in an + // interactive shell, then prompt the user as a last resort. + if (lstBinder.isEmpty() && this.pin == null && System.console() != null) { + System.err.println("Authentication required."); + char[] pinChars = System.console().readPassword("Enter CLI PIN: "); + if (pinChars != null) { + this.pin = new String(pinChars); + // Second attempt: Retry with the PIN from the interactive prompt. + service.requestCLIBinder(this.pin, lstBinder); + } + } + + // 5. Final check and smart error reporting. + if (lstBinder.isEmpty()) { + String errorMessage; + if (initialPin == null) { + // The user never provided a PIN. The daemon requires one. Guide the user. + errorMessage = "Authentication required. Use --pin, set LSPOSED_CLI_PIN, or use an interactive shell."; + } else { + // The user provided a PIN, but it was rejected by the daemon. + errorMessage = "Authentication failed. The provided PIN is incorrect or CLI is disabled in the Manager app."; + } + throw new SecurityException(errorMessage); + } + + // If we reach here, we are successful. + return ICLIService.Stub.asInterface(lstBinder.get(0)); + } } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java index 1afab1f3f..87df256f6 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/CLIService.java @@ -1,22 +1,3 @@ -/* - * This file is part of LSPosed. - * - * LSPosed 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, either version 3 of the License, or - * (at your option) any later version. - * - * LSPosed is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with LSPosed. If not, see . - * - * Copyright (C) 2022 LSPosed Contributors - */ - package org.lsposed.lspd.service; import static org.lsposed.lspd.service.ServiceManager.TAG; @@ -74,13 +55,13 @@ public class CLIService extends ICLIService.Stub { CLIService() { } - @Override + @Override public void revokeCurrentPin() { ConfigManager.getInstance().disableCli(); } public static boolean basicCheck(int uid) { - return uid == 0; + return uid == 0; } public static boolean applicationStageNameValid(int pid, String processName) { diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java index c5983ddee..ab4c48b94 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java @@ -1253,7 +1253,7 @@ synchronized SharedMemory getPreloadDex() { public boolean getAutoInclude(String packageName) { try (Cursor cursor = db.query("modules", new String[]{"auto_include"}, - "module_pkg_name = ? and auto_include = 1", new String[]{packageName}, null, null, null, null)) { + "module_pkg_name = ? and auto_include = 1", new String[]{packageName}, null, null, null, null)) { return cursor == null || cursor.moveToNext(); } } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java index f8b48593c..f6db18ab6 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java @@ -158,32 +158,31 @@ public ParcelFileDescriptor requestInjectedManagerBinder(List binder) t return ConfigManager.getInstance().getManagerApk(); } - @Override + @Override public void requestCLIBinder(String pin, List binder) throws RemoteException { ensureRegistered(); // Ensures caller is a valid process - ConfigManager config = ConfigManager.getInstance(); - - boolean allowAccess = false; - // Rule 1: Special case for DEBUG builds. - if (BuildConfig.DEBUG) { - // If the daemon is a debug build AND no PIN has been set in memory yet, - // we allow access by default without a PIN. - if (config.getCurrentCliPin() == null && pin == null) { - allowAccess = true; - } - } - - // Rule 2: Standard PIN validation for ALL builds. - // If access wasn't already granted by the debug rule, we perform the normal check. - if (!allowAccess && config.isCliPinValid(pin)) { - allowAccess = true; - } - - if (allowAccess) { - binder.add(ServiceManager.getCLIService()); - } - } + + boolean allowAccess = false; + // Rule 1: Special case for DEBUG builds. + if (BuildConfig.DEBUG) { + // If the daemon is a debug build AND no PIN has been set in memory yet, + // we allow access by default without a PIN. + if (config.getCurrentCliPin() == null && pin == null) { + allowAccess = true; + } + } + + // Rule 2: Standard PIN validation for ALL builds. + // If access wasn't already granted by the debug rule, we perform the normal check. + if (!allowAccess && config.isCliPinValid(pin)) { + allowAccess = true; + } + + if (allowAccess) { + binder.add(ServiceManager.getCLIService()); + } + } public boolean hasRegister(int uid, int pid) { return processes.containsKey(new Pair<>(uid, pid)); diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java index feeea9873..878837eee 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPNotificationManager.java @@ -61,7 +61,7 @@ public void binderDied() { } }; - static INotificationManager getNotificationManager() throws RemoteException { + private static INotificationManager getNotificationManager() throws RemoteException { if (binder == null || notificationManager == null) { binder = android.os.ServiceManager.getService(Context.NOTIFICATION_SERVICE); binder.linkToDeath(recipient, 0); @@ -90,11 +90,11 @@ private static Bitmap getBitmap(int id) { } } - static Icon getNotificationIcon() { + private static Icon getNotificationIcon() { return Icon.createWithBitmap(getBitmap(R.drawable.ic_notification)); } - static boolean hasNotificationChannelForSystem( + private static boolean hasNotificationChannelForSystem( INotificationManager nm, String channelId) throws RemoteException { NotificationChannel channel; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/daemon/src/main/res/values/strings.xml b/daemon/src/main/res/values/strings.xml index 70a1a8dd5..0d195d2dd 100644 --- a/daemon/src/main/res/values/strings.xml +++ b/daemon/src/main/res/values/strings.xml @@ -11,8 +11,6 @@ LSPosed status LSPosed loaded Tap the notification to open manager - - PIN Code requested Scope Request %1$s on user %2$s requests to add %3$s to its scope. Scope request diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index efef50b3e..bbfa1615b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ appiconloader = { module = "me.zhanghai.android.appiconloader:appiconloader", ve material = { module = "com.google.android.material:material", version = "1.12.0" } gson = { module = "com.google.code.gson:gson", version = "2.13.1" } hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version = "6.1" } +picocli = { module = "info.picocli:picocli", version = "4.7.7" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.10.2" } From 0292779d349a0f7d8585dc001e9d937082d77eab Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 22 Aug 2025 08:21:50 +0200 Subject: [PATCH 6/9] objManager is not static --- .../main/java/org/lsposed/lspd/cli/Main.java | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index 692ba4c43..1e12f7156 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -677,8 +677,6 @@ public class Main implements Runnable { @CommandLine.Spec CommandLine.Model.CommandSpec spec; - private static ICLIService objManager; - public Main() { } @@ -713,17 +711,16 @@ public int handleExecutionException(Exception ex, CommandLine commandLine, Parse } public final ICLIService getManager() { - if (objManager == null) { - try { - objManager = connectToService(); - if (objManager == null) { - // connectToService will throw, but as a fallback: - throw new SecurityException("Authentication failed or daemon service not available."); - } - } catch (RemoteException | SecurityException e) { - System.err.println("Error: " + e.getMessage()); - System.exit(ERRCODES.NO_DAEMON.ordinal()); + ICLIService objManager; + try { + objManager = connectToService(); + if (objManager == null) { + // connectToService will throw, but as a fallback: + throw new SecurityException("Authentication failed or daemon service not available."); } + } catch (RemoteException | SecurityException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(ERRCODES.NO_DAEMON.ordinal()); } return objManager; } From f031cb2237b61bf03d59f5d182c594030d72cee8 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 22 Aug 2025 10:12:33 +0200 Subject: [PATCH 7/9] Add json output --- .../org/lsposed/lspd/cli/GlobalOptions.java | 8 + .../main/java/org/lsposed/lspd/cli/Main.java | 1031 +++++++++-------- .../main/java/org/lsposed/lspd/cli/Utils.java | 6 +- .../lsposed/lspd/service/ConfigManager.java | 14 + .../lspd/service/LSPApplicationService.java | 3 + 5 files changed, 563 insertions(+), 499 deletions(-) create mode 100644 daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java b/daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java new file mode 100644 index 000000000..67e71b577 --- /dev/null +++ b/daemon/src/main/java/org/lsposed/lspd/cli/GlobalOptions.java @@ -0,0 +1,8 @@ +package org.lsposed.lspd.cli; + +import picocli.CommandLine; + +public class GlobalOptions { + @CommandLine.Option(names = {"-j", "--json"}, description = "Output results in JSON format.") + public boolean jsonOutput; +} diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index 1e12f7156..9c393f55d 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -1,5 +1,9 @@ package org.lsposed.lspd.cli; +import static org.lsposed.lspd.cli.Utils.CMDNAME; +import static org.lsposed.lspd.cli.Utils.ERRCODES; + +import android.content.pm.PackageManager; import android.os.Binder; import android.os.Build; import android.os.IBinder; @@ -7,270 +11,197 @@ import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.ServiceManager; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.lsposed.lspd.models.Application; -import org.lsposed.lspd.ICLIService; -import org.lsposed.lspd.service.ILSPApplicationService; -import org.lsposed.lspd.util.SignInfo; - -import static org.lsposed.lspd.cli.Utils.CMDNAME; -import static org.lsposed.lspd.cli.Utils.ERRCODES; - -import picocli.CommandLine; -import picocli.CommandLine.IExecutionExceptionHandler; -import picocli.CommandLine.IExitCodeExceptionMapper; -import picocli.CommandLine.ParseResult; - -import java.io.ByteArrayOutputStream; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; -import java.io.FileInputStream; -import java.io.FileOutputStream; - import java.time.LocalDateTime; - -import java.util.Arrays; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.concurrent.Callable; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.lsposed.lspd.ICLIService; +import org.lsposed.lspd.models.Application; +import org.lsposed.lspd.service.ILSPApplicationService; +import picocli.CommandLine; +import picocli.CommandLine.IExecutionExceptionHandler; -@CommandLine.Command(name = "log") -class LogCommand implements Callable { - @CommandLine.ParentCommand - private Main parent; - - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - @CommandLine.Option(names = {"-f", "--follow", "follow"}, description = "Follow update of log, as tail -f") - boolean bFollow; - @CommandLine.Option(names = {"-v", "--verbose", "verbose"}, description = "Get verbose log") - boolean bVerboseLog; - @CommandLine.Option(names = {"-c", "--clear", "clear"}, description = "Clear log") - boolean bClear; - - @Override - public Integer call() throws RemoteException { - ICLIService manager = parent.getManager(); - if (bClear) { - manager.clearLogs(bVerboseLog); - if (!bFollow) { // we can clear old logs and follow new - return 0; - } - } - ParcelFileDescriptor pfdLog = bVerboseLog ? manager.getVerboseLog() : manager.getModulesLog(); - printLog(pfdLog); - - return ERRCODES.NOERROR.ordinal(); - } - - private void printLog(ParcelFileDescriptor parcelFileDescriptor) { - var br = new BufferedReader(new InputStreamReader(new FileInputStream(parcelFileDescriptor.getFileDescriptor()))); - // TODO handle sigint when in follow mode for clean exit - while (true) { - String sLine; - try { - sLine = br.readLine(); - } catch (IOException ioe) { break; } - if (sLine == null) { - if (bFollow) { - try { - Thread.sleep(500); - } catch (InterruptedException ie) {} - } else { - break; - } - } else { - System.out.println(sLine); - } - } - } -} - -@CommandLine.Command(name = "login", - description = "Verifies the PIN and prints the shell command to set the session environment variable.") -class LoginCommand implements Callable { - @CommandLine.ParentCommand - private Main parent; - - @CommandLine.Option(names = "--for-eval", - description = "Output only the export command for use with eval().") - private boolean forEval; - - @Override - public Integer call() throws Exception { - // Step 1: Authenticate by requesting the manager. - // If the PIN is wrong, parent.getManager() will throw a SecurityException, - // which our main exception handler will catch and report to the user. - // We don't need any try-catch block here. - parent.getManager(); - - // Step 2: If we reach here, authentication was successful. - String pin = parent.pin; - if (pin == null) { - // This case should ideally not be hit if auth succeeded, but as a safeguard: - System.err.println("Error: Could not retrieve the PIN used for authentication."); - return 1; - } +/** + * Main entry point for the LSPosed Command Line Interface (CLI). + *

+ * This application uses the picocli framework to parse commands and arguments, + * and communicates with the LSPosed daemon via Binder IPC to perform actions. + */ - String exportCommand = "export LSPOSED_CLI_PIN=\"" + pin + "\""; +//================================================================================ +// Sub-Commands +//================================================================================ - if (forEval) { - // For power-users using `eval $(...)` - System.out.println(exportCommand); - } else { - // For regular interactive users - System.out.println("✅ Authentication successful."); - System.out.println(); - System.out.println("To avoid typing the PIN for every command in this shell session, run the following command:"); - System.out.println(); - System.out.println(" " + exportCommand); - System.out.println(); - System.out.println("You will then be able to run commands like 'lsposed-cli status' without the --pin argument."); - } - - return 0; // Success - } -} - -@CommandLine.Command(name = "ls") +@CommandLine.Command(name = "ls", description = "Lists installed Xposed modules.") class ListModulesCommand implements Callable { @CommandLine.ParentCommand private ModulesCommand parent; + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + @CommandLine.ArgGroup(exclusive = true) LSModuleOpts objArgs = new LSModuleOpts(); static class LSModuleOpts { - @CommandLine.Option(names = {"-e", "--enabled"}, description = "list only enabled modules", required = true) + @CommandLine.Option(names = {"-e", "--enabled"}, description = "List only modules that are currently enabled.", required = true) boolean bEnable; - @CommandLine.Option(names = {"-d", "--disabled"}, description = "list only disabled modules", required = true) + @CommandLine.Option(names = {"-d", "--disabled"}, description = "List only modules that are currently disabled.", required = true) boolean bDisable; } - private static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER + private static final int MATCH_ANY_USER = 0x00400000; private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; @Override - public Integer call() throws RemoteException { + public Integer call() throws RemoteException, JSONException { ICLIService manager = parent.parent.getManager(); - - var lstEnabledModules = Arrays.asList(manager.enabledModules()); + List lstEnabledModules = Arrays.asList(manager.enabledModules()); var lstPackages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); - for (var packageInfo : lstPackages.getList()) { - var metaData = packageInfo.applicationInfo.metaData; + JSONArray modulesArray = new JSONArray(); + boolean printedHeader = false; - if (metaData != null && metaData.containsKey("xposedmodule")) { - var bPkgEnabled = lstEnabledModules.contains(packageInfo.packageName); - - if ((objArgs.bEnable && bPkgEnabled) || (objArgs.bDisable && !bPkgEnabled) || (!objArgs.bEnable && !objArgs.bDisable)) { - var sFmt = "%-40s %10d %-8s"; - System.out.println(String.format(sFmt, packageInfo.packageName, packageInfo.applicationInfo.uid, bPkgEnabled ? "enable" : "disable")); + for (var packageInfo : lstPackages.getList()) { + if (packageInfo.applicationInfo.metaData != null && packageInfo.applicationInfo.metaData.containsKey("xposedmodule")) { + boolean isPkgEnabled = lstEnabledModules.contains(packageInfo.packageName); + + boolean shouldList = (!objArgs.bEnable && !objArgs.bDisable) || (objArgs.bEnable && isPkgEnabled) || (objArgs.bDisable && !isPkgEnabled); + + if (shouldList) { + if (globalOptions.jsonOutput) { + JSONObject moduleObject = new JSONObject(); + moduleObject.put("packageName", packageInfo.packageName); + moduleObject.put("uid", packageInfo.applicationInfo.uid); + moduleObject.put("enabled", isPkgEnabled); + modulesArray.put(moduleObject); + } else { + if (!printedHeader) { + System.out.println(String.format("%-45s %-10s %-8s", "PACKAGE", "UID", "STATUS")); + printedHeader = true; + } + System.out.println(String.format("%-45s %-10d %-8s", packageInfo.packageName, packageInfo.applicationInfo.uid, isPkgEnabled ? "enabled" : "disabled")); + } } } } + if (globalOptions.jsonOutput) { + System.out.println(modulesArray.toString(2)); + } return ERRCODES.NOERROR.ordinal(); } } -@CommandLine.Command(name = "revoke-pin", description = "Revokes the current CLI PIN, disabling CLI access.") -class RevokePinCommand implements Callable { - @CommandLine.ParentCommand - private Main parent; - - @Override - public Integer call() throws Exception { - System.out.println("Revoking current CLI PIN..."); - parent.getManager().revokeCurrentPin(); - System.out.println("PIN has been revoked. You must re-enable the CLI from the Manager app."); - return 0; - } -} - -@CommandLine.Command(name = "set") +@CommandLine.Command(name = "set", description = "Enables or disables one or more modules.") class SetModulesCommand implements Callable { @CommandLine.ParentCommand private ModulesCommand parent; + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") SetModuleOpts objArgs = new SetModuleOpts(); static class SetModuleOpts { - @CommandLine.Option(names = {"-e", "--enable"}, description = "enable modules", required = true) + @CommandLine.Option(names = {"-e", "--enable"}, description = "Enable the specified modules.", required = true) boolean bEnable; - @CommandLine.Option(names = {"-d", "--disable"}, description = "disable modules", required = true) + @CommandLine.Option(names = {"-d", "--disable"}, description = "Disable the specified modules.", required = true) boolean bDisable; } - @CommandLine.Option(names = {"-i", "--ignore"}, description = "ignore not installed packages") + @CommandLine.Option(names = {"-i", "--ignore"}, description = "Ignore modules that are not installed.") boolean bIgnore; - @CommandLine.Parameters(index = "0..*", description = "packages name", paramLabel="", arity = "1") + @CommandLine.Parameters(index = "0..*", description = "The package name(s) of the module(s) to modify.", paramLabel = "", arity = "1..*") List lstModules; @Override - public Integer call() throws RemoteException { + public Integer call() throws RemoteException, JSONException { ICLIService manager = parent.parent.getManager(); - boolean bMsgReboot = false; - - for (var module : lstModules) { - var lstScope = manager.getModuleScope(module); - if (lstScope == null) - { - System.err.println(manager.getLastErrorMsg()); - continue; - } - if (objArgs.bEnable) { - if (lstScope.size() < 2) { - System.err.println("Scope list is empty " + module + " not enabled"); - return Utils.ERRCODES.EMPTY_SCOPE.ordinal(); + boolean rebootRequired = false; + boolean allSuccess = true; + JSONArray resultsArray = new JSONArray(); + + for (String module : lstModules) { + String status = "unknown"; + String message; + boolean success = false; + String action = objArgs.bEnable ? "enable" : "disable"; + + List scope = manager.getModuleScope(module); + if (scope == null) { + message = manager.getLastErrorMsg(); + allSuccess = false; + } else if (objArgs.bEnable && scope.size() < 2) { + message = "Cannot enable: module scope is empty."; + allSuccess = false; + } else { + if (objArgs.bEnable ? manager.enableModule(module) : manager.disableModule(module)) { + success = true; + status = objArgs.bEnable ? "enabled" : "disabled"; + message = "Module successfully " + status + "."; + if (Utils.checkPackageInScope("android", scope)) { + rebootRequired = true; + } + } else { + message = "Failed to " + action + " module via daemon."; + allSuccess = false; } } - if (objArgs.bEnable) { - if (!manager.enableModule(module)) { - System.err.println("Failed to enable"); - return ERRCODES.ENABLE_DISABLE.ordinal(); - } + + if (globalOptions.jsonOutput) { + JSONObject result = new JSONObject(); + result.put("module", module); + result.put("success", success); + result.put("status", status); + result.put("message", message); + resultsArray.put(result); } else { - if (!manager.disableModule(module)) { - System.err.println("Failed to disable"); - return ERRCODES.ENABLE_DISABLE.ordinal(); + if (success) { + System.out.println(module + ": " + message); + } else { + System.err.println(module + ": Error! " + message); } } - if (Utils.checkPackageInScope("android", lstScope)) { - bMsgReboot = true; - } } - if (bMsgReboot) { - System.err.println("Reboot is required"); + + if (globalOptions.jsonOutput) { + JSONObject finalOutput = new JSONObject(); + finalOutput.put("success", allSuccess); + finalOutput.put("rebootRequired", rebootRequired); + finalOutput.put("results", resultsArray); + System.out.println(finalOutput.toString(2)); + } else if (rebootRequired) { + System.err.println("\nWarning: A reboot is required for some changes to take full effect."); } - return ERRCODES.NOERROR.ordinal(); + return allSuccess ? ERRCODES.NOERROR.ordinal() : ERRCODES.ENABLE_DISABLE.ordinal(); } } -@CommandLine.Command(name = "modules", subcommands = {ListModulesCommand.class, SetModulesCommand.class}) +@CommandLine.Command(name = "modules", description = "Manages Xposed modules.", subcommands = {ListModulesCommand.class, SetModulesCommand.class}) class ModulesCommand implements Runnable { @CommandLine.ParentCommand Main parent; - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - @CommandLine.Spec CommandLine.Model.CommandSpec spec; public void run() { - throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand. See 'lsposed-cli modules --help'."); } } @@ -278,8 +209,15 @@ class Scope extends Application { public static class Converter implements CommandLine.ITypeConverter { @Override public Scope convert(String value) { - var s = value.split("/", 2); - return new Scope(s[0], Integer.parseInt(s[1])); + String[] parts = value.split("/", 2); + if (parts.length != 2) { + throw new CommandLine.TypeConversionException("Invalid scope format. Expected 'package/user_id', but got '" + value + "'."); + } + try { + return new Scope(parts[0], Integer.parseInt(parts[1])); + } catch (NumberFormatException e) { + throw new CommandLine.TypeConversionException("Invalid user_id in scope '" + value + "'. Must be an integer."); + } } } @@ -287,213 +225,157 @@ public Scope(String packageName, int userId) { this.packageName = packageName; this.userId = userId; } - - @Override - public String toString() { - return "Scope{" + - "packageName='" + packageName + '\'' + - ", userId=" + userId + - '}'; - } } -@CommandLine.Command(name = "ls") +@CommandLine.Command(name = "ls", description = "Displays the scope of a specific module.") class ListScopeCommand implements Callable { @CommandLine.ParentCommand private ScopeCommand parent; - @CommandLine.Parameters(index = "0", description = "module's name", paramLabel="") + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @CommandLine.Parameters(index = "0", description = "The package name of the module.", paramLabel = "") String moduleName; @Override - public Integer call() throws RemoteException { + public Integer call() throws RemoteException, JSONException { ICLIService manager = parent.parent.getManager(); + List scopeList = manager.getModuleScope(moduleName); - var lstScope = manager.getModuleScope(moduleName); - if (lstScope == null) - { - System.err.println(manager.getLastErrorMsg()); + if (scopeList == null) { + System.err.println("Error: " + manager.getLastErrorMsg()); return ERRCODES.LS_SCOPE.ordinal(); } - for (var scope : lstScope) { - System.out.println(scope.packageName + "/" + scope.userId); + if (globalOptions.jsonOutput) { + JSONArray scopeArray = new JSONArray(); + for (Application app : scopeList) { + scopeArray.put(app.packageName + "/" + app.userId); + } + System.out.println(scopeArray.toString(2)); + } else { + for (Application app : scopeList) { + System.out.println(app.packageName + "/" + app.userId); + } } return ERRCODES.NOERROR.ordinal(); } } -@CommandLine.Command(name = "set", exitCodeOnExecutionException = 4 /* ERRCODES.SET_SCOPE */) +@CommandLine.Command(name = "set", description = "Sets, appends to, or removes from a module's scope.") class SetScopeCommand implements Callable { - /*, multiplicity = "0..1"*/ @CommandLine.ParentCommand private ScopeCommand parent; - @CommandLine.ArgGroup(exclusive = true) + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") ScopeOpts objArgs = new ScopeOpts(); static class ScopeOpts { - @CommandLine.Option(names = {"-s", "--set"}, description = "set a new scope (default)", required = true) + @CommandLine.Option(names = {"-s", "--set"}, description = "Overwrite the entire scope with the given list.", required = true) boolean bSet; - @CommandLine.Option(names = {"-a", "--append"}, description = "append packages to scope", required = true) + @CommandLine.Option(names = {"-a", "--append"}, description = "Add the given applications to the existing scope.", required = true) boolean bAppend; - @CommandLine.Option(names = {"-d", "--remove"}, description = "remove packages to scope", required = true) + @CommandLine.Option(names = {"-d", "--remove"}, description = "Remove the given applications from the existing scope.", required = true) boolean bDel; } - @CommandLine.Option(names = {"-i", "--ignore"}, description = "ignore not installed packages") - boolean bIgnore; - @CommandLine.Parameters(index = "0", description = "module's name", paramLabel="", arity = "1") + @CommandLine.Parameters(index = "0", description = "The package name of the module to configure.", paramLabel = "", arity = "1") String moduleName; - @CommandLine.Parameters(index = "1..*", description = "package name/uid", arity = "1") + @CommandLine.Parameters(index = "1..*", description = "Application(s) to form the scope, as 'package/user_id'.", paramLabel = "", arity = "1..*") Scope[] scopes; @Override - public Integer call() throws RemoteException { - boolean bMsgReboot = false; + public Integer call() throws RemoteException, JSONException { ICLIService manager = parent.parent.getManager(); + boolean rebootRequired = false; - // default operation set - // TODO find a mode for manage in picocli - if (!objArgs.bSet && !objArgs.bAppend && !objArgs.bDel) { - objArgs.bSet = true; - } - - boolean bAndroidExist = false; - if (objArgs.bSet) { - var lstScope = manager.getModuleScope(moduleName); - if (lstScope == null) { - System.err.println(manager.getLastErrorMsg()); - return ERRCODES.SET_SCOPE.ordinal(); + for (Scope scope : scopes) { + if (!parent.parent.getCliUtils().validPackageNameAndUserId(manager, scope.packageName, scope.userId)) { + throw new RuntimeException("Error: Invalid application '" + scope.packageName + "/" + scope.userId + "'. Not an installed package for that user."); } - bAndroidExist = Utils.checkPackageInScope("android", lstScope); - } - - for(var scope : scopes) { - if (Utils.validPackageNameAndUserId(manager, scope.packageName, scope.userId)) { - if (scope.packageName.equals("android")) { - bMsgReboot = true; - } - } else if (!bIgnore) { - throw new RuntimeException("Error: " + scope.packageName + (scope.userId < 0? "" : ("/" + scope.userId)) + " is not a valid package name"); + if ("android".equals(scope.packageName)) { + rebootRequired = true; } } - if (bAndroidExist && !bMsgReboot) { // if android is removed with setcommand reboot is required - bMsgReboot = true; - } - if (bMsgReboot) { - System.err.println("Reboot is required"); - } + + List finalScope; if (objArgs.bSet) { - List lstScope = new ArrayList<>(Arrays.asList(scopes)); // Arrays.asList return a read-only list and we require a changeable list - if (Utils.checkPackageModule(moduleName, lstScope)) { - System.err.println("Added package of module into scope!"); - } - if (!manager.setModuleScope(moduleName, lstScope)) { - throw new RuntimeException("Failed to set scope for " + moduleName); - } - if (lstScope.size() < 2) { - manager.disableModule(moduleName); + finalScope = new ArrayList<>(Arrays.asList(scopes)); + List oldScope = manager.getModuleScope(moduleName); + if (oldScope != null && Utils.checkPackageInScope("android", oldScope) && !rebootRequired) { + rebootRequired = true; // Reboot is needed if 'android' is removed from scope. } } else { - var lstScope = manager.getModuleScope(moduleName); - if (lstScope == null) { - System.err.println(manager.getLastErrorMsg()); - return ERRCODES.SET_SCOPE.ordinal(); + finalScope = manager.getModuleScope(moduleName); + if (finalScope == null) { + throw new RuntimeException("Error: " + manager.getLastErrorMsg()); } - for (var scope : scopes) { + for (Scope scope : scopes) { if (objArgs.bAppend) { - Application app = new Application(); - app.packageName = scope.packageName; - app.userId = scope.userId; - lstScope.add(app); - } else { - lstScope.removeIf(app -> scope.packageName.equals(app.packageName) && scope.userId == app.userId); + finalScope.add(scope); + } else { // bDel + finalScope.removeIf(app -> scope.packageName.equals(app.packageName) && scope.userId == app.userId); } } - if (Utils.checkPackageModule(moduleName, lstScope)) { - System.err.println("Added package of module into scope!"); - } - if (!manager.setModuleScope(moduleName, lstScope)) { - throw new RuntimeException("Failed to set scope for " + moduleName); - } - if (lstScope.size() < 2) { + } + + if (manager.setModuleScope(moduleName, finalScope)) { + if (finalScope.size() < 2) { manager.disableModule(moduleName); + if (!globalOptions.jsonOutput) { + System.err.println("Warning: Scope is now empty or contains only the module itself. Module has been disabled."); + } + } + + if (globalOptions.jsonOutput) { + JSONObject result = new JSONObject(); + result.put("success", true); + result.put("module", moduleName); + result.put("rebootRequired", rebootRequired); + System.out.println(result.toString(2)); + } else { + System.out.println("Successfully updated scope for " + moduleName + "."); + if (rebootRequired) { + System.err.println("Warning: A reboot is required for changes to take full effect."); + } } + } else { + throw new RuntimeException("Failed to set scope for " + moduleName + ". Reason: " + manager.getLastErrorMsg()); } return ERRCODES.NOERROR.ordinal(); } } -@CommandLine.Command(name = "scope", subcommands = {ListScopeCommand.class, SetScopeCommand.class}) +@CommandLine.Command(name = "scope", description = "Manages module scopes.", subcommands = {ListScopeCommand.class, SetScopeCommand.class}) class ScopeCommand implements Runnable { @CommandLine.ParentCommand Main parent; - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - @CommandLine.Spec CommandLine.Model.CommandSpec spec; public void run() { - throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand. See 'lsposed-cli scope --help'."); } } -@CommandLine.Command(name = "status") -class StatusCommand implements Callable { - @CommandLine.ParentCommand - private Main parent; - - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - - @Override - public Integer call() throws RemoteException { - ICLIService manager = parent.getManager(); - String sSysVer; - if (Build.VERSION.PREVIEW_SDK_INT != 0) { - sSysVer = String.format("%1$s Preview (API %2$d)", Build.VERSION.CODENAME, Build.VERSION.SDK_INT); - } else { - sSysVer = String.format("%1$s (API %2$d)", Build.VERSION.RELEASE, Build.VERSION.SDK_INT); - } - - var sPrint = "API version: " + manager.getXposedApiVersion() + '\n' + - "Injection Interface: " + manager.getApi() + '\n' + - "Framework version: " + manager.getXposedVersionName() + '(' + manager.getXposedVersionCode() + ")\n" + - "System version: " + sSysVer + '\n' + - "Device: " + getDevice() + '\n' + - "System ABI: " + Build.SUPPORTED_ABIS[0]; - System.out.println(sPrint); - return ERRCODES.NOERROR.ordinal(); - } - - private String getDevice() { - String manufacturer = Character.toUpperCase(Build.MANUFACTURER.charAt(0)) + Build.MANUFACTURER.substring(1); - if (!Build.BRAND.equals(Build.MANUFACTURER)) { - manufacturer += " " + Character.toUpperCase(Build.BRAND.charAt(0)) + Build.BRAND.substring(1); - } - manufacturer += " " + Build.MODEL + " "; - return manufacturer; - } -} - -@CommandLine.Command(name = "backup") +@CommandLine.Command(name = "backup", description = "Creates a compressed backup of module settings and scopes.") class BackupCommand implements Callable { @CommandLine.ParentCommand private Main parent; - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - @CommandLine.Parameters(index = "0..*", description = "module's name default all", paramLabel="") + @CommandLine.Parameters(index = "0..*", description = "Specific module(s) to back up. If omitted, all modules are backed up.", paramLabel = "") String[] modulesName; - @CommandLine.Option(names = {"-f", "--file"}, description = "output file") + @CommandLine.Option(names = {"-f", "--file"}, description = "Output file path. If omitted, a timestamped file is created in the current directory.", paramLabel = "") String file; private static final int VERSION = 2; - private static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER + private static final int MATCH_ANY_USER = 0x00400000; private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; @@ -501,292 +383,449 @@ class BackupCommand implements Callable { public Integer call() throws RemoteException { ICLIService manager = parent.getManager(); - if (modulesName == null) { + if (modulesName == null || modulesName.length == 0) { List modules = new ArrayList<>(); var packages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); for (var packageInfo : packages.getList()) { - var metaData = packageInfo.applicationInfo.metaData; - - if (metaData != null && metaData.containsKey("xposedmodule")) { + if (packageInfo.applicationInfo.metaData != null && packageInfo.applicationInfo.metaData.containsKey("xposedmodule")) { modules.add(packageInfo.packageName); } } modulesName = modules.toArray(new String[0]); } if (file == null) { - file = String.format("LSPosed_%s.lsp", LocalDateTime.now().toString()); + file = String.format("LSPosed_%s.lsp.gz", LocalDateTime.now()); } - var enabledModules = Arrays.asList(manager.enabledModules()); + List enabledModules = Arrays.asList(manager.enabledModules()); JSONObject rootObject = new JSONObject(); try { rootObject.put("version", VERSION); JSONArray modulesArray = new JSONArray(); - for (var module : modulesName) { + for (String module : modulesName) { JSONObject moduleObject = new JSONObject(); moduleObject.put("enable", enabledModules.contains(module)); moduleObject.put("package", module); moduleObject.put("autoInclude", manager.getAutoInclude(module)); - var scopes = manager.getModuleScope(module); JSONArray scopeArray = new JSONArray(); - for (var s : scopes) { - JSONObject app = new JSONObject(); - app.put("package", s.packageName); - app.put("userId", s.userId); - scopeArray.put(app); + List scopes = manager.getModuleScope(module); + if (scopes != null) { + for (Application s : scopes) { + JSONObject app = new JSONObject(); + app.put("package", s.packageName); + app.put("userId", s.userId); + scopeArray.put(app); + } } moduleObject.put("scope", scopeArray); modulesArray.put(moduleObject); } rootObject.put("modules", modulesArray); - FileOutputStream fos = new FileOutputStream(file + ".gz"); - GZIPOutputStream gzipOutputStream = new GZIPOutputStream(fos); - gzipOutputStream.write(rootObject.toString().getBytes()); - gzipOutputStream.close(); - fos.close(); - } catch(Exception ex) { - throw new RemoteException(ex.getMessage()); + try (FileOutputStream fos = new FileOutputStream(file); + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(fos)) { + gzipOutputStream.write(rootObject.toString().getBytes()); + } + System.out.println("Backup created successfully at: " + file); + } catch (Exception ex) { + throw new RuntimeException("Failed to create backup: " + ex.getMessage(), ex); + } + return ERRCODES.NOERROR.ordinal(); + } +} + +@ommandLine.Command(name = "log", description = "Streams and manages the LSPosed framework and module logs.") +class LogCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Option(names = {"-f", "--follow"}, description = "Continuously print new log entries as they appear.") + boolean bFollow; + @CommandLine.Option(names = {"-v", "--verbose"}, description = "Access the verbose framework logs instead of module logs.") + boolean bVerboseLog; + @CommandLine.Option(names = {"-c", "--clear"}, description = "Clear the specified log file before streaming.") + boolean bClear; + + @Override + public Integer call() throws RemoteException { + ICLIService manager = parent.getManager(); + if (bClear) { + manager.clearLogs(bVerboseLog); + if (!bFollow) { + return 0; + } + } + ParcelFileDescriptor pfdLog = bVerboseLog ? manager.getVerboseLog() : manager.getModulesLog(); + printLog(pfdLog); + + return ERRCODES.NOERROR.ordinal(); + } + + private void printLog(ParcelFileDescriptor parcelFileDescriptor) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(parcelFileDescriptor.getFileDescriptor())))) { + // TODO: Handle SIGINT for a graceful exit when in follow mode. + while (true) { + String sLine = br.readLine(); + if (sLine == null) { + if (bFollow) { + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + break; // Exit loop on interrupt + } + } else { + break; + } + } else { + System.out.println(sLine); + } + } + } catch (IOException e) { + System.err.println("Error reading log file: " + e.getMessage()); + } + } +} + +@CommandLine.Command(name = "login", description = "Authenticates the CLI and provides a session variable for subsequent commands.") +class LoginCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Option(names = "--for-eval", description = "Output only the export command for use with `eval $(...)`.") + private boolean forEval; + + @Override + public Integer call() throws Exception { + // Authenticate by simply requesting the manager. An exception will be thrown on failure. + parent.getManager(); + + String pin = parent.pin; + if (pin == null) { + System.err.println("Error: Could not retrieve the PIN used for authentication."); + return 1; + } + + String exportCommand = "export LSPOSED_CLI_PIN=\"" + pin + "\""; + + if (forEval) { + System.out.println(exportCommand); + } else { + System.out.println("✅ Authentication successful."); + System.out.println("\nTo avoid typing the PIN for every command in this shell session, run:"); + System.out.println("\n " + exportCommand + "\n"); + System.out.println("You can then run commands like 'lsposed-cli status' without the --pin argument."); + } + return 0; + } +} + +@CommandLine.Command(name = "status", description = "Displays the status and version of the LSPosed framework.") +class StatusCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @Override + public Integer call() throws RemoteException, JSONException { + ICLIService manager = parent.getManager(); + if (globalOptions.jsonOutput) { + JSONObject status = new JSONObject(); + status.put("xposedApiVersion", manager.getXposedApiVersion()); + status.put("injectionInterface", manager.getApi()); + status.put("frameworkVersionName", manager.getXposedVersionName()); + status.put("frameworkVersionCode", manager.getXposedVersionCode()); + status.put("systemVersion", getSystemVersion()); + status.put("device", getDevice()); + status.put("systemAbi", Build.SUPPORTED_ABIS[0]); + System.out.println(status.toString(2)); + } else { + System.out.printf("API Version: %d\n", manager.getXposedApiVersion()); + System.out.printf("Injection Interface: %s\n", manager.getApi()); + System.out.printf("Framework Version: %s (%d)\n", manager.getXposedVersionName(), manager.getXposedVersionCode()); + System.out.printf("System Version: %s\n", getSystemVersion()); + System.out.printf("Device: %s\n", getDevice()); + System.out.printf("System ABI: %s\n", Build.SUPPORTED_ABIS[0]); } return ERRCODES.NOERROR.ordinal(); } + + private String getSystemVersion() { + if (Build.VERSION.PREVIEW_SDK_INT != 0) { + return String.format("%s Preview (API %d)", Build.VERSION.CODENAME, Build.VERSION.SDK_INT); + } else { + return String.format("%s (API %d)", Build.VERSION.RELEASE, Build.VERSION.SDK_INT); + } + } + + private String getDevice() { + String manufacturer = Character.toUpperCase(Build.MANUFACTURER.charAt(0)) + Build.MANUFACTURER.substring(1); + if (!Build.BRAND.equals(Build.MANUFACTURER)) { + manufacturer += " " + Character.toUpperCase(Build.BRAND.charAt(0)) + Build.BRAND.substring(1); + } + manufacturer += " " + Build.MODEL; + return manufacturer; + } } -@CommandLine.Command(name = "restore") +@CommandLine.Command(name = "restore", description = "Restores module settings and scopes from a backup file.") class RestoreCommand implements Callable { @CommandLine.ParentCommand private Main parent; - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - @CommandLine.Parameters(index = "0..*", description = "module's name default all", paramLabel="") + @CommandLine.Parameters(index = "0..*", description = "Specific module(s) to restore. If omitted, all modules in the backup are restored.", paramLabel = "") String[] modulesName; - @CommandLine.Option(names = {"-f", "--file"}, description = "input file", required = true) + @CommandLine.Option(names = {"-f", "--file"}, description = "Path to the backup file to restore from.", required = true, paramLabel = "") String file; private static final int VERSION = 2; - private static final int MATCH_ANY_USER = 0x00400000; // PackageManager.MATCH_ANY_USER - private static final int MATCH_ALL_FLAGS = PackageManager.MATCH_DISABLED_COMPONENTS | PackageManager.MATCH_DIRECT_BOOT_AWARE | - PackageManager.MATCH_DIRECT_BOOT_UNAWARE | PackageManager.MATCH_UNINSTALLED_PACKAGES | MATCH_ANY_USER; @Override public Integer call() throws RemoteException { ICLIService manager = parent.getManager(); - StringBuilder json = new StringBuilder(); - try { - FileInputStream fis = new FileInputStream(file); - GZIPInputStream gzipInputStream = new GZIPInputStream(fis, 64); - var os = new ByteArrayOutputStream(); + String jsonString; + try (FileInputStream fis = new FileInputStream(file); + GZIPInputStream gzipInputStream = new GZIPInputStream(fis); + ByteArrayOutputStream os = new ByteArrayOutputStream()) { byte[] buf = new byte[8192]; int length; while ((length = gzipInputStream.read(buf)) > 0) { os.write(buf, 0, length); } - json.append(os); - gzipInputStream.close(); - fis.close(); - os.close(); - } catch(Exception ex) { - throw new RemoteException(ex.getMessage()); + jsonString = os.toString(); + } catch (Exception ex) { + throw new RuntimeException("Failed to read backup file: " + ex.getMessage(), ex); } - List modules; - if (modulesName == null) { - modules = new ArrayList<>(); - var packages = manager.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | MATCH_ALL_FLAGS, false); - for (var packageInfo : packages.getList()) { - var metaData = packageInfo.applicationInfo.metaData; - - if (metaData != null && metaData.containsKey("xposedmodule")) { - modules.add(packageInfo.packageName); - } - } - } else { - modules = Arrays.asList(modulesName); - } + List modulesToRestore = (modulesName == null || modulesName.length == 0) ? null : Arrays.asList(modulesName); try { - JSONObject rootObject = new JSONObject(json.toString()); + JSONObject rootObject = new JSONObject(jsonString); int version = rootObject.getInt("version"); - if (version == VERSION || version == 1) { - JSONArray jsmodules = rootObject.getJSONArray("modules"); - int len = jsmodules.length(); - for (int i = 0; i < len; i++) { - JSONObject moduleObject = jsmodules.getJSONObject(i); - String name = moduleObject.getString("package"); - if (!modules.contains(name)) { - continue; - } - var enabled = moduleObject.getBoolean("enable"); - if (enabled) { - if (!manager.enableModule(name)) { - System.err.println(manager.getLastErrorMsg()); - throw new RuntimeException("Failed to enable " + name); - } - } else { - if (!manager.disableModule(name)) { - System.err.println(manager.getLastErrorMsg()); - throw new RuntimeException("Failed to disable " + name); - } - } - var autoInclude = false; - try { - autoInclude = moduleObject.getBoolean("autoInclude"); - } catch (JSONException ignore) { } - manager.setAutoInclude(name, autoInclude); - JSONArray scopeArray = moduleObject.getJSONArray("scope"); - List scopes = new ArrayList<>(); - for (int j = 0; j < scopeArray.length(); j++) { - if (version == VERSION) { - JSONObject app = scopeArray.getJSONObject(j); - scopes.add(new Scope(app.getString("package"), app.getInt("userId"))); - } else { - scopes.add(new Scope(scopeArray.getString(j), 0)); - } - } - if (!manager.setModuleScope(name, scopes)) { - System.err.println(manager.getLastErrorMsg()); - throw new RuntimeException("Failed to set scope for " + name); + if (version > VERSION) { + throw new RuntimeException("Unsupported backup version: " + version); + } + + JSONArray jsmodules = rootObject.getJSONArray("modules"); + for (int i = 0; i < jsmodules.length(); i++) { + JSONObject moduleObject = jsmodules.getJSONObject(i); + String name = moduleObject.getString("package"); + + if (modulesToRestore != null && !modulesToRestore.contains(name)) { + continue; // Skip module if it's not in the user's specified list + } + + System.out.println("Restoring settings for: " + name); + + if (moduleObject.getBoolean("enable")) { + manager.enableModule(name); + } else { + manager.disableModule(name); + } + + manager.setAutoInclude(name, moduleObject.optBoolean("autoInclude", false)); + + JSONArray scopeArray = moduleObject.getJSONArray("scope"); + List scopes = new ArrayList<>(); + for (int j = 0; j < scopeArray.length(); j++) { + if (version == VERSION) { + JSONObject app = scopeArray.getJSONObject(j); + scopes.add(new Scope(app.getString("package"), app.getInt("userId"))); + } else { // Legacy v1 format + scopes.add(new Scope(scopeArray.getString(j), 0)); } } - } else { - throw new RemoteException("Unknown backup file version"); + manager.setModuleScope(name, scopes); } - }catch(JSONException je) { - throw new RemoteException(je.getMessage()); + System.out.println("Restore completed successfully."); + } catch (JSONException je) { + throw new RuntimeException("Failed to parse backup file: " + je.getMessage(), je); } - return ERRCODES.NOERROR.ordinal(); } } -@CommandLine.Command(name = CMDNAME, subcommands = {LogCommand.class, LoginCommand.class, BackupCommand.class, ModulesCommand.class, RestoreCommand.class, ScopeCommand.class, StatusCommand.class, RevokePinCommand.class}, version = "0.3") +@CommandLine.Command(name = "revoke-pin", description = "Revokes the current CLI PIN. Disables CLI access until re-enabled from the Manager app.") +class RevokePinCommand implements Callable { + @CommandLine.ParentCommand + private Main parent; + + @CommandLine.Mixin + private GlobalOptions globalOptions = new GlobalOptions(); + + @Override + public Integer call() throws Exception { + parent.getManager().revokeCurrentPin(); + if (globalOptions.jsonOutput) { + JSONObject result = new JSONObject(); + result.put("success", true); + result.put("message", "PIN has been revoked. Re-enable the CLI from the Manager app to generate a new one."); + System.out.println(result.toString(2)); + } else { + System.out.println("✅ PIN has been revoked. You must re-enable the CLI from the Manager app to generate a new one."); + } + return 0; + } +} + +//================================================================================ +// Main Application Class +//================================================================================ + +@CommandLine.Command(name = CMDNAME, + version = "LSPosed CLI 0.4", + mixinStandardHelpOptions = true, // Use picocli's built-in --help and --version + header = "LSPosed Command Line Interface", + description = "A tool to manage the LSPosed framework and modules from the command line.", + subcommands = { + ModulesCommand.class, + ScopeCommand.class, + BackupCommand.class, + LogCommand.class, + LoginCommand.class, + StatusCommand.class, + RestoreCommand.class, + RevokePinCommand.class + }) public class Main implements Runnable { + @CommandLine.Option(names = {"-p", "--pin"}, description = "Authentication PIN for the CLI.", scope = CommandLine.ScopeType.INHERIT) String pin; - @CommandLine.Option(names = {"-V", "--version", "version"}, versionHelp = true, description = "display version info") - boolean versionInfoRequested; - - @CommandLine.Option(names = {"-h", "--help", "help"}, usageHelp = true, description = "display this help message") - boolean usageHelpRequested; - @CommandLine.Spec CommandLine.Model.CommandSpec spec; + private ICLIService objManager; + private final Utils cliUtils; + public Main() { + this.cliUtils = new Utils(); } + public Utils getCliUtils() { + return this.cliUtils; + } + + /** + * The main entry point for the CLI application. + * This method sets up picocli and includes robust, two-level error handling. + */ public static void main(String[] args) { - System.exit(new CommandLine(new Main()) - .setExecutionExceptionHandler((ex, commandLine, parseResult) -> { - commandLine.getErr().println(ex.getMessage()); + try { + // Level 1: Handles errors during command execution (inside a command's call() method). + IExecutionExceptionHandler executionErrorHandler = (ex, commandLine, parseResult) -> { + commandLine.getErr().println("Error: " + ex.getMessage()); + // For debug purposes, uncomment the next line to see the full stack trace. + // ex.printStackTrace(commandLine.getErr()); return ex instanceof SecurityException ? ERRCODES.AUTH_FAILED.ordinal() : ERRCODES.REMOTE_ERROR.ordinal(); - }) - .execute(args)); - } + }; - public void run() { - throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand"); + int exitCode = new CommandLine(new Main()) + .registerConverter(Scope.class, new Scope.Converter()) + .setExecutionExceptionHandler(executionErrorHandler) + .execute(args); + + System.exit(exitCode); + + } catch (Exception e) { + // Level 2: Catches errors during picocli initialization (e.g., parsing annotations). + // This is crucial for debugging the command structure itself. + System.err.println("A fatal initialization error occurred:"); + e.printStackTrace(System.err); + System.exit(ERRCODES.REMOTE_ERROR.ordinal()); + } } - private static int exec(String[] args) { - IExecutionExceptionHandler errorHandler = new IExecutionExceptionHandler() { - public int handleExecutionException(Exception ex, CommandLine commandLine, ParseResult parseResult) { - commandLine.getErr().println(ex.getMessage()); - if (ex instanceof RemoteException) { - return ERRCODES.REMOTE_ERROR.ordinal(); - } - return commandLine.getCommandSpec().exitCodeOnExecutionException(); - } - }; - int rc = new CommandLine(new Main()) - .registerConverter(Scope.class, new Scope.Converter()) - .setExecutionExceptionHandler(errorHandler) - .execute(args); - return rc; + @Override + public void run() { + // This is triggered if the user runs `lsposed-cli` with no subcommand. + throw new CommandLine.ParameterException(spec.commandLine(), "Missing required subcommand. Use '--help' to see available commands."); } + /** + * Gets or creates a connection to the LSPosed daemon service. + * This method caches the connection for the lifetime of the command. + */ public final ICLIService getManager() { - ICLIService objManager; - try { - objManager = connectToService(); - if (objManager == null) { - // connectToService will throw, but as a fallback: - throw new SecurityException("Authentication failed or daemon service not available."); + if (objManager == null) { + try { + objManager = connectToService(); + if (objManager == null) { + throw new SecurityException("Authentication failed or daemon service not available."); + } + } catch (RemoteException | SecurityException e) { + System.err.println("Error: " + e.getMessage()); + System.exit(ERRCODES.NO_DAEMON.ordinal()); } - } catch (RemoteException | SecurityException e) { - System.err.println("Error: " + e.getMessage()); - System.exit(ERRCODES.NO_DAEMON.ordinal()); } return objManager; } + /** + * Establishes a secure Binder connection to the LSPosed daemon. + * This method handles PIN retrieval, interactive prompts, and authentication. + */ private ICLIService connectToService() throws RemoteException { - // 1. Check for credentials provided by the user via arguments or environment. - // We store this in a separate variable to remember if the user even tried to provide a PIN. - String initialPin = this.pin; // `this.pin` is populated by picocli from the --pin arg + // Step 1: Determine the PIN provided by the user via argument or environment variable. + String initialPin = this.pin; if (initialPin == null) { initialPin = System.getenv("LSPOSED_CLI_PIN"); } - // `this.pin` will be used for the actual connection attempts. - this.pin = initialPin; + this.pin = initialPin; // This instance variable will hold the PIN used for the actual attempt. - // 2. Connect to the basic application service binder (boilerplate). - var activityService = ServiceManager.getService("activity"); + // Step 2: Connect to the 'activity' service to request the LSPosed application service binder. + IBinder activityService = ServiceManager.getService("activity"); if (activityService == null) throw new RemoteException("Could not get activity service."); - var binder = new Binder(); Parcel data = Parcel.obtain(); - data.writeInterfaceToken("LSPosed"); - data.writeInt(2); - data.writeString("lsp-cli:" + org.lsposed.lspd.util.SignInfo.CLI_UUID); - data.writeStrongBinder(binder); Parcel reply = Parcel.obtain(); + ILSPApplicationService service; + try { + data.writeInterfaceToken("LSPosed"); + data.writeInt(2); + data.writeString("lsp-cli:" + org.lsposed.lspd.util.SignInfo.CLI_UUID); + data.writeStrongBinder(new Binder()); - if (!activityService.transact(1598837584, data, reply, 0)) { - throw new RemoteException("Transaction to activity service failed."); + if (!activityService.transact(1598837584, data, reply, 0)) { + throw new RemoteException("Transaction to activity service failed."); + } + reply.readException(); + IBinder serviceBinder = reply.readStrongBinder(); + if (serviceBinder == null) throw new RemoteException("Daemon did not return a service binder."); + service = ILSPApplicationService.Stub.asInterface(serviceBinder); + } finally { + data.recycle(); + reply.recycle(); } - reply.readException(); - var serviceBinder = reply.readStrongBinder(); - if (serviceBinder == null) throw new RemoteException("Daemon did not return a service binder."); - - var service = ILSPApplicationService.Stub.asInterface(serviceBinder); - var lstBinder = new ArrayList(1); - - // 3. First attempt: Authenticate with the credentials we have (which could be null). + // Step 3: First authentication attempt with the provided PIN (which could be null). + List lstBinder = new ArrayList<>(1); service.requestCLIBinder(this.pin, lstBinder); - // 4. Recovery step: If the first attempt failed, we have no PIN, AND we're in an - // interactive shell, then prompt the user as a last resort. + // Step 4: If the first attempt failed and no PIN was provided in an interactive shell, + // prompt the user for the PIN as a final recovery step. if (lstBinder.isEmpty() && this.pin == null && System.console() != null) { System.err.println("Authentication required."); char[] pinChars = System.console().readPassword("Enter CLI PIN: "); if (pinChars != null) { this.pin = new String(pinChars); - // Second attempt: Retry with the PIN from the interactive prompt. - service.requestCLIBinder(this.pin, lstBinder); + Arrays.fill(pinChars, ' '); // Clear the PIN from memory + service.requestCLIBinder(this.pin, lstBinder); // Second authentication attempt. } } - // 5. Final check and smart error reporting. + // Step 5: Final validation and user-friendly error reporting. if (lstBinder.isEmpty()) { - String errorMessage; - if (initialPin == null) { - // The user never provided a PIN. The daemon requires one. Guide the user. - errorMessage = "Authentication required. Use --pin, set LSPOSED_CLI_PIN, or use an interactive shell."; - } else { - // The user provided a PIN, but it was rejected by the daemon. - errorMessage = "Authentication failed. The provided PIN is incorrect or CLI is disabled in the Manager app."; - } + String errorMessage = (initialPin == null) + ? "Authentication required. Use the --pin option, set the LSPOSED_CLI_PIN environment variable, or use an interactive shell." + : "Authentication failed. The provided PIN is incorrect, has been revoked, or the CLI is disabled in the Manager app."; throw new SecurityException(errorMessage); } - // If we reach here, we are successful. return ICLIService.Stub.asInterface(lstBinder.get(0)); } } diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java index 0fb17a484..7761168a5 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Utils.java @@ -27,9 +27,9 @@ public enum ERRCODES { REMOTE_ERROR } - private static HashMap packagesMap; + private HashMap packagesMap; - private static void initPackagesMap(ICLIService managerService) throws RemoteException { + private void initPackagesMap(ICLIService managerService) throws RemoteException { var packages = managerService.getInstalledPackagesFromAllUsers(PackageManager.GET_META_DATA | PackageManager.MATCH_UNINSTALLED_PACKAGES, true).getList(); packagesMap = new HashMap<>(); @@ -39,7 +39,7 @@ private static void initPackagesMap(ICLIService managerService) throws RemoteExc } } - public static boolean validPackageNameAndUserId(ICLIService managerService, String packageName, int userId) throws RemoteException { + public boolean validPackageNameAndUserId(ICLIService managerService, String packageName, int userId) throws RemoteException { if (packagesMap == null) { initPackagesMap(managerService); } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java index ab4c48b94..904db106d 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/ConfigManager.java @@ -114,6 +114,8 @@ public class ConfigManager { private String api = "(???)"; private String volatileCliPin = null; + private int failedCliAttempts = 0; + private static final int MAX_CLI_ATTEMPTS = 5; static class ProcessScope { final String processName; @@ -1075,6 +1077,18 @@ public void setEnableStatusNotification(boolean enable) { enableStatusNotification = enable; } + public void recordFailedCliAttempt() { + failedCliAttempts++; + if (failedCliAttempts >= MAX_CLI_ATTEMPTS) { + disableCli(); + failedCliAttempts = 0; + } + } + + public void resetCliFailedAttempts() { + failedCliAttempts = 0; + } + public String getCurrentCliPin() { return volatileCliPin; } diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java index f6db18ab6..febd6af8e 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPApplicationService.java @@ -181,6 +181,9 @@ public void requestCLIBinder(String pin, List binder) throws RemoteExce if (allowAccess) { binder.add(ServiceManager.getCLIService()); + config.resetCliFailedAttempts(); + } else { + config.recordFailedCliAttempt(); } } From 40cca3402a8d00be56da7b1afeee3efa3791712a Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 22 Aug 2025 10:21:19 +0200 Subject: [PATCH 8/9] Fix type and improve formatting --- daemon/src/main/java/org/lsposed/lspd/cli/Main.java | 6 +++++- .../main/java/org/lsposed/lspd/service/LSPosedService.java | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index 9c393f55d..d1e779c3b 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -11,6 +11,7 @@ import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.os.ServiceManager; + import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.FileInputStream; @@ -24,12 +25,15 @@ import java.util.concurrent.Callable; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; + import org.lsposed.lspd.ICLIService; import org.lsposed.lspd.models.Application; import org.lsposed.lspd.service.ILSPApplicationService; + import picocli.CommandLine; import picocli.CommandLine.IExecutionExceptionHandler; @@ -436,7 +440,7 @@ public Integer call() throws RemoteException { } } -@ommandLine.Command(name = "log", description = "Streams and manages the LSPosed framework and module logs.") +@CommandLine.Command(name = "log", description = "Streams and manages the LSPosed framework and module logs.") class LogCommand implements Callable { @CommandLine.ParentCommand private Main parent; diff --git a/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java b/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java index 4c41cecec..3ee62b436 100644 --- a/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java +++ b/daemon/src/main/java/org/lsposed/lspd/service/LSPosedService.java @@ -95,7 +95,6 @@ public ILSPApplicationService requestApplicationService(int uid, int pid, String return null; } - // for cli if (CLIService.basicCheck(uid) && CLIService.applicationStageNameValid(pid, processName)) { Log.d(TAG, "CLI start, pid: " + pid); return ServiceManager.requestApplicationService(uid, pid, processName, heartBeat); From c600654959c843a1a41a560ae62fe9e3935bfac2 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Wed, 10 Sep 2025 15:08:51 +0200 Subject: [PATCH 9/9] Fix empty scope condition It seems that the module application itself is no longer (by default) in its scope list. --- daemon/src/main/java/org/lsposed/lspd/cli/Main.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java index d1e779c3b..3f2059b72 100644 --- a/daemon/src/main/java/org/lsposed/lspd/cli/Main.java +++ b/daemon/src/main/java/org/lsposed/lspd/cli/Main.java @@ -149,7 +149,7 @@ public Integer call() throws RemoteException, JSONException { if (scope == null) { message = manager.getLastErrorMsg(); allSuccess = false; - } else if (objArgs.bEnable && scope.size() < 2) { + } else if (objArgs.bEnable && scope.size() == 0) { message = "Cannot enable: module scope is empty."; allSuccess = false; } else {