在 MacOS 上做 Linux 内核开发的一些有趣经验和坑

近一年多一直在用 MacOS 做 Linux 内核开发,因为是 POSIX 兼容的环境所以把一些坑旅顺之后,竟然感觉还挺顺手的,而且因为基本不用其他特性也没感受到几个 MacOS 的 Bug 问题,体验甚佳。不过也有一些奇怪的地方,花了一些精力去适配,还是挺值得的:

UNIX 兼容

MacOS 上还算友好的就这点,默认的用户目录就是 UNIX 环境中的 $HOME 变量,文件路径分隔符是 “ / ”,有 / 根目录,软连接,chmod 权限等,一些常见的命令基本都一样,不过是更偏 BSD 风格。对于不太依赖 Linux/GNU 特性的 POSIX 兼容程序,脚本之类的,基本都可以直接跑起来。所以整体对没有复杂依赖的内核开发,Web 开发等,还是很友好的。

直接在 Launchpad 上面搜索框里搜 Terminal / 终端,然后固定一个底端快捷方式),一些常用工具,比如 SUDO, SSH,都有,比如可以直接在终端中运行:

ssh-keygen -t rsa -b 4096 -o -a 100

生成的 SSH key 默认就放在 MacOS 用户目录,~/.ssh/id_rsa.pub。

当然也有很多不一样的,毕竟是苹果的 OS。桌面环境就是基于此进行了很多封装,更偏向于以 Application 为对象进行管理和交互,文件管理就不怎么方便了… 比较方便的是终端里可以用 open <filename> 的方式正常打开一个文件或者跳到 Finder 中。

当然默认并没有包管理器(下面会用 Homebrew 进行软件包管理)。

Homebrew 包管理

这个基本是 MacOS 上想做任何和代码沾边的事情的标配了,安装也很简单,跟着主页的操作就一两步:https://brew.sh/

之后就可以用 Brew 安装各种软件,Terminal 里执行 brew install <name> 来安装,brew update 升级,brew uninstall <name> 卸载,没什么特别的。

或许值得注意的是开发细节是,在 M1 平台上,Brew 默认把所有文件都安装到了 /opt/homebrew 中而不是我们常见的 /usr,然后通过更改 $PATH 的方式使其可用。

有时在系统更新后会出现一些 binary 不可用的情况,可以用 xcode-select –install 来解决。

终端和字体

MacOS 的字体渲染和配置是真的好,默认终端对我来说也足够用了,只是为了支持特殊符号,我一般会安装 Nerd Font 来支持一些奇怪的 TUI 应用符号。

MacOS 默认终端设置中可以更改默认 Profile 来改变样式,我改为了暗色主题 + 字体选择了 Nerd Font Hack,这里也有很多其他不错的字体可用:

https://www.nerdfonts.com/

适应 MacOS 的快捷键

MacOS 上似乎很多快捷键都和操作方式都是完全钉死的… 因此我也不打算去 Hack 相关内容,尽量去适应 MacOS 的操作逻辑,实际用下来竟然意外的不错。

工作流上,每次基本只打开一个终端实例,然后按 Ctrl + Command + F 设为全屏,这样这个桌面我就只用来访问终端了。Ctrl + ⬅️ 和 Ctrl + ➡️ 可以只用键盘在终端和其他 App 之间切换,在终端页面 Command + T 创建新 Tab,每个 Tab 里用开一个 Tmux,这样,Command + 数字 1 – 9 切换 Tab,每个 Tab 里 Ctrl + B 后按 1 – 9 切换 Tmux 窗口,一个 Tab 可以专注一个 Task。

Shell 选择和配置

MacOS 自带的 Shell 很老旧,Bash 3.x,我切换到了 Fish。

Fish Shell:https://fishshell.com/,官方整合了 Brew 安装,即直接执行 brew install fish。安装后在 Terminal 中按 Command + “,” 打开设置,把 General 中的 “Shells open with” 改成 “/opt/homebrew/bin/fish”,新打开的 Terminal 就默认使用 Fish 了。

在 Shell 上进行更一步的定制化就和其他 OS 差异不大,比如我一直在用的 Starship,一个可以提供非常好看的 Shell Prompt:https://starship.rs/,按照 Installation 中的 macOS 以及 Fish 对应的方法即可配置安装。

我自己的 Shell 中各种 Helper 和 配置都比较繁多,整理了一个 Git Repo:https://github.com/ryncsn/.home

大小写敏感的 Volume

MacOS 上我遇到的第一个重大问题就是文件系统是默认大小写不敏感的,我的解决方法也还好,在 Launchpad 里面的 Disk Utility 里可以很方便的创建一个新 Volume ,创建的时候格式可以选择带 “Case-Sensitive” 的即可。这个 Volume 类似 ZFS / BTRFS 中的抽象 Volume,不需要预设大小等。

点击图上右上角加号就可以添加 Volume 了。

新建的 Volume 会出现在 /Volume 路径中,为了方便使用我用 ln -s /Volume/<vol name>/<dir> ~/<dir> 的形式,将需要区分大小写的项目等放在软连接的目录中。

虚拟机

目前 HVF + Libvirt 可用度还不错,只需要用 brew install libvirt,然后 brew service start libvirt,就可以使用熟悉的 virsh 操作了。我的虚拟机 XML 参考如下,注意把里面 CREATE_YOUR_OWN_QCOW2_IMAGE_HERE 换成你自己的 Qcow2 image。

<domain type='hvf'>
  <name>VM1</name>
  <memory unit='MiB'>4096</memory>
  <vcpu placement='static'>8</vcpu>
  <os firmware='efi'>
    <type arch='aarch64' machine='virt-6.2'>hvm</type>
    <firmware>
      <feature enabled='no' name='enrolled-keys'/>
      <feature enabled='no' name='secure-boot'/>
    </firmware>
    <loader readonly='yes' type='pflash'>/opt/homebrew/Cellar/qemu/8.0.2/share/qemu/edk2-aarch64-code.fd</loader>
    <nvram template='/opt/homebrew/Cellar/qemu/8.0.2/share/qemu/edk2-arm-vars.fd'></nvram>
    <boot dev='cdrom'/>
    <boot dev='hd'/>
  </os>
  <features>
    <acpi/>
    <apic/>
    <gic version='2'/>
  </features>
  <cpu mode='custom' match='exact' check='none'>
    <model fallback='forbid'>host</model>
  </cpu>
  <clock offset='utc'>
    <timer name='rtc' tickpolicy='catchup'/>
    <timer name='hpet' present='no'/>
    <timer name='pit' tickpolicy='delay'/>
  </clock>
  <on_poweroff>destroy</on_poweroff>
  <on_reboot>restart</on_reboot>
  <on_crash>destroy</on_crash>
  <devices>
    <emulator>/opt/homebrew/bin/qemu-system-aarch64</emulator>
    <disk type='file' device='disk'>
      <driver name='qemu' type='qcow2'/>
      <source file='CREATE_YOUR_OWN_QCOW2_IMAGE_HERE'/>
      <target dev='vda' bus='virtio'/>
    </disk>
    <interface type='user'>
      <mac address='52:54:00:1f:51:94'/>
      <model type='virtio'/>
      <address type='pci' domain='0x0000' bus='0x01' slot='0x00' function='0x0'/>
    </interface>
    <serial type='pty'>
      <target type='system-serial' port='0'>
        <model name='pl011'/>
      </target>
    </serial>
    <console type='pty'>
      <target type='serial' port='0'/>
    </console>
    <audio id='1' type='none'/>
  </devices>
  <seclabel type='none' model='none'/>
</domain>

因为经常需要对虚拟机的细节进行调整和串口访问,又不想操作太多 Qemu 命令,所以使用了 Libvirt 来管理。这个虚拟机使用的是 HVF,硬件虚拟化没有什么开销,不过用的是 SLIRP 网络栈,网络性能略有损失,虚拟机看到的宿主机 IP 是 10.0.2.2。

NFS 文件共享

文件共享我用的是 MacOS 自带的 NFSv3,配置 /etc/exports 即可,不过 exports 里面格式比较怪,如下(详细可在 Terminal 中运行 man exports 来查看):

/Volumes/<YOUR_NFS_SHARE_PATH> -alldirs -maproot=root:wheel 127.0.0.1

直接挂载可能会看到类似如下错误:

mount -v -t nfs 10.0.2.2:/Volumes/XXX /mnt/Volume/XXX
mount.nfs: timeout set for Mon Oct 30 17:41:01 2023
mount.nfs: trying text-based options 'vers=4.2,addr=10.0.2.2,clientaddr=10.0.2.15'
mount.nfs: mount(2): Protocol not supported
mount.nfs: trying text-based options 'vers=4,minorversion=1,addr=10.0.2.2,clientaddr=10.0.2.15'
mount.nfs: mount(2): Protocol not supported
mount.nfs: trying text-based options 'vers=4,addr=10.0.2.2,clientaddr=10.0.2.15'
mount.nfs: mount(2): Protocol not supported
mount.nfs: trying text-based options 'addr=10.0.2.2'
mount.nfs: prog 100003, trying vers=3, prot=6
mount.nfs: trying 10.0.2.2 prog 100003 vers 3 prot TCP port 2049
mount.nfs: prog 100005, trying vers=3, prot=17
mount.nfs: trying 10.0.2.2 prog 100005 vers 3 prot UDP port 835
[933.642116] RPC: server 10.0.2.2 requires stronger authentication.
mount.nfs: mount(2): Permission denied
mount.nfs: access denied by server while mounting 10.0.2.2:/Volumes/XXX

原因是 MacOS 的 NFS server 默认要求源端口需要是 reserved 范围内,这个并不能保证而且并不能提升安全性。在 MacOS 侧的 /etc/nfs.conf 中,加入如下内容即可修复:

nfs.server.require_resv_port = 0
nfs.server.mount.require_resv_port = 0

另外值得一提的是,不需要串口的,需要更好的网络和文件共享性能,只想用 Docker 等的话,podman 是个非常好的选择,brew 里的 podman 自带虚拟机管理,会启动一个虚拟机并通过其来。

内核代码补全

在 Mac OS 上进行原生代码编辑和补全比想象中容易得多,我使用 VIM + YCM,配合这个 .ycm_extra_conf.py:

https://github.com/ryncsn/.home/blob/master/misc/linux.ycm_extra_conf.py

在配置好 Vim YCM 插件后,只需要拷贝到 Linux 内核目录下就可以进行补全了,错误检查会有一些 False Positive,一些特殊的文件格式,比如 sched 中一些 C 语言原文件是 Concat 而成的,这个还暂时没法很好的处理。

(持续更新中)

用 Wireguard 随时随地”回家”

这两天外出,希望能随时无缝访问家里内网,试了一下效果确实不错,记录一下,基本上参考了这两篇文章:

https://fedoramagazine.org/configure-wireguard-vpns-with-networkmanager/

https://www.wireguard.com/#conceptual-overview

我的家里的局域网设置如下:

主路由器内网IP:192.168.0.1/24

一个Raspberry Pi(Peer A)专门作Wireguard的Gateway,内网 IP:192.168.0.4/24,有IPv6外网地址。

准备连接的笔记本电脑(Peer B),连接在外网上。

首先确保Peer A和Peer B都安装了wireguard-tools,此时应有/etc/wireguard这个文件夹,权限700。

然后通过如下方法在Peer A和Peer B上生成各自的Private Key和Public Key:

cd /etc/wireguard; wg genkey | tee privatekey | wg pubkey > publickey

先在Peer A上创建/etc/wireguard/wg0.conf。已有的内网子网是192.168.0.0/24,于是新划192.168.16.0/24作为Wireguard内部网络网段。监听端口这里设置为10000,可以更改。同时由于联通4G网络的最大MTU是1400,Wireguard自己的是1420,于是用1400减去80得到MTU 1320。这个MTU可以应该可以兼容国内的大部分移动网络,wg0.conf内容如下:

[Interface]
Address = 192.168.16.1/24
SaveConfig = true
ListenPort = 10000
PrivateKey = <Peer A's Private Key>
MTU = 1320

[Peer]
PublicKey = <Peer B's Public Key>
AllowedIPs = 192.168.16.2/24

然后通过Network Manager创建端口,允许转发路由包,并且使用FirewallD配置规则:

# 创建wg0
nmcli con import type wireguard file /etc/wireguard/wg0.conf

# 允许转发
echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/99-ip-forward.conf
sysctl --system

# 创建监听Wireguard端口防火墙规则
firewall-cmd --permanent --add-port 10000/udp

# 创建允许转发路由的防火墙规则
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -d 192.168.16.0/24 -o wg0 -j ACCEPT
firewall-cmd --permanent --direct --add-rule ipv4 filter FORWARD 0 -i wg0 -j ACCEPT

# 重新读取规则
firewall-cmd --reload

这里需要在路由器上加一条规则,以便内网到Wireguard网段的包可以正确路由:

ip route add 192.168.16.0/24 via 192.168.0.4 

然后就只只需要在Peer B上配置了,同上,创建/etc/wireguard/wg0.conf。这里AllowedIPs加入内网网段,允许通过Wireguard路由到内网。

[Interface]
Address = 192.168.16.2/24
SaveConfig = true
PrivateKey = <Peer B's Private Key>
MTU = 1320

[Peer]
PublicKey = <Peer A's Public Key>
AllowedIPs = 192.168.0.0/24, 192.168.16.0/24
Endpoint = <Peer A's address>:60000

之后建立wg0:

nmcli con import type wireguard file /etc/wireguard/wg0.conf

这样就应该可以ping通了。可以通过wg命令查看接口状态,还可以给Peer A配上DDNS,即时更新IPv6地址,将Endpoint改为域名即可。

使用 memstrack 分析 shmem 的使用

最近笔记本上经常内存使用率激增,然而 ps_mem, top 等工具毫无线索, /proc/meminfo 可以看出是 shmem 激增。不过 ipcs 也没有什么有用信息,各个 tmpfs 使用也是正常的。

于是试了一下前阵子写的 memstrack,开机先启动然后正常使用一段时间,得到如下结果:

看到大概 1.3G 的 shmem 是被 chrome -> i915 driver 给用掉了,而且稳步上升中。禁止 chrome 的硬件加速之后一切正常了。

memstrack 还不能 track userspace stack,不清楚具体是什么东西 leak 了还是别的问题,不知道如果加一下 userspace 支持的话,会不会得到更有用的信息。

网站跑在运行 Fedora + K8s + CRIO的树莓派集群上

成功把整个网站放到自己的树莓派集群上了,现在有数据在自己手上(物理的)的感觉了。

树莓派4-4G X 4的集群

自己动手搞了个架子,然后弄了几个开关(下方四个小开关)方便断路,并给每个开关加了个自恢复保险防止手滑短路,并且可以增加仪式感。启动集群的时候一个开关一个开关地打开感觉自己在启动核反应堆😁,不过这个24小时常开的,基本上用不太到开关功能。自恢复保险确实起了一次作用,在面包版插元件不小心短路了,直接断开。

搭建基本参照K8s官网,用的Kubeadm启动集群,Calico作为CNI。然后左上方路由器跑的HAproxy通过autossh穿透到外网,然后反代到Nginx Ingress,CEPH作为存储(另有一个现成的CEPH集群),(恩,在那个CEPH集群上再跑K8s更合理一些毕竟性能好,但我就是觉得树莓派在日常工作的桌子旁边跑更好玩…)。

内核需要自己编译,然后配置引导,参考树莓派官网,uboot形式暂时走不通。Gentoo上有很好的关于如何boot一个64bit kernel的说明。其他也遇到不少坑,其中就包括Fedora里的K8s太过陈旧,正在帮maintainer更新包发个新Release,PR已提,快了™。

在那之前我自己弄了个COPR:https://copr.fedorainfracloud.org/coprs/kasong/kubernetes/

其他大部分坑都包含在这个playbook里,可以参考,请不要直接用,IP地址域名都不通用:

- - hosts: all
  tasks:
  - name: Enable crio 1.17 dnf module
    command: "dnf module enable cri-o:1.17/default -y"

  - name: Basic K8s package
    yum:
      name: "{{ packages }}"
      state: installed
    vars:
      packages:
      - kubernetes-node
      - kubernetes-client
      - kubernetes-kubeadm
      - kubernetes-master
      - cri-tools
      - crio

  - name: Ensure overlayfs, br_netfilter is loaded
    lineinfile:
      path: "/etc/modules-load.d/kubernetes-crio.conf"
      line: "{{ item }}"
      create: yes
    with_items:
      - br_netfilter
      - overlay

  - name: Enable Kluster firewall zone
    firewalld:
      zone: pi-cluster
      state: present
      permanent: yes

  - name: Enable 192.168.2.0/24 to Kluster zone
    firewalld:
      source: 192.168.2.0/24
      zone: pi-cluster
      state: enabled
      permanent: yes

  - name: Enable Kluster firewall zone
    firewalld:
      zone: intra-intra-net
      state: present
      permanent: yes

  - name: Enable 192.168.2.0/24 to Intra zone
    firewalld:
      source: 192.168.12.0/24
      zone: intra-intra-net
      state: enabled
      permanent: yes

  - name: Enable 192.168.2.0/24 to Intra zone
    firewalld:
      source: 192.168.0.0/24
      zone: intra-intra-net
      state: enabled
      permanent: yes

  - name: Enable K8s API port to Intra
    firewalld:
      port: 6443/tcp
      permanent: yes
      zone: intra-intra-net
      state: enabled

  - name: Enable K8s API port to Intra
    firewalld:
      service: ssh
      permanent: yes
      zone: intra-intra-net
      state: enabled

  - name: Enable NodePort Services to Intra
    firewalld:
      port: 30000-32767/tcp
      permanent: yes
      zone: intra-intra-net
      state: enabled

  - name: Enable crio
    service:
      name: crio
      enabled: yes
      state: started

  - name: Enable kubelet
    service:
      name: kubelet
      enabled: yes

#  - name: Disable ZRAM
#    service:
#      name: zram-swap
#      enabled: no
#      state: stopped

  - name: Enable K8s API port
    firewalld:
      port: 6443/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable etcd ports
    firewalld:
      port: 2379-2380/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable mdns port
    firewalld:
      service: mdns
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable kubelet port
    firewalld:
      port: 10250/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable kube-scheduler port
    firewalld:
      port: 10251/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable kube-controller-manager port
    firewalld:
      port: 10252/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable NodePort Services port
    firewalld:
      port: 30000-32767/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: Enable BGP ports
    firewalld:
      port: 179/tcp
      permanent: yes
      zone: pi-cluster
      state: enabled

  - name: QUIRK Firewalld disable nft
    lineinfile:
      dest: "/etc/firewalld/firewalld.conf"
      regexp: '^FirewallBackend=.*'
      line: 'FirewallBackend=iptables'

  - name: Start/Restart Firewalld
    service:
      name: firewalld
      state: restarted

  - name: QUIRK Remove invalid Kubelet config option
    replace:
      path: /etc/systemd/system/kubelet.service.d/kubeadm.conf
      regexp: '^(.+)--allow-privileged=([^ "]*)(.*)'
      replace: '\1\3'

  - name: QUIRK Kubelet don't depend on docker
    lineinfile:
      dest: "/usr/lib/systemd/system/kubelet.service"
      regexp: '^(Requires=docker.service)$'
      line: '# \1'
      backrefs: yes

  - name: Tell NetworkManager not to control calico interface
    blockinfile:
      create: yes
      path: /etc/NetworkManager/conf.d/calico.conf
      block: |
        [keyfile]
        unmanaged-devices=interface-name:cali*;interface-name:tunl*

  - name: Start/Restart NetworkManager
    service:
      name: NetworkManager
      state: restarted

  - name: K8s kernel modules
    blockinfile:
      create: yes
      path: /etc/modules-load.d/kubernetes-crio.conf
      block: |
        br_netfilter
        overlay

  - name: K8s kernel sysctl
    blockinfile:
      create: yes
      path: /etc/sysctl.d/99-calico.conf
      block: |
        net.ipv4.conf.all.rp_filter = 1

  - name: K8s kernel sysctl
    blockinfile:
      create: yes
      path: /etc/sysctl.d/99-kubernetes-cri.conf
      block: |
        net.bridge.bridge-nf-call-iptables  = 1
        net.ipv4.ip_forward                 = 1
        net.bridge.bridge-nf-call-ip6tables = 1

  - name: Update Kubelet config
    lineinfile:
      dest: "/etc/systemd/system/kubelet.service.d/kubeadm.conf"
      line: 'Environment="KUBELET_CRIO_ARGS=--container-runtime=remote --container-runtime-endpoint=/var/run/crio/crio.sock"'
      insertbefore: "ExecStart=.*"

  - name: Update Kubelet config
    lineinfile:
      dest: "/etc/systemd/system/kubelet.service.d/kubeadm.conf"
      regexp: '^ExecStart=(((?!\$KUBELET_CRIO_ARGS).)+)$'
      line: 'ExecStart=\1 $KUBELET_CRIO_ARGS'
      backrefs: yes

  - name: Update CRIO config for insecure repo
    ini_file:
      dest: "/etc/crio/crio.conf"
      section: "crio.image"
      option: 'insecure_registries'
      value: '[ "registry.intra.intra-net.com" ]'

  - name: Update common container config for insecure repo
    ini_file:
      dest: "/etc/containers/registries.conf"
      section: "registries.insecure"
      option: 'registries'
      value: '[ "registry.intra.intra-net.com" ]'

  - name: Ensure /opt/cni exists for symlink creation in next step
    file:
      path: "/opt/cni"
      state: directory

  - name: Create symbolic link for CNI Plugin
    file:
      src: "/usr/libexec/cni"
      dest: "/opt/cni/bin"
      state: link

  - name: "Now run 'sudo kubeadm init --cri-socket /var/run/crio/crio.sock --pod-network-cidr=10.24.0.0/16'"
    debug:

# Misc TODO: Apply a Docker mirror
#
# Misc TODO: Apply following config for calico yaml
#  - name: IP
#    value: "autodetect"
#  - name: IP_AUTODETECTION_METHOD
#    value: "interface=eth.*"
#  - name: IP6_AUTODETECTION_METHOD
#    value: "interface=eth.*"
#
# Misc TODO: Black list vc4 if kernel is failing
# echo blacklist vc4 > /etc/modprobe.d/blacklist-vc4.conf
#
# Misc TODO: Still need old fashion cgroup
# cgroup_enable=memory systemd.unified_cgroup_hierarchy=0
#
# Misc TODO: Ensure following kernel configs are enabled
# CONFIG_NETFILTER_XT_MATCH_CGROUP
# CONFIG_F2FS_FS_SECURITY
# CONFIG_CFS_BANDWIDTH
# CONFIG_BLK_DEV_RBD
# CONFIG_BRIDGE_NETFILTER
#
# Misc TODO: Tune etcd
# - name: ETCD_MAX_WALS
#   value: "5"
# - name: ETCD_HEARTBEAT_INTERVAL
#   value: "500"
# - name: ETCD_ELECTION_TIMEOUT
#   value: "10000"
# - name: ETCD_SNAPSHOT_COUNT
#   value: "5000"
# - name: ETCD_LOG_LEVEL
#   value: "error"

正如所见,还有一堆TODO没写成playbook形式,回头需要再搭一遍的时候再说了XD。很多坑我会尝试在打包环节修一下,playbook中的一部分就不再需要了。

优雅地让docker,nginx,acme.sh一起工作

前阵子买了个配置不错的阿里云,先搭了个Minecraft上去,Minecraft也跑在Docker里,并加载了DynMap显示实时地图。为了日后好扩展,方便上一些比较好看的页面,先用一个nginx反代一下,顺便挂上HTTPS,证书当然要从Letsencrypt搞。

看了下现有的方案,感觉都不太好看,都需要独立维护一个Dockerfile,不能直接用Nginx mainline,刷新证书的时候需要先关闭服务器,而且acme.sh和nginx跑在同一个容器里。想了一下,打算让acme.sh和nginx分开跑,nginx把challenge内容转发给acme.sh,这样应该就妥了,惟一的不好的地方是需要写个crontab,每隔2个月重启一下nginx的容器。不过其他方案也有downtime,也算可以接受。

目录结构和配置文件:

/
    acme.sh  -  SSL证书存放和acme帐号信息,配置信息
    nginx  -  自定义nginx配置存放的地方
        conf.d  -  一些nginx全局配置
        sites-enabled - 各个domain设置
        sites-conf  -  domain之间共用的设置

nignx/conf.d/acme.sh.conf

upstream acme-sh {
  server acme-sh:80;
}

nignx/conf.d/enabled-sites.conf

include /etc/nginx/sites-enabled/*.conf;

nginx/sites-conf/acme.sh.conf

location ~ "^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)$" {
    default_type text/plain;
    proxy_read_timeout    60;
    proxy_connect_timeout 60;
    proxy_redirect        off;
    proxy_pass http://acme-sh;

    proxy_set_header      X-Real-IP $remote_addr;
    proxy_set_header      X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header      X-Forwarded-Proto $scheme;
    proxy_set_header      Host $host;
}

nginx/sites-enabled/example.com.conf

server {
    server_name example.com;

    listen 0.0.0.0:443 ssl;
    listen [::]:443 ssl;

    ssl on;
    ssl_certificate /etc/ssl/letsencrypt/example.com/fullchain.cer;
    ssl_certificate_key /etc/ssl/letsencrypt/example.com/example.com.key;
    include sites-conf/acme.sh.conf;

    ...
}

server {
    server_name example.com;

    listen 0.0.0.0:80;
    listen [::]:80;

    include sites-conf/acme.sh.conf;

    location / {
        return 301 https://example.com$request_uri;
    }
}

第一次要先获取一个有效证书:

docker run --rm  -it  \
  -v "$(pwd)/acme.sh":/acme.sh  \
  --net=host \
  neilpang/acme.sh  --issue -d example.com  --standalone

使用docker-compose做容器管理,yml内容如下:

some-other-service:
    ...

nginx-srv:
    image: nginx:mainline
    volumes:
        - ./nginx/conf.d:/etc/nginx/conf.d
        - ./nginx/sites-conf:/etc/nginx/sites-conf
        - ./nginx/sites-enabled:/etc/nginx/sites-enabled
        - ./acme.sh:/etc/ssl/letsencrypt
    links:
        - some-other-service:upstream
        - acme-sh:acme-sh
    ports:
        - "443:443/tcp"
        - "80:80/tcp"

acme-sh:
    image: neilpang/acme.sh
    command: daemon
    volumes:
        - ./acme.sh:/acme.sh

调 docker-compose up就可以了。

最后加一个cronjob让nginx定期重新读取证书:

/var/spool/cron/root:

cd /path/to/docker-compose.yaml && /usr/bin/docker-compose exec nginx-srv nginx -s reload

 

记一次openwrt上折腾使用xkcptun,openvpn,ipset的策略路由

工作需求,需要加速外网访问,因此一个国外VPS服务器是必须的。由于外网条件限制,常常会有丢包,因此使用KCP进行加速可以极大的改善高丢包网络下的通讯速度,副作用是会重复发包放大流量,不过一般流量管够随便用,不需要担心。

首先要安装xkcptun:

基于Golang的kcptun无法很好的运行在MIPS环境上,因此使用C编写的xkcptun。

服务端:

我在repo中加入了可以build使用的Dockerfile,直接clone:

git clone https://github.com/ryncsn/xkcptun

之后直接进入目录,修改一下Dockerfile里面的参数,比如密码,端口地址等等,可以直接build了。这些参数应该单独拿出来,不过一时省事先不管了。

docker build -t xkcptun .

我一般习惯用docker-compose来管理:

xkcptun-ovpn:
  image: xkcptun
  net: "host"
  command: xkcp_server -c /etc/xkcptun.json -f
  restart: always
Openwrt(客户端):

直接从这里下载包安装:

https://github.com/gigibox/openwrt-xkcptun/releases

之后修改/etc/config/xkcptun,之后执行

/etc/init.d/xkcptun_client start
/etc/init.d/xkcptun_client enable

没研究UI,我的WNDR4300上只安装openwrt_xkcptun_0.4.409-1_ar71xx.ipk就够了。

此处挖坑

Openwrt-xkcptun release的build编译的有问题,用原repo的编译出来client和server无法连接,经过各种抓包和调试,发现是竟然是大小端的问题。应该重新编译一下修复大小端问题,考虑到Openwrt编译又要好久,就先在自己的repo里dirty hack了一下来兼容老的ar71xx build,因此这个repo编译出的xkcptun与其他的不兼容。

Openvpn配置:

直接使用docker-openvpn进行配置:

https://github.com/kylemanna/docker-openvpn

其中udp://VPN.SERVERNAME.COM换成vpn的地址,并且换成tcp协议,方便kcp进行包装。可以加上一个不存在的子域名前缀,方便之后添加hosts项来让kcp充当代理的作用。

CLIENTNAME是要链接的client name。

生成ovpn文件之后,将其拷到Openwrt设备上,并且在/etc/hosts中添加127.0.0.1 VPN.SERVERNAME.COM,让xkcp监听本地的1194转发到远端的1194,这样就可以让openvpn跑在kcp上面了。

策略路由:

为了完成策略路由,要先安装相关包:

opkg install iptables-mod-nat-extra ipset

opkg remove dnsmasq && opkg install dnsmasq-full

修改/etc/dnsmasq.conf,加入confdir=/etc/dnsmasq.d

建立/etc/dnsmasq.d,使用下面的脚本生成一个配置文件,命名dnsmasq_flist_ipset.conf,扔到/etc/dnsmasq.d/文件夹中:

https://github.com/cokebar/gfwlist2dnsmasq/blob/master/gfwlist2dnsmasq.sh

使用如下命令建立一个ipset list:

ipset -N flist iphash

防止DNS解析问题,先加入两个DNS服务器:

ipset add flist 8.8.8.8
ipset add flist 8.8.4.4

使用iptables做一下标记和允许转发,让标记包走特定路由表:

# iptables mark package matches the ipset
iptables -t mangle -A OUTPUT -j fwmark
iptables -t mangle -A fwmark -m set --match-set flist dst -j MARK --set-mark 0xffff

# Masquirade the package as routing is done before iptables mangle
iptables -t nat -A POSTROUTING -m mark --mark 0xffff -j MASQUERADE

# Route all package using ovpn table
ip rule add fwmark 0xffff table ovpn

# And allow forwarding
iptables -I FORWARD 1 -o tun0 -j ACCEPT

在之前的ovpn文件中加入,实现自动恢复:

script-security 2

up '/bin/sh -c "ip route add default dev tun0 table ovpn"'

这样便可以实现vpn连接自动添加路由和允许iptables转发的功能了,大功告成。

将iptables和ipset相关命令放入/etc/firewall.user,iproute2相关放入/etc/rc.local即可完成自动恢复。

Python Hybrid Method

这两天有一个特殊需求,在一个Python class上实现一个method,可以被当作Class method调用,也可以被当作Instance的Method调用。看起来不是很Pythonic,一个函数有两个不同的行为,不过在特殊情况下确实很实用。

尝试对性能进行了一下优化,并没有用到太复杂的优化方法,所以只是相对性能可以接受。调用消耗大概是普通调用的一倍。

from functools import wraps
class hybridmethod(object):
    """
    High performance hybrid function wrapper
    """
    __slot__ = ['context', 'method', 'clsmethod']

    def __init__(self, func):
        self.clsmethod = self.method = wraps(func)(lambda *a, **kw: func(self.context, *a, **kw))

    def classmethod(self, func):
        """
        Function to call when calling with class
        """
        self.clsmethod = wraps(func)(lambda *a, **kw: func(self.context, *a, **kw))
        return self

    def __get__(self, instance, cls):
        if instance is None:
            self.context = cls
            return self.clsmethod
        self.context = instance
        return self.method

自己用到的场景:

class Model(object):
    @hybridmethod
    def from_dict(self, dict_):
        """
        Inflate the instance with dict
        """
        for k, v in dict_.items():
            setattr(self, k, v)
        return self

    @from_dict.classmethod
    def from_dict(cls, dict_):
        """
        Inflate the instance with dict
        """
        instance = cls()
        for k, v in dict_.items():
            setattr(instance, k, v)
        return instance

新技能GET – 修热水器

家里的燃气热水器坏了一阵子,本来打算打算趁五一电商活动买个新的换上。不过,本着电器不用到彻底报废就不打算坏的原则,决定自己修一下。

打开盖子前后还是有点犹豫,毕竟涉及天然气,水路和电路,万一搞出差错出问题就不好办了。不过一番七手八脚折腾打开之后,经过仔细端详感觉原理也挺简单。感觉主要是温度反馈控制和可靠性设计这两天比较考验厂家实力。

好了不装逼了,经过大致判断应该是水流传感器坏了。依据是:打开水龙头由于有旧的存水,所以随着新水源进入温度变化看显示面板是有变化的,不过就是不打火。而且是完全没有打火的痕迹,比如风扇预启动,打火装置充电之类的都没发生。

水流传感器样子看起来是一个管子上多了一个方形盒子的感觉。淘宝上20一个,快递要好几天才能到,那就愉快地拆传感器吧。不过家里正好水阀坏了,ORZ。但拆都拆了,决定带着水压尝试拆一下电路部分。(似乎有的水流传感器电路部分和密封的盖子是一起固定的,拆开就崩。而家里的水流传感器可能是分离设计的或者锈地太厉害结果密封部分粘住了)

打开之后发现果然,电路部分整个浸水了,而且都锈了。再一看发现这个水流传感器似乎安装有问题,塑料外壳上有两个突起用来卡住电路,但直接怼在的电路板上了,导致密封不严实。猜测这应该也是进水的原因。直接把电路拆下来之后对着传感器剩下部分观察了一下,看起来大概原理是密封部分里面一个水轮,末端是磁铁,正对着电路部分的一个霍尔元件,水流导致水轮转动就有信号了。霍尔元件直接三个引脚接出来。其中两个脚分别是+5V和GND,另一个是信号脚,+5V处串一个5K电阻。其中电阻锈地彻底废了,但还好霍尔原件完好。

尝试修复比较简单就不做更多监测了,用飞线大法重连接了一下电路板短路的地方,朽烂了的电阻从其他报废电器上拆一个过来。装回热水器,安全起见先关闭燃起阀门测试一下水流,热水器立马嗡嗡地跑起来了,稳如狗。

Gentoo Zsh 缺少 curses.so 的问题

长期以来一直快乐的使用着 oh-my-zsh 里的 zsh-navigation-tools plugin 来进行模糊查询,不知道哪次 Gentoo 升级之后这个插件不能用了,报错:
command not found: zcurses
网上简单查询发现这个是个 zsh 内置模块的指令,应该是编译的时候 curses.so 被跳过了。
检查一下ebuild,没有看到有相关的USE flag,尝试调整ncurse和zsh包的版本和USE flag均无果。
检查一下 zsh 代码中的 configure.ac,发现$LIB中没有加入 ncurses 相关的 lib,zsh 会编译的时候会跳过 ncurses 相关模块的编译。

临时解决方法:
1、本地建立一个 Portage Overlay – https://forums.gentoo.org/viewtopic-t-827407-start-0.html,按需求 copy & paste 相关 ebuild
2、src_configure 中加入一行 myconf += ( –enable-libs=-lncursesw )
3、emerge zsh

zsh-navigation-tools plugin 又欢乐的跑起来了。

自己的Node.JS + Arduino的家居控制系统

很久以前的课设时实现了自己的一个想法,无线控制屋里的灯,再也不用每次都要站起来去手动关灯了。为了能通用些,写了成了一个小的框架,每个控制功能作为一个“驱动/模块“,然后在这个基础上把温湿度传感,红外/空调遥控之类的都做了出来。之后借来的Intel Gailieo还回去了,开始磨磨蹭蹭地把系统放到了树梅派上,然后改进了一下Arduino端的结构,也尝试写了一个比较通用的框架。

使用Node.js作为服务器提供Web端控制,通过不同的通讯模块(现在有NRF24L01,网络和WIFI)处理请求和传感器发回的信息等,将用户请求发送到下位机(比如Arduino),服务器处理各种传感器和控制器的具体任务。

Arduino上尝试实现了一个库,提供一个框架,方便实现硬件逻辑,可以将一个操作绑定到一个函数上,实现不同操作的处理。

服务器端对应的灯的“驱动”目前看起来是这样的:

var light = {};
var plat = undefined;

light.decorate = function (thing, desc){
  thing.turnOff = function(){
    var op = {
      'op':'turnOff',
    }
    thing.send(op);
  }
  thing.turnOn = function(){
    var op = {
      'op':'turnOn',
    }
    thing.send(op);
  }
  thing.powerOff = thing.turnOff;
  thing.powerOn = thing.turnOn;
  thing.receives.add(function(data){
    thing.status = data.status;
  })
  thing.interfaces['Power On'] = {
    type:'switch',
    name:'Power On',
    desc:'开灯',
    action:{
      1:'turnOn',
      0:'turnOff'
    },
    value:'power'
  };
}

light.init = function (base){
  plat = base;
}


module.exports = light;

Arduino上用自己的这个框架的话代码也比较干净利索,目前看起来是这样:

#define RF24_ADDR 0xFFFF000001
//#define SOME_DEBUG
//#define SOME_ERROR
#include <ArduinoJson.h>
#include <EEPROM.h>
#include <SPI.h>
#include "printf.h"
#include "Some.h"

Thing<link_RF24> me(0,
                    "{\"name\":\"智能灯\",\"groups\":{\"power\":{\"hasBattery\":false},\"smart\":{\"alwaysOn\":true,\"heartBeat\":false},\"light\":{}}}");


void turnOn(void) {
  Serial.println("turnOn");
  digitalWrite(6, LOW);
  me.send("{\"power\":1}");
}

void turnOff(void) {
  Serial.println("turnOff");
  digitalWrite(6, HIGH);
  me.send("{\"power\":0}");
}

void setup() {
//  Serial.begin(115200);
//  printf_begin();
  pinMode(6, OUTPUT);
  digitalWrite(6, LOW); //Turn the light on as soon as possible.
  me.setup();
  me.poke();
  me.on("turnOn", turnOn);
  me.on("turnOff", turnOff);
  turnOn();
}

void loop(void) {
  delay(50);
  me.refresh();
}

不少地方需要跟进,比如使用Arduino设备的自动发现,给每个设备分配更加独立可识别的ID,加密和安全性等,服务器中对事件的处理等等。

目前服务器跑在树梅派上,使用Arduino改造了自己的屋顶灯,做了几个传感器,跑得很欢。

持续更新中…

Github:

Server:https://github.com/ryncsn/some

Arduino:https://github.com/ryncsn/some-arduino