[!todo] #一键展开 (不含 NSFW) 点击查看本期内容 (点击了解更多详细信息) [!todo]# NSFW NSFW QAQ Vol.023-NSFW01 (点击了解更多详细信息) NSFW QVQ Vol.023-NSFW02 (点击了解更多详细信息) NSFW QoQ Vol.023-NSFW03 (点击了解更多详细信息) [!todo]# 往期周刊 【摸鱼周刊】Vol.022 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.021 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.018 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.017 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.016 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.015 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.014 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.013 一周梗图沙雕乐子图合集 查看更多 4 个帖子 - 4 位参与者 阅读完整话题
其他内容参见 JAVA学习记录总贴 本期是oop的简单练习,确实都很简单,主要负责熟手 第一题 public static void main(String[] args) { Car c = new Car(); Car car = new Car(100); System.out.println(c); System.out.println(car); } class Car { double price = 10; static String color = "white"; public String toString() { return price + "\t" + color; } public Car(){ this.price = 9; this.color = "black"; } public Car(double price){ this.price = price; } } 答案: 9.0 black 100.0 black 分析: 这道题应该挺好理解的。 注意 color 是一个静态变量,在第一次 new Car 的时候,static变量 就已经被修改了。 所以,最后两个实例的静态变量都是 black。 第二题 不难,纯粹练手 在Frock类中声明私有的静态属性currentNum[int类型],初始值为100000,作为衣服出厂的序列号起始值。 声明公有的静态方法getNextNum,作为生成上衣唯一序列号的方法。每调用一次,将currentNum增加100,并作为返回值。 在Homework02类的main方法中,分两次调用getNextNum方法,获取序列号并打印输出。 在Frock类中声明serialNumber(序列号)属性,并提供对应的get方法。 在Frock类的构造器中,通过调用getNextNum方法为Frock对象获取唯一序列号,赋给serialNumber属性。 在Homework02类的main方法中,分别创建三个Frock 对象,并打印三个对象的序列号,验证是否为按100递增。 答案: package hspedu.homework; public class Homework02 { public static void main(String[] args) { System.out.println(Frock.getNextNum()); System.out.println(Frock.getNextNum()); Frock f1 = new Frock(); System.out.println(f1.getSerialNumber()); Frock f2 = new Frock(); System.out.println(f2.getSerialNumber()); Frock f3 = new Frock(); System.out.println(f3.getSerialNumber()); } } class Frock { private static int currentNum = 100000; private int serialNumber; public static int getNextNum() { currentNum += 100; return currentNum; } public Frock() { serialNumber = getNextNum(); } public int getSerialNumber() { return serialNumber; } } 第三题 一个简单的多态练习,题干如下 动物类Animal包含了抽象方法 shout(); Cat类继承了Animal,并实现方法shout,打印“猫会喵喵叫” Dog类继承了Animal,并实现方法shout,打印“狗会汪汪叫” 在测试类中实例化对象Animal cat = new Cat(),并调用cat的shout方法 在测试类中实例化对象Animal dog = new Dog(),并调用dog的shout方法 答案: public class Homework03 { public static void main(String[] args) { Animal cat = new Cat(); cat.shout(); Animal dog = new Dog(); dog.shout(); } } abstract class Animal{ abstract void shout(); } class Cat extends Animal{ @Override void shout() { System.out.println("猫会喵喵叫"); } } class Dog extends Animal{ @Override void shout() { System.out.println("狗会汪汪叫"); } } 第四题 计算器接口具有 work 方法,功能是运算,有一个手机类 Cellphone,定义方法 testWork 测试计算功能,调用计算接口的 work 方法 要求调用 CellPhone 对象 的 testWork 方法,使用上匿名内部类 答案: ps:设计匿名内部类,建议还是认真写一下 public class Homework04 { public static void main(String[] args) { Cellphone cellphone = new Cellphone(); Calculator cal = new Calculator() { @Override public void work(int a, int b) { System.out.println(a + b); } }; cellphone.testWork(cal, 7, 8); } } interface Calculator { void work(int a, int b); } class Cellphone { public void testWork(Calculator calculator, int a, int b) { System.out.println("测试计算功能"); calculator.work(a, b); } } 第五题 编一个类 A,在类中定义局部内部类 B,B 中有一个私有常量 name,有一个方法 show () 打印常量 name。进行测试 进阶:A 中也定义一个私有的变量 name,在 show 方法中打印测试 答案:a piece of cake public class Homework05 { public static void main(String[] args) { new A().new B().show(); } } class A{ private final String name = "AAA"; class B{ private final String name = "BBB"; public void show(){ System.out.println(name); System.out.println(A.this.name); } } } 第六题 有一个交通工具接口类Vehicles,有work接口 有Horse类和Boat类分别实现Vehicles 创建交通工具工厂类,有两个方法分别获得交通工具Horse和Boat 有Person类,有name和Vehicles属性,在构造器中为两个属性赋值 实例化Person对象“唐僧”,要求一般情况下用Horse作为交通工具,遇到大河时用Boat作为交通工具 额外:使用匿名内部类,增加一个用飞机过火焰山的方法,因为只用一次,因此不要写一个新的类 答案: public class Homework06 { public static void main(String[] args) { Person tang = new Person("唐僧", null); tang.passRiver(); tang.common(); tang.passRiver(); tang.passMountain(); } } interface Vehicles { void work(); } class Horse implements Vehicles { @Override public void work() { System.out.println("horse is working"); } } class Boat implements Vehicles { @Override public void work() { System.out.println("boat is working"); } } class Plane implements Vehicles { @Override public void work() { System.out.println("plane is working"); } } class Factory { private static final Horse HORSE = new Horse(); private static final Boat BOAT = new Boat(); private static final Plane PLANE = new Plane(); public static Horse getHorse() { return HORSE; } public static Boat getBoat() { return BOAT; } public static Plane getPlane() { return PLANE; } } class Person { private String name; private Vehicles vehicle; public Person(String name, Vehicles vehicle) { this.name = name; this.vehicle = vehicle; } public void setVehicle(Vehicles vehicle) { this.vehicle = vehicle; } public void passRiver() { if (!(vehicle instanceof Boat)) { vehicle = Factory.getBoat(); } vehicle.work(); } public void common() { if (!(vehicle instanceof Horse)) { vehicle = Factory.getHorse(); } vehicle.work(); } public void passMountain() { vehicle = new Vehicles(){ @Override public void work() { System.out.println("plane is working"); } }; vehicle.work(); } } 简要分析: 用懒汉式单例,保证只创建一个船和马对象,节省资源 用 vehicle instanceof Horse 来判断具体情况 第七题 有一个 Car 类,有属性 temperature(温度),车内有 Air(空调)类,有吹风的功能 flow,Air 会监视车内的温度,如果温度超过 40 度则吹冷气。如果温度低于 0 度则吹暖气,如果在这之间则关掉空调。实例化具有不同温度的 Car 对象,调用空调的 flow 方法,测试空调吹的风是否正确。 public class Homework07 { public static void main(String[] args) { Car car = new Car(42); Car.Air air = car.getAir(); air.flow(); Car car1 = new Car(2); Car.Air air1 = car1.getAir(); air1.flow(); Car car2 = new Car(-1); Car.Air air2 = car2.getAir(); air2.flow(); } } class Car{ private double temperature; public Car(double temperature) { this.temperature = temperature; } class Air{ public void flow(){ if(temperature > 40){ System.out.println("吹冷风"); } else if(temperature < 0){ System.out.println("吹暖风"); } else { System.out.println("关闭空调"); } } } public Air getAir(){ return new Air(); } } 第八题 创建一个Color枚举类 有 RED,BLUE,BLACK,YELLOW,GREEN这五个枚举值/对象; Color有三个属性redValue, greenValue, blueValue, 创建构造方法,参数包括这三个属性, 每个枚举值都要给这三个属性赋值,三个属性对应的值分别是 red: 255,0,0 blue:0,0,255 black:0,0,0 yellow:255,255,0 green:0,255,0 定义接口,里面有方法show,要求Color实现该接口 show方法中显示三属性的值 将枚举对象在switch语句中匹配使用 1 个帖子 - 1 位参与者 阅读完整话题
[!todo] #一键展开 (不含 NSFW) 点击查看本期内容 (点击了解更多详细信息) [!todo]# NSFW NSFW QAQ Vol.022-NSFW01 (点击了解更多详细信息) NSFW QVQ Vol.022-NSFW02 (点击了解更多详细信息) NSFW QoQ Vol.022-NSFW03 (点击了解更多详细信息) [!todo]# 往期周刊 【摸鱼周刊】Vol.021 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.018 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.017 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.016 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.015 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.014 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.013 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.012 一周梗图沙雕乐子图合集 查看更多 1 个帖子 - 1 位参与者 阅读完整话题
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
老倔驴每周一抽,码农值得拥有的精选好物,本期🎁 黄杨木木雕小和尚 🎁。没事盘两下,保佑少出 bug 不背锅、股市长虹~~放办公桌放车上都可以。 规则: 回复即可参与,抽 7 个 + 内幕 3 个。 如何内幕?一个好问题,一个有营养的分享就算。 不因奖品而参与股市,得不偿失! 开户点这里: jue.lv (防失联先收藏) 1. 关于老倔驴? 证券免五低佣低两融开户,聚合几十家券商优惠,连接 100+家营业部,已帮助 3000 人+,费率即便不免五也比官方直开低很多,还有很多券商一起选,费率低不低?经得起对比。 我们注重个人隐私,承诺个人信息仅用于开户,手机做脱敏处理; 我们注重服务,承诺发生多收佣金,凭交割单先行赔付; 开户只需 3 步: 加微帮你选券商 > 加经理完成开户 > 入金调佣金 2.券商怎么选? 只玩 ETF 的聪明钱、银河 ETF 万 0.5 1 毛起 ,适合大多数散户,宽指 ETF+逆回购+打新。 低门槛川 C 万 0.9 免五 1 元起,每天 3 个名额; 国 T 万 0.8 免五,国 x 万 0.75 免五,长 C 万 0.75 免五还支持 miniQMT 量化: miniqmt 免五东 B 长 C 可选,不免五低门槛江海东莞国金可选,ptrade10 万有山西,学习入门不错的,也是吃 AI 红利一种方式。 两融: 西南广发银河等,50 万 3.2%,最低 2.6%。 开户点这里: jue.lv (防失联先收藏)
[!todo] #一键展开 (不含 NSFW) 点击查看本期内容 (点击了解更多详细信息) [!todo]# NSFW NSFW QAQ Vol.021-NSFW01 (点击了解更多详细信息) NSFW QVQ Vol.021-NSFW02 (点击了解更多详细信息) NSFW QoQ Vol.021-NSFW03 (点击了解更多详细信息) [!todo]# 往期周刊 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.018 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.017 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.016 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.015 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.014 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.013 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.012 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.011 一周梗图沙雕乐子图合集 查看更多 2 个帖子 - 2 位参与者 阅读完整话题
本期主题:termux永久唤醒 & ubuntu环境自启动 & 实现sshd 前五期回顾: 【超详细】手机搭建服务器 · 第 一 期 【超详细】手机搭建服务器 · 第 二 期 【超详细】手机搭建服务器 · 第 三 期 【超详细】手机搭建服务器 · 第 四 期 【超详细】手机搭建服务器 · 第 五 期 一、termux永久唤醒: 1. 打开MT管理器,进入 /data/adb/service.d/ 文件夹,创建 60-termux-wake-keeper.sh 开机脚本: #!/system/bin/sh # ============================================================ # 60-termux-wake-keeper.sh # # 功能: # 1. 手机开机后自动启动 termux-wake-keeper.sh # 2. 定时执行 termux-wake-lock,保持 wake lock # # 注意: # - 这个脚本只负责“开机拉起守护脚本” # - 真正循环执行 termux-wake-lock 的逻辑在: # /data/local/termux-wake-keeper/termux-wake-keeper.sh # ============================================================ BASE_DIR="/data/local/termux-wake-keeper" SCRIPT="$BASE_DIR/termux-wake-keeper.sh" LOG="$BASE_DIR/termux-wake-keeper-start.log" mkdir -p "$BASE_DIR" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG" } log "============================================================" log "===== termux wake keeper auto start | START =====" log "BASE_DIR=$BASE_DIR" log "SCRIPT=$SCRIPT" log "LOG=$LOG" # ------------------------------------------------------------ # 1. 等待 Android 系统启动完成 # ------------------------------------------------------------ while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 5 done log "Android boot completed" # ------------------------------------------------------------ # 2. 再额外等待一会儿 # 避免刚开机时 /data/data、Termux、ZeroTermux 相关环境还没完全稳定 # ------------------------------------------------------------ sleep 30 # ------------------------------------------------------------ # 3. 检查主守护脚本是否存在 # ------------------------------------------------------------ if [ ! -x "$SCRIPT" ]; then log "ERROR: script not found or not executable: $SCRIPT" log "Please check file path and chmod 755." log "===== termux wake keeper auto start | FAILED =====" exit 1 fi # ------------------------------------------------------------ # 4. 启动守护脚本 # 守护脚本内部有 PID 判断,重复启动不会产生多个实例 # ------------------------------------------------------------ nohup "$SCRIPT" >> "$LOG" 2>&1 & log "termux wake keeper started" log "===== termux wake keeper auto start | DONE =====" log "============================================================" 2.在 /data/local/termux-wake-keeper/ 文件夹中创建termux-wake-keeper.sh脚本 #!/system/bin/sh # ============================================================ # termux-wake-keeper.sh # # 功能: # 1. 定时执行 termux-wake-lock # 2. 防止 wake lock 失效 # 3. 不依赖 Ubuntu # 4. 不启动 sshd / nginx # # 日志: # /data/local/termux-wake-keeper/termux-wake-keeper.log # ============================================================ BASE_DIR="/data/local/termux-wake-keeper" LOG="$BASE_DIR/termux-wake-keeper.log" RUN_DIR="/dev/.termux-wake-keeper" PIDFILE="$RUN_DIR/termux-wake-keeper.pid" # 每隔多少秒重新执行一次 termux-wake-lock INTERVAL="60" mkdir -p "$BASE_DIR" mkdir -p "$RUN_DIR" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG" } is_running() { OLD_PID="$1" if [ -z "$OLD_PID" ]; then return 1 fi if [ ! -d "/proc/$OLD_PID" ]; then return 1 fi tr '\0' ' ' < "/proc/$OLD_PID/cmdline" 2>/dev/null | grep -q "termux-wake-keeper.sh" } find_wake_lock_cmd() { for p in \ /data/data/com.termux/files/usr/bin/termux-wake-lock \ /data/data/com.zerotermux/files/usr/bin/termux-wake-lock \ /data/data/com.termux.zero/files/usr/bin/termux-wake-lock do if [ -x "$p" ]; then echo "$p" return 0 fi done find /data/data -path '*/files/usr/bin/termux-wake-lock' -type f -perm -111 2>/dev/null | head -n 1 } find_wake_unlock_cmd() { for p in \ /data/data/com.termux/files/usr/bin/termux-wake-unlock \ /data/data/com.zerotermux/files/usr/bin/termux-wake-unlock \ /data/data/com.termux.zero/files/usr/bin/termux-wake-unlock do if [ -x "$p" ]; then echo "$p" return 0 fi done find /data/data -path '*/files/usr/bin/termux-wake-unlock' -type f -perm -111 2>/dev/null | head -n 1 } # 防止重复启动 if [ -f "$PIDFILE" ]; then OLD_PID="$(cat "$PIDFILE" 2>/dev/null)" if is_running "$OLD_PID"; then log "already running, pid=$OLD_PID" exit 0 else log "stale pidfile found, overwrite" fi fi echo "$$" > "$PIDFILE" WAKE_LOCK_CMD="$(find_wake_lock_cmd)" WAKE_UNLOCK_CMD="$(find_wake_unlock_cmd)" log "============================================================" log "===== termux wake keeper | START =====" log "PID=$$" log "BASE_DIR=$BASE_DIR" log "LOG=$LOG" log "INTERVAL=$INTERVAL" log "WAKE_LOCK_CMD=$WAKE_LOCK_CMD" log "WAKE_UNLOCK_CMD=$WAKE_UNLOCK_CMD" if [ -z "$WAKE_LOCK_CMD" ]; then log "ERROR: termux-wake-lock not found" log "Please check: find /data/data -path '*/files/usr/bin/termux-wake-lock'" rm -f "$PIDFILE" exit 1 fi release_lock() { log "release termux wake lock" if [ -n "$WAKE_UNLOCK_CMD" ] && [ -x "$WAKE_UNLOCK_CMD" ]; then "$WAKE_UNLOCK_CMD" >> "$LOG" 2>&1 log "termux-wake-unlock executed" else log "WARN: termux-wake-unlock not found, skip unlock" fi rm -f "$PIDFILE" } trap 'release_lock; exit 0' INT TERM # 启动时先执行一次 "$WAKE_LOCK_CMD" >> "$LOG" 2>&1 RESULT="$?" if [ "$RESULT" = "0" ]; then log "termux-wake-lock executed at startup" else log "ERROR: termux-wake-lock startup failed, result=$RESULT" fi # 循环续保 while true; do sleep "$INTERVAL" "$WAKE_LOCK_CMD" >> "$LOG" 2>&1 RESULT="$?" if [ "$RESULT" = "0" ]; then log "termux-wake-lock refresh ok" else log "ERROR: termux-wake-lock refresh failed, result=$RESULT" fi done [!note]如何手动停止唤醒 如果需要手动停止唤醒,可以用下面这个脚本,如果不需要,可以忽略,一般是不需要的 stop-termux-wake-keeper.sh: #!/system/bin/sh # ============================================================ # stop-termux-wake-keeper.sh # # 功能: # 1. 停止 termux-wake-keeper.sh 守护脚本 # 2. 执行 termux-wake-unlock # # 日志: # /data/local/termux-wake-keeper/termux-wake-keeper.log # ============================================================ BASE_DIR="/data/local/termux-wake-keeper" LOG="$BASE_DIR/termux-wake-keeper.log" RUN_DIR="/dev/.termux-wake-keeper" PIDFILE="$RUN_DIR/termux-wake-keeper.pid" mkdir -p "$BASE_DIR" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG" } find_wake_unlock_cmd() { for p in \ /data/data/com.termux/files/usr/bin/termux-wake-unlock \ /data/data/com.zerotermux/files/usr/bin/termux-wake-unlock \ /data/data/com.termux.zero/files/usr/bin/termux-wake-unlock do if [ -x "$p" ]; then echo "$p" return 0 fi done find /data/data -path '*/files/usr/bin/termux-wake-unlock' -type f -perm -111 2>/dev/null | head -n 1 } log "============================================================" log "===== stop termux wake keeper | START =====" if [ -f "$PIDFILE" ]; then PID="$(cat "$PIDFILE" 2>/dev/null)" if [ -n "$PID" ] && [ -d "/proc/$PID" ]; then kill -TERM "$PID" log "sent TERM to termux-wake-keeper pid=$PID" sleep 1 else log "pid not running: $PID" fi rm -f "$PIDFILE" else log "pidfile not found: $PIDFILE" fi WAKE_UNLOCK_CMD="$(find_wake_unlock_cmd)" log "WAKE_UNLOCK_CMD=$WAKE_UNLOCK_CMD" if [ -n "$WAKE_UNLOCK_CMD" ] && [ -x "$WAKE_UNLOCK_CMD" ]; then "$WAKE_UNLOCK_CMD" >> "$LOG" 2>&1 RESULT="$?" if [ "$RESULT" = "0" ]; then log "termux-wake-unlock executed" else log "ERROR: termux-wake-unlock failed, result=$RESULT" fi else log "WARN: termux-wake-unlock not found" fi log "===== stop termux wake keeper | DONE =====" log "============================================================" [!warning]提醒 上面的脚本一定要记得给足权限 二、实现ubuntu开机自启 1. 打开MT管理器,进入 /data/adb/service.d/ 文件夹,新建文件 90-ubuntu-sshd.sh ,代码如下: #!/system/bin/sh # ============================================================ # 90-ubuntu-sshd.sh # # 功能: # 1. 开机后调用 Magisk 版 chroot-distro # 2. 自动准备 ubuntu chroot 环境 # 3. 自动启动 ubuntu 里的 sshd # # 说明: # - 不处理 wake lock # - 不依赖 Termux 路径 # - 不在脚本里写死 SSH 端口 # - SSH 端口以 Ubuntu 的 /etc/ssh/sshd_config 为准 # ============================================================ # 日志目录 LOG_DIR="/data/local/chroot-distro/logs" # 日志文件 LOG="$LOG_DIR/ubuntu-sshd-start.log" # 你平时是 chroot-distro login ubuntu # 所以这里发行版名称就是 ubuntu DISTRO="ubuntu" # 开机后额外等待时间 BOOT_WAIT="25" # 设置 Android / Magisk 常见命令路径 # 这个不是 Termux PATH,只是为了让 service.d 脚本能找到系统命令 export PATH="/system/bin:/system/xbin:/vendor/bin:/odm/bin:/data/adb/magisk:/data/adb/ksu/bin:$PATH" # 确保日志目录存在 mkdir -p "$LOG_DIR" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG" } log "============================================================" log "===== Ubuntu sshd auto start via chroot-distro | START =====" log "DISTRO=$DISTRO" log "BOOT_WAIT=$BOOT_WAIT" log "LOG=$LOG" # ------------------------------------------------------------ # 1. 等待 Android 开机完成 # ------------------------------------------------------------ while [ "$(getprop sys.boot_completed)" != "1" ]; do sleep 5 done log "Android boot completed" sleep "$BOOT_WAIT" # ------------------------------------------------------------ # 2. 查找 chroot-distro # ------------------------------------------------------------ CHROOT_DISTRO="$(command -v chroot-distro 2>/dev/null)" if [ -z "$CHROOT_DISTRO" ]; then if [ -x /system/bin/chroot-distro ]; then CHROOT_DISTRO="/system/bin/chroot-distro" elif [ -x /system/xbin/chroot-distro ]; then CHROOT_DISTRO="/system/xbin/chroot-distro" else log "ERROR: chroot-distro command not found" log "Please run: command -v chroot-distro" log "===== FAILED =====" exit 1 fi fi log "CHROOT_DISTRO=$CHROOT_DISTRO" # ------------------------------------------------------------ # 3. 让 chroot-distro 准备 Ubuntu 环境 # ------------------------------------------------------------ log "mount distro: $DISTRO" "$CHROOT_DISTRO" mount "$DISTRO" >> "$LOG" 2>&1 # ------------------------------------------------------------ # 4. 在 Ubuntu 环境里启动 sshd # ------------------------------------------------------------ log "start sshd inside Ubuntu" "$CHROOT_DISTRO" command "$DISTRO" ' mkdir -p /run/sshd chmod 755 /run/sshd ssh-keygen -A >/dev/null 2>&1 || true /usr/sbin/sshd -t if [ $? -ne 0 ]; then echo "ERROR: sshd config test failed" exit 1 fi echo "effective sshd config:" /usr/sbin/sshd -T | awk "/^(port|addressfamily|listenaddress|permitrootlogin|passwordauthentication|pubkeyauthentication) / {print}" if pgrep -x sshd >/dev/null 2>&1; then echo "sshd already running" else /usr/sbin/sshd echo "sshd started" fi ' >> "$LOG" 2>&1 RESULT="$?" if [ "$RESULT" != "0" ]; then log "ERROR: chroot-distro command failed, result=$RESULT" log "===== Ubuntu sshd auto start via chroot-distro | FAILED =====" exit 1 fi log "chroot-distro command finished" # ------------------------------------------------------------ # 5. 输出监听状态 # 不写死端口,直接看 sshd 实际监听了什么 # ------------------------------------------------------------ log "listening status:" ss -lntp 2>/dev/null | grep sshd >> "$LOG" 2>&1 log "process status:" ps -A 2>/dev/null | grep sshd >> "$LOG" 2>&1 log "===== Ubuntu sshd auto start via chroot-distro | DONE =====" log "============================================================" [!warning]权限提醒 脚本同样记得给足权限 三、如何在ubuntu中实现sshd 1.修改 sshd 配置 打开mt管理器 /data/local/chroot-distro/ubuntu/etc/ssh/sshd_config , 修改 sshd_config文件: 找到这段代码: #Port 22 #AddressFamily any #ListenAddress 0.0.0.0 #ListenAddress :: 改成 Port 2222 AddressFamily any ListenAddress 0.0.0.0 ListenAddress :: 再找到 # PasswordAuthentication yes 改成 PasswordAuthentication yes PubkeyAuthentication yes 也就是密码和允许使用 SSH 密钥登录 2.创建 sshd 运行目录 sudo mkdir -p /run/sshd sudo chmod 755 /run/sshd 3.启动 sshd 如果有旧的ssh,就先停掉旧的 sshd: sudo pkill sshd 2>/dev/null 再启动: sudo /usr/sbin/sshd 确认是否监听成功: ss -lntp | grep sshd 正常应该看到类似: LISTEN 0 128 [::]:2222 [::]:* users:(("sshd",pid=xxxx,fd=3)) 这说明 Ubuntu 已经在 IPv6 的 2222 端口上等待外部连接。 给几个常用的命令: 查看sshd状态: ss -lntp | grep sshd 停止: pkill -TERM -x sshd 启动: mkdir -p /run/sshd && chmod 755 /run/sshd && /usr/sbin/sshd 重启: /usr/sbin/sshd -t && pkill -TERM -x sshd && sleep 1 && mkdir -p /run/sshd && chmod 755 /run/sshd && /usr/sbin/sshd [!warning]远端ssh报错问题 如果远端ssh访问时,有下面这张报错问题: -bash: /etc/bash.bashrc: Required key not available -bash: /home/semmering/.profile: Required key not available -bash-5.2$ 解决办法是: 用MT管理器,把 /data/local/chroot-distro/ubuntu/etc/pam.d/sshd 这句话注销就不会报错了。 # session optional pam_keyinit.so force revoke 也就是在这句话前面加上#号,或者删除这句话。 这个报错问题非常不好解决,网络上的信息非常少。非常幸运再github上找到了方法。 [!success] 按照上面的教程来一步一步操作。脚本记得给足权限,然后重新开关机,termux会永久唤醒,然后ubuntu环境也会准备好,然后sshd也会准备好,就可以实现远端通过域名访问了。 3 个帖子 - 3 位参与者 阅读完整话题
本期主题:解决termux安装ubuntu的各种问题 前四期回顾: 【超详细】手机搭建服务器 · 第 一 期 【超详细】手机搭建服务器 · 第 二 期 【超详细】手机搭建服务器 · 第 三 期 【超详细】手机搭建服务器 · 第 四 期 [!note] 前几期完成了什么 第一期主要说的是必备条件; 第二期讲了如何解决手机供电问题; 第三期讲了如何让wifi用不断连(不过代码在第四期做了重大更新); 第四期实现了如何让ipv6一直处于可用状态和如何自动更新DDNS 这一期主要讲termux如何安装ubuntu。 原本类似的教程网络上很多,感觉没有出的必要。但是在安装的过程中,遇到了很诡异的问题,然后困扰了我很久,而且网上相关的解决方案基本没有,好不容易终于找到了一个解决方案,所以想着记录下来。 一、先准备 Termux 环境 1. 客户端选择: 大家可以安装 termux termux,也可以安装 ZeroTermux 。我自己安装的是ZerTermux。 2.更新环境 ,并且安装常用工具包 pkg update -y pkg upgrade -y pkg install -y curl wget unzip nano coreutils openssl openssl-tool termux-api 3.刷入 BusyBox模块 官方仓库: GitHub - Magisk-Modules-Repo/busybox-ndk: busybox-ndk · GitHub 下载链接: Download Busybox for Android NDK-1.36.1-13614.zip (Magisk) 4.刷入chroot-distro 模块 chroot-distro.zip (18.4 KB) 两个模块都刷入以后,记得重启手机 5.给 Termux 创建快捷的chroot-distro 命令: 在 Termux 执行: cat > $PREFIX/bin/chroot-distro <<'EOF' #!/data/data/com.termux/files/usr/bin/bash args="" for arg in "$@"; do escaped_arg=$(printf '%s' "$arg" | sed "s/'/'\\\\''/g") args="$args '$escaped_arg'" done su -c "/system/bin/chroot-distro $args" EOF chmod +x $PREFIX/bin/chroot-distro 然后测试: chroot-distro list 如果正常,说明快捷命令创建成功。 6.下载ubuntu: chroot-distro download ubuntu 看自己要安装什么版本,输入对应的数字回车即可,我选的是ubuntu 24.04。所以输入6回车。此处没有截图,因为忘记截图了。 7.安装ubuntu: chroot-distro install ubuntu 出现 ~ $ chroot-distro install ubuntu -bash: cannot set terminal process group (25325): Inappropriate ioctl for device -bash: no job control in this shell root@localhost:~# 意味着成功了。 [!warning] 到这里其实一切都很正常。也比较简单,几乎不会出什么问题。重点在下面步骤。 8.更新ubuntu内置包 在termux中执行 apt update 命令,然后会遇到报错问题。 解决apt update 办法是: 8.1 用MT管理器,把 /data/local/chroot-distro/ubuntu/etc/pam.d/su-l 这个文件中的这句话注销就不会报错了: # session optional pam_keyinit.so force revoke 。也就是在这句话前面加上#号,或者删除这句话。 8.2 继续在termux中执行下面这代码: cat > /etc/resolv.conf <<'EOF' nameserver 223.5.5.5 nameserver 119.29.29.29 nameserver 8.8.8.8 nameserver 1.1.1.1 EOF groupadd -g 3003 aid_inet groupadd -g 3004 aid_net_raw groupadd -g 1003 aid_graphics usermod -g 3003 -G 3003,3004 -a _apt usermod -G 3003 -a root 9.安装几个最基础的工具: apt install -y curl wget vim nano iproute2 procps ca-certificates 如果输入 ip addr 或者输入 ps -ef | head 都有输出,那就表示ubuntu现在基本可用了 10.创建普通用户和禁止root用户登录 为了安全,强烈建议使用拥有 sudo 权限的普通用户进行日常操作,然后禁止 root 用户直接通过 SSH 登录。在termux中的操作如下: 创建新用户: useradd -m -s /bin/bash 用户名 更改密码: passwd 用户名 安装 sudo: apt install -y sudo 加入sudo组: usermod -aG sudo server 更改密码时,控制台看不见,自己注意输入就行 测试普通用户提权是否ok: 先退出整个temux,重新进入termux,然后输入su回车 接着登录ubuntu: chroot-distro login ubuntu 然后切换到刚刚创建的普通用户 su - 用户名 进入后测试提权: sudo -i 如果能进入: root@localhost:~# 说明普通用户创建成功,并且 sudo 正常。 11.如何禁止root ssh登录: 先安装ssh: apt install -y openssh-server 直接执行这组命令: if grep -qE '^[# ]*PermitRootLogin' /etc/ssh/sshd_config; then sed -i 's/^[# ]*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_configelse echo 'PermitRootLogin no' >> /etc/ssh/sshd_configfi 然后确认: grep -n '^PermitRootLogin' /etc/ssh/sshd_config 应该看到: PermitRootLogin no 如果不放心,也可以用mt管理器,进入 /data/local/chroot-distro/ubuntu/etc/ssh/sshd_config 中查看。 [!note]结束语 还是老规矩,有什么问题欢迎大家及时反馈。看到后都会一一回复解答的 5 个帖子 - 4 位参与者 阅读完整话题
本期主题:解决ipv6稳定性和DDNS同步问题 前两期回顾: 【超详细】手机搭建服务器 · 第 一 期 【超详细】手机搭建服务器 · 第 二 期 【超详细】手机搭建服务器 · 第 三 期 [!warning]第三期的内容可以不用管 第三期的内容主要是做一个记录,告诉大家在实现手机做服务器这个事情上,有这么一个环节需要去做,如果第三期没有实现成功,不用管,可以直接忽略,然后用本期的代码。因为这一期会有新的代码变更。用本期的代码就好了 前言: [!note]域名服务商推荐 在DDNS同步这个问题上,我推荐大家去华为购买服务器,因为我发现,只有华为的服务器能够做到给免费用户的TTL解析值降低到1s。阿里云是10分钟,腾讯云是5分钟。如果要设置1s,就需要成为付费用户。之前我一直用阿里云的服务器。后来因为这个原因改成华为了。 教程开始 一、如何让ipv6一直保持健 这里先说一下,当你的手机连接wifi以后,不是说你的ipv6就一直可以用了,因为ipv6是有有效期的,一旦过了有效期。ipv6就被废弃了,需要新的ipv6地址。所以ipv6不是大家想的,只要一直连着Wi-Fi,ipv6就是永远有效的。所以第一步,就是如何让手机的ipv6处于一直可用的状态。 1. 打开MT管理器,进入 /data/adb/service.d/ 文件夹,更新 99-phone-server.sh 代码 #!/system/bin/sh # # 99-wifi-keeper.sh # Magisk service.d 启动脚本 # BASE_DIR="/data/local/wifi-keeper" MAIN_SCRIPT="$BASE_DIR/wifi-keeper.sh" LAUNCH_LOG="$BASE_DIR/service.d-launcher.log" mkdir -p "$BASE_DIR" 2>/dev/null echo "============================================================" >> "$LAUNCH_LOG" echo "[$(date '+%Y-%m-%d %H:%M:%S')] service.d launcher started" >> "$LAUNCH_LOG" echo "MAIN_SCRIPT=$MAIN_SCRIPT" >> "$LAUNCH_LOG" if [ ! -f "$MAIN_SCRIPT" ]; then echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: main script not found" >> "$LAUNCH_LOG" exit 0 fi chmod 755 "$MAIN_SCRIPT" 2>/dev/null ( echo "[$(date '+%Y-%m-%d %H:%M:%S')] launching main script in background" >> "$LAUNCH_LOG" if command -v nohup >/dev/null 2>&1; then nohup sh "$MAIN_SCRIPT" >/dev/null 2>&1 & else sh "$MAIN_SCRIPT" >/dev/null 2>&1 & fi echo "[$(date '+%Y-%m-%d %H:%M:%S')] launcher finished" >> "$LAUNCH_LOG" ) & exit 0 2 进入 /data/local/ 文件夹,新建 wifi-keeper 文件夹,然后进入 wifi-server 文件夹再新建 wifi-keeper.sh 文件;输入以下内容并保存: #!/system/bin/sh # # wifi-keeper.sh # # 作用: # 1. 开机后自动保障 Wi-Fi 连接。 # 2. 使用 cmd wifi connect-network 主动连接指定 Wi-Fi。 # 3. 不使用 wpa_cli / add-suggestion / add-request。 # 4. 获取 2 开头、非 temporary、带 mngtmpaddr 的长期 IPv6。 # 5. 每隔固定时间检查 IPv6 健康度。 # 6. 如果 IPv6 不健康,则重启 Wi-Fi,再重新连接。 # 7. 如果有效 IPv6 相比上一次发生变化,则执行 DDNS 脚本。 # # ============================================================ # 一、基础配置 # ============================================================ # 开机后等待多久再开始执行主逻辑。 # 原因: # Android 刚开机时,Wi-Fi 服务、网络服务、Magisk service.d 可能还没有完全稳定。 # 这里等待 10 秒,可以减少开机初期误判。 BOOT_DELAY_SECONDS=10 # IPv6 健康度检查间隔。 # 每隔多少秒检查一次是否仍然存在有效 IPv6。 # 你当前要求是每 10 秒检查一次。 IPV6_HEALTH_INTERVAL=10 # 打开 Wi-Fi 后,最多等待多少秒确认 Wi-Fi 已经开启。 # 如果超过这个时间仍然检测不到 Wi-Fi 开启,就认为本轮失败。 WAIT_WIFI_ON_SECONDS=40 # 扫描 Wi-Fi 后等待多少秒,让扫描结果刷新。 # Android 的扫描结果有时不是马上返回,适当等待更稳。 WAIT_SCAN_SECONDS=8 # 发起 Wi-Fi 连接后,最多等待多少秒确认已经连接成功。 # 如果第一个 Wi-Fi 在这个时间内没有连接成功,就尝试下一个 Wi-Fi。 WAIT_CONNECT_SECONDS=60 # Wi-Fi 连接成功后,最多等待多少秒获取有效 IPv6。 # 有些路由器下发 IPv6 会比 Wi-Fi 连接慢,所以这里不能太短。 WAIT_IPV6_SECONDS=80 # 各类等待循环中的检查间隔。 # 比如每 2 秒检查一次 Wi-Fi 是否开启、是否连接成功、IPv6 是否出现。 CHECK_INTERVAL=2 # 当 IPv6 不健康时,需要重启 Wi-Fi。 # 关闭 Wi-Fi 后等待多少秒再重新打开。 WIFI_RESTART_OFF_SECONDS=5 # 连接 Wi-Fi 时是否尝试使用固定 MAC,而不是随机 MAC。 # 1 = 优先使用 -r none # 0 = 不使用 -r none # # 对于手机做服务器,建议为 1。 # 好处: # 路由器里看到的 MAC 更稳定; # DHCP 绑定、设备识别、IPv6 分配可能更稳定。 PREFER_MAC_RANDOMIZATION_NONE=1 # 日志保留天数。 # 超过这个天数的动作日志会被自动删除。 LOG_RETENTION_DAYS=7 # 是否在首次获取到有效 IPv6 时执行 DDNS 脚本。 # 1 = 执行 # 0 = 只记录,不执行 # # 建议为 1。 # 因为手机重启后,即使 IPv6 和上次一样,也可能需要重新同步一次 DDNS。 DDNS_ON_FIRST_IP=1 # DDNS 脚本路径。 # 当检测到有效 IPv6 发生变化时,会执行这个脚本。 # # 执行方式: # sh /data/local/ipv6-ddns/ipv6-ddns.sh 当前IPv6地址 # # 同时也会传递环境变量: # CURRENT_IPV6 # VALID_IPV6 # WIFI_SSID DDNS_SCRIPT="/data/local/ipv6-ddns/ipv6-ddns.sh" # IPv6 历史记录最多保留多少行。 # 这个不是主日志,只是专门记录每次有效 IPv6 变化。 IPV6_HISTORY_MAX_LINES=500 # ============================================================ # 二、Wi-Fi 列表 # ============================================================ # # 格式: # WiFi名称|WiFi密码|加密方式 # # 加密方式: # WPA2 写 WPA2 即可,脚本内部会自动转成 cmd wifi 需要的 wpa2。 # 如果是开放网络,可以写 open。 # # 说明: # 1. 每一行代表一个 Wi-Fi。 # 2. 按照从上到下的顺序优先连接。 # 3. 第一个连接失败,才会尝试下一个。 # 4. 不管系统是否保存过 Wi-Fi,都会用这里写死的名称和密码主动连接。 # WIFI_LIST=' CMCC-Semmering|Semmering|WPA2 ' # ============================================================ # 三、路径配置 # ============================================================ SELF="$0" case "$SELF" in /*) SELF_PATH="$SELF" ;; *) SELF_PATH="$(pwd)/$SELF" ;; esac SCRIPT_DIR="$(cd "$(dirname "$SELF_PATH")" 2>/dev/null && pwd)" [ -n "$SCRIPT_DIR" ] || SCRIPT_DIR="$(pwd)" # 主日志。 # 最新动作在最上面。 LOG_FILE="$SCRIPT_DIR/wifi-keeper.log" # 临时目录。 # 用于临时存放单个动作日志,动作结束后再整体插入到 LOG_FILE 顶部。 # 这个目录不是业务数据,脚本停止后可以删除。 TEMP_DIR="$SCRIPT_DIR/wifi-keeper-temp" # 保存有效 IPv6 记录。 # 格式: # YYYY-MM-DD HH:mm:ss|IPv6地址|来源动作|previous=上一次IPv6 # # 说明: # 1. 只保留这一个 IPv6 记录文件,不再单独保存 last-valid-ipv6.txt。 # 2. 脚本会从这个文件尾部读取最新 IPv6,用于和本次 IPv6 做 diff。 # 3. 只有首次记录或者 IPv6 发生变化时,才会追加新记录。 IPV6_HISTORY_FILE="$SCRIPT_DIR/valid-ipv6-history.txt" mkdir -p "$TEMP_DIR" 2>/dev/null touch "$LOG_FILE" "$IPV6_HISTORY_FILE" 2>/dev/null if [ ! -w "$LOG_FILE" ]; then echo "ERROR: 无法写入日志文件:$LOG_FILE" echo "建议把脚本放到可写目录,例如:/data/local/phone-server/wifi-keeper.sh" exit 1 fi # ============================================================ # 四、全局变量 # ============================================================ WIFI_IFACE="wlan0" ACTION_FILE="" ACTION_NAME="" ACTION_SEQ=0 LAST_CONNECTED_SSID="" LAST_VALID_IPV6="" HEALTH_FAIL_REASON="" # ============================================================ # 五、基础工具函数 # ============================================================ now_ts() { date '+%Y-%m-%d %H:%M:%S' 2>/dev/null } now_epoch() { date '+%s' 2>/dev/null } print_line() { printf '%s\n' "$*" } get_wifi_iface() { IFACE="$(ip link 2>/dev/null \ | sed -n 's/^[0-9][0-9]*: \(wlan[0-9][^: ]*\).*/\1/p' \ | head -n 1 \ | sed 's/@.*//')" [ -n "$IFACE" ] || IFACE="wlan0" print_line "$IFACE" } normalize_security() { SEC="$(print_line "$1" | tr 'A-Z' 'a-z')" case "$SEC" in wap2|wpa-psk|psk) SEC="wpa2" ;; wpa2|wpa3|open|owe) ;; *) SEC="wpa2" ;; esac print_line "$SEC" } # ============================================================ # 六、日志函数 # ============================================================ start_action() { ACTION_NAME="$1" ACTION_SEQ=$((ACTION_SEQ + 1)) TS="$(now_ts)" EPOCH="$(now_epoch)" [ -n "$EPOCH" ] || EPOCH="0" ACTION_FILE="$TEMP_DIR/action.$$.${ACTION_SEQ}.log" { print_line "##### ACTION_BLOCK_BEGIN #####" print_line "============================================================" print_line "===== $TS | $ACTION_NAME | START =====" print_line "============================================================" print_line "LOG_EPOCH: $EPOCH" } > "$ACTION_FILE" } log_detail() { TS="$(now_ts)" if [ -n "$ACTION_FILE" ]; then print_line "[$TS] $*" >> "$ACTION_FILE" else print_line "[$TS] $*" >> "$LOG_FILE" fi } log_multiline() { PREFIX="$1" CONTENT="$2" if [ -n "$CONTENT" ]; then print_line "$CONTENT" | while IFS= read -r LINE || [ -n "$LINE" ]; do log_detail "$PREFIX$LINE" done else log_detail "${PREFIX}<empty>" fi } prune_log_by_days() { [ "$LOG_RETENTION_DAYS" -gt 0 ] 2>/dev/null || return 0 NOW="$(now_epoch)" [ -n "$NOW" ] || return 0 CUTOFF=$((NOW - LOG_RETENTION_DAYS * 86400)) PRUNED="$TEMP_DIR/pruned.$$.log" awk -v cutoff="$CUTOFF" ' BEGIN { inside = 0 block = "" keep = 1 } $0 == "##### ACTION_BLOCK_BEGIN #####" { if (inside == 1 && keep == 1) { printf "%s", block } inside = 1 block = $0 "\n" keep = 1 next } inside == 1 { block = block $0 "\n" if ($1 == "LOG_EPOCH:") { if (($2 + 0) < cutoff) { keep = 0 } } if ($0 == "##### ACTION_BLOCK_END #####") { if (keep == 1) { printf "%s", block } inside = 0 block = "" keep = 1 } next } { print } END { if (inside == 1 && keep == 1) { printf "%s", block } } ' "$LOG_FILE" > "$PRUNED" 2>/dev/null if [ -s "$PRUNED" ] || [ -f "$PRUNED" ]; then mv "$PRUNED" "$LOG_FILE" 2>/dev/null else rm -f "$PRUNED" 2>/dev/null fi } end_action() { RESULT="$1" TS="$(now_ts)" { print_line "[$TS] RESULT: $RESULT" print_line "============================================================" print_line "===== $TS | $ACTION_NAME | END: $RESULT =====" print_line "============================================================" print_line "##### ACTION_BLOCK_END #####" print_line "" } >> "$ACTION_FILE" MERGED="$TEMP_DIR/merged.$$.log" cat "$ACTION_FILE" "$LOG_FILE" > "$MERGED" 2>/dev/null && mv "$MERGED" "$LOG_FILE" rm -f "$ACTION_FILE" 2>/dev/null ACTION_FILE="" ACTION_NAME="" prune_log_by_days } run_cmd_args() { SHOW="$1" shift log_detail "CMD : $SHOW" OUT="$("$@" 2>&1)" RC="$?" log_multiline "OUT : " "$OUT" log_detail "RC : $RC" return "$RC" } # ============================================================ # 七、Wi-Fi 状态判断 # ============================================================ is_wifi_enabled() { STATUS="$(cmd wifi status 2>/dev/null)" print_line "$STATUS" | grep -qi "Wifi is disabled" && return 1 print_line "$STATUS" | grep -qi "Wi-Fi is disabled" && return 1 print_line "$STATUS" | grep -qi "Wifi is enabled" && return 0 print_line "$STATUS" | grep -qi "Wi-Fi is enabled" && return 0 print_line "$STATUS" | grep -qi "Wifi is connected" && return 0 print_line "$STATUS" | grep -qi "Wi-Fi is connected" && return 0 return 1 } is_connected_to_ssid() { TARGET_SSID="$1" STATUS="$(cmd wifi status 2>/dev/null)" print_line "$STATUS" | grep -qi "Wifi is disabled" && return 1 print_line "$STATUS" | grep -qi "Wi-Fi is disabled" && return 1 if print_line "$STATUS" | grep -qi "Wifi is connected" \ && print_line "$STATUS" | grep -F "\"$TARGET_SSID\"" >/dev/null 2>&1; then return 0 fi if print_line "$STATUS" | grep -qi "Wi-Fi is connected" \ && print_line "$STATUS" | grep -F "\"$TARGET_SSID\"" >/dev/null 2>&1; then return 0 fi if command -v iw >/dev/null 2>&1; then IW_STATUS="$(iw dev "$WIFI_IFACE" link 2>/dev/null)" print_line "$IW_STATUS" | grep -q "^Connected to " || return 1 print_line "$IW_STATUS" | grep -F "SSID: $TARGET_SSID" >/dev/null 2>&1 && return 0 fi return 1 } get_connected_ssid() { STATUS="$(cmd wifi status 2>/dev/null)" SSID_NOW="$(print_line "$STATUS" | sed -n 's/.*connected to "\([^"]*\)".*/\1/p' | head -n 1)" if [ -n "$SSID_NOW" ]; then print_line "$SSID_NOW" return 0 fi if command -v iw >/dev/null 2>&1; then iw dev "$WIFI_IFACE" link 2>/dev/null | sed -n 's/^[[:space:]]*SSID: //p' | head -n 1 return 0 fi print_line "" } is_connected_to_any_configured_wifi() { LIST_FILE="$TEMP_DIR/wifi-list-any.$$.txt" printf '%s\n' "$WIFI_LIST" > "$LIST_FILE" while IFS='|' read -r SSID PASS SEC EXTRA || [ -n "$SSID" ]; do [ -z "$SSID" ] && continue case "$SSID" in \#*) continue ;; esac if is_connected_to_ssid "$SSID"; then rm -f "$LIST_FILE" 2>/dev/null return 0 fi done < "$LIST_FILE" rm -f "$LIST_FILE" 2>/dev/null return 1 } # ============================================================ # 八、Wi-Fi 操作函数 # ============================================================ log_status() { log_detail "---------- 当前 Wi-Fi / IP 状态 ----------" run_cmd_args "cmd wifi status" cmd wifi status if command -v iw >/dev/null 2>&1; then run_cmd_args "iw dev $WIFI_IFACE link" iw dev "$WIFI_IFACE" link else log_detail "INFO : 当前系统没有 iw 命令,跳过 iw dev link" fi run_cmd_args "ip addr show dev $WIFI_IFACE" ip addr show dev "$WIFI_IFACE" run_cmd_args "ip -6 addr show dev $WIFI_IFACE" ip -6 addr show dev "$WIFI_IFACE" log_detail "---------- 当前状态结束 ----------" } wait_wifi_enabled() { log_detail "INFO : 等待 Wi-Fi 开启,最多 ${WAIT_WIFI_ON_SECONDS}s" ELAPSED=0 while [ "$ELAPSED" -le "$WAIT_WIFI_ON_SECONDS" ]; do if is_wifi_enabled; then log_detail "OK : Wi-Fi 已开启" return 0 fi sleep "$CHECK_INTERVAL" ELAPSED=$((ELAPSED + CHECK_INTERVAL)) log_detail "WAIT : Wi-Fi 尚未确认开启,已等待 ${ELAPSED}s" done log_detail "ERROR: Wi-Fi 开启超时" return 1 } ensure_wifi_on() { if is_wifi_enabled; then log_detail "OK : Wi-Fi 当前已经开启,不重复打开" return 0 fi log_detail "INFO : Wi-Fi 当前关闭,开始打开" if command -v svc >/dev/null 2>&1; then run_cmd_args "svc wifi enable" svc wifi enable else log_detail "WARN : 当前系统没有 svc 命令,跳过 svc wifi enable" fi run_cmd_args "cmd wifi set-wifi-enabled enabled" cmd wifi set-wifi-enabled enabled wait_wifi_enabled } scan_wifi() { log_detail "INFO : 开始扫描周围 Wi-Fi" run_cmd_args "cmd wifi start-scan" cmd wifi start-scan log_detail "INFO : 等待扫描结果刷新 ${WAIT_SCAN_SECONDS}s" sleep "$WAIT_SCAN_SECONDS" SCAN_RESULT="$(cmd wifi list-scan-results 2>&1)" log_detail "CMD : cmd wifi list-scan-results" log_multiline "SCAN : " "$SCAN_RESULT" LIST_FILE="$TEMP_DIR/wifi-list-scan.$$.txt" printf '%s\n' "$WIFI_LIST" > "$LIST_FILE" while IFS='|' read -r SSID PASS SEC EXTRA || [ -n "$SSID" ]; do [ -z "$SSID" ] && continue case "$SSID" in \#*) continue ;; esac if print_line "$SCAN_RESULT" | grep -F "$SSID" >/dev/null 2>&1; then log_detail "OK : 扫描结果中发现配置 Wi-Fi:$SSID" else log_detail "WARN : 扫描结果中没有发现配置 Wi-Fi:$SSID;仍会继续尝试连接" fi done < "$LIST_FILE" rm -f "$LIST_FILE" 2>/dev/null } wait_connected_to_ssid() { TARGET_SSID="$1" log_detail "INFO : 等待连接到 Wi-Fi:$TARGET_SSID,最多 ${WAIT_CONNECT_SECONDS}s" ELAPSED=0 while [ "$ELAPSED" -le "$WAIT_CONNECT_SECONDS" ]; do if is_connected_to_ssid "$TARGET_SSID"; then LAST_CONNECTED_SSID="$TARGET_SSID" log_detail "OK : 已连接到目标 Wi-Fi:$TARGET_SSID" return 0 fi sleep "$CHECK_INTERVAL" ELAPSED=$((ELAPSED + CHECK_INTERVAL)) log_detail "WAIT : 尚未连接到 $TARGET_SSID,已等待 ${ELAPSED}s" done log_detail "ERROR: 连接 $TARGET_SSID 超时" return 1 } connect_one_wifi() { TARGET_SSID="$1" TARGET_PASS="$2" TARGET_SEC="$(normalize_security "$3")" log_detail "------------------------------------------------------------" log_detail "INFO : 尝试连接 Wi-Fi" log_detail "SSID : $TARGET_SSID" log_detail "SEC : $TARGET_SEC" log_detail "NOTE : 不管系统是否保存过该 Wi-Fi,都会使用 SSID + 密码主动连接" if [ "$TARGET_SEC" = "open" ] || [ "$TARGET_SEC" = "owe" ]; then if [ "$PREFER_MAC_RANDOMIZATION_NONE" = "1" ]; then log_detail "CMD : cmd wifi connect-network \"$TARGET_SSID\" $TARGET_SEC -r none" OUT="$(cmd wifi connect-network "$TARGET_SSID" "$TARGET_SEC" -r none 2>&1)" RC="$?" log_multiline "OUT : " "$OUT" log_detail "RC : $RC" if [ "$RC" != "0" ]; then log_detail "WARN : 带 -r none 的 open/owe 连接失败,回退到不带 -r" run_cmd_args "cmd wifi connect-network \"$TARGET_SSID\" $TARGET_SEC" \ cmd wifi connect-network "$TARGET_SSID" "$TARGET_SEC" fi else run_cmd_args "cmd wifi connect-network \"$TARGET_SSID\" $TARGET_SEC" \ cmd wifi connect-network "$TARGET_SSID" "$TARGET_SEC" fi else if [ "$PREFER_MAC_RANDOMIZATION_NONE" = "1" ]; then log_detail "CMD : cmd wifi connect-network \"$TARGET_SSID\" $TARGET_SEC \"******\" -r none" OUT="$(cmd wifi connect-network "$TARGET_SSID" "$TARGET_SEC" "$TARGET_PASS" -r none 2>&1)" RC="$?" log_multiline "OUT : " "$OUT" log_detail "RC : $RC" if [ "$RC" != "0" ]; then log_detail "WARN : 带 -r none 的连接失败,回退到不带 -r" log_detail "CMD : cmd wifi connect-network \"$TARGET_SSID\" $TARGET_SEC \"******\"" OUT="$(cmd wifi connect-network "$TARGET_SSID" "$TARGET_SEC" "$TARGET_PASS" 2>&1)" RC="$?" log_multiline "OUT : " "$OUT" log_detail "RC : $RC" fi else log_detail "CMD : cmd wifi connect-network \"$TARGET_SSID\" $TARGET_SEC \"******\"" OUT="$(cmd wifi connect-network "$TARGET_SSID" "$TARGET_SEC" "$TARGET_PASS" 2>&1)" RC="$?" log_multiline "OUT : " "$OUT" log_detail "RC : $RC" fi fi wait_connected_to_ssid "$TARGET_SSID" } connect_by_wifi_list() { LIST_FILE="$TEMP_DIR/wifi-list-connect.$$.txt" printf '%s\n' "$WIFI_LIST" > "$LIST_FILE" INDEX=0 while IFS='|' read -r SSID PASS SEC EXTRA || [ -n "$SSID" ]; do [ -z "$SSID" ] && continue case "$SSID" in \#*) continue ;; esac INDEX=$((INDEX + 1)) log_detail "INFO : 开始尝试第 ${INDEX} 个 Wi-Fi:$SSID" if connect_one_wifi "$SSID" "$PASS" "$SEC"; then log_detail "OK : 第 ${INDEX} 个 Wi-Fi 连接成功:$SSID" rm -f "$LIST_FILE" 2>/dev/null return 0 fi log_detail "WARN : 第 ${INDEX} 个 Wi-Fi 连接失败:$SSID,准备尝试下一个" done < "$LIST_FILE" rm -f "$LIST_FILE" 2>/dev/null log_detail "ERROR: Wi-Fi 列表全部尝试失败" return 1 } # ============================================================ # 九、IPv6 相关函数 # ============================================================ get_valid_ipv6() { ip -6 addr show dev "$WIFI_IFACE" scope global 2>/dev/null | while IFS= read -r ADDR_LINE || [ -n "$ADDR_LINE" ]; do # 只处理 inet6 地址行 set -- $ADDR_LINE [ "$1" = "inet6" ] || continue ADDR_CIDR="$2" IPV6_ADDR="${ADDR_CIDR%%/*}" # 读取下一行 lifetime 信息 IFS= read -r LFT_LINE || LFT_LINE="" # 规则 1:必须是 2 开头 case "$IPV6_ADDR" in 2*) ;; *) continue ;; esac # 规则 2:必须是 mngtmpaddr echo "$ADDR_LINE" | grep -q "mngtmpaddr" || continue # 规则 3:不能是 temporary echo "$ADDR_LINE" | grep -q "temporary" && continue # 规则 4:preferred_lft 不能是 0sec echo "$LFT_LINE" | grep -q "preferred_lft 0sec" && continue echo "$IPV6_ADDR" exit 0 done | head -n 1 } log_ipv6_detail() { IPV6_ALL="$(ip -6 addr show dev "$WIFI_IFACE" 2>&1)" IPV6_GLOBAL="$(ip -6 addr show dev "$WIFI_IFACE" scope global 2>&1)" VALID_IPV6="$(get_valid_ipv6)" log_detail "---------- IPv6 地址详情 ----------" log_multiline "IPv6-ALL : " "$IPV6_ALL" log_multiline "IPv6-GLOBAL : " "$IPV6_GLOBAL" if [ -n "$VALID_IPV6" ]; then log_detail "IPv6-VALID : $VALID_IPV6" log_detail "IPv6-RULE : 命中规则:2 开头 + 非 temporary + mngtmpaddr + preferred_lft 非 0sec" else log_detail "IPv6-VALID : <none>" log_detail "IPv6-RULE : 未找到 2 开头 + 非 temporary + mngtmpaddr 的长期 IPv6" print_line "$IPV6_GLOBAL" | grep -q "temporary" && \ log_detail "IPv6-INFO : 存在 temporary IPv6,但不作为服务器长期地址" print_line "$IPV6_GLOBAL" | grep -E "inet6 f" >/dev/null 2>&1 && \ log_detail "IPv6-INFO : 存在 f 开头 IPv6,不符合要求" print_line "$IPV6_GLOBAL" | grep -q "inet6" || \ log_detail "IPv6-INFO : 当前没有 scope global IPv6" fi log_detail "---------- IPv6 地址详情结束 ----------" } wait_valid_ipv6() { log_detail "INFO : 等待获取有效 IPv6,最多 ${WAIT_IPV6_SECONDS}s" ELAPSED=0 while [ "$ELAPSED" -le "$WAIT_IPV6_SECONDS" ]; do VALID_IPV6="$(get_valid_ipv6)" if [ -n "$VALID_IPV6" ]; then LAST_VALID_IPV6="$VALID_IPV6" log_detail "OK : 已获取有效 IPv6:$VALID_IPV6" return 0 fi sleep "$CHECK_INTERVAL" ELAPSED=$((ELAPSED + CHECK_INTERVAL)) log_detail "WAIT : 尚未获取有效 IPv6,已等待 ${ELAPSED}s" done log_detail "ERROR: 获取有效 IPv6 超时" return 1 } trim_ipv6_history() { [ -f "$IPV6_HISTORY_FILE" ] || return 0 TMP_HISTORY="$TEMP_DIR/ipv6-history.$$.tmp" tail -n "$IPV6_HISTORY_MAX_LINES" "$IPV6_HISTORY_FILE" > "$TMP_HISTORY" 2>/dev/null \ && mv "$TMP_HISTORY" "$IPV6_HISTORY_FILE" 2>/dev/null rm -f "$TMP_HISTORY" 2>/dev/null } ipv6_change_action() { CURRENT_IPV6="$1" SOURCE_ACTION="$2" [ -n "$CURRENT_IPV6" ] || return 0 PREVIOUS_LINE="" PREVIOUS_IPV6="" # 只从 valid-ipv6-history.txt 的最后一行读取上一次 IPv6, # 不再使用独立的 last-valid-ipv6.txt 文件。 if [ -f "$IPV6_HISTORY_FILE" ]; then PREVIOUS_LINE="$(tail -n 1 "$IPV6_HISTORY_FILE" 2>/dev/null)" PREVIOUS_IPV6="$(print_line "$PREVIOUS_LINE" | awk -F'|' '{print $2}')" fi start_action "IPv6地址变更检查" TS="$(now_ts)" CONNECTED_SSID="$(get_connected_ssid)" log_detail "来源动作: $SOURCE_ACTION" log_detail "当前连接 Wi-Fi: ${CONNECTED_SSID:-<none>}" log_detail "有效 IPv6 记录文件: $IPV6_HISTORY_FILE" log_detail "上一条记录: ${PREVIOUS_LINE:-<none>}" log_detail "上一次 IPv6: ${PREVIOUS_IPV6:-<none>}" log_detail "本次 IPv6: $CURRENT_IPV6" if [ -n "$PREVIOUS_IPV6" ] && [ "$CURRENT_IPV6" = "$PREVIOUS_IPV6" ]; then log_detail "DIFF : IPv6 未变化:$CURRENT_IPV6" log_detail "SAVE : IPv6 未变化,不追加有效 IPv6 记录文件" log_detail "DDNS : IPv6 未变化,不执行 DDNS 脚本" end_action "unchanged" return 0 fi if [ -z "$PREVIOUS_IPV6" ]; then log_detail "DIFF : 这是首次记录有效 IPv6" if [ "$DDNS_ON_FIRST_IP" = "1" ]; then SHOULD_RUN_DDNS=1 log_detail "DDNS : DDNS_ON_FIRST_IP=1,首次获取 IPv6 也会执行 DDNS 脚本" else SHOULD_RUN_DDNS=0 log_detail "DDNS : DDNS_ON_FIRST_IP=0,首次获取 IPv6 只记录,不执行 DDNS 脚本" fi else SHOULD_RUN_DDNS=1 log_detail "DIFF : IPv6 已变化:$PREVIOUS_IPV6 -> $CURRENT_IPV6" fi if [ "$SHOULD_RUN_DDNS" != "1" ]; then print_line "$TS|$CURRENT_IPV6|$SOURCE_ACTION|previous=${PREVIOUS_IPV6:-none}|ddns=not_required" >> "$IPV6_HISTORY_FILE" 2>/dev/null log_detail "SAVE : DDNS 不需要执行,已追加有效 IPv6 记录文件:$IPV6_HISTORY_FILE" trim_ipv6_history end_action "done_without_ddns" return 0 fi if [ ! -f "$DDNS_SCRIPT" ]; then log_detail "DDNS : DDNS 脚本不存在,暂不执行:$DDNS_SCRIPT" log_detail "DDNS : 本次 IPv6 不会写入有效 IPv6 记录文件,等待下次健康检查继续重试" end_action "ddns_script_missing" return 1 fi log_detail "DDNS : 准备执行 DDNS 脚本" log_detail "CMD : CURRENT_IPV6=\"$CURRENT_IPV6\" VALID_IPV6=\"$CURRENT_IPV6\" WIFI_SSID=\"$CONNECTED_SSID\" sh \"$DDNS_SCRIPT\" \"$CURRENT_IPV6\"" DDNS_OUT="$( CURRENT_IPV6="$CURRENT_IPV6" \ VALID_IPV6="$CURRENT_IPV6" \ WIFI_SSID="$CONNECTED_SSID" \ sh "$DDNS_SCRIPT" "$CURRENT_IPV6" 2>&1 )" DDNS_RC="$?" log_multiline "DDNS-OUT : " "$DDNS_OUT" log_detail "DDNS-RC : $DDNS_RC" if [ "$DDNS_RC" = "0" ]; then log_detail "DDNS : DDNS 脚本执行成功" print_line "$TS|$CURRENT_IPV6|$SOURCE_ACTION|previous=${PREVIOUS_IPV6:-none}|ddns=success" >> "$IPV6_HISTORY_FILE" 2>/dev/null log_detail "SAVE : DDNS 成功后,已追加有效 IPv6 记录文件:$IPV6_HISTORY_FILE" trim_ipv6_history end_action "done" return 0 fi log_detail "DDNS : DDNS 脚本执行失败" log_detail "SAVE : DDNS 失败,本次 IPv6 不写入有效 IPv6 记录文件,下一轮会继续重试" end_action "ddns_failed" return 1 } # ============================================================ # 十、IPv6 健康检查 # ============================================================ check_ipv6_health() { HEALTH_FAIL_REASON="" CURRENT_SSID="$(get_connected_ssid)" log_detail "INFO : 当前连接 SSID:${CURRENT_SSID:-<none>}" if ! is_connected_to_any_configured_wifi; then HEALTH_FAIL_REASON="当前没有连接到脚本配置的 Wi-Fi" log_detail "ERROR: $HEALTH_FAIL_REASON" log_status log_ipv6_detail return 1 fi log_status log_ipv6_detail VALID_IPV6="$(get_valid_ipv6)" if [ -n "$VALID_IPV6" ]; then LAST_VALID_IPV6="$VALID_IPV6" log_detail "OK : IPv6 健康,有效 IPv6:$VALID_IPV6" return 0 fi HEALTH_FAIL_REASON="没有找到 2 开头、非 temporary、mngtmpaddr 的长期 IPv6 地址" log_detail "ERROR: IPv6 不健康:$HEALTH_FAIL_REASON" return 1 } # ============================================================ # 十一、恢复动作 # ============================================================ restart_wifi_switch() { start_action "Wi-Fi重启恢复" log_detail "INFO : 开始重启 Wi-Fi 开关" log_status if command -v svc >/dev/null 2>&1; then run_cmd_args "svc wifi disable" svc wifi disable else log_detail "WARN : 当前系统没有 svc 命令,跳过 svc wifi disable" fi run_cmd_args "cmd wifi set-wifi-enabled disabled" cmd wifi set-wifi-enabled disabled log_detail "INFO : Wi-Fi 关闭命令已发送,等待 ${WIFI_RESTART_OFF_SECONDS}s" sleep "$WIFI_RESTART_OFF_SECONDS" if command -v svc >/dev/null 2>&1; then run_cmd_args "svc wifi enable" svc wifi enable else log_detail "WARN : 当前系统没有 svc 命令,跳过 svc wifi enable" fi run_cmd_args "cmd wifi set-wifi-enabled enabled" cmd wifi set-wifi-enabled enabled if wait_wifi_enabled; then log_status end_action "success" return 0 fi log_status end_action "failed_wifi_not_enabled" return 1 } ensure_connection_and_ipv6() { REASON="$1" start_action "Wi-Fi连接保障" log_detail "触发原因: $REASON" log_detail "脚本目录: $SCRIPT_DIR" log_detail "主日志: $LOG_FILE" log_detail "临时目录: $TEMP_DIR" log_detail "有效 IPv6 记录文件: $IPV6_HISTORY_FILE" log_detail "Wi-Fi 网卡: $WIFI_IFACE" log_detail "Wi-Fi 列表:" log_multiline " - " "$WIFI_LIST" log_status if ! ensure_wifi_on; then log_status end_action "failed_wifi_not_enabled" return 1 fi scan_wifi if ! connect_by_wifi_list; then log_status end_action "failed_wifi_connect" return 1 fi log_status log_ipv6_detail if wait_valid_ipv6; then log_ipv6_detail CURRENT_VALID_IPV6="$LAST_VALID_IPV6" end_action "success" ipv6_change_action "$CURRENT_VALID_IPV6" "$REASON" return 0 fi log_ipv6_detail end_action "failed_ipv6_not_ready" return 1 } # ============================================================ # 十二、主流程 # ============================================================ # 清理上一次异常退出残留的临时文件。 rm -f "$TEMP_DIR"/action.*.log "$TEMP_DIR"/merged.*.log "$TEMP_DIR"/pruned.*.log 2>/dev/null WIFI_IFACE="$(get_wifi_iface)" start_action "脚本启动" log_detail "START: wifi-keeper" log_detail "PID : $$" log_detail "DIR : $SCRIPT_DIR" log_detail "LOG : $LOG_FILE" log_detail "IFACE: $WIFI_IFACE" log_detail "BOOT_DELAY_SECONDS: $BOOT_DELAY_SECONDS" log_detail "IPV6_HEALTH_INTERVAL: $IPV6_HEALTH_INTERVAL" log_detail "LOG_RETENTION_DAYS: $LOG_RETENTION_DAYS" if ! command -v cmd >/dev/null 2>&1; then log_detail "ERROR: 当前系统没有 cmd 命令,无法使用 cmd wifi" end_action "fatal_no_cmd" exit 1 fi run_cmd_args "id" id run_cmd_args "getprop ro.build.version.release" getprop ro.build.version.release run_cmd_args "getprop ro.build.version.sdk" getprop ro.build.version.sdk run_cmd_args "getprop ro.product.model" getprop ro.product.model run_cmd_args "getprop ro.product.device" getprop ro.product.device run_cmd_args "cmd wifi status" cmd wifi status log_detail "INFO : 开机后先等待 ${BOOT_DELAY_SECONDS}s" sleep "$BOOT_DELAY_SECONDS" log_detail "INFO : 开机等待结束,进入主流程" end_action "success" ensure_connection_and_ipv6 "开机初始化" while true; do sleep "$IPV6_HEALTH_INTERVAL" start_action "IPv6健康度检查" if check_ipv6_health; then CURRENT_VALID_IPV6="$LAST_VALID_IPV6" end_action "healthy" ipv6_change_action "$CURRENT_VALID_IPV6" "IPv6健康度检查" continue fi log_detail "处理策略: IPv6 不健康,准备重启 Wi-Fi 并重新连接" log_detail "失败原因: ${HEALTH_FAIL_REASON:-unknown}" end_action "unhealthy_need_recovery" restart_wifi_switch ensure_connection_and_ipv6 "IPv6健康度异常后的恢复连接" done [!note]重点说下wifi-keeper.sh 这一次wifi-keeper.sh相比较第三期,做出了极大的改变,可以说废弃了第三期的代码。因为我发现了一个重大的问题:在国产的系统中,控制wifi的命令,比如cmd wifi是无法使用的,根本不支持。只有类原生才支持。如果不支持cmd命令就无法做到这些事情: · 无法扫描wifi列表; · 无法查看Wi-Fi状态; · 无法通过wifi名称和Wi-Fi密码连接Wi-Fi(这个才是最致命的); 所以我把手里的MIUI 11刷成了 crDroid 。这里不推荐刷入 Pixel Experience ,因为 Pixel Experience 对cmd wifi的支持也有问题,当然,也可能是小米8SE的包原因,大家自行测试吧。 在wifi-keeper.sh这个脚本中, 大家要修改的地方是WIFI_LIST部分 ,这里需要把你家里的wifi名称和Wi-Fi密码和加密方式写到脚本中,这样不管是你手机重新开关机还是取消保存了wifi,还是开启了飞行模式,脚本都可以很好的自动连接上Wi-Fi。Wi-Fi是不会再断的,哪怕你手动断开wifi,wifi过10s都会自动连接上的。 大家不用担心脚本安全问题,因为脚本是公开透明的。 3.记得修改wifi-keeper.sh文件的权限,可以参考 上一期 如何修改权限,这里就不截图了 二、如何实现ipv6自动更新到DDNS,实现用域名访问 1. 自行去华为云购买服务器 。这里只拿华为云举例,原因开头已经说了。大家自行注册和购买域名。注册好域名后看下一步。 2. 配置AK和SK,后面脚本会用到 2.1 访问 统一身份认证服务IAM ,然后左边菜单找到 用户 ,在 用户 页面 右上角点击创建用户 2.2 如图操作,然后下一步 2.3 到这一步的时候,一定要点击确定下载密钥文件,因为密钥只能在这里下载一次,如果不小心点了取消,只能重新创建用户 3.进入 /data/local/ 文件夹,新建 ipv6-ddns 文件夹,然后进入 ipv6-ddns 文件夹再新建 ipv6-ddns.sh 文件;输入以下内容并保存: [!warning]提醒 记得务必把代码中的AK和SK替换成自己的。 文件同样记得修改权限。 图中域名填写时,小数点不能去掉 图中域名填写时,小数点不能去掉 图中域名填写时,小数点不能去掉 #!/system/bin/sh # # ipv6-ddns.sh # # 作用: # 接收 wifi-keeper.sh 传入的有效 IPv6,然后同步到华为云 DNS AAAA 记录。 # # 核心逻辑: # 1. 接收第一个参数作为新的 IPv6。 # 2. 查询华为云 DNS,获取 ZONE_ID。 # 3. 查询目标 AAAA 记录,读取华为云当前 IPv6。 # 4. 如果华为云当前 IPv6 与传入 IPv6 一致,则不更新。 # 5. 如果不一致,则 PUT 更新记录。 # 6. 每次执行作为一个动作写入日志,最新动作在日志最上方,动作内部细节正序。 # # 注意: # 这个脚本是给 wifi-keeper.sh 调用的: # sh /data/local/ipv6-ddns/ipv6-ddns.sh 2409:xxxx:xxxx::xxxx # # 因为 wifi-keeper.sh 使用 sh 调用 DDNS 脚本,所以本脚本尽量使用 /system/bin/sh 兼容写法, # 不使用 bash 数组。 # # ============================================================ # 一、Termux 工具路径 # ============================================================ # Termux 的安装目录。 # 因为 Magisk service.d 启动的脚本不一定有 Termux 的 PATH, # 所以这里主动把 Termux bin 目录加进去。 PREFIX="/data/data/com.termux/files/usr" export PATH="$PREFIX/bin:/system/bin:/system/xbin:/vendor/bin:$PATH" export LD_LIBRARY_PATH="$PREFIX/lib:${LD_LIBRARY_PATH:-}" # 华为云签名需要 openssl 和 xxd。 # HTTP 请求需要 curl。 OPENSSL_BIN="$PREFIX/bin/openssl" XXD_BIN="$PREFIX/bin/xxd" CURL_BIN="$PREFIX/bin/curl" # 常用命令。 DATE_BIN="/system/bin/date" [ -x "$PREFIX/bin/date" ] && DATE_BIN="$PREFIX/bin/date" MKDIR_BIN="/system/bin/mkdir" [ -x "$PREFIX/bin/mkdir" ] && MKDIR_BIN="$PREFIX/bin/mkdir" CAT_BIN="/system/bin/cat" [ -x "$PREFIX/bin/cat" ] && CAT_BIN="$PREFIX/bin/cat" MV_BIN="/system/bin/mv" [ -x "$PREFIX/bin/mv" ] && MV_BIN="$PREFIX/bin/mv" RM_BIN="/system/bin/rm" [ -x "$PREFIX/bin/rm" ] && RM_BIN="$PREFIX/bin/rm" AWK_BIN="/system/bin/awk" [ -x "$PREFIX/bin/awk" ] && AWK_BIN="$PREFIX/bin/awk" SED_BIN="/system/bin/sed" [ -x "$PREFIX/bin/sed" ] && SED_BIN="$PREFIX/bin/sed" GREP_BIN="/system/bin/grep" [ -x "$PREFIX/bin/grep" ] && GREP_BIN="$PREFIX/bin/grep" TAIL_BIN="/system/bin/tail" [ -x "$PREFIX/bin/tail" ] && TAIL_BIN="$PREFIX/bin/tail" # ============================================================ # 二、用户配置区 # ============================================================ # 华为云访问密钥。 # 为避免把你的 AK/SK 在聊天记录里再次明文展示, # 请把你参考脚本里的 AK 和 SK 填到下面两行。 # # 也可以通过环境变量传入: # HUAWEICLOUD_AK="你的AK" HUAWEICLOUD_SK="你的SK" sh ipv6-ddns.sh 2409:... AK="${填入AK}" SK="${填入SK}" # 华为云 DNS API Host。 HOST="dns.myhuaweicloud.com" # 主域名 Zone 名称。 # 注意:华为云 DNS API 返回的 zone name 通常带结尾点。 ZONE_NAME="自己的域名." # 后面的点不能去掉 # 需要同步的 AAAA 记录列表。 # 一行一个,必须带结尾点。 # 示例: # xxxx.top. # www.xxxx.top. # ssh.xxxx.top. RECORD_NAMES=' xxxx.top. ' # 记录类型,IPv6 固定使用 AAAA。 RECORD_TYPE="AAAA" # DNS TTL。 # 这里沿用你参考脚本里的 TTL=1。 TTL="1" # 调试开关: # 0 = 常规日志 # 1 = 写入更多签名和响应调试信息 # 注意:即使 DEBUG=1,也不会打印 SK。 DEBUG="0" # 日志保留天数。 # 超过这个天数的动作日志会被清理。 LOG_RETENTION_DAYS=7 # HTTP 请求最大重试次数。 # 主要用于处理刚开机、Wi-Fi 刚恢复、IPv6 刚出现时,网络栈还不稳定导致的 curl 56、HTTP 000、5xx 等临时错误。 HTTP_MAX_RETRIES=3 # HTTP 重试基础等待秒数。 # 第 1 次失败后等待 3 秒,第 2 次失败后等待 6 秒。 HTTP_RETRY_BASE_SLEEP_SECONDS=3 # ============================================================ # 三、路径配置 # ============================================================ BASE_DIR="/data/local/ipv6-ddns" LOG_FILE="$BASE_DIR/ipv6-ddns.log" RUNTIME_DIR="$BASE_DIR/.runtime" RUN_LOG="$RUNTIME_DIR/ipv6-ddns.run.$$.log" # ============================================================ # 四、运行参数 # ============================================================ NEW_IPV6="$1" # ============================================================ # 五、全局变量 # ============================================================ RUN_START_TIME="" RUN_START_EPOCH="" SCRIPT_NAME="ipv6-ddns.sh" FINAL_EXIT_CODE=0 LAST_HTTP_STATUS="" LAST_RESPONSE_BODY="" LAST_CURL_RC="" TOTAL_COUNT=0 UPDATED_COUNT=0 SKIPPED_COUNT=0 FAILED_COUNT=0 # ============================================================ # 六、基础函数 # ============================================================ now_ts() { "$DATE_BIN" '+%Y-%m-%d %H:%M:%S' 2>/dev/null } now_epoch() { "$DATE_BIN" '+%s' 2>/dev/null } print_line() { printf '%s\n' "$*" } log_line() { LEVEL="$1" shift TS="$(now_ts)" [ -n "$TS" ] || TS="unknown-time" printf '[%s] %-5s %s\n' "$TS" "$LEVEL" "$*" >> "$RUN_LOG" } log_info() { log_line "INFO" "$*" } log_warn() { log_line "WARN" "$*" } log_error() { log_line "ERROR" "$*" } log_debug() { if [ "$DEBUG" = "1" ]; then log_line "DEBUG" "$*" fi } log_multiline() { PREFIX="$1" CONTENT="$2" if [ -n "$CONTENT" ]; then print_line "$CONTENT" | while IFS= read -r L || [ -n "$L" ]; do log_info "$PREFIX$L" done else log_info "${PREFIX}<empty>" fi } is_placeholder_secret() { case "$1" in *请把参考脚本中的AK填到这里*|*请把参考脚本中的SK填到这里*|"") return 0 ;; *) return 1 ;; esac } mask_text() { VALUE="$1" LEN=${#VALUE} if [ "$LEN" -le 8 ]; then printf '******' else HEAD=$(printf '%s' "$VALUE" | cut -c 1-4) TAIL=$(printf '%s' "$VALUE" | cut -c "$((LEN - 3))"-"$LEN") printf '%s******%s' "$HEAD" "$TAIL" fi } # ============================================================ # 七、日志动作:插入主日志顶部 + 清理旧日志 # ============================================================ prune_log_by_days() { [ "$LOG_RETENTION_DAYS" -gt 0 ] 2>/dev/null || return 0 [ -f "$LOG_FILE" ] || return 0 NOW="$(now_epoch)" [ -n "$NOW" ] || return 0 CUTOFF=$((NOW - LOG_RETENTION_DAYS * 86400)) PRUNED="$RUNTIME_DIR/ipv6-ddns.pruned.$$.log" "$AWK_BIN" -v cutoff="$CUTOFF" ' BEGIN { inside = 0 block = "" keep = 1 } $0 == "##### ACTION_BLOCK_BEGIN #####" { if (inside == 1 && keep == 1) { printf "%s", block } inside = 1 block = $0 "\n" keep = 1 next } inside == 1 { block = block $0 "\n" if ($1 == "LOG_EPOCH:") { if (($2 + 0) < cutoff) { keep = 0 } } if ($0 == "##### ACTION_BLOCK_END #####") { if (keep == 1) { printf "%s", block } inside = 0 block = "" keep = 1 } next } { print } END { if (inside == 1 && keep == 1) { printf "%s", block } } ' "$LOG_FILE" > "$PRUNED" 2>/dev/null if [ -f "$PRUNED" ]; then "$MV_BIN" "$PRUNED" "$LOG_FILE" 2>/dev/null fi } prepend_run_log_to_main_log() { TMP_LOG="$RUNTIME_DIR/ipv6-ddns.merged.$$.log" END_TIME="$(now_ts)" { print_line "##### ACTION_BLOCK_BEGIN #####" print_line "============================================================" print_line "===== $RUN_START_TIME | IPv6-DDNS同步 | START =====" print_line "============================================================" print_line "LOG_EPOCH: $RUN_START_EPOCH" print_line "[$RUN_START_TIME] ACTION : IPv6-DDNS同步" print_line "[$RUN_START_TIME] SCRIPT : $0" print_line "[$RUN_START_TIME] LOG : $LOG_FILE" print_line "[$RUN_START_TIME] ZONE : $ZONE_NAME" print_line "[$RUN_START_TIME] TYPE : $RECORD_TYPE" print_line "[$RUN_START_TIME] TTL : $TTL" print_line "[$RUN_START_TIME] INPUT : $NEW_IPV6" print_line "[$RUN_START_TIME] RESULT : exit_code=$FINAL_EXIT_CODE total=$TOTAL_COUNT updated=$UPDATED_COUNT skipped=$SKIPPED_COUNT failed=$FAILED_COUNT" print_line "------------------------------------------------------------" [ -f "$RUN_LOG" ] && "$CAT_BIN" "$RUN_LOG" print_line "------------------------------------------------------------" print_line "[$END_TIME] RESULT: exit_code=$FINAL_EXIT_CODE total=$TOTAL_COUNT updated=$UPDATED_COUNT skipped=$SKIPPED_COUNT failed=$FAILED_COUNT" print_line "============================================================" print_line "===== $END_TIME | IPv6-DDNS同步 | END =====" print_line "============================================================" print_line "##### ACTION_BLOCK_END #####" print_line "" [ -f "$LOG_FILE" ] && "$CAT_BIN" "$LOG_FILE" } > "$TMP_LOG" "$MV_BIN" "$TMP_LOG" "$LOG_FILE" 2>/dev/null prune_log_by_days } finish() { EXIT_CODE=$? FINAL_EXIT_CODE="$EXIT_CODE" if [ -f "$RUN_LOG" ]; then prepend_run_log_to_main_log "$RM_BIN" -f "$RUN_LOG" 2>/dev/null fi exit "$EXIT_CODE" } # ============================================================ # 八、签名函数:只沿用参考脚本里的 AK/SK 使用方式 # ============================================================ sha256_hex() { DATA="$1" printf '%s' "$DATA" | "$OPENSSL_BIN" dgst -sha256 -binary | "$XXD_BIN" -p -c 256 } hmac_sha256_hex() { KEY="$1" DATA="$2" printf '%s' "$DATA" | "$OPENSSL_BIN" dgst -sha256 -mac HMAC -macopt "key:${KEY}" -binary | "$XXD_BIN" -p -c 256 } is_retryable_http_status() { case "$1" in 000|408|429|500|502|503|504) return 0 ;; *) return 1 ;; esac } signed_request() { METHOD="$1" REQUEST_PATH="$2" CANONICAL_URI="$3" CANONICAL_QUERY="$4" BODY="$5" CONTENT_TYPE="$6" URL="https://${HOST}${REQUEST_PATH}" if [ -n "$CANONICAL_QUERY" ]; then URL="${URL}?${CANONICAL_QUERY}" fi ATTEMPT=1 [ "$HTTP_MAX_RETRIES" -gt 0 ] 2>/dev/null || HTTP_MAX_RETRIES=1 while [ "$ATTEMPT" -le "$HTTP_MAX_RETRIES" ]; do X_SDK_DATE="$(TZ=UTC "$DATE_BIN" +%Y%m%dT%H%M%SZ 2>/dev/null)" PAYLOAD_HASH="$(sha256_hex "$BODY")" if [ -n "$CONTENT_TYPE" ]; then CANONICAL_HEADERS="content-type:${CONTENT_TYPE} host:${HOST} x-sdk-date:${X_SDK_DATE} " SIGNED_HEADERS="content-type;host;x-sdk-date" else CANONICAL_HEADERS="host:${HOST} x-sdk-date:${X_SDK_DATE} " SIGNED_HEADERS="host;x-sdk-date" fi CANONICAL_REQUEST="${METHOD} ${CANONICAL_URI} ${CANONICAL_QUERY} ${CANONICAL_HEADERS} ${SIGNED_HEADERS} ${PAYLOAD_HASH}" HASHED_CANONICAL_REQUEST="$(sha256_hex "$CANONICAL_REQUEST")" STRING_TO_SIGN="SDK-HMAC-SHA256 ${X_SDK_DATE} ${HASHED_CANONICAL_REQUEST}" SIGNATURE="$(hmac_sha256_hex "$SK" "$STRING_TO_SIGN")" AUTHORIZATION="SDK-HMAC-SHA256 Access=${AK}, SignedHeaders=${SIGNED_HEADERS}, Signature=${SIGNATURE}" log_info "HTTP 请求开始: attempt=${ATTEMPT}/${HTTP_MAX_RETRIES} method=$METHOD url=$URL" log_debug "X-Sdk-Date=$X_SDK_DATE" log_debug "SignedHeaders=$SIGNED_HEADERS" log_debug "PayloadHash=$PAYLOAD_HASH" log_debug "CanonicalRequest=$(printf '%s\n' "$CANONICAL_REQUEST" | "$SED_BIN" ':a;N;$!ba;s/\n/|/g')" [ -n "$BODY" ] && log_debug "RequestBody=$BODY" if [ -n "$CONTENT_TYPE" ]; then RESPONSE="$( "$CURL_BIN" -sS \ --connect-timeout 10 \ --max-time 30 \ -w '\n__HTTP_STATUS__:%{http_code}' \ -X "$METHOD" \ "$URL" \ -H "Host: ${HOST}" \ -H "X-Sdk-Date: ${X_SDK_DATE}" \ -H "Authorization: ${AUTHORIZATION}" \ -H "Content-Type: ${CONTENT_TYPE}" \ --data "$BODY" 2>&1 )" CURL_RC="$?" else RESPONSE="$( "$CURL_BIN" -sS \ --connect-timeout 10 \ --max-time 30 \ -w '\n__HTTP_STATUS__:%{http_code}' \ -X "$METHOD" \ "$URL" \ -H "Host: ${HOST}" \ -H "X-Sdk-Date: ${X_SDK_DATE}" \ -H "Authorization: ${AUTHORIZATION}" 2>&1 )" CURL_RC="$?" fi LAST_CURL_RC="$CURL_RC" if [ "$CURL_RC" != "0" ]; then LAST_HTTP_STATUS="CURL_ERROR" LAST_RESPONSE_BODY="$RESPONSE" log_error "HTTP 请求失败: attempt=${ATTEMPT}/${HTTP_MAX_RETRIES} curl_rc=$CURL_RC method=$METHOD url=$URL" log_multiline "curl输出: " "$RESPONSE" if [ "$ATTEMPT" -lt "$HTTP_MAX_RETRIES" ]; then SLEEP_SECONDS=$((HTTP_RETRY_BASE_SLEEP_SECONDS * ATTEMPT)) log_warn "HTTP 请求将重试: ${SLEEP_SECONDS}s 后进行第 $((ATTEMPT + 1)) 次尝试" sleep "$SLEEP_SECONDS" ATTEMPT=$((ATTEMPT + 1)) continue fi log_error "HTTP 请求失败且已达到最大重试次数: $HTTP_MAX_RETRIES" return 1 fi LAST_HTTP_STATUS="$(printf '%s' "$RESPONSE" | "$SED_BIN" -n 's/^__HTTP_STATUS__://p' | "$TAIL_BIN" -n 1)" LAST_RESPONSE_BODY="$(printf '%s' "$RESPONSE" | "$SED_BIN" '/^__HTTP_STATUS__:/d')" [ -n "$LAST_HTTP_STATUS" ] || LAST_HTTP_STATUS="000" log_info "HTTP 请求结束: attempt=${ATTEMPT}/${HTTP_MAX_RETRIES} status=${LAST_HTTP_STATUS} method=$METHOD url=$URL" log_debug "ResponseBody=$LAST_RESPONSE_BODY" if is_retryable_http_status "$LAST_HTTP_STATUS" && [ "$ATTEMPT" -lt "$HTTP_MAX_RETRIES" ]; then SLEEP_SECONDS=$((HTTP_RETRY_BASE_SLEEP_SECONDS * ATTEMPT)) log_warn "HTTP 状态可能是临时错误: status=${LAST_HTTP_STATUS},${SLEEP_SECONDS}s 后进行第 $((ATTEMPT + 1)) 次尝试" sleep "$SLEEP_SECONDS" ATTEMPT=$((ATTEMPT + 1)) continue fi return 0 done return 1 } # ============================================================ # 九、华为云 DNS API 操作函数 # ============================================================ get_zone_id() { signed_request \ "GET" \ "/v2/zones" \ "/v2/zones/" \ "limit=500&type=public" \ "" \ "" if [ "$LAST_HTTP_STATUS" != "200" ]; then log_error "查询 ZONE_ID 失败: HTTP=$LAST_HTTP_STATUS" log_multiline "响应内容: " "$LAST_RESPONSE_BODY" return 1 fi ZONE_ID="$(printf '%s' "$LAST_RESPONSE_BODY" \ | "$SED_BIN" 's/},{/},\ {/g' \ | "$GREP_BIN" -F "\"name\":\"${ZONE_NAME}\"" \ | "$SED_BIN" -n 's/.*"id":"\([^"]*\)".*/\1/p' \ | "$TAIL_BIN" -n 1)" if [ -n "$ZONE_ID" ]; then log_info "解析到 ZONE_ID: $ZONE_ID" return 0 fi log_error "没有从华为云 zones 响应中找到 ZONE_NAME=$ZONE_NAME" return 1 } get_record_line() { G_ZONE_ID="$1" G_RECORD_NAME="$2" signed_request \ "GET" \ "/v2.1/zones/${G_ZONE_ID}/recordsets" \ "/v2.1/zones/${G_ZONE_ID}/recordsets/" \ "limit=500" \ "" \ "" if [ "$LAST_HTTP_STATUS" != "200" ]; then log_error "查询 recordsets 失败: record=$G_RECORD_NAME HTTP=$LAST_HTTP_STATUS" log_multiline "响应内容: " "$LAST_RESPONSE_BODY" return 1 fi MATCH_LINE="$(printf '%s' "$LAST_RESPONSE_BODY" \ | "$SED_BIN" 's/},{/},\ {/g' \ | "$GREP_BIN" -F "\"name\":\"${G_RECORD_NAME}\"" \ | "$GREP_BIN" -F "\"type\":\"${RECORD_TYPE}\"" \ | "$TAIL_BIN" -n 1)" printf '%s' "$MATCH_LINE" return 0 } parse_recordset_id() { printf '%s' "$1" | "$SED_BIN" -n 's/.*"id":"\([^"]*\)".*/\1/p' | "$TAIL_BIN" -n 1 } parse_record_ipv6() { printf '%s' "$1" | "$SED_BIN" -n 's/.*"records":\["\([^"]*\)"\].*/\1/p' | "$TAIL_BIN" -n 1 } parse_record_ttl() { printf '%s' "$1" | "$SED_BIN" -n 's/.*"ttl":\([0-9]*\).*/\1/p' | "$TAIL_BIN" -n 1 } update_recordset() { U_ZONE_ID="$1" U_RECORDSET_ID="$2" U_RECORD_NAME="$3" U_NEW_IPV6="$4" BODY="{\"name\":\"${U_RECORD_NAME}\",\"type\":\"${RECORD_TYPE}\",\"ttl\":${TTL},\"records\":[\"${U_NEW_IPV6}\"]}" log_info "准备更新华为云记录: record=$U_RECORD_NAME recordset_id=$U_RECORDSET_ID" log_info "更新目标 IPv6: $U_NEW_IPV6" log_debug "更新请求 body=$BODY" signed_request \ "PUT" \ "/v2.1/zones/${U_ZONE_ID}/recordsets/${U_RECORDSET_ID}" \ "/v2.1/zones/${U_ZONE_ID}/recordsets/${U_RECORDSET_ID}/" \ "" \ "$BODY" \ "application/json" if [ "$LAST_HTTP_STATUS" = "202" ] || [ "$LAST_HTTP_STATUS" = "200" ]; then log_info "更新请求已被华为云接受: record=$U_RECORD_NAME HTTP=$LAST_HTTP_STATUS" log_debug "更新响应: $LAST_RESPONSE_BODY" return 0 fi log_error "更新记录失败: record=$U_RECORD_NAME HTTP=$LAST_HTTP_STATUS" log_multiline "响应内容: " "$LAST_RESPONSE_BODY" return 1 } # ============================================================ # 十、环境检查与参数检查 # ============================================================ prepare_runtime() { "$MKDIR_BIN" -p "$RUNTIME_DIR" 2>/dev/null touch "$RUN_LOG" 2>/dev/null touch "$LOG_FILE" 2>/dev/null RUN_START_TIME="$(now_ts)" RUN_START_EPOCH="$(now_epoch)" [ -n "$RUN_START_TIME" ] || RUN_START_TIME="unknown-time" [ -n "$RUN_START_EPOCH" ] || RUN_START_EPOCH="0" } check_environment() { log_info "脚本开始执行" log_info "接收到的 IPv6 参数: ${NEW_IPV6:-<empty>}" log_info "脚本路径: $0" log_info "日志文件: $LOG_FILE" log_info "运行目录: $RUNTIME_DIR" log_info "华为云 HOST: $HOST" log_info "主域名 ZONE_NAME: $ZONE_NAME" log_info "记录类型: $RECORD_TYPE" log_info "目标 TTL: $TTL" log_info "AK: $(mask_text "$AK")" log_info "SK: ******" if is_placeholder_secret "$AK" || is_placeholder_secret "$SK"; then log_error "AK/SK 尚未配置。请把参考脚本中的 AK/SK 填入本脚本,或通过 HUAWEICLOUD_AK/HUAWEICLOUD_SK 环境变量传入。" return 1 fi if [ ! -x "$OPENSSL_BIN" ]; then log_error "找不到 openssl: $OPENSSL_BIN" return 1 fi if [ ! -x "$XXD_BIN" ]; then log_error "找不到 xxd: $XXD_BIN" return 1 fi if [ ! -x "$CURL_BIN" ]; then log_error "找不到 curl: $CURL_BIN" return 1 fi log_info "工具检查通过: openssl=$OPENSSL_BIN" log_info "工具检查通过: xxd=$XXD_BIN" log_info "工具检查通过: curl=$CURL_BIN" if [ -z "$NEW_IPV6" ]; then log_error "没有收到 IPv6 参数。正确用法: sh $0 2409:xxxx:xxxx::xxxx" return 1 fi if ! printf '%s' "$NEW_IPV6" | "$GREP_BIN" -qiE '^[0-9a-f:]+$'; then log_error "IPv6 格式看起来不正确: $NEW_IPV6" return 1 fi case "$NEW_IPV6" in 2*) log_info "IPv6 参数基础校验通过: 以 2 开头" ;; *) log_warn "IPv6 参数不是 2 开头: $NEW_IPV6" log_warn "wifi-keeper.sh 正常情况下只会传入 2 开头的长期 IPv6,这里仍继续执行。" ;; esac return 0 } # ============================================================ # 十一、主流程 # ============================================================ process_one_record() { P_ZONE_ID="$1" P_RECORD_NAME="$2" TOTAL_COUNT=$((TOTAL_COUNT + 1)) log_info "------------------------------------------------------------" log_info "开始处理记录: $P_RECORD_NAME" RECORD_LINE="$(get_record_line "$P_ZONE_ID" "$P_RECORD_NAME")" GET_RECORD_RC="$?" if [ "$GET_RECORD_RC" != "0" ]; then log_error "查询目标 AAAA 记录失败,无法判断记录是否存在: $P_RECORD_NAME" FAILED_COUNT=$((FAILED_COUNT + 1)) return 1 fi if [ -z "$RECORD_LINE" ]; then log_error "华为云没有找到目标 AAAA 记录: $P_RECORD_NAME" FAILED_COUNT=$((FAILED_COUNT + 1)) return 1 fi log_info "华为云记录原始信息: $RECORD_LINE" RECORDSET_ID="$(parse_recordset_id "$RECORD_LINE")" REMOTE_IPV6="$(parse_record_ipv6 "$RECORD_LINE")" REMOTE_TTL="$(parse_record_ttl "$RECORD_LINE")" log_info "解析到 RECORDSET_ID: ${RECORDSET_ID:-<empty>}" log_info "从华为云读取到的原 IPv6: ${REMOTE_IPV6:-<empty>}" log_info "从华为云读取到的 TTL: ${REMOTE_TTL:-<unknown>}" log_info "wifi-keeper.sh 传入的新 IPv6: $NEW_IPV6" if [ -z "$RECORDSET_ID" ]; then log_error "无法解析 RECORDSET_ID,跳过记录: $P_RECORD_NAME" FAILED_COUNT=$((FAILED_COUNT + 1)) return 1 fi if [ "$REMOTE_IPV6" = "$NEW_IPV6" ]; then log_info "对比结果: IPv6 一致,不需要更新" log_info "跳过更新: $P_RECORD_NAME" SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) return 0 fi log_info "对比结果: IPv6 不一致,需要更新" log_info "变化详情: ${REMOTE_IPV6:-<empty>} -> $NEW_IPV6" if ! update_recordset "$P_ZONE_ID" "$RECORDSET_ID" "$P_RECORD_NAME" "$NEW_IPV6"; then FAILED_COUNT=$((FAILED_COUNT + 1)) return 1 fi log_info "开始二次查询,验证华为云是否已经更新为新 IPv6" VERIFY_LINE="$(get_record_line "$P_ZONE_ID" "$P_RECORD_NAME")" VERIFY_RC="$?" if [ "$VERIFY_RC" != "0" ]; then log_error "二次查询失败,无法验证华为云是否已经更新: $P_RECORD_NAME" FAILED_COUNT=$((FAILED_COUNT + 1)) return 1 fi VERIFY_IPV6="$(parse_record_ipv6 "$VERIFY_LINE")" log_info "二次查询记录原始信息: ${VERIFY_LINE:-<empty>}" log_info "二次查询解析到的 IPv6: ${VERIFY_IPV6:-<empty>}" if [ "$VERIFY_IPV6" = "$NEW_IPV6" ]; then log_info "更新验证成功: $P_RECORD_NAME 已经是 $NEW_IPV6" UPDATED_COUNT=$((UPDATED_COUNT + 1)) return 0 fi log_error "更新验证失败: $P_RECORD_NAME 当前仍是 ${VERIFY_IPV6:-<empty>},期望 $NEW_IPV6" FAILED_COUNT=$((FAILED_COUNT + 1)) return 1 } main() { prepare_runtime trap finish EXIT if ! check_environment; then exit 1 fi log_info "开始查询华为云 ZONE_ID" if ! get_zone_id; then exit 1 fi if [ -z "$ZONE_ID" ]; then log_error "ZONE_ID 为空,无法继续" exit 1 fi log_info "ZONE_ID 查询成功: $ZONE_ID" log_info "开始处理 RECORD_NAMES 列表" log_multiline "目标记录: " "$RECORD_NAMES" RECORD_LIST_FILE="$RUNTIME_DIR/record-list.$$.txt" printf '%s\n' "$RECORD_NAMES" > "$RECORD_LIST_FILE" while IFS= read -r RECORD_NAME || [ -n "$RECORD_NAME" ]; do [ -z "$RECORD_NAME" ] && continue case "$RECORD_NAME" in \#*) continue ;; esac process_one_record "$ZONE_ID" "$RECORD_NAME" done < "$RECORD_LIST_FILE" "$RM_BIN" -f "$RECORD_LIST_FILE" 2>/dev/null log_info "全部记录处理完成: total=$TOTAL_COUNT updated=$UPDATED_COUNT skipped=$SKIPPED_COUNT failed=$FAILED_COUNT" if [ "$FAILED_COUNT" -gt 0 ]; then log_error "存在失败记录,脚本以失败状态退出" exit 1 fi log_info "脚本执行成功" exit 0 } main 4. 同步DDNS时,需要用到openssl,所以先安装termux。 4.1 执行命令 pkg update 可能会提示: No mirror or mirror group selected. You might want to select one by running 'termux-change-repo' Testing the available mirrors: [*] (10) https://packages-cf.termux.dev/apt/termux-main: ok [*] (1) https://mirror.freedif.org/termux/termux-main: ok [*] (1) https://mirror.nevacloud.com/applications/termux/termux-main: ok [*] (1) https://mirrors.krnk.org/apt/termux/termux-main: ok [*] (1) https://mirrors.saswata.cc/termux/termux-main: bad [*] (1) https://tmx.xvx.my.id/apt/termux-main: bad [*] (1) https://mirror.rinarin.dev/termux/termux-main: ok [*] (1) https://mirror.albony.in/termux/termux-main: ok [*] (1) https://mirror.meowsmp.net/termux/termux-main: bad [*] (1) https://mirrors.in.sahilister.net/termux/termux-main/: ok [*] (1) https://mirrors.ravidwivedi.in/termux/termux-main: ok [*] (1) https://linux.domainesia.com/applications/termux/termux-main: ok [*] (1) https://mirror.jeonnam.school/termux/termux-main: ok [*] (1) https://termux.niranjan.co/termux-main: ok [*] (1) https://mirrors.cbrx.io/apt/termux/termux-main: ok [*] (1) https://mirror.bardia.tech/termux/termux-main: bad [*] (1) https://mirror.textcord.xyz/termux/termux-main: ok [*] (1) https://mirror.twds.com.tw/termux/termux-main: ok [*] (1) https://mirrors.nguyenhoang.cloud/termux/termux-main: ok [*] (1) https://mirrors.zju.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.pku.edu.cn/termux/termux-main/: ok [*] (1) https://mirrors.cernet.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.hust.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.bfsu.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.cqupt.edu.cn/termux/termux-main: ok [*] (1) https://mirrors.tuna.tsinghua.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirror.nyist.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirror.sjtu.edu.cn/termux/termux-main/: ok [*] (1) https://mirrors.sau.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.sdu.edu.cn/termux/termux-main: ok [*] (1) https://mirrors.nju.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.aliyun.com/termux/termux-main: ok [*] (1) https://mirror.iscas.ac.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.sustech.edu.cn/termux/apt/termux-main: ok [*] (1) https://mirrors.ustc.edu.cn/termux/termux-main: ok [*] (1) https://mirrors.de.sahilister.net/termux/termux-main: ok [*] (1) https://ro.mirror.flokinet.net/termux/termux-main: ok [*] (1) https://mirrors.medzik.dev/termux/termux-main: bad [*] (1) https://mirror.leitecastro.com/termux/termux-main: bad [*] (1) https://mirror.mwt.me/termux/main: ok [*] (1) https://termux.librehat.com/apt/termux-main: ok [*] (1) https://ftp.fau.de/termux/termux-main: ok [*] (1) https://packages.termux.dev/apt/termux-main: ok [*] (1) https://termux.mentality.rip/termux-main: ok [*] (1) https://md.mirrors.hacktegic.com/termux/termux-main: bad [*] (1) https://mirror.termux.dev/termux-main: bad [*] (1) https://mirror.bouwhuis.network/termux/termux-main: ok [*] (1) https://ftp.agdsn.de/termux/termux-main: ok [*] (1) https://mirrors.cfe.re/termux/termux-main: bad [*] (1) https://is.mirror.flokinet.net/termux/termux-main: ok [*] (1) https://termux.3san.dev/termux/termux-main: ok [*] (1) https://mirror.accum.se/mirror/termux.dev/termux-main: ok [*] (1) https://mirror.sunred.org/termux/termux-main: ok [*] (4) https://grimler.se/termux/termux-main: ok [*] (1) https://mirror.autkin.net/termux/termux-main: ok [*] (1) https://mirror.polido.pt/termux/termux-main: bad [*] (1) https://nl.mirror.flokinet.net/termux/termux-main: ok [*] (1) https://termux.cdn.lumito.net/termux-main: ok [*] (1) https://mirrors.utermux.dev/termux/termux-main: ok [*] (1) https://mirror.quantum5.ca/termux/termux-main: ok [*] (1) https://gnlug.org/pub/termux/termux-main: ok [*] (1) https://mirror.mwt.me/termux/main: ok [*] (1) https://plug-mirror.rcac.purdue.edu/termux/termux-main: bad [*] (1) https://termux.danyael.xyz/termux/termux-main: ok [*] (1) https://mirror.vern.cc/termux/termux-main: bad [*] (1) https://mirror.csclub.uwaterloo.ca/termux/termux-main: ok [*] (1) https://dl.kcubeterm.com/termux-main: bad [*] (1) https://mirror.fcix.net/termux/termux-main: bad [*] (1) https://mirrors.middlendian.com/termux/termux-main: bad [*] (1) http://mirror.mephi.ru/termux/termux-main: ok [*] (1) https://repository.su/termux/termux-main/: bad Picking mirror: (40) /data/data/com.termux/files/usr/etc/termux/mirrors/europe/mirrors.de.sahilister.net Get:1 https://mirrors.de.sahilister.net/termux/termux-main stable InRelease [14.0 kB] Get:2 https://mirrors.de.sahilister.net/termux/termux-main stable/main aarch64 Packages [547 kB] Fetched 561 kB in 6s (91.4 kB/s) Reading package lists... Done Building dependency tree... Done 73 packages can be upgraded. Run 'apt list --upgradable' to see them. ~ $ 意思是:你当前 Termux 还没有固定选择一个软件源镜像,所以它自动测试了一堆可用镜像。 不用管,termux会自动选择可用源帮你更新。 4.2然后执行命令安装openssl: pkg install openssl openssl-tool curl vim -y 安装过程中,如果提示输入内容,默认输入Y就行。 如果安装过程缓慢,可以搭载全局节点,也可以切换源地址后重新执行命令安装。 [!success] 到此,ipv6的健康度检查和ddns自动同步问题就解决了。手机重启以后,wifi会自动连接,然后ipv6会做健康度检查,然后自动同步DDNS。 [!warning] 由于论坛的文本编辑框,内容输入过多的时候,就会变得非常卡,由于本期代码太多了,所以帖子写的很慢。有很多细节上的东西不太重要我就暂时不补充了。不管是 ipv6 的检查脚本还是 DDNS 的同步脚本,都有非常详细的日志记录,如果有什么问题,大家可以把日志发出来,到时候我看看 4 个帖子 - 4 位参与者 阅读完整话题
[!todo] #一键展开 (不含 NSFW) 点击查看本期内容 (点击了解更多详细信息) [!todo]# NSFW NSFW QAQ Vol.020-NSFW01 (点击了解更多详细信息) NSFW QVQ Vol.020-NSFW02 (点击了解更多详细信息) [!todo]# 往期周刊 【摸鱼周刊】Vol.019 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.018 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.017 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.016 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.015 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.014 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.013 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.012 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.011 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.010 一周梗图沙雕乐子图合集 查看更多 1 个帖子 - 1 位参与者 阅读完整话题
本期游戏已更新 本期可领取游戏如下: PC 端 The Telltale Batman Shadows Edition 《Telltale 蝙蝠侠:暗影版》 Sunderfolk 《破碎族裔》 该游戏存在锁区,请以实际领取页面为准 移动端 Arranger 《茶杯》 下期预告 下期将上线两款「神秘游戏」。 领取截止:5 月 22 日 茶杯上期应该有人领取过了~~ 链接: 官网 • PC 1 • PC 2 • Android & iOS 1 个帖子 - 1 位参与者 阅读完整话题
[!todo] #一键展开 (不含 NSFW) 点击查看本期内容 (点击了解更多详细信息) [!todo]# NSFW NSFW QAQ Vol.019-NSFW01 (点击了解更多详细信息) NSFW QVQ Vol.019-NSFW02 (点击了解更多详细信息) NSFW QoQ Vol.019-NSFW03 (点击了解更多详细信息) [!todo]# 往期周刊 【摸鱼周刊】Vol.018 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.017 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.016 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.015 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.014 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.013 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.012 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.011 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.010 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.009 一周梗图沙雕乐子图合集 查看更多 10 个帖子 - 10 位参与者 阅读完整话题
从本期视频开始,我将采用 AI 编程的方式,挑战开发 Linux.do 网站。 视频观看: https://www.bilibili.com/video/BV1TAdwBsEJ5/?vd_source=eac57d23025c06a23af985f3ce0640d3
[!todo] #一键展开 (不含 NSFW) 点击查看本期内容 (点击了解更多详细信息) [!todo]# NSFW NSFW QAQ Vol.018-NSFW01 (点击了解更多详细信息) NSFW QVQ Vol.018-NSFW02 (点击了解更多详细信息) NSFW QoQ Vol.018-NSFW03 (点击了解更多详细信息) [!todo]# 往期周刊 【摸鱼周刊】Vol.017 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.016 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.015 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.014 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.013 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.012 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.011 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.010 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.009 一周梗图沙雕乐子图合集 【摸鱼周刊】Vol.008 一周梗图沙雕乐子图合集 查看更多 1 个帖子 - 1 位参与者 阅读完整话题