<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://wingu.se</id>
    <title>Yingyu Pages</title>
    <updated>2026-02-17T20:05:22.307Z</updated>
    <generator>Feed for Node.js</generator>
    <author>
        <name>Yingyu Cheng</name>
        <email>emerald_cahoots0j@icloud.com</email>
        <uri>https://github.com/winguse</uri>
    </author>
    <link rel="alternate" href="https://wingu.se"/>
    <link rel="self" href="https://wingu.se/atom.xml"/>
    <subtitle>Yingyu's blog hosted in GitHub Pages</subtitle>
    <icon>https://wingu.se/favicon.ico</icon>
    <rights>All rights reserved, Yingyu Cheng</rights>
    <entry>
        <title type="html"><![CDATA[换了位置时间线工具]]></title>
        <id>https://wingu.se/2026/01/02/location-tracking-solution.html</id>
        <link href="https://wingu.se/2026/01/02/location-tracking-solution.html"/>
        <updated>2026-01-03T05:05:00.000Z</updated>
        <summary type="html"><![CDATA[
不知不觉，时间已经划入 2026 年的第二天。本来想写点东西，让 2025 不至于一篇文章都没有，可惜，我懒。
最近放假在家，基本啥也没干，折腾了一圈各种东西，可是就连厨房的防水胶我都还没去重新整。...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2026/01/02/location-tracking-solution&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>不知不觉，时间已经划入 2026 年的第二天。本来想写点东西，让 2025 不至于一篇文章都没有，可惜，我懒。
最近放假在家，基本啥也没干，折腾了一圈各种东西，可是就连厨房的防水胶我都还没去重新整。
这里仅记录一下，最近折腾了一圈保存我历史记录工具的小结吧。</p>
<p>最最开始我用的是 Google 的 Timeline History，印象中是从 2010 年开始的。2025 年，这个服务正式进入了 Google 的坟墓。
我去导出过一次数据，不过很可惜，在我执行导出之前，它已经把我 2010 年代的数据 expire 掉了，所以基本没导出任何东西。
2015 年的时候，我开始使用 Moves，后来这个 App 被 Facebook 收购，然后在 2018 年也进了 Facebook 的坟墓。
2018 年折腾了一圈之后，我开始使用 Arc，一直用到最近，才换成了 Overland。</p>
<p>Arc 的数据是保存在 iCloud 上的。我一开始就比较诟病它需要根据我的位置下载一个模型，用于识别我各种活动的类型。
但其实我根本不在乎这个识别（Moves 也有，我同样不在乎）。
随着数据积累变多，在我更换手机的时候，它也曾多次出现导出数据的问题。
我一直默认开启了导出 Daily 和 Monthly 的 GPX 和 JSON 数据，即便如此，它还是让我丢了不少数据。家里领导的手机也是同样的问题。
Arc 是收费的，我觉得我既然用了它，就给了 100 刀，充了个永久解锁。不过感觉这个 App 后期更新、改 Bug 都不怎么积极。
Arc 也曾开源过核心代码，做成了 Arc Mini，不过现在看已经下架了。核心代码开源时用的还是它自己的 Location 库，也就是说，依然需要下载它的模型。
在“凑合用”的层面来说，这个 App 还不错，但它显然在大量数据管理方面做得不太好，而且这个问题感觉有点滚雪球的趋势。</p>
<p>2024 年的冬天，我就开始琢磨着换一种方法。于是我用 PostgreSQL 导入了所有 Arc 的数据，再用 Grafana 做数据可视化，其实我觉得挺不错的。
今年冬天，我看到了一个<a href="https://dabr.ca/notice/B1YPr3scGjZMCQYdYO" target="_blank" rel="noopener noreferrer">帖子</a>，提到了 Reitti 这个工具，于是开始了这次新的折腾。
Reitti 需要用 PostGIS，但它没有 Docker 的 ARM64 Image，所以我一开始是放弃的。不过想着<s>下雨天打孩子</s>放假了，闲着也是闲着，就折腾了一下。
我在手里唯一一个低功耗 x86 设备——小米平板 2 上装了 Ubuntu（是真™耗时间啊），然后跑起来玩玩，导入了一点数据，感觉可视化做得还不错。
与此同时，我也装了一整套 OwnTracks 解决方案，这个假期基本都把玩了一遍。</p>
<p>先说我最后留下来的解决方案吧。
我现在手机上用的是 Overland App，数据发送到一个 Deno 写的服务端。服务端会：</p>
<ol>
<li>把请求写到磁盘上，保留原始数据（学 OwnTracks Recorder）</li>
<li>写入我自己创建的 PostgreSQL 数据表（继续用 Grafana）</li>
<li>最后把请求原封不动地转发给 Reitti</li>
</ol>
<p>OwnTracks 其实有自己的 App，服务端是用 C 写的。它整套系统的设计非常克制，目标是做成类似 Apple Find My 那样，可以和朋友实时共享位置的功能。
这里说的“克制”，一方面是功能上非常简单（也因此可靠），另一方面是服务端资源占用极低。
它最开始只支持 MQTT 协议，后来才加了 HTTP。MQTT 还用了 mTLS 认证，这一点让我觉得难能可贵。
不过我个人最不喜欢它的一点也正是 MQTT。这玩意儿是持久连接，客户端可能因此比其他方案更费电。
而 HTTP 模式似乎又不支持 batch，所以有人抱怨它流量消耗很大。
服务端的理念是尽可能保留数据，基本就是把收到的请求全写成文本文件。可视化方面默认给了一个非常基础的 HTML 页面，也有一个 Vue 版本，但都只是“能看”的程度，远不如我用 Grafana Dashboard 做得好看。
所以我没打算长期用它。但服务端确实非常省资源，我也就留着了，看看后面会不会有别的发展。不过也不一定，这个项目已经 10 年了，虽然还在维护，但整体已经非常稳定、固定了。</p>
<p>Reitti 是用 Java 写的，内存消耗非常大，尤其是在导入数据和处理的时候。不过它在可视化方面做了很多努力，可以生成年度、月度分析，比如在某个地方待了多久、用了什么交通方式（基于速度判断）。
它的设计非常重视隐私：坐标点转换成地名时，可以下载 OpenStreetMap 的离线数据在本地查询，而不用调用外部 API（不过说实话，我图省事，没这么干）。
Reitti 的数据只存了三维坐标信息，可视化部分也只用了二维数据，像速度这种信息并没有存下来。所以我也不太喜欢它丢了这么多数据（虽然实际上也没啥用）。
数据导出方面，目前做得也很简陋，导出界面稍微选的时间范围长一点，就会卡死。当然，既然是自己部署，写点代码导出也不是什么难事。
综合这些考虑，我目前也只是把它当成一个可视化 UI，并没有奢求更多。
因为最终还是要用 Reitti，我又折腾了一会儿，把数据库直接装在 Docker 外面。导入数据的时候，Reitti 甚至把数据库进程写挂过一次，不过重启之后能搞定。</p>
<p>最后，说说现在这套方案的安全性。
严格来说，数据安全未必比 Arc 更好。Overland 也可能被 iOS 杀掉，导致不记录数据；我家的服务器两天才备份一次，如果没来得及备份，数据也会丢；服务器本身的可用性也不高。
不过 Overland 会先把数据存在手机本地，直到服务器确认接收才删除，所以问题可能也不大。
我还是期待 Overland 能支持（或者有别的 App 能支持）把数据长期存储在手机本地。</p>
<p>写了这么多，最后贴一下我用的代码，万一有人用得上呢。
MIT License，不放 GitHub 了，大部分都是 GPT 写的，我是真的懒啊。。</p>
<pre data-language="typescript"><code class="language-typescript"><span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">"https://deno.land/std@0.208.0/http/server.ts"</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">Client</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">"https://deno.land/x/postgres@v0.17.0/mod.ts"</span>;

<span class="hljs-comment">// Type definitions</span>
<span class="hljs-keyword">interface</span> <span class="hljs-title class_">ValidationRule</span> {
  <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span>;
  queries?: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>[]&gt;;
  headers?: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">string</span>[]&gt;;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">DatabaseConfig</span> {
  <span class="hljs-attr">hostname</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">port</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">database</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">user</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">password</span>: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">Config</span> {
  <span class="hljs-attr">port</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">validationRules</span>: <span class="hljs-title class_">ValidationRule</span>[];
  <span class="hljs-attr">database</span>: <span class="hljs-title class_">DatabaseConfig</span>;
  <span class="hljs-attr">fileStoragePath</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">upstreamUri</span>: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">LocationProperties</span> {
  <span class="hljs-attr">timestamp</span>: <span class="hljs-built_in">string</span>;
  latitude?: <span class="hljs-built_in">number</span>;
  longitude?: <span class="hljs-built_in">number</span>;
  altitude?: <span class="hljs-built_in">number</span>;
  speed?: <span class="hljs-built_in">number</span>;
  horizontal_accuracy?: <span class="hljs-built_in">number</span>;
  vertical_accuracy?: <span class="hljs-built_in">number</span>;
  course?: <span class="hljs-built_in">number</span>;
  course_accuracy?: <span class="hljs-built_in">number</span>;
  speed_accuracy?: <span class="hljs-built_in">number</span>;
  motion?: <span class="hljs-built_in">string</span>[];
  battery_state?: <span class="hljs-built_in">string</span>;
  battery_level?: <span class="hljs-built_in">number</span>;
  wifi?: <span class="hljs-built_in">string</span>;
  pauses?: <span class="hljs-built_in">boolean</span>;
  activity?: <span class="hljs-built_in">string</span>;
  desired_accuracy?: <span class="hljs-built_in">number</span>;
  deferred?: <span class="hljs-built_in">number</span>;
  significant_change?: <span class="hljs-built_in">string</span>;
  locations_in_payload?: <span class="hljs-built_in">number</span>;
  device_id?: <span class="hljs-built_in">string</span>;
  <span class="hljs-comment">// Trip-related fields</span>
  start?: <span class="hljs-built_in">string</span>;
  end?: <span class="hljs-built_in">string</span>;
  <span class="hljs-keyword">type</span>?: <span class="hljs-built_in">string</span>;
  mode?: <span class="hljs-built_in">string</span>;
  distance?: <span class="hljs-built_in">number</span>;
  duration?: <span class="hljs-built_in">number</span>;
  steps?: <span class="hljs-built_in">number</span>;
  stopped_automatically?: <span class="hljs-built_in">boolean</span>;
  start_location?: <span class="hljs-title class_">Location</span> | <span class="hljs-literal">null</span>;
  end_location?: <span class="hljs-title class_">Location</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">PointGeometry</span> {
  <span class="hljs-attr">type</span>: <span class="hljs-string">"Point"</span>;
  <span class="hljs-attr">coordinates</span>: [<span class="hljs-built_in">number</span>, <span class="hljs-built_in">number</span>];
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">Location</span> {
  <span class="hljs-attr">type</span>: <span class="hljs-string">"Feature"</span>;
  geometry?: <span class="hljs-title class_">PointGeometry</span>;
  <span class="hljs-attr">properties</span>: <span class="hljs-title class_">LocationProperties</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">ApiRequest</span> {
  <span class="hljs-attr">locations</span>: <span class="hljs-title class_">Location</span>[];
  current?: <span class="hljs-built_in">unknown</span>;
  trip?: <span class="hljs-built_in">unknown</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">DbLocationData</span> {
  <span class="hljs-attr">user_id</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">ts</span>: <span class="hljs-title class_">Date</span>;
  <span class="hljs-attr">latitude</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">longitude</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">horizontal_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">altitude</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">vertical_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">course</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">course_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">speed</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">speed_accuracy</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">battery_state</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">battery_level</span>: <span class="hljs-built_in">number</span> | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">motions</span>: <span class="hljs-built_in">string</span>[] | <span class="hljs-literal">null</span>;
  <span class="hljs-attr">wifi</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
}

<span class="hljs-comment">// Global configuration</span>
<span class="hljs-keyword">const</span> <span class="hljs-attr">CONFIG</span>: <span class="hljs-title class_">Config</span> = {
  <span class="hljs-attr">port</span>: <span class="hljs-built_in">parseInt</span>(<span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"SERVER_PORT"</span>) || <span class="hljs-string">"8080"</span>),
  <span class="hljs-attr">validationRules</span>: [
    {
      <span class="hljs-attr">userId</span>: <span class="hljs-number">1</span>,
      <span class="hljs-attr">queries</span>: { <span class="hljs-attr">token</span>: [<span class="hljs-string">"user 1 token"</span>] },
      <span class="hljs-comment">// headers: { headerName: ["validValue3"] },</span>
    },
    {
      <span class="hljs-attr">userId</span>: <span class="hljs-number">2</span>,
      <span class="hljs-attr">queries</span>: { <span class="hljs-attr">token</span>: [<span class="hljs-string">"user 2 token"</span>] },
      <span class="hljs-comment">// headers: { headerName: ["validValue3"] },</span>
    },
    <span class="hljs-comment">// Add more rules as needed</span>
  ],
  <span class="hljs-attr">database</span>: {
    <span class="hljs-attr">hostname</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_HOSTNAME"</span>) || <span class="hljs-string">"localhost"</span>,
    <span class="hljs-attr">port</span>: <span class="hljs-built_in">parseInt</span>(<span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_PORT"</span>) || <span class="hljs-string">"5432"</span>),
    <span class="hljs-attr">database</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_NAME"</span>) || <span class="hljs-string">"location_history"</span>,
    <span class="hljs-attr">user</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_USER"</span>) || <span class="hljs-string">"postgres"</span>,
    <span class="hljs-attr">password</span>: <span class="hljs-title class_">Deno</span>.<span class="hljs-property">env</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">"DB_PASSWORD"</span>) || <span class="hljs-string">""</span>,
  },
  <span class="hljs-attr">fileStoragePath</span>: <span class="hljs-string">"/app/data"</span>,
  <span class="hljs-attr">upstreamUri</span>: <span class="hljs-string">"http://reitti-server-address:8080/api/v1/ingest/overland"</span>,
};

<span class="hljs-comment">// Database client</span>
<span class="hljs-keyword">let</span> <span class="hljs-attr">dbClient</span>: <span class="hljs-title class_">Client</span> | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;

<span class="hljs-comment">// Initialize database connection</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">initDatabase</span>(<span class="hljs-params"></span>) {
  dbClient = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Client</span>(<span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">database</span>);
  <span class="hljs-keyword">await</span> dbClient.<span class="hljs-title function_">connect</span>();
  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"Database connected"</span>);
}

<span class="hljs-comment">// Validate request against rules</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">validateRequest</span>(<span class="hljs-params">
  <span class="hljs-attr">url</span>: URL,
  <span class="hljs-attr">headers</span>: <span class="hljs-title class_">Headers</span>,
</span>): { <span class="hljs-attr">valid</span>: <span class="hljs-built_in">boolean</span>; userId?: <span class="hljs-built_in">number</span> } {
  <span class="hljs-keyword">const</span> queryParams = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">entries</span>());
  <span class="hljs-keyword">const</span> headerMap = <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">fromEntries</span>(headers.<span class="hljs-title function_">entries</span>());

  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> rule <span class="hljs-keyword">of</span> <span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">validationRules</span>) {
    <span class="hljs-keyword">let</span> matches = <span class="hljs-literal">true</span>;

    <span class="hljs-comment">// Check queries</span>
    <span class="hljs-keyword">if</span> (rule.<span class="hljs-property">queries</span>) {
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, validValues] <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(rule.<span class="hljs-property">queries</span>)) {
        <span class="hljs-keyword">const</span> value = queryParams[key];
        <span class="hljs-keyword">if</span> (!value || !validValues.<span class="hljs-title function_">includes</span>(value)) {
          matches = <span class="hljs-literal">false</span>;
          <span class="hljs-keyword">break</span>;
        }
      }
    }

    <span class="hljs-comment">// Check headers</span>
    <span class="hljs-keyword">if</span> (matches &amp;&amp; rule.<span class="hljs-property">headers</span>) {
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, validValues] <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(rule.<span class="hljs-property">headers</span>)) {
        <span class="hljs-keyword">const</span> value = headerMap[key.<span class="hljs-title function_">toLowerCase</span>()];
        <span class="hljs-keyword">if</span> (!value || !validValues.<span class="hljs-title function_">includes</span>(value)) {
          matches = <span class="hljs-literal">false</span>;
          <span class="hljs-keyword">break</span>;
        }
      }
    }

    <span class="hljs-keyword">if</span> (matches) {
      <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">userId</span>: rule.<span class="hljs-property">userId</span> };
    }
  }

  <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span> };
}

<span class="hljs-comment">// Validate location data</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">validateLocation</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span></span>): {
  <span class="hljs-attr">valid</span>: <span class="hljs-built_in">boolean</span>;
  error?: <span class="hljs-built_in">string</span>;
} {
  <span class="hljs-keyword">if</span> (!location.<span class="hljs-property">properties</span>) {
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing properties"</span> };
  }

  <span class="hljs-keyword">const</span> props = location.<span class="hljs-property">properties</span>;
  
  <span class="hljs-comment">// For trip records, timestamp might be in 'end' field, or use 'timestamp'</span>
  <span class="hljs-keyword">const</span> hasTimestamp = props.<span class="hljs-property">timestamp</span> || props.<span class="hljs-property">end</span> || props.<span class="hljs-property">start</span>;
  <span class="hljs-keyword">if</span> (!hasTimestamp) {
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing timestamp"</span> };
  }

  <span class="hljs-comment">// Check for coordinates in geometry.coordinates or properties</span>
  <span class="hljs-keyword">const</span> coords = location.<span class="hljs-property">geometry</span>?.<span class="hljs-property">coordinates</span> || [];
  <span class="hljs-keyword">const</span> hasCoordsInGeometry = coords.<span class="hljs-property">length</span> &gt;= <span class="hljs-number">2</span> &amp;&amp; 
    coords[<span class="hljs-number">0</span>] !== <span class="hljs-literal">undefined</span> &amp;&amp; coords[<span class="hljs-number">0</span>] !== <span class="hljs-literal">null</span> &amp;&amp;
    coords[<span class="hljs-number">1</span>] !== <span class="hljs-literal">undefined</span> &amp;&amp; coords[<span class="hljs-number">1</span>] !== <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">const</span> hasCoordsInProps = props.<span class="hljs-property">latitude</span> !== <span class="hljs-literal">undefined</span> &amp;&amp; props.<span class="hljs-property">latitude</span> !== <span class="hljs-literal">null</span> &amp;&amp;
    props.<span class="hljs-property">longitude</span> !== <span class="hljs-literal">undefined</span> &amp;&amp; props.<span class="hljs-property">longitude</span> !== <span class="hljs-literal">null</span>;

  <span class="hljs-keyword">if</span> (!hasCoordsInGeometry &amp;&amp; !hasCoordsInProps) {
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing latitude/longitude"</span> };
  }

  <span class="hljs-keyword">return</span> { <span class="hljs-attr">valid</span>: <span class="hljs-literal">true</span> };
}

<span class="hljs-comment">// Convert location to database format</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">locationToDbFormat</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span>, <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>): <span class="hljs-title class_">DbLocationData</span> {
  <span class="hljs-keyword">const</span> props = location.<span class="hljs-property">properties</span>;
  <span class="hljs-keyword">const</span> coords = location.<span class="hljs-property">geometry</span>?.<span class="hljs-property">coordinates</span> || [];

  <span class="hljs-comment">// Use timestamp, end, or start (in that order of preference)</span>
  <span class="hljs-keyword">const</span> timestampStr = props.<span class="hljs-property">timestamp</span> || props.<span class="hljs-property">end</span> || props.<span class="hljs-property">start</span>;
  <span class="hljs-keyword">if</span> (!timestampStr) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"No timestamp available"</span>);
  }
  <span class="hljs-keyword">const</span> timestamp = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(timestampStr);
  <span class="hljs-keyword">const</span> latitude = coords[<span class="hljs-number">1</span>] ?? props.<span class="hljs-property">latitude</span>;
  <span class="hljs-keyword">const</span> longitude = coords[<span class="hljs-number">0</span>] ?? props.<span class="hljs-property">longitude</span>;

  <span class="hljs-comment">// Enum values from schema</span>
  <span class="hljs-keyword">const</span> validMotionTypes = [
    <span class="hljs-string">"driving"</span>,
    <span class="hljs-string">"walking"</span>,
    <span class="hljs-string">"running"</span>,
    <span class="hljs-string">"cycling"</span>,
    <span class="hljs-string">"stationary"</span>,
    <span class="hljs-string">"automotive_navigation"</span>,
    <span class="hljs-string">"fitness"</span>,
    <span class="hljs-string">"other_navigation"</span>,
    <span class="hljs-string">"other"</span>,
    <span class="hljs-string">"moving"</span>,
    <span class="hljs-string">"uncertain"</span>,
  ];
  <span class="hljs-keyword">const</span> validBatteryStates = [<span class="hljs-string">"unknown"</span>, <span class="hljs-string">"charging"</span>, <span class="hljs-string">"full"</span>, <span class="hljs-string">"unplugged"</span>];

  <span class="hljs-comment">// Process motion array</span>
  <span class="hljs-keyword">let</span> <span class="hljs-attr">motions</span>: <span class="hljs-built_in">string</span>[] | <span class="hljs-literal">null</span> = <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">if</span> (<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(props.<span class="hljs-property">motion</span>)) {
    motions = props.<span class="hljs-property">motion</span>.<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">m</span>) =&gt;</span>
      validMotionTypes.<span class="hljs-title function_">includes</span>(m)
    );
    <span class="hljs-keyword">if</span> (motions.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) {
      motions = <span class="hljs-literal">null</span>;
    }
  }

  <span class="hljs-comment">// Process battery_state</span>
  <span class="hljs-keyword">let</span> <span class="hljs-attr">batteryState</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span> = props.<span class="hljs-property">battery_state</span> || <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">if</span> (batteryState &amp;&amp; !validBatteryStates.<span class="hljs-title function_">includes</span>(batteryState)) {
    batteryState = <span class="hljs-literal">null</span>;
  }

  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">user_id</span>: userId,
    <span class="hljs-attr">ts</span>: timestamp,
    <span class="hljs-attr">latitude</span>: latitude!,
    <span class="hljs-attr">longitude</span>: longitude!,
    <span class="hljs-attr">horizontal_accuracy</span>: props.<span class="hljs-property">horizontal_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">altitude</span>: props.<span class="hljs-property">altitude</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">vertical_accuracy</span>: props.<span class="hljs-property">vertical_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">course</span>: props.<span class="hljs-property">course</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">course_accuracy</span>: props.<span class="hljs-property">course_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">speed</span>: props.<span class="hljs-property">speed</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">speed_accuracy</span>: props.<span class="hljs-property">speed_accuracy</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">battery_state</span>: batteryState,
    <span class="hljs-attr">battery_level</span>: props.<span class="hljs-property">battery_level</span> ?? <span class="hljs-literal">null</span>,
    <span class="hljs-attr">motions</span>: motions,
    <span class="hljs-attr">wifi</span>: props.<span class="hljs-property">wifi</span> || <span class="hljs-literal">null</span>,
  };
}

<span class="hljs-comment">// Insert a single location into database</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">insertSingleLocation</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span>, <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-keyword">if</span> (!dbClient) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Database not connected"</span>);
  }

  <span class="hljs-keyword">const</span> validation = <span class="hljs-title function_">validateLocation</span>(location);
  <span class="hljs-keyword">if</span> (!validation.<span class="hljs-property">valid</span>) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">warn</span>(<span class="hljs-string">`Skipping invalid location: <span class="hljs-subst">${validation.error}</span>`</span>);
    <span class="hljs-keyword">return</span>;
  }

  <span class="hljs-keyword">const</span> dbData = <span class="hljs-title function_">locationToDbFormat</span>(location, userId);

  <span class="hljs-keyword">await</span> dbClient.<span class="hljs-property">queryObject</span><span class="hljs-string">`
    INSERT INTO public.positions (
      user_id, ts, geom, horizontal_accuracy,
      altitude, vertical_accuracy, course, course_accuracy,
      speed, speed_accuracy, battery_state, battery_level,
      motions, wifi
    ) VALUES (
      <span class="hljs-subst">${dbData.user_id}</span>, <span class="hljs-subst">${dbData.ts}</span>,
      ST_SetSRID(ST_MakePoint(<span class="hljs-subst">${dbData.longitude}</span>, <span class="hljs-subst">${dbData.latitude}</span>), 4326),
      <span class="hljs-subst">${dbData.horizontal_accuracy}</span>, <span class="hljs-subst">${dbData.altitude}</span>, <span class="hljs-subst">${dbData.vertical_accuracy}</span>,
      <span class="hljs-subst">${dbData.course}</span>, <span class="hljs-subst">${dbData.course_accuracy}</span>, <span class="hljs-subst">${dbData.speed}</span>,
      <span class="hljs-subst">${dbData.speed_accuracy}</span>, <span class="hljs-subst">${dbData.battery_state}</span>, <span class="hljs-subst">${dbData.battery_level}</span>,
      <span class="hljs-subst">${dbData.motions}</span>, <span class="hljs-subst">${dbData.wifi}</span>
    )
    ON CONFLICT (ts, user_id, geom) DO UPDATE SET
      horizontal_accuracy = EXCLUDED.horizontal_accuracy,
      altitude = EXCLUDED.altitude,
      vertical_accuracy = EXCLUDED.vertical_accuracy,
      course = EXCLUDED.course,
      course_accuracy = EXCLUDED.course_accuracy,
      speed = EXCLUDED.speed,
      speed_accuracy = EXCLUDED.speed_accuracy,
      battery_state = EXCLUDED.battery_state,
      battery_level = EXCLUDED.battery_level,
      motions = EXCLUDED.motions,
      wifi = EXCLUDED.wifi
  `</span>;
}

<span class="hljs-comment">// Extract all locations from a location object, including nested ones</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">extractAllLocations</span>(<span class="hljs-params"><span class="hljs-attr">location</span>: <span class="hljs-title class_">Location</span></span>): <span class="hljs-title class_">Location</span>[] {
  <span class="hljs-keyword">const</span> <span class="hljs-attr">result</span>: <span class="hljs-title class_">Location</span>[] = [];
  <span class="hljs-keyword">const</span> props = location.<span class="hljs-property">properties</span>;

  <span class="hljs-comment">// Extract start_location if it exists</span>
  <span class="hljs-keyword">if</span> (props.<span class="hljs-property">start_location</span>) {
    result.<span class="hljs-title function_">push</span>(props.<span class="hljs-property">start_location</span>);
  }

  <span class="hljs-comment">// Extract the main location if it has valid coordinates</span>
  <span class="hljs-keyword">if</span> (location.<span class="hljs-property">geometry</span>?.<span class="hljs-property">coordinates</span> || props.<span class="hljs-property">latitude</span> !== <span class="hljs-literal">undefined</span>) {
    result.<span class="hljs-title function_">push</span>(location);
  }

  <span class="hljs-comment">// Extract end_location if it exists</span>
  <span class="hljs-keyword">if</span> (props.<span class="hljs-property">end_location</span>) {
    result.<span class="hljs-title function_">push</span>(props.<span class="hljs-property">end_location</span>);
  }

  <span class="hljs-keyword">return</span> result;
}

<span class="hljs-comment">// Insert locations into database</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">insertLocations</span>(<span class="hljs-params"><span class="hljs-attr">locations</span>: <span class="hljs-title class_">Location</span>[], <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-keyword">if</span> (!dbClient) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Database not connected"</span>);
  }

  <span class="hljs-comment">// Extract all locations (including nested ones) into a flat list</span>
  <span class="hljs-keyword">const</span> <span class="hljs-attr">allLocations</span>: <span class="hljs-title class_">Location</span>[] = [];
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> location <span class="hljs-keyword">of</span> locations) {
    <span class="hljs-keyword">const</span> extracted = <span class="hljs-title function_">extractAllLocations</span>(location);
    allLocations.<span class="hljs-title function_">push</span>(...extracted);
  }

  <span class="hljs-comment">// Insert each location individually as a separate record</span>
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> location <span class="hljs-keyword">of</span> allLocations) {
    <span class="hljs-keyword">await</span> <span class="hljs-title function_">insertSingleLocation</span>(location, userId);
  }
}

<span class="hljs-comment">// Write JSON to disk</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">writeToDisk</span>(<span class="hljs-params"><span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>, <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-keyword">const</span> now = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>();
  <span class="hljs-keyword">const</span> year = now.<span class="hljs-title function_">getUTCFullYear</span>();
  <span class="hljs-keyword">const</span> month = <span class="hljs-title class_">String</span>(now.<span class="hljs-title function_">getUTCMonth</span>() + <span class="hljs-number">1</span>).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);
  <span class="hljs-keyword">const</span> day = <span class="hljs-title class_">String</span>(now.<span class="hljs-title function_">getUTCDate</span>()).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);

  <span class="hljs-keyword">const</span> dirPath = <span class="hljs-string">`<span class="hljs-subst">${CONFIG.fileStoragePath}</span>/<span class="hljs-subst">${year}</span>/<span class="hljs-subst">${month}</span>`</span>;
  <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">mkdir</span>(dirPath, { <span class="hljs-attr">recursive</span>: <span class="hljs-literal">true</span> });

  <span class="hljs-keyword">const</span> filePath = <span class="hljs-string">`<span class="hljs-subst">${dirPath}</span>/<span class="hljs-subst">${day}</span>.rec`</span>;
  <span class="hljs-keyword">const</span> jsonStr = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(jsonData);
  <span class="hljs-keyword">const</span> line = <span class="hljs-string">`<span class="hljs-subst">${userId}</span> <span class="hljs-subst">${jsonStr}</span>\n`</span>;

  <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">writeTextFile</span>(filePath, line, { <span class="hljs-attr">append</span>: <span class="hljs-literal">true</span> });
}

<span class="hljs-comment">// Forward request to upstream</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">forwardRequest</span>(<span class="hljs-params">
  <span class="hljs-attr">originalRequest</span>: <span class="hljs-title class_">Request</span>,
  <span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>,
</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">const</span> upstreamUrl = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(<span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">upstreamUri</span>);

  <span class="hljs-comment">// Copy query parameters</span>
  <span class="hljs-keyword">const</span> originalUrl = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(originalRequest.<span class="hljs-property">url</span>);
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, value] <span class="hljs-keyword">of</span> originalUrl.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">entries</span>()) {
    upstreamUrl.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">set</span>(key, value);
  }

  <span class="hljs-comment">// Copy headers</span>
  <span class="hljs-keyword">const</span> headers = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Headers</span>();
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, value] <span class="hljs-keyword">of</span> originalRequest.<span class="hljs-property">headers</span>.<span class="hljs-title function_">entries</span>()) {
    headers.<span class="hljs-title function_">set</span>(key, value);
  }
  <span class="hljs-comment">// Ensure Content-Type is set for JSON</span>
  <span class="hljs-keyword">if</span> (!headers.<span class="hljs-title function_">has</span>(<span class="hljs-string">"Content-Type"</span>)) {
    headers.<span class="hljs-title function_">set</span>(<span class="hljs-string">"Content-Type"</span>, <span class="hljs-string">"application/json"</span>);
  }

  <span class="hljs-comment">// Forward request</span>
  <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(upstreamUrl.<span class="hljs-title function_">toString</span>(), {
    <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
    <span class="hljs-attr">headers</span>: headers,
    <span class="hljs-attr">body</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(jsonData),
  });

  <span class="hljs-keyword">return</span> response;
}

<span class="hljs-comment">// Handle API request</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleApiRequest</span>(<span class="hljs-params"><span class="hljs-attr">request</span>: <span class="hljs-title class_">Request</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-comment">// Validate request</span>
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);
    <span class="hljs-keyword">const</span> validation = <span class="hljs-title function_">validateRequest</span>(url, request.<span class="hljs-property">headers</span>);

    <span class="hljs-keyword">if</span> (!validation.<span class="hljs-property">valid</span>) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Validation failed"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">403</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-keyword">const</span> userId = validation.<span class="hljs-property">userId</span>!;

    <span class="hljs-comment">// Parse JSON</span>
    <span class="hljs-keyword">let</span> <span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>;
    <span class="hljs-keyword">try</span> {
      jsonData = <span class="hljs-keyword">await</span> request.<span class="hljs-title function_">json</span>() <span class="hljs-keyword">as</span> <span class="hljs-title class_">ApiRequest</span>;
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Invalid JSON"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Validate locations array exists</span>
    <span class="hljs-keyword">if</span> (!jsonData.<span class="hljs-property">locations</span> || !<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(jsonData.<span class="hljs-property">locations</span>)) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Missing or invalid locations array"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// always forward first</span>
    <span class="hljs-keyword">const</span> fwRequested = <span class="hljs-title function_">forwardRequest</span>(request, jsonData);

    <span class="hljs-comment">// Write to disk</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">writeToDisk</span>(jsonData, userId);
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"File write error:"</span>, error);
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Write file error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Write to database</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">insertLocations</span>(jsonData.<span class="hljs-property">locations</span>, userId);
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Database error:"</span>, error);
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Database error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Wait Forward to upstream</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> upstreamResponse = <span class="hljs-keyword">await</span> fwRequested;
      <span class="hljs-keyword">return</span> upstreamResponse;
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Upstream forward error:"</span>, error);
      <span class="hljs-comment">// Return success even if upstream fails</span>
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">message</span>: <span class="hljs-string">"Processed but upstream failed"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Unexpected error:"</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
      <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Internal server error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
      { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
    );
  }
}

<span class="hljs-comment">// Forward reprocessed data to upstream (no original Request available)</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">forwardReprocessRequest</span>(<span class="hljs-params">
  <span class="hljs-attr">jsonData</span>: <span class="hljs-title class_">ApiRequest</span>,
  <span class="hljs-attr">userId</span>: <span class="hljs-built_in">number</span>,
</span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">const</span> upstreamUrl = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(<span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">upstreamUri</span>);

  <span class="hljs-comment">// Re-apply validation rule query params (e.g. token)</span>
  <span class="hljs-keyword">const</span> rule = <span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">validationRules</span>.<span class="hljs-title function_">find</span>(<span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> r.<span class="hljs-property">userId</span> === userId);
  <span class="hljs-keyword">if</span> (rule?.<span class="hljs-property">queries</span>) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, values] <span class="hljs-keyword">of</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">entries</span>(rule.<span class="hljs-property">queries</span>)) {
      <span class="hljs-keyword">if</span> (values.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>) {
        upstreamUrl.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">set</span>(key, values[<span class="hljs-number">0</span>]);
      }
    }
  }

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(upstreamUrl.<span class="hljs-title function_">toString</span>(), {
    <span class="hljs-attr">method</span>: <span class="hljs-string">"POST"</span>,
    <span class="hljs-attr">headers</span>: {
      <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span>,
    },
    <span class="hljs-attr">body</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(jsonData),
  });
}



<span class="hljs-comment">// Reprocess data from rec file for a given date</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleReprocessRequest</span>(<span class="hljs-params"><span class="hljs-attr">request</span>: <span class="hljs-title class_">Request</span>, <span class="hljs-attr">dateStr</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">try</span> {

    <span class="hljs-comment">// Parse date (expecting YYYY-MM-DD format)</span>
    <span class="hljs-keyword">let</span> <span class="hljs-attr">date</span>: <span class="hljs-title class_">Date</span>;
    <span class="hljs-keyword">try</span> {
      date = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(dateStr + <span class="hljs-string">"T00:00:00Z"</span>);
      <span class="hljs-keyword">if</span> (<span class="hljs-built_in">isNaN</span>(date.<span class="hljs-title function_">getTime</span>())) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">"Invalid date format"</span>);
      }
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Invalid date format. Expected YYYY-MM-DD"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-keyword">const</span> year = date.<span class="hljs-title function_">getUTCFullYear</span>();
    <span class="hljs-keyword">const</span> month = <span class="hljs-title class_">String</span>(date.<span class="hljs-title function_">getUTCMonth</span>() + <span class="hljs-number">1</span>).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);
    <span class="hljs-keyword">const</span> day = <span class="hljs-title class_">String</span>(date.<span class="hljs-title function_">getUTCDate</span>()).<span class="hljs-title function_">padStart</span>(<span class="hljs-number">2</span>, <span class="hljs-string">"0"</span>);

    <span class="hljs-keyword">const</span> filePath = <span class="hljs-string">`<span class="hljs-subst">${CONFIG.fileStoragePath}</span>/<span class="hljs-subst">${year}</span>/<span class="hljs-subst">${month}</span>/<span class="hljs-subst">${day}</span>.rec`</span>;

    <span class="hljs-comment">// Check if file exists</span>
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">stat</span>(filePath);
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"File not found"</span>, <span class="hljs-attr">path</span>: filePath }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }

    <span class="hljs-comment">// Read and process file</span>
    <span class="hljs-keyword">const</span> fileContent = <span class="hljs-keyword">await</span> <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">readTextFile</span>(filePath);
    <span class="hljs-keyword">const</span> lines = fileContent.<span class="hljs-title function_">trim</span>().<span class="hljs-title function_">split</span>(<span class="hljs-string">"\n"</span>).<span class="hljs-title function_">filter</span>(<span class="hljs-function"><span class="hljs-params">line</span> =&gt;</span> line.<span class="hljs-title function_">trim</span>().<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>);

    <span class="hljs-keyword">let</span> processedCount = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">let</span> errorCount = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">const</span> <span class="hljs-attr">errors</span>: <span class="hljs-built_in">string</span>[] = [];
    <span class="hljs-keyword">const</span> <span class="hljs-attr">userCounts</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">number</span>, <span class="hljs-built_in">number</span>&gt; = {};

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> line <span class="hljs-keyword">of</span> lines) {
      <span class="hljs-keyword">try</span> {
        <span class="hljs-comment">// Parse format: {user_id} {json}</span>
        <span class="hljs-keyword">const</span> spaceIndex = line.<span class="hljs-title function_">indexOf</span>(<span class="hljs-string">" "</span>);
        <span class="hljs-keyword">if</span> (spaceIndex === -<span class="hljs-number">1</span>) {
          errorCount++;
          errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Invalid line format (missing space): <span class="hljs-subst">${line.substring(<span class="hljs-number">0</span>, <span class="hljs-number">50</span>)}</span>`</span>);
          <span class="hljs-keyword">continue</span>;
        }

        <span class="hljs-keyword">const</span> userIdStr = line.<span class="hljs-title function_">substring</span>(<span class="hljs-number">0</span>, spaceIndex);
        <span class="hljs-keyword">const</span> userId = <span class="hljs-built_in">parseInt</span>(userIdStr, <span class="hljs-number">10</span>);
        
        <span class="hljs-keyword">if</span> (<span class="hljs-built_in">isNaN</span>(userId) || userId &lt;= <span class="hljs-number">0</span>) {
          errorCount++;
          errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Invalid user_id: <span class="hljs-subst">${userIdStr}</span>`</span>);
          <span class="hljs-keyword">continue</span>;
        }

        <span class="hljs-keyword">const</span> jsonStr = line.<span class="hljs-title function_">substring</span>(spaceIndex + <span class="hljs-number">1</span>);
        <span class="hljs-keyword">const</span> jsonData = <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">parse</span>(jsonStr) <span class="hljs-keyword">as</span> <span class="hljs-title class_">ApiRequest</span>;
        
        <span class="hljs-keyword">if</span> (!jsonData.<span class="hljs-property">locations</span> || !<span class="hljs-title class_">Array</span>.<span class="hljs-title function_">isArray</span>(jsonData.<span class="hljs-property">locations</span>)) {
          errorCount++;
          errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Invalid locations array in line`</span>);
          <span class="hljs-keyword">continue</span>;
        }

      <span class="hljs-comment">// Forward to upstream FIRST (same behavior as live ingest)</span>
      <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">const</span> upstreamResp = <span class="hljs-keyword">await</span> <span class="hljs-title function_">forwardReprocessRequest</span>(jsonData, userId);
        <span class="hljs-keyword">if</span> (!upstreamResp.<span class="hljs-property">ok</span>) {
          <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">`Upstream failed: <span class="hljs-subst">${upstreamResp.status}</span>`</span>);
        }
      } <span class="hljs-keyword">catch</span> (error) {
        errorCount++;
        errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Upstream error for user <span class="hljs-subst">${userId}</span>: <span class="hljs-subst">${<span class="hljs-built_in">String</span>(error)}</span>`</span>);
        <span class="hljs-keyword">continue</span>; <span class="hljs-comment">// skip DB insert if upstream fails</span>
      }

      <span class="hljs-comment">// Then write to database</span>
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">insertLocations</span>(jsonData.<span class="hljs-property">locations</span>, userId);

      processedCount++;
      userCounts[userId] = (userCounts[userId] || <span class="hljs-number">0</span>) + <span class="hljs-number">1</span>;

      } <span class="hljs-keyword">catch</span> (error) {
        errorCount++;
        errors.<span class="hljs-title function_">push</span>(<span class="hljs-string">`Error processing line: <span class="hljs-subst">${<span class="hljs-built_in">String</span>(error)}</span>`</span>);
        <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Error processing line:"</span>, error);
      }
    }

    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
      <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({
        <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span>,
        <span class="hljs-attr">message</span>: <span class="hljs-string">"Reprocessing completed"</span>,
        <span class="hljs-attr">date</span>: dateStr,
        <span class="hljs-attr">processed</span>: processedCount,
        <span class="hljs-attr">errors</span>: errorCount,
        <span class="hljs-attr">user_counts</span>: userCounts,
        <span class="hljs-attr">error_details</span>: errors.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span> ? errors.<span class="hljs-title function_">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-number">10</span>) : <span class="hljs-literal">undefined</span>, <span class="hljs-comment">// Limit error details</span>
      }),
      { <span class="hljs-attr">status</span>: <span class="hljs-number">200</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
    );
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">error</span>(<span class="hljs-string">"Reprocess error:"</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
      <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Internal server error"</span>, <span class="hljs-attr">details</span>: <span class="hljs-title class_">String</span>(error) }),
      { <span class="hljs-attr">status</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
    );
  }
}

<span class="hljs-comment">// Main server handler</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handler</span>(<span class="hljs-params"><span class="hljs-attr">request</span>: <span class="hljs-title class_">Request</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">Response</span>&gt; {
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);

  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span> === <span class="hljs-string">"/api/v1/ingest/overland"</span> &amp;&amp; request.<span class="hljs-property">method</span> === <span class="hljs-string">"POST"</span>) {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">handleApiRequest</span>(request);
  }

  <span class="hljs-comment">// Handle reprocess endpoint: /api/v1/reprocess/YYYY-MM-DD</span>
  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">"/api/v1/reprocess/"</span>) &amp;&amp; request.<span class="hljs-property">method</span> === <span class="hljs-string">"POST"</span>) {
    <span class="hljs-keyword">const</span> dateStr = url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">replace</span>(<span class="hljs-string">"/api/v1/reprocess/"</span>, <span class="hljs-string">""</span>);
    <span class="hljs-keyword">if</span> (dateStr &amp;&amp; <span class="hljs-regexp">/^\d{4}-\d{2}-\d{2}$/</span>.<span class="hljs-title function_">test</span>(dateStr)) {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> <span class="hljs-title function_">handleReprocessRequest</span>(request, dateStr);
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(
        <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">"Invalid date format in URL. Expected /api/v1/reprocess/YYYY-MM-DD"</span> }),
        { <span class="hljs-attr">status</span>: <span class="hljs-number">400</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span> } },
      );
    }
  }

  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">"Not Found"</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span> });
}

<span class="hljs-comment">// Start server</span>
<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">main</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">await</span> <span class="hljs-title function_">initDatabase</span>();

  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">`Server listening on port <span class="hljs-subst">${CONFIG.port}</span>`</span>);
  <span class="hljs-keyword">await</span> <span class="hljs-title function_">serve</span>(handler, { <span class="hljs-attr">hostname</span>: <span class="hljs-string">'0.0.0.0'</span>, <span class="hljs-attr">port</span>: <span class="hljs-variable constant_">CONFIG</span>.<span class="hljs-property">port</span> });
}

<span class="hljs-comment">// Handle cleanup</span>
<span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">addSignalListener</span>(<span class="hljs-string">"SIGINT"</span>, <span class="hljs-title function_">async</span> () =&gt; {
  <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">"\nShutting down..."</span>);
  <span class="hljs-keyword">if</span> (dbClient) {
    <span class="hljs-keyword">await</span> dbClient.<span class="hljs-title function_">end</span>();
  }
  <span class="hljs-title class_">Deno</span>.<span class="hljs-title function_">exit</span>(<span class="hljs-number">0</span>);
});

<span class="hljs-keyword">if</span> (<span class="hljs-keyword">import</span>.<span class="hljs-property">meta</span>.<span class="hljs-property">main</span>) {
  <span class="hljs-title function_">main</span>();
}

</code></pre>
<pre data-language="sql"><code class="language-sql"><span class="hljs-keyword">CREATE</span> TYPE public.battery_state_type <span class="hljs-keyword">AS</span> ENUM
    (<span class="hljs-string">'unknown'</span>, <span class="hljs-string">'charging'</span>, <span class="hljs-string">'full'</span>, <span class="hljs-string">'unplugged'</span>);

<span class="hljs-keyword">CREATE</span> TYPE public.motion_type <span class="hljs-keyword">AS</span> ENUM
    (<span class="hljs-string">'driving'</span>, <span class="hljs-string">'walking'</span>, <span class="hljs-string">'running'</span>, <span class="hljs-string">'cycling'</span>, <span class="hljs-string">'stationary'</span>, <span class="hljs-string">'automotive_navigation'</span>, <span class="hljs-string">'fitness'</span>, <span class="hljs-string">'other_navigation'</span>, <span class="hljs-string">'other'</span>, <span class="hljs-string">'moving'</span>, <span class="hljs-string">'uncertain'</span>);

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> IF <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> public.positions
(
    user_id <span class="hljs-type">integer</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span>,
    ts <span class="hljs-type">timestamp</span> <span class="hljs-keyword">without</span> <span class="hljs-type">time</span> zone <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span>,
    geom geometry(Point,<span class="hljs-number">4326</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span>,
    horizontal_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">6</span>,<span class="hljs-number">2</span>),
    altitude <span class="hljs-type">numeric</span>(<span class="hljs-number">7</span>,<span class="hljs-number">2</span>),
    vertical_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">5</span>,<span class="hljs-number">2</span>),
    course <span class="hljs-type">numeric</span>(<span class="hljs-number">4</span>,<span class="hljs-number">1</span>),
    course_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">4</span>,<span class="hljs-number">1</span>),
    speed <span class="hljs-type">numeric</span>(<span class="hljs-number">5</span>,<span class="hljs-number">2</span>),
    speed_accuracy <span class="hljs-type">numeric</span>(<span class="hljs-number">4</span>,<span class="hljs-number">1</span>),
    battery_state battery_state_type,
    battery_level <span class="hljs-type">numeric</span>(<span class="hljs-number">3</span>,<span class="hljs-number">2</span>),
    motions motion_type[],
    wifi text <span class="hljs-keyword">COLLATE</span> pg_catalog."default",
    <span class="hljs-keyword">CONSTRAINT</span> positions_pkey <span class="hljs-keyword">PRIMARY</span> KEY (ts, user_id, geom)
);
</code></pre>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Setting Up a Podman Machine Bridge Network on macOS: A Step-by-Step Guide]]></title>
        <id>https://wingu.se/2024/10/16/podman-compose-bridge-network.html</id>
        <link href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html"/>
        <updated>2024-10-17T02:30:00.000Z</updated>
        <summary type="html"><![CDATA[

本文中文在后面

Running containers on macOS using Podman involves setting up a Linux virtual machine (VM)...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2024/10/16/podman-compose-bridge-network&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<blockquote>
<p>本文中文在后面</p>
</blockquote>
<p>Running containers on macOS using Podman involves setting up a Linux virtual machine (VM) to handle the containers. However, when using a Docker Compose configuration with a <code>bridge</code> network, you may encounter challenges accessing the containers directly from macOS. This blog will guide you through how to set up a bridge network between your Podman VM and macOS using WireGuard, enabling direct access to your containers.</p>
<h2 id="problem-overview" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#problem-overview">Problem Overview</a></h2>
<p>Let’s say you have a Docker Compose configuration like the one below, which defines a Redis leader and replica on a custom network:</p>
<pre data-language="yaml"><code class="language-yaml"><span class="hljs-attr">services:</span>
  <span class="hljs-attr">redis-leader:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:6.2.6-alpine</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-attr">my-net:</span>
        <span class="hljs-attr">ipv4_address:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.100</span>

  <span class="hljs-attr">redis-replica:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:6.2.6-alpine</span>
    <span class="hljs-attr">command:</span> <span class="hljs-string">redis-server</span> <span class="hljs-string">--replicaof</span> <span class="hljs-string">redis-leader</span> <span class="hljs-number">6379</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-leader</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-attr">my-net:</span>
        <span class="hljs-attr">ipv4_address:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.101</span>

<span class="hljs-attr">networks:</span>
  <span class="hljs-attr">my-net:</span>
    <span class="hljs-attr">driver:</span> <span class="hljs-string">bridge</span>
    <span class="hljs-attr">ipam:</span>
      <span class="hljs-attr">config:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">subnet:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.0</span><span class="hljs-string">/24</span>
</code></pre>
<p>In this setup, you won’t be able to directly access the Redis containers from macOS because the bridge network only connects the containers inside the Podman VM, isolating them from the macOS host.</p>
<h3 id="solution%3A-use-wireguard-to-bridge-networks" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#solution%3A-use-wireguard-to-bridge-networks">Solution: Use WireGuard to Bridge Networks</a></h3>
<p>To solve this problem, we will set up a WireGuard connection between the Podman VM and macOS. This setup allows macOS to communicate with containers running on the VM.</p>
<h2 id="step-1%3A-install-wireguard-on-macos" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#step-1%3A-install-wireguard-on-macos">Step 1: Install WireGuard on macOS</a></h2>
<p>Start by installing the WireGuard tools on your macOS system using Homebrew:</p>
<pre data-language="bash"><code class="language-bash">brew install wireguard-tools
</code></pre>
<h2 id="step-2%3A-generate-keys-for-wireguard" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#step-2%3A-generate-keys-for-wireguard">Step 2: Generate Keys for WireGuard</a></h2>
<p>Next, you need to generate two pairs of public and private keys—one for the Podman VM and one for macOS. Run the following command twice to generate the keys:</p>
<pre data-language="bash"><code class="language-bash">wg genkey | <span class="hljs-built_in">tee</span> /dev/stderr | wg pubkey
</code></pre>
<p>The first line of the output will be the private key, and the second line will be the public key. Make sure to run this command twice to generate two sets of keys.</p>
<h2 id="step-3%3A-configure-wireguard-on-macos" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#step-3%3A-configure-wireguard-on-macos">Step 3: Configure WireGuard on macOS</a></h2>
<p>After generating the keys, configure WireGuard on macOS. First, create the necessary directory:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">mkdir</span> -p /opt/homebrew/etc/wireguard/
</code></pre>
<p>Then, create the configuration file <code>/opt/homebrew/etc/wireguard/wg0.conf</code>:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">cat</span> &lt;&lt;<span class="hljs-string">EOF &gt; /opt/homebrew/etc/wireguard/wg0.conf
[Interface]
PrivateKey = &lt;private key A&gt; # private key for macOS
Address = 10.0.0.2/24 # WireGuard IP for macOS
ListenPort = 51820 # listening port for WireGuard
PostUp = ifconfig lo0 inet 100.64.64.64/30 100.64.64.64 alias
PostDown = ifconfig lo0 inet 100.64.64.64/30 100.64.64.64 delete

[Peer]
PublicKey = &lt;public key B&gt; # public key for Podman VM
AllowedIPs = 10.2.0.0/16, 10.0.0.1/32 # range of the bridge network
PersistentKeepalive = 25
EOF</span>
</code></pre>
<p>The <code>AllowedIPs</code> field should match your Docker bridge network range. Start WireGuard on macOS using the following command:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> wg-quick up wg0
</code></pre>
<p>Check the status of WireGuard by running:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> wg
</code></pre>
<p>Keep in mind that after a reboot, you’ll need to run <code>sudo wg-quick up wg0</code> again to restart WireGuard.</p>
<h2 id="step-4%3A-set-up-wireguard-on-the-podman-vm" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#step-4%3A-set-up-wireguard-on-the-podman-vm">Step 4: Set Up WireGuard on the Podman VM</a></h2>
<p>Next, log in to the Podman VM via SSH:</p>
<pre data-language="bash"><code class="language-bash">podman machine ssh
</code></pre>
<p>Create a WireGuard configuration file <code>/etc/wireguard/wg0.conf</code>:</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">cat</span> &lt;&lt; <span class="hljs-string">EOF &gt; /etc/wireguard/wg0.conf
[Interface]
PrivateKey = &lt;private key B&gt; # private key for the Podman VM
Address = 10.0.0.1/24 # WireGuard IP for Podman VM
PostUp = iptables -A FORWARD -i %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT

[Peer]
PublicKey = &lt;public key A&gt; # public key for macOS
AllowedIPs = 10.0.0.2/32 # WireGuard IP for macOS
Endpoint = 100.64.64.64:51820
PersistentKeepalive = 25
EOF</span>
</code></pre>
<p>Start WireGuard on the Podman VM with:</p>
<pre data-language="bash"><code class="language-bash">wg-quick up wg0
</code></pre>
<p>To ensure that WireGuard starts automatically whenever the Podman machine starts, enable it with:</p>
<pre data-language="bash"><code class="language-bash">systemctl <span class="hljs-built_in">enable</span> wg-quick@wg0
</code></pre>
<h2 id="step-5%3A-access-the-containers-from-macos" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#step-5%3A-access-the-containers-from-macos">Step 5: Access the Containers from macOS</a></h2>
<p>Once WireGuard is running on both macOS and the Podman VM, you should be able to access the containers directly from macOS. For example, to ping the Redis replica:</p>
<pre data-language="bash"><code class="language-bash">ping 10.2.2.101
</code></pre>
<p>You should receive a response like:</p>
<pre data-language="bash"><code class="language-bash">PING 10.2.2.101 (10.2.2.101): 56 data bytes
64 bytes from 10.2.2.101: icmp_seq=0 ttl=63 time=5.992 ms
</code></pre>
<h2 id="conclusion" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#conclusion">Conclusion</a></h2>
<p>By configuring a WireGuard connection between your Podman VM and macOS, you can successfully bridge the network and access containers directly from your macOS host. This setup is particularly useful when working with isolated containers in a <code>bridge</code> network on macOS using Podman.</p>
<hr>
<h1 id="%E5%9C%A8macos%E4%B8%8A%E8%AE%BE%E7%BD%AEpodman%E8%99%9A%E6%8B%9F%E6%9C%BA%E6%A1%A5%E6%8E%A5%E7%BD%91%E7%BB%9C%EF%BC%9A%E9%80%90%E6%AD%A5%E6%8C%87%E5%8D%97" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E5%9C%A8macos%E4%B8%8A%E8%AE%BE%E7%BD%AEpodman%E8%99%9A%E6%8B%9F%E6%9C%BA%E6%A1%A5%E6%8E%A5%E7%BD%91%E7%BB%9C%EF%BC%9A%E9%80%90%E6%AD%A5%E6%8C%87%E5%8D%97">在macOS上设置Podman虚拟机桥接网络：逐步指南</a></h1>
<p>在macOS上使用Podman运行容器时，涉及设置一个Linux虚拟机（VM）来处理容器。然而，当你使用一个<code>bridge</code>网络的Docker Compose配置时，可能会遇到无法直接从macOS访问容器的问题。本篇博客将指导您如何通过使用WireGuard在Podman虚拟机和macOS之间设置桥接网络，从而实现对容器的直接访问。</p>
<h2 id="%E9%97%AE%E9%A2%98%E6%A6%82%E8%BF%B0" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E9%97%AE%E9%A2%98%E6%A6%82%E8%BF%B0">问题概述</a></h2>
<p>假设您有如下的Docker Compose配置，它在自定义网络上定义了一个Redis主节点和副本：</p>
<pre data-language="yaml"><code class="language-yaml"><span class="hljs-attr">services:</span>
  <span class="hljs-attr">redis-leader:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:6.2.6-alpine</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-attr">my-net:</span>
        <span class="hljs-attr">ipv4_address:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.100</span>

  <span class="hljs-attr">redis-replica:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:6.2.6-alpine</span>
    <span class="hljs-attr">command:</span> <span class="hljs-string">redis-server</span> <span class="hljs-string">--replicaof</span> <span class="hljs-string">redis-leader</span> <span class="hljs-number">6379</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-leader</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-attr">my-net:</span>
        <span class="hljs-attr">ipv4_address:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.101</span>

<span class="hljs-attr">networks:</span>
  <span class="hljs-attr">my-net:</span>
    <span class="hljs-attr">driver:</span> <span class="hljs-string">bridge</span>
    <span class="hljs-attr">ipam:</span>
      <span class="hljs-attr">config:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">subnet:</span> <span class="hljs-number">10.2</span><span class="hljs-number">.2</span><span class="hljs-number">.0</span><span class="hljs-string">/24</span>
</code></pre>
<p>在这个设置中，您将无法直接从macOS访问Redis容器，因为桥接网络仅连接Podman虚拟机内部的容器，使它们与macOS主机隔离。</p>
<h3 id="%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%EF%BC%9A%E4%BD%BF%E7%94%A8wireguard%E6%A1%A5%E6%8E%A5%E7%BD%91%E7%BB%9C" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E8%A7%A3%E5%86%B3%E6%96%B9%E6%A1%88%EF%BC%9A%E4%BD%BF%E7%94%A8wireguard%E6%A1%A5%E6%8E%A5%E7%BD%91%E7%BB%9C">解决方案：使用WireGuard桥接网络</a></h3>
<p>为了解决这个问题，我们将设置一个WireGuard连接，使Podman虚拟机和macOS之间的通信变得可能。这一设置允许macOS与运行在虚拟机中的容器进行通信。</p>
<h2 id="%E7%AC%AC%E4%B8%80%E6%AD%A5%EF%BC%9A%E5%9C%A8macos%E4%B8%8A%E5%AE%89%E8%A3%85wireguard" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E7%AC%AC%E4%B8%80%E6%AD%A5%EF%BC%9A%E5%9C%A8macos%E4%B8%8A%E5%AE%89%E8%A3%85wireguard">第一步：在macOS上安装WireGuard</a></h2>
<p>首先，使用Homebrew在macOS上安装WireGuard工具：</p>
<pre data-language="bash"><code class="language-bash">brew install wireguard-tools
</code></pre>
<h2 id="%E7%AC%AC%E4%BA%8C%E6%AD%A5%EF%BC%9A%E4%B8%BAwireguard%E7%94%9F%E6%88%90%E5%AF%86%E9%92%A5" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E7%AC%AC%E4%BA%8C%E6%AD%A5%EF%BC%9A%E4%B8%BAwireguard%E7%94%9F%E6%88%90%E5%AF%86%E9%92%A5">第二步：为WireGuard生成密钥</a></h2>
<p>接下来，您需要为Podman虚拟机和macOS分别生成两对公钥和私钥。运行以下命令两次以生成密钥：</p>
<pre data-language="bash"><code class="language-bash">wg genkey | <span class="hljs-built_in">tee</span> /dev/stderr | wg pubkey
</code></pre>
<p>输出的第一行是私钥，第二行是公钥。请确保运行两次命令以生成两组密钥。</p>
<h2 id="%E7%AC%AC%E4%B8%89%E6%AD%A5%EF%BC%9A%E5%9C%A8macos%E4%B8%8A%E9%85%8D%E7%BD%AEwireguard" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E7%AC%AC%E4%B8%89%E6%AD%A5%EF%BC%9A%E5%9C%A8macos%E4%B8%8A%E9%85%8D%E7%BD%AEwireguard">第三步：在macOS上配置WireGuard</a></h2>
<p>生成密钥后，在macOS上配置WireGuard。首先，创建所需的目录：</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> <span class="hljs-built_in">mkdir</span> -p /opt/homebrew/etc/wireguard/
</code></pre>
<p>然后，创建配置文件 <code>/opt/homebrew/etc/wireguard/wg0.conf</code>：</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">cat</span> &lt;&lt;<span class="hljs-string">EOF &gt; /opt/homebrew/etc/wireguard/wg0.conf
[Interface]
PrivateKey = &lt;private key A&gt; # macOS的私钥
Address = 10.0.0.2/24 # macOS的WireGuard IP
ListenPort = 51820 # WireGuard监听端口
PostUp = ifconfig lo0 inet 100.64.64.64/30 100.64.64.64 alias
PostDown = ifconfig lo0 inet 100.64.64.64/30 100.64.64.64 delete

[Peer]
PublicKey = &lt;public key B&gt; # Podman虚拟机的公钥
AllowedIPs = 10.2.0.0/16, 10.0.0.1/32 # 桥接网络的范围
PersistentKeepalive = 25
EOF</span>
</code></pre>
<p><code>AllowedIPs</code>字段应与您的Docker桥接网络范围匹配。使用以下命令启动macOS上的WireGuard：</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> wg-quick up wg0
</code></pre>
<p>通过运行以下命令检查WireGuard的状态：</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">sudo</span> wg
</code></pre>
<p>请注意，重启后您需要再次运行<code>sudo wg-quick up wg0</code>来重新启动WireGuard。</p>
<h2 id="%E7%AC%AC%E5%9B%9B%E6%AD%A5%EF%BC%9A%E5%9C%A8podman%E8%99%9A%E6%8B%9F%E6%9C%BA%E4%B8%8A%E8%AE%BE%E7%BD%AEwireguard" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E7%AC%AC%E5%9B%9B%E6%AD%A5%EF%BC%9A%E5%9C%A8podman%E8%99%9A%E6%8B%9F%E6%9C%BA%E4%B8%8A%E8%AE%BE%E7%BD%AEwireguard">第四步：在Podman虚拟机上设置WireGuard</a></h2>
<p>接下来，通过SSH登录到Podman虚拟机：</p>
<pre data-language="bash"><code class="language-bash">podman machine ssh
</code></pre>
<p>创建WireGuard配置文件 <code>/etc/wireguard/wg0.conf</code>：</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-built_in">cat</span> &lt;&lt; <span class="hljs-string">EOF &gt; /etc/wireguard/wg0.conf
[Interface]
PrivateKey = &lt;private key B&gt; # Podman虚拟机的私钥
Address = 10.0.0.1/24 # Podman虚拟机的WireGuard IP
PostUp = iptables -A FORWARD -i %i -j ACCEPT
PostDown = iptables -D FORWARD -i %i -j ACCEPT

[Peer]
PublicKey = &lt;public key A&gt; # macOS的公钥
AllowedIPs = 10.0.0.2/32 # macOS的WireGuard IP
Endpoint = 100.64.64.64:51820
PersistentKeepalive = 25
EOF</span>
</code></pre>
<p>在Podman虚拟机上启动WireGuard：</p>
<pre data-language="bash"><code class="language-bash">wg-quick up wg0
</code></pre>
<p>为了确保每次Podman虚拟机启动时WireGuard也自动启动，运行以下命令启用它：</p>
<pre data-language="bash"><code class="language-bash">systemctl <span class="hljs-built_in">enable</span> wg-quick@wg0
</code></pre>
<h2 id="%E7%AC%AC%E4%BA%94%E6%AD%A5%EF%BC%9A%E4%BB%8Emacos%E8%AE%BF%E9%97%AE%E5%AE%B9%E5%99%A8" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E7%AC%AC%E4%BA%94%E6%AD%A5%EF%BC%9A%E4%BB%8Emacos%E8%AE%BF%E9%97%AE%E5%AE%B9%E5%99%A8">第五步：从macOS访问容器</a></h2>
<p>一旦macOS和Podman虚拟机上的WireGuard都运行起来，您就可以从macOS直接访问容器了。例如，要ping Redis副本：</p>
<pre data-language="bash"><code class="language-bash">ping 10.2.2.101
</code></pre>
<p>您应该收到如下响应：</p>
<pre data-language="bash"><code class="language-bash">PING 10.2.2.101 (10.2.2.101): 56 data bytes
64 bytes from 10.2.2.101: icmp_seq=0 ttl=63 time=5.992 ms
</code></pre>
<h2 id="%E7%BB%93%E8%AE%BA" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2024/10/16/podman-compose-bridge-network.html#%E7%BB%93%E8%AE%BA">结论</a></h2>
<p>通过在Podman虚拟机和macOS之间配置WireGuard连接，您可以成功地桥接网络并直接从macOS主机访问容器。这一设置在使用Podman在macOS上处理隔离的<code>bridge</code>网络中的容器时特别有用。</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Apple allows applications to track user locations without authorization]]></title>
        <id>https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html</id>
        <link href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html"/>
        <updated>2023-11-30T16:30:00.000Z</updated>
        <summary type="html"><![CDATA[

中文在英文文末

Apple asserts itself as a champion of user privacy; however, this claim will be proven un...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<blockquote>
<p>中文在英文文末</p>
</blockquote>
<p>Apple asserts itself as a champion of user privacy; however, this claim will be proven untrue in this article.
For almost a decade, Apple allowed apps had the capability to track users' locations without affording them the option to disable this feature or even raising awareness about it.
And this is "ONLY APPLE CAN DO"!</p>
<h2 id="the-hotspothelper-api-in-action" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#the-hotspothelper-api-in-action">The HotspotHelper API in Action</a></h2>
<p>Since the introduction of iOS 9 in 2015, Apple has included an API call named "HotspotHelper," enabling developers to request a capability for their apps to assist the system in connecting to WiFi access points.
Let's delve into how this API works with a simplified code snippet:</p>
<pre data-language="swift"><code class="language-swift"><span class="hljs-keyword">import</span> CoreLocation
<span class="hljs-keyword">import</span> NetworkExtension

<span class="hljs-keyword">class</span> <span class="hljs-title class_">LocationTrackingManager</span> {
    <span class="hljs-keyword">func</span> <span class="hljs-title function_">setupHotspotHelper</span>() {
        <span class="hljs-comment">// Request HotspotHelper capability</span>
        <span class="hljs-type">NEHotspotHelper</span>.register(options: <span class="hljs-literal">nil</span>, queue: <span class="hljs-type">DispatchQueue</span>.main) { (command) <span class="hljs-keyword">in</span>
            <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> networkList <span class="hljs-operator">=</span> command.networkList {
                <span class="hljs-keyword">for</span> network <span class="hljs-keyword">in</span> networkList {
                    <span class="hljs-comment">// Access WiFi network information (SSID, MAC address)</span>
                    <span class="hljs-comment">// see: https://developer.apple.com/documentation/networkextension/nehotspotnetwork</span>
                    <span class="hljs-keyword">let</span> ssid <span class="hljs-operator">=</span> network.ssid
                    <span class="hljs-keyword">let</span> macAddress <span class="hljs-operator">=</span> network.bssid

                    <span class="hljs-comment">// Perform location tracking logic with ssid and macAddress</span>
                    <span class="hljs-keyword">self</span>.trackLocation(withSSID: ssid, andMACAddress: macAddress)
                }
            }
        }
    }

    <span class="hljs-keyword">func</span> <span class="hljs-title function_">trackLocation</span>(<span class="hljs-params">withSSID</span> <span class="hljs-params">ssid</span>: <span class="hljs-type">String</span>, <span class="hljs-params">andMACAddress</span> <span class="hljs-params">macAddress</span>: <span class="hljs-type">String</span>) {
        <span class="hljs-comment">// Your location tracking logic goes here</span>
        <span class="hljs-comment">// Use the ssid and macAddress to determine user location</span>
    }
}
</code></pre>
<p>This snippet demonstrates how developers can utilize the HotspotHelper API to register for WiFi network information.
The trackLocation method showcases the potential for extracting data that can be used for location tracking.</p>
<h2 id="the-privacy-dilemma" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#the-privacy-dilemma">The Privacy Dilemma</a></h2>
<p>The real cause for concern arises from the fact that, with access to such information, apps can effectively track a user's location.
This is based on the premise that most WiFi access points remain stationary after deployment, providing a consistent reference for triangulating a user's whereabouts.
Public API avalible such as <a href="https://developer.precisely.com/apis/geolocation" target="_blank" rel="noopener noreferrer">Precisely Location By Wi-fi Access Point</a>,
<a href="https://developers.google.com/maps/documentation/geolocation/requests-geolocation" target="_blank" rel="noopener noreferrer">Google's Geolocation API</a>.
While the intentions behind HotspotHelper may be rooted in facilitating seamless connectivity, the unintended consequence of potential location tracking without explicit user consent raises eyebrows in the ongoing privacy debate.</p>
<p>This capability is activated whenever the user's device scans nearby WiFi access points, extending beyond explicit user engagement with the system settings to include instances where the device is locked in someone's pocket.
The system will initiate the registered app with this API, enabling the app to retrieve nearby SSIDs and their MAC addresses and transmit this information to the server side.
Consequently, if the app developer wishes, they possess the capability to nearly real-time track the user's location.
Importantly, users remain unaware of this process occurring on their screens, and they lack the option to disable it.
On the other hand, almost all the users doesn't know the App has this feature and they don't need/use this feature to help their lives.
But again, they have no choice, their devices has to launch the App and submit near by WiFi info to the developers of the App.</p>
<h2 id="global-impact%3A-wechat-and-alipay" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#global-impact%3A-wechat-and-alipay">Global Impact: WeChat and Alipay</a></h2>
<p>Adding another layer to the discussion is the fact that major apps like WeChat and Alipay have already implemented this capability.
These two apps are ubiquitous in mainland China, touching almost every aspect of people's lives.
The widespread use of these applications in a densely populated region intensifies the implications of location tracking without user consent.</p>
<p>A compelling debate could center around whether WeChat and/or Alipay function as responsible citizens in the app world,
asserting that their data collection aims solely at enhancing user experience and facilitating seamless connections to nearby WiFi.
Nevertheless, the opaque server-side logic embedded in their code raises questions.
Could it be that once again, "ONLY APPLE CAN DO" in terms of ensuring transparency and accountability?</p>
<h2 id="apple's-%22response%22" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#apple's-%22response%22">Apple's "response"</a></h2>
<p>In reality, I discovered this issue approximately two years ago and created a <a href="https://www.bilibili.com/video/BV16Z4y1Q7fN/" target="_blank" rel="noopener noreferrer">video</a> on Bilibili (a Chinese alternative to YouTube) discussing the matter.
However, it has only very limited public awareness. I also brought this concern to Apple's attention and received an email response, but as of now, there has been no further update on the matter.</p>
<p><img src="https://wingu.se/_file/images/apple-response-to-hotspot-helper.3579bd72.jpg" alt="Apple email response regarding HotspotHelper"></p>
<h2 id="conclusions" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#conclusions">Conclusions</a></h2>
<p>I strongly advocate for Apple to offer users the option to disable this feature, akin to other privacy settings such as location and notifications.
Apps should explicitly seek permission before accessing this feature, ensuring users have the ability to grant or deny access while using the app.</p>
<p>As the conversation around digital privacy continues to evolve, Apple finds itself navigating the fine line between innovation and safeguarding user data.
The question remains: can Apple maintain its commitment to privacy while addressing concerns raised by the HotspotHelper feature?
Only time will tell how this controversial aspect fits into Apple's broader privacy narrative.</p>
<blockquote>
<p>Credit: This article was written with the assistance of ChatGPT for the purpose of refining my English writing.</p>
</blockquote>
<hr>
<p>苹果公司自诩为用户隐私的捍卫者，然而这并非事实。
在近十年的时间里，苹果允许应用程序具备跟踪用户位置的能力，而不提供关闭此功能或引起用户对此的关注的选项。
而且这是「只有苹果可以做到的」（Only Apple Can Do）！</p>
<h2 id="hotspothelper-api-%E7%9A%84%E7%A4%BA%E4%BE%8B%E4%BB%A3%E7%A0%81" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#hotspothelper-api-%E7%9A%84%E7%A4%BA%E4%BE%8B%E4%BB%A3%E7%A0%81">HotspotHelper API 的示例代码</a></h2>
<p>自 2015 年 iOS 9 推出以来，苹果已经包含了一个名为「HotspotHelper」的 API 调用，使开发人员能够请求其应用程序协助系统连接到 WiFi 接入点的能力。
让我们深入了解这个 API 是如何与一个简化的代码片段一起工作的：</p>
<pre data-language="swift"><code class="language-swift"><span class="hljs-keyword">import</span> CoreLocation
<span class="hljs-keyword">import</span> NetworkExtension

<span class="hljs-keyword">class</span> <span class="hljs-title class_">LocationTrackingManager</span> {
    <span class="hljs-keyword">func</span> <span class="hljs-title function_">setupHotspotHelper</span>() {
        <span class="hljs-comment">// 请求 HotspotHelper 能力</span>
        <span class="hljs-type">NEHotspotHelper</span>.register(options: <span class="hljs-literal">nil</span>, queue: <span class="hljs-type">DispatchQueue</span>.main) { (command) <span class="hljs-keyword">in</span>
            <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> networkList <span class="hljs-operator">=</span> command.networkList {
                <span class="hljs-keyword">for</span> network <span class="hljs-keyword">in</span> networkList {
                    <span class="hljs-comment">// 访问 WiFi 网络信息（SSID、MAC 地址）</span>
                    <span class="hljs-comment">// 参见：https://developer.apple.com/documentation/networkextension/nehotspotnetwork</span>
                    <span class="hljs-keyword">let</span> ssid <span class="hljs-operator">=</span> network.ssid
                    <span class="hljs-keyword">let</span> macAddress <span class="hljs-operator">=</span> network.bssid

                    <span class="hljs-comment">// 使用 ssid 和 macAddress 执行位置跟踪逻辑</span>
                    <span class="hljs-keyword">self</span>.trackLocation(withSSID: ssid, andMACAddress: macAddress)
                }
            }
        }
    }

    <span class="hljs-keyword">func</span> <span class="hljs-title function_">trackLocation</span>(<span class="hljs-params">withSSID</span> <span class="hljs-params">ssid</span>: <span class="hljs-type">String</span>, <span class="hljs-params">andMACAddress</span> <span class="hljs-params">macAddress</span>: <span class="hljs-type">String</span>) {
        <span class="hljs-comment">// 你的位置跟踪逻辑在这里</span>
        <span class="hljs-comment">// 使用 ssid 和 macAddress 确定用户位置</span>
    }
}
</code></pre>
<p>这个片段演示了开发人员如何利用 HotspotHelper API 注册 WiFi 网络信息。
<code>trackLocation</code> 方法展示了提取可用于位置跟踪的数据的潜力。</p>
<h2 id="%E9%9A%90%E7%A7%81%E5%9B%B0%E5%A2%83" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#%E9%9A%90%E7%A7%81%E5%9B%B0%E5%A2%83">隐私困境</a></h2>
<p>真正引起关注的原因在于，有了这样的信息访问权限，应用程序可以有效地跟踪用户的位置。
这是基于这样一个前提，即大多数 WiFi 接入点在部署后保持不动，为三角定位用户位置提供了一个一致的参考。
公开的 API 包括 <a href="https://developer.precisely.com/apis/geolocation" target="_blank" rel="noopener noreferrer">Precisely 的 Wi-fi 接入点的准确位置</a>，
<a href="https://developers.google.com/maps/documentation/geolocation/requests-geolocation" target="_blank" rel="noopener noreferrer">Google 的 Geolocation API</a>。
尽管 HotspotHelper 的初衷可能是促进无缝连接，但潜在的未经用户明示同意的位置跟踪的意外后果应在持续的隐私辩论中引起关注。</p>
<p>这一功能在用户设备扫描附近 WiFi 接入点时激活，超出了用户明确与系统设置互动的情况，还包括设备被锁在口袋里的情况。
系统将使用此 API 启动注册的应用程序，使应用程序检索附近的 SSID 和它们的 MAC 地址，并将此信息传输到服务器端。
因此，如果应用程序开发人员希望，他们就可以几乎实时跟踪用户的位置。
重要的是，用户对其屏幕上发生的此过程毫不知情，并且他们无法禁用它。
另一方面，几乎所有用户都不知道应用程序具有此功能，他们不需要/使用此功能来帮助他们的生活。
但再次，他们别无选择，他们的设备必须启动应用程序并将附近的 WiFi 信息提交给应用程序的开发人员。</p>
<h2 id="%E4%B8%96%E7%95%8C%E8%8C%83%E5%9B%B4%E7%9A%84%E5%BD%B1%E5%93%8D%EF%BC%9A%E5%BE%AE%E4%BF%A1%E5%92%8C%E6%94%AF%E4%BB%98%E5%AE%9D" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#%E4%B8%96%E7%95%8C%E8%8C%83%E5%9B%B4%E7%9A%84%E5%BD%B1%E5%93%8D%EF%BC%9A%E5%BE%AE%E4%BF%A1%E5%92%8C%E6%94%AF%E4%BB%98%E5%AE%9D">世界范围的影响：微信和支付宝</a></h2>
<p>讨论的另一个层面是微信和支付宝等主要应用已经实施了这一功能。
这两个应用在中国大陆无处不在，几乎触及人们生活的方方面面。
这些应用在人口密集地区的广泛使用加剧了未经用户同意的位置跟踪的影响。</p>
<p>一个可能有力的抗辩可能会说，微信和/或支付宝是在应用程序世界中有责任感的公民，他们的数据收集目的仅在于增强用户体验和促进与附近 WiFi 的无缝连接。
然而，我们无法审查他们服务器端的代码，我们无从得知从我们设备发送出去的数据他们会怎么处理。
难道再次可以说，「只有苹果可以做到的」（Only Apple Can Do）确保他们的透明度和付责任吗？</p>
<h2 id="%E8%8B%B9%E6%9E%9C%E7%9A%84%E3%80%8C%E5%9B%9E%E5%BA%94%E3%80%8D" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#%E8%8B%B9%E6%9E%9C%E7%9A%84%E3%80%8C%E5%9B%9E%E5%BA%94%E3%80%8D">苹果的「回应」</a></h2>
<p>实际上，我大约两年前发现了这个问题，并在哔哩哔哩上创建了一个 <a href="https://www.bilibili.com/video/BV16Z4y1Q7fN/" target="_blank" rel="noopener noreferrer">视频</a> 来讨论这个问题。
然而，它的公众认知非常有限。我还把这个问题带给了苹果的注意，并收到了一封电子邮件回复，但截至目前，对此事并没有进一步的更新。</p>
<p><img src="https://wingu.se/_file/images/apple-response-to-hotspot-helper.3579bd72.jpg" alt="苹果关于 HotspotHelper 的电子邮件回应"></p>
<h2 id="%E5%B0%8F%E7%BB%93" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2023/11/30/only-apple-can-do-allow-apps-tracking-users-location-without-consensus.html#%E5%B0%8F%E7%BB%93">小结</a></h2>
<p>我强烈主张苹果向用户提供禁用此功能的选项，类似于其他隐私设置，如位置和通知。
应用程序在访问此功能之前应明确请求权限，确保用户在使用应用程序时具有授予或拒绝访问的能力。</p>
<p>随着数字隐私讨论的不断发展，苹果会发现自己在创新和保护用户数据之间的窄缝中航行。
问题仍然是：苹果是否希望在解决 HotspotHelper 功能引起的担忧的，保持对隐私的承诺？
只有时间能告诉我们这种致用户隐私不顾的行为，会如何融入到苹果宏大的隐私叙事中。</p>
<blockquote>
<p>致谢：本文是在 ChatGPT 的协助下写成，目的是完善我的英语写作。</p>
</blockquote>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[从 YubiKey 上导入 GPG public key]]></title>
        <id>https://wingu.se/2023/11/18/import-gpg-key-from-yubikey.html</id>
        <link href="https://wingu.se/2023/11/18/import-gpg-key-from-yubikey.html"/>
        <updated>2023-11-18T20:30:00.000Z</updated>
        <summary type="html"><![CDATA[
本文主要参考了这篇博客，以及这里，这里是一个简单总结。
# reset all gpg data
rm -r ~/.gnupg

# list key on YubiKey
gpg --card-s...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2023/11/18/import-gpg-key-from-yubikey&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>本文主要参考了<a href="https://www.nicksherlock.com/2021/08/recovering-lost-gpg-public-keys-from-your-yubikey/" target="_blank" rel="noopener noreferrer">这篇博客</a>，以及<a href="https://github.com/drduh/YubiKey-Guide/tree/master" target="_blank" rel="noopener noreferrer">这里</a>，这里是一个简单总结。</p>
<pre data-language="shell"><code class="language-shell"><span class="hljs-meta prompt_"># </span><span class="language-bash">reset all gpg data</span>
rm -r ~/.gnupg
<span class="hljs-meta prompt_">
# </span><span class="language-bash">list key on YubiKey</span>
gpg --card-status --with-keygrip
<span class="hljs-meta prompt_">
# </span><span class="language-bash">get the <span class="hljs-built_in">date</span> time of the key above and generate pub key with the <span class="hljs-built_in">date</span> above</span>
gpg --faked-system-time '20231112T191616!' --full-generate-key
<span class="hljs-meta prompt_">
# </span><span class="language-bash">import the subkeys, use `addkey` <span class="hljs-keyword">in</span> the prompt</span>
gpg --faked-system-time '20231112T191616!' --edit-key A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6
<span class="hljs-meta prompt_">
# </span><span class="language-bash"><span class="hljs-built_in">export</span> key</span>
gpg --armor --export A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6
<span class="hljs-meta prompt_">
# </span><span class="language-bash">import key</span>
gpg --import keys/*
<span class="hljs-meta prompt_">
# </span><span class="language-bash">trust key, use the trust <span class="hljs-built_in">command</span> <span class="hljs-keyword">in</span> the prompt</span>
gpg --edit-key A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6
<span class="hljs-meta prompt_">
# </span><span class="language-bash">admin the yubikey</span>
gpg --card-edit
<span class="hljs-meta prompt_">
# </span><span class="language-bash">encrypt</span>
gpg --encrypt \
  --recipient BE387B4AEF2E85A025C0EAF8A603F43145D6FC6D \
  --recipient A83F5C04715B2C25DB2FBEA7DBBF1C31DD587CC6 \
  --output output.gpg \
  input_file.txt
<span class="hljs-meta prompt_">
# </span><span class="language-bash">list the encrypted file</span>
gpg --pinentry-mode cancel --list-packets file.gpg
</code></pre>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[DNAT 保留客户端 IP 且源进源出]]></title>
        <id>https://wingu.se/2023/04/21/dnat-source-in-source-out.html</id>
        <link href="https://wingu.se/2023/04/21/dnat-source-in-source-out.html"/>
        <updated>2023-04-21T21:30:00.000Z</updated>
        <summary type="html"><![CDATA[
最近搞了一个对称的宽带，所以想把一些服务挪到家里。。毕竟可信计算这种东西，还是跑自己的硬件比较好。
如果只是简单做端口映射，那么家里的服务器是看不到客户端实际的地址的，所以想搞点事情。这个事情应该也...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2023/04/21/dnat-source-in-source-out&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>最近搞了一个对称的宽带，所以想把一些服务挪到家里。。毕竟可信计算这种东西，还是跑自己的硬件比较好。</p>
<p>如果只是简单做端口映射，那么家里的服务器是看不到客户端实际的地址的，所以想搞点事情。这个事情应该也不算少见，我也鼓捣过 iptables，我其实很早就写好了服务器的 DNAT 了，就是死活调不通。</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-meta">#!/bin/sh</span>

sysctl -w net.ipv4.ip_forward=1
iptables -P FORWARD DROP
iptables -F FORWARD
iptables -t nat -F

wg-quick down wg_px
wg-quick up wg_px

pub_addr=1.2.3.4
prv_addr=192.168.101.2
pub_if=eth0
prv_if=wg_px
proto=tcp


<span class="hljs-function"><span class="hljs-title">port_map</span></span>() {
  bind_port=<span class="hljs-variable">$1</span>
  prv_port=<span class="hljs-variable">$2</span>

  iptables -t nat -A PREROUTING -p <span class="hljs-variable">$proto</span> -d <span class="hljs-variable">$pub_addr</span> --dport <span class="hljs-variable">$bind_port</span> -j DNAT --to <span class="hljs-variable">$prv_addr</span>:<span class="hljs-variable">$prv_port</span>
  iptables -I FORWARD -p <span class="hljs-variable">$proto</span> -i <span class="hljs-variable">$pub_if</span> -o <span class="hljs-variable">$prv_if</span> -d <span class="hljs-variable">$prv_addr</span> --dport <span class="hljs-variable">$prv_port</span> -j ACCEPT
  iptables -t nat -A POSTROUTING -p <span class="hljs-variable">$proto</span> -s <span class="hljs-variable">$prv_addr</span> --sport <span class="hljs-variable">$prv_port</span> -j SNAT --to <span class="hljs-variable">$pub_addr</span>:<span class="hljs-variable">$bind_port</span>
}

iptables -I FORWARD -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT
iptables -I FORWARD -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

port_map 443 40443
port_map 80  40080

</code></pre>
<p>上面这段写完以后，就发现一个很奇怪的事情，回程的数据包不知道为啥有给我重新发到了 wireguard 上面了。我搞不定，也没搜到结果。。最后想着这个不会是个 bug 吧，然后换了一个机器，就发现没问题了。。害我没了 2 小时。。</p>
<p>下面就是用了确保本地数据包能正确路由的脚本：</p>
<pre data-language="bash"><code class="language-bash"><span class="hljs-meta">#!/bin/sh</span>

docker_if=br-web-services

<span class="hljs-function"><span class="hljs-title">ensure_chain</span></span>() {
  name=<span class="hljs-variable">$1</span>
  sys_chain=<span class="hljs-variable">$2</span>
  new_chain=$1_<span class="hljs-variable">$2</span>
  (iptables -t mangle -L | grep -qF -- <span class="hljs-string">"Chain <span class="hljs-variable">$new_chain</span>"</span>) || \
    (iptables -t mangle -N <span class="hljs-variable">$new_chain</span> &amp;&amp; iptables -t mangle -I <span class="hljs-variable">$sys_chain</span> -j <span class="hljs-variable">$new_chain</span>)
  iptables -t mangle -F <span class="hljs-variable">$new_chain</span>
}

ensure_chain WG_PX PREROUTING
<span class="hljs-comment"># ensure_chain WG_PX OUTPUT</span>


<span class="hljs-function"><span class="hljs-title">ensure_line</span></span>() {
  file=<span class="hljs-variable">$1</span>
  line=<span class="hljs-string">"<span class="hljs-variable">$2</span>"</span>
  grep -qF -- <span class="hljs-string">"<span class="hljs-variable">$line</span>"</span> <span class="hljs-variable">$file</span> || <span class="hljs-built_in">echo</span> <span class="hljs-variable">$line</span> &gt;&gt; <span class="hljs-variable">$file</span>
}

<span class="hljs-function"><span class="hljs-title">same_in_out</span></span>() {
  fw_if=<span class="hljs-variable">$1</span>
  fw_table=<span class="hljs-variable">$1_table</span>
  mk_value=<span class="hljs-variable">$2</span>

  <span class="hljs-comment"># wireguard</span>
  wg-quick down <span class="hljs-variable">$fw_if</span>
  wg-quick up <span class="hljs-variable">$fw_if</span>

  <span class="hljs-comment"># route</span>
  ensure_line /etc/iproute2/rt_tables <span class="hljs-string">"<span class="hljs-variable">$mk_value</span> <span class="hljs-variable">$fw_table</span>"</span>
  ip route flush table <span class="hljs-variable">$fw_table</span>
  ip route add default dev <span class="hljs-variable">$fw_if</span> table <span class="hljs-variable">$fw_table</span>
  existing_rule_count=$(ip rule list fwmark <span class="hljs-variable">$mk_value</span> | <span class="hljs-built_in">wc</span> -l)
  <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> $(<span class="hljs-built_in">seq</span> 1 <span class="hljs-variable">$existing_rule_count</span>)
  <span class="hljs-keyword">do</span>
    ip rule delete fwmark <span class="hljs-variable">$mk_value</span>
  <span class="hljs-keyword">done</span>
  ip rule add fwmark <span class="hljs-variable">$mk_value</span> table <span class="hljs-variable">$fw_table</span>

  <span class="hljs-comment"># iptable markers</span>
  iptables -t mangle -I WG_PX_PREROUTING -i <span class="hljs-variable">$fw_if</span> -j CONNMARK --set-mark <span class="hljs-variable">$mk_value</span>
  <span class="hljs-comment"># OUTPUT only for host itself, but it's using docker here</span>
  <span class="hljs-comment"># iptables -t mangle -I WG_PX_OUTPUT     -m connmark --mark $mk_value -j CONNMARK --restore-mark</span>
  iptables -t mangle -I WG_PX_PREROUTING -i <span class="hljs-variable">$docker_if</span> -m connmark --mark <span class="hljs-variable">$mk_value</span> -j CONNMARK --restore-mark
}


same_in_out wg_vps    101

</code></pre>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[安全上网的迷思]]></title>
        <id>https://wingu.se/2021/07/29/networking.html</id>
        <link href="https://wingu.se/2021/07/29/networking.html"/>
        <updated>2021-07-29T13:19:00.000Z</updated>
        <summary type="html"><![CDATA[
世界上有很多好人，但是也有坏人嘛，互联网也不例外，所以无论在哪里，都有安全上网的需求。这东西，在中国大陆，有不可描述的原因，这个需求也很大。
八仙过海，各显神通，市面上有很多安全上网的办法，并且办法...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/07/29/networking&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>世界上有很多好人，但是也有坏人嘛，互联网也不例外，所以无论在哪里，都有安全上网的需求。这东西，在中国大陆，有不可描述的原因，这个需求也很大。</p>
<p>八仙过海，各显神通，市面上有很多安全上网的办法，并且办法一直在变多。</p>
<p>最开始，也最容易被人找到的办法，就是使用 HTTP 代理，网上也能找到很多免费的服务器。这种办法，在 HTTPS 没有完全普及的年代，基本就是把自己的所有信息都出卖给了代理服务器，同时，自己跟代理服务器之间的通讯也是明文的。哪怕当今，HTTPS 相对已经很普及了，但是 HTTP 代理也会暴露你访问的域名。类似的，还有明文的 SOCKS 代理。</p>
<p>大约 9 年前，著名的 Shadowsocks 项目启动，开启了加密代理的时代。最开始，项目一度非常成功，但是由于密码套件使用存在的一些问题，使得 Shadowsocks 变得容易被检测，并且协议虽然没有特别的握手特征，但是就像一辆涂黑窗口行驶在马路上的面包车，Shadowsocks 的流量也容易被怀疑和屏蔽。因此，Shadowsocks 也出现了各种混淆的办法。类似的，还有 V2Ray 这个项目，虽然解决了很多 Shadowsocks 的问题，并且功能更加强大，但是配置起来也过分复杂。最近一些年，又出现了 Trojan 这个项目，直接使用 TLS 进行伪装，不过也未能逃脱配置过于复杂的问题。</p>
<p>VPN 方面，市面上也有很多方案。比较有代表性的比如 PPTP，但是由于安全问题，PPTP 已经很少见了；又比如 L2TP/PSK-IPSec，这个协议还广泛存在，但是由于 IPSec 握手有各种问题或者被屏蔽，并不是特别稳定的存在。近些年，Wireguard 以其简练巧妙的设计打动了不少人，并且性能非常好。但是 Wireguard 跟别的 VPN 一样，UDP 作为中国互联网的二等公民，过得非常难；而且 Wireguard 也有明显的数据包特征，可以被轻易地识别。VPN 其实也是有 TLS 的方案的，那就是微软的 SSTP，但是这个方案部署起来比较困难，原生的你需要用 Windows Server，开源的实现也不多（SoftEther 是其中一个），暴露的端口也不好隐藏 VPN 服务。</p>
<p>在隐藏代理或者 VPN 意图，以及避免 ISP QoS 的路上，代理和 VPN 最终都指向 TLS，伪装成正常的网站服务。但是这两个其实都没特别好的实现，所以最近一些日子，我琢磨琢磨着，自己写了好多工具。到今天，我发现，其实写这些工具并没多难，而且上面提到的一些工具，我觉得都搞得大而全，搞得太复杂了。实现这些工具，其核心其实并不需要多少代码或者逻辑。</p>
<p>最开始的，我们使用的是普通 HTTP 代理，但是其实与代理服务器之间的连接，也是可以建立 TLS 通讯的，这样可以隐藏正在使用代理的情况，于是我写了 <a href="https://github.com/winguse/go-shp" target="_blank" rel="noopener noreferrer">go-shp</a> 这个项目。服务端并非没有现成的实现，比如 Caddy 1.x 就是一个。但是支持的客户端不多，比如操作系统就没支持的，浏览器我也就看到 Chrome 支持（Firefox 或许也支持），所以我也撸了一个本地的转发代理。而既然 Chrome 支持，我也写了一个浏览器插件，不过步子迈得有点大，自动检测并使用代理那个功能写了很多代码却没写多好。</p>
<p>不过 HTTP 代理天生是没办法转发 TCP 以外的流量的。有这种需求的时候，我用的是 Wireguard。直接用原生的 Wireguard 确实太容易被识别了，所以我撸了一个 <a href="https://github.com/winguse/udp-xor" target="_blank" rel="noopener noreferrer">udp-xor</a>。但是只是 xor 还是有特征的，所以我在练习 rust 的时候，写了 <a href="https://github.com/winguse/udp-prepend" target="_blank" rel="noopener noreferrer">udp-prepend</a> 这个项目。UDP 虽好，但是 QoS 啊，所以我写了 <a href="https://github.com/winguse/ws-udp" target="_blank" rel="noopener noreferrer">ws-udp</a> 把 UDP 流量塞进 WebSocket 里头，这样就顺便可以使用 TLS 伪装了。这却没办法地引入了 TCP over TCP 的问题，但是，这也没好多解决方案了。不过，Wireguard 本身已经有一层加密了，TLS 的 Websocket 再做一次确实让我不爽，我开始想念 SSTP 的好了。但正如前面所言，市面上并没有特别好的实现，于是我又动手写了 <a href="https://github.com/winguse/ws-tun" target="_blank" rel="noopener noreferrer">ws-tun</a>。</p>
<p>我曾经一度想实现一个 SSTP 的 server 的，但是这个协议还是稍微有点复杂，并且不大好跟正常的流量区分开。为此，我选择直接自己做一个 tun 或者 tap VPN。坦白说，websocket 的开源实现我也是找到过的，但是有一个用的是一个小众的语言，还有一个项目不支持 TLS 被我放弃了，而且，他们确实写得有点复杂。tun / tap 之间，tap 我觉得没有必要，而且它需要 root 权限才能跑，所以我决定写一个 tun 就好了。tun 的数据包传输，选择 websocket 也是很自然的，研究 websocket 的协议，其实它的 overhead 不算太大，有了它，我也不需要自己实现一套分片的逻辑了。tun 的调用，我也没自己写，直接把 Cloudflare 的 boring-tun 项目抄了过了，改吧改吧就有了。唯一比较坑我的地方，就是 rust 的编写过程，要编译通过非常痛苦，并且异步改造也费了我很多时间。这个项目实测可以在 macOS 和 Linux 上和谐运行。至于配置，服务端和客户端只需要商量好一个 websocket 的地址就可以了，别的都不需要另外配置。我这里把服务端和客户端都写一个程序里头了，其实这个并不是特别好的选择，因为服务端我是设计为放到 nginx 之类的后面的，所以 TLS 加密是不需要配置的，搞得服务端体积也不小；客户端没办法，就包含了 TLS 所需要的包。</p>
<p>但 ws-tun 的移动端之路却不是那么容易，因为我没写过移动端端程序。不过这周，我花了一点时间，写了 <a href="https://github.com/winguse/ws-tun-android" target="_blank" rel="noopener noreferrer">ws-tun-android</a>，我发现 Android 端 VPN 还是非常简单的，Google 给了一个 ToyVPN 的项目，改吧改吧又能用了。但是昨晚我准备去写 iOS 的时候，发现并没那么容易，首先开发者账户是必须的，然后 NetworkExtension 只能给企业用户了？我就 GG 告辞了。</p>
<p>Anyway 了，过一段时间，或许我并不需要再折腾这个事情了，谨以此文作为折腾网络多年的总结吧，从用别人的服务，到自己用别人到代码搭一个服务，最后到自己动手写代码实现一个服务，谢谢 GFW 教会了我许多。</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[大同的周末]]></title>
        <id>https://wingu.se/2021/07/18/da-tong.html</id>
        <link href="https://wingu.se/2021/07/18/da-tong.html"/>
        <updated>2021-07-15T15:00:00.000Z</updated>
        <summary type="html"><![CDATA[
出发
上个周末我也不知道去哪，家里领导想出去玩，看了一下北京周边，好像也没啥能去的地方，不过搜寻了一会，发现大同只要 2 小时高铁，有点意外，所以就坐了一次从没坐过的京张高铁。
高铁从清河站始发，终...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/07/18/da-tong&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<h2 id="%E5%87%BA%E5%8F%91" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong.html#%E5%87%BA%E5%8F%91">出发</a></h2>
<p>上个周末我也不知道去哪，家里领导想出去玩，看了一下北京周边，好像也没啥能去的地方，不过搜寻了一会，发现大同只要 2 小时高铁，有点意外，所以就坐了一次从没坐过的京张高铁。</p>
<p>高铁从清河站始发，终到大同南，其实如果不是这趟停很多站的车，快的可以 1 小时 40 分钟多点就到了。</p>
<p>打车去清河站的司机说，这个站去年才落成，有些路牌还不准，想起上次我路过清河，13 号线还走着临时铁轨，清河站这个位置还是一个大坑。当然啦，新冠疫情，似乎让时钟转得比以往要快一些，转眼两年多了，这个车站修好了，京张高铁也通了。这条为冬奥会打造的高铁，护栏都是运动的小人。清河站其实已经五环外了，不在地下走了，不知道下次有没有机会从北京北坐一次试试地下高铁。</p>
<p>周五华北天气非常好，新车新线路，一切都非常干净，加上窗外景色饱和度实在太高了，让我联想到几年前去日本关西旅行的时候，由于做过了站看到的日本农村的田园风光。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jing-zhang-gao-tie-1.2d05762e.jpeg" alt="京张高铁路上"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jing-zhang-gao-tie-2.7c3042d4.jpeg" alt="京张高铁路上"></p>
<p>到达大同，天上还挂着一点晚霞，从大同南站出来，打车直奔市区，第一印象就是，这城市真新，几乎一切都是新建的。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/da-tong-wan-xia.cdf7264d.jpeg" alt="大同晚霞"></p>
<p>后面的体验也印证了这一点，大同很多地方都被拆了重建，城市中心很多平房都拆掉了，盖成新楼房，市区里面还能看到很多拆掉不久的建筑瓦砾。周五入住酒店，然后出来吃饭，表示对消费水平表示惊奇，领导挑了一个面馆，装修十分精致，服务员统一着装，都是一身黑色，领导结账给我推送了一个刷卡通知，35 元，我问了一句，「你就点了一个面嘛？」，「不是啊，两个，你不吃嘛」…表示离开帝都不到 3 个小时，有点不适应。</p>
<h2 id="day-1" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong.html#day-1">Day 1</a></h2>
<p>第二天的行程，第一站选择的事云岗石窟，从市中心打车 15 公里左右，花了 31 块。云冈石窟是北魏孝文帝时期开始修建的，和洛阳的龙门石窟、敦煌的莫高窟并称中国三大石窟。龙门石窟我还没去过，不过那个也是北魏孝文帝修的（可想而知人家多牛逼），比起莫高窟，在我眼中，我会觉得艺术价值要差一些，但也有很多牛逼的石刻技艺。在文物保护方面，我有点觉得做得远不如莫高窟到位，本来风化就不少了，但是感觉修缮做得不大好。值得一提的是，这里一样有清朝时期一些狗尾续貂的操作。网上图很多，这里就不放太多了。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yun-gang-shi-ku.7bd11078.jpeg" alt="云冈石窟"></p>
<p>因为是佛教胜地，所以外面还有一个寺庙，寺庙的建筑还是真材实料的木质结构，很有意思，屋檐上的风铃古色古香，之前就在日本看到，让我产生了一点错觉，又转而变成可惜，明明这里就是唐文化的发源地，但是保护得却没传到东瀛好。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yun-gang-shi-ku-si-miao-1.04df697c.jpeg" alt="云冈石窟寺庙"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yun-gang-shi-ku-si-miao-2.a9cf8f12.jpeg" alt="云冈石窟寺庙"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/dou-gong-feng-ling.677fa7eb.jpg" alt="斗拱风铃"></p>
<p>景区门口吃个午饭，太咸了，吃都吃不下，就打车回城里了。大同的老城区，把古城墙完全重新修了，还弄了一个带状公园，内城的平房也是几乎全部拆了。我们从华严寺起，到法华寺终，由西往东走了一遍核心部分，下面是地图可以参考一下：</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/da-tong-map.6aff4f8e.png" alt="大同老城地图"></p>
<p>华严寺要 50 块门票，我们虽然没进去，但是围墙之外也看到了其斗拱精致，很是漂亮。我是有点想进去的，不过领导嫌贵，而且我们两个对寺庙没多大兴趣，就放弃了。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/hua-yan-si.83b67ce9.jpeg" alt="华严寺"></p>
<p>寺庙东侧是一个很大的商业区，做得很仿古，但是门可罗雀，也没多少店铺开着。</p>
<p>往西一点，走到了一个清真寺，很有特色，中西结合，但是好像没开门，也没进去。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/qing-zhen-si.78945f4f.jpeg" alt="清真寺"></p>
<p>然后就是四牌楼，一般般吧。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/si-pai-lou.3e7c45a4.jpeg" alt="四牌楼"></p>
<p>路过号称宇宙最大的九龙壁，比京城的还要大。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jiu-long-bi-1.60237250.jpeg" alt="九龙壁"></p>
<p>现在这个是新中国中央人民政府后挪过位置的，原来是代王府的门面。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/jiu-long-bi-2.351ee228.jpeg" alt="九龙壁"></p>
<p>九龙壁北边就是代王府了，这地方有意思了，简直就是个故宫复刻，就是大部分屋顶换成了蓝绿色，毕竟是「代」。这个地方现在免费参观，但是入口有点小，一不注意就错过了，还有免费讲解。代王府其实大部分都是现代拆了平房新建的，但是我觉得修得非常有诚意，因为很多现代建的古建筑，斗拱、柱子用的都是水泥了，这里用的都是木头。规制还是按照原来的原址修建的，之所以「代」王府都可以修这么大，是因为这是给朱元璋最宠爱的儿子的，明代刚刚开始，也没什么具体建制，所以基本是想修就修了这么大。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/yu-men.d834dd19.jpg" alt="裕门"></p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/dai-wang-fu-dou-gong.80ccd320.jpg" alt="代王府斗拱"></p>
<p>也不完全是蓝绿房顶的，有「承运殿」是黄色房顶的，像不像故宫？</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/cheng-yun-dian.896d3089.jpeg" alt="承运殿"></p>
<p>景区太小众了，几乎没人，简直是小姐姐拍照片的好去处。</p>
<p>最后一站，去了法华寺，很干净，古色古香的寺庙</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/fa-hua-si.1b33c34c.jpeg" alt="法华寺"></p>
<p>运气很好，还拍到了佛光。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/fa-hua-si-fo-guang.d720b6f9.jpeg" alt="法华寺佛光"></p>
<p>总结一下，古城里头其实现在没多少生活气息，基本都拆得差不多了，建了一大半，但是游客很少。最有生活气息的居然还是最后看的法华寺了，因为里面的僧人都还在。就是，出门看到个兔肉店，天呐，在寺院门口开这个，佛祖知道嘛？</p>
<h2 id="day-2" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong.html#day-2">Day 2</a></h2>
<p>这天的行程是悬空寺、北岳恒山。诶，你说我拖着任小姐上恒山，仪琳小师妹会吃醋躲着我不出来见我嘛？</p>
<p>悬空寺就在恒山下面，离大同还有七八十公里，本来想着报个团的，但是看了一下，这些一日游都是包含云冈石窟的，行程也真够赶的。然而后面我们发现，其实一天也是可以差不多搞定的（雾）。</p>
<p>睡醒吃过早饭走到租车的地方已经 9 点多快 10 点了，出城还堵了一小会，最后快 12 点钟到了浑源县吃午饭。县城到悬空寺就很近了，一小会就到了。悬空寺门票很便宜，上去寺庙要另外加 100，我们就在下面看了看，其实吧，停车场都可以看到了。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/xuan-kong-si-1.fa9a1382.jpeg" alt="悬空寺"></p>
<p>紧紧贴着岩壁建筑的，不过远远可以看到，其实现在已经不是木质结构了，早换成钢筋混凝土了。「壮观」两个字是李白题的字。换个角度，能看到真的很险峻：</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/xuan-kong-si-2.e09ade23.jpeg" alt="悬空寺"></p>
<p>悬空寺前面有一条河，现在水不大，上游围了一个水电站。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/heng-shan-shui-ku.c74638ff.jpeg" alt="恒山水库"></p>
<p>10 块钱停车费告诉我，我在悬空寺呆了 40 分钟，怪不得有些人直接停到了收费卡位前面的空地上。原路出来，进国道，开过恒山隧道，然后上恒山了。这条国道啊，被重车压得挺破的，恒山风景区就在国道边，停车场也是挺小的。恒山是五岳唯一的 AAAA 景区，另外四个都是 AAAAA，上去下来发现 AAAA 也是有点悬。</p>
<p>山脚的恒山派演武场（其实是这里是道观，恒山并没有尼姑庵）：</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/dao-guan-guang-chang.d445afa8.jpg" alt="道观广场"></p>
<p>直接爬是可以的，就是这山还是蛮高的，你可以选择坐缆车，或者坐大巴。缆车到大半山腰，大巴到半山腰吧。下面这个照片，还有三分之一到顶，右边树梢叶子所指的位置就是景区门口（水库左边三角空地），中间看到一块空地，就是上山大巴车下车的地方。我们这次来，因为山西最近下雨比较多，山顶最上面不让上了，感觉强度还不如香山，爬山含下山一共花了两个多小时。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/heng-shan-overview.e61506f3.jpeg" alt="恒山概览"></p>
<p>工具人在爬山。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/gong-ju-ren.f8755bd9.jpg" alt="工具人在爬山"></p>
<p>康熙帝御笔</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/kang-xi-heng-shan.b055bd1b.jpeg" alt="康熙帝御笔"></p>
<p>反正要是没啥期待，倒也还行吧。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/heng-shan.937b11a1.jpg" alt="恒山"></p>
<h2 id="%E5%88%AB%E7%9A%84" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/07/18/da-tong.html#%E5%88%AB%E7%9A%84">别的</a></h2>
<p>来山西嘛，还是看到煤矿的，就包括云冈石窟旁边，还是能看到煤矿和运煤的火车。这里是著名的「大秦铁路」的起点，大同-秦皇岛的煤炭专用重载铁路，运送了中国将近 20% 的煤炭，贡献将近 10% 的全国电力，也是上市股票。有传闻说，从大同到秦皇岛的火车，几乎不用消耗电力，甚至火车头机车刹车制动发的电可以导致电力消耗为负数。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/mei-kuang.6b4b9280.jpg" alt="煤矿"></p>
<p>黄土高原，也真的是邱壑纵横，动不动就一个大深坑，植被也不是很多，所以水土流失也是真的。</p>
<p><img src="https://wingu.se/_file/images/2021-07-18-da-tong/huang-tu-gao-yuan.46935f67.jpeg" alt="黄土高原"></p>
<p>大同的消费非常低，感觉当地人收入也不高，但是能感觉得到当地政府倒是有钱得多，到处拆迁重建古建筑。想想也是啊，这里一个资源型城市，除了少数煤老板，很多矿都是政府的，普通人确实没什么特别的机会。政府这么拆啊建啊也可以理解，毕竟总有资源枯竭的一天，趁当下有钱，早投资搞点第三产业也是可持续发展的路子。</p>
<p>而对于北京的小伙伴，既然现在京张高铁通了，大同确实可以作为周末的一个旅行目的地，不算特别优秀，但是期待不高，体验一下也还可以，就着目前大同的消费水平而言，我甚至觉得玩出了点性价比。</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[小米 11 Pro 折腾笔记]]></title>
        <id>https://wingu.se/2021/06/14/xiaomi.html</id>
        <link href="https://wingu.se/2021/06/14/xiaomi.html"/>
        <updated>2021-06-11T15:00:00.000Z</updated>
        <summary type="html"><![CDATA[

10 年前我用安卓是个刷机 boy，10 年后我用安卓依然还是那个刷机 boy

这句话几乎就是我这个端午假期最好的总结。
再之前一周，我买了一部小米 11 Pro，其实也不是为了换手机，就是想折...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/06/14/xiaomi&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<blockquote>
<p>10 年前我用安卓是个刷机 boy，10 年后我用安卓依然还是那个刷机 boy</p>
</blockquote>
<p>这句话几乎就是我这个端午假期最好的总结。</p>
<p>再之前一周，我买了一部小米 11 Pro，其实也不是为了换手机，就是想折腾一下，看看当今安卓的生态都发展成什么样子了，另外，有一个可以跑 Linux 的设备，也可以玩很多别的花样，当然啦，毕竟 618 打折嘛，加上在北京出差，正好用上北京消费券，用不到 3900 的价钱就买到了这部 8 + 128GB 的机器了。</p>
<p>买回来既然是要折腾，肯定就是想 root 啦，不过现在不能直接 root，解锁是有限制的，需要先绑定手机到自己的小米账户，然后等 168 小时（7 天），具体可以参考<a href="https://www.miui.com/unlock/index.html" target="_blank" rel="noopener noreferrer">官方教程</a>。</p>
<p>我也不着急，所以就先体验了一把原版的国产 MIUI。MIUI 还是如当年一般做了好多功能，不过也真的是有各种广告。表示越来越觉得手里的 iPhone 12 mini 真香，安卓的生态太不让人省心了，虽然感觉比以前已经好了不少，同样是国产 App，虽然都一样卷，花里胡哨各种推荐各种噪音，但是 iOS 还是要收敛一些的。就小米而言，我觉得目前体验离高端还有距离，功能确实很接地气，功能也让我惊讶，但很多细节有待打磨。比如说，三个摄像头的白平衡是不一致的，对比之下，iOS 这边简直是调教逆天。iOS 最近几年软件质量不行了，但是比 MIUI 还是要领先一两个身位的。最近也留意了一下华为的鸿蒙，我觉得吧，小米在研发上的投入可能真的还不够。广告的问题，也是其高端路线的一个坎。我体验下来，确实大多数广告都是可以关闭的，但是藏得都很深。小米的运营策略也很尴尬，想要互联网估值，就需要有互联网业务啊，这一块好像广告就是为数不多的变现方式了，但是其实贡献的营收也不是很多，我觉得很鸡肋，当然我不知道小米的大佬们怎么看吧。我也不知道为啥，我装了 Google Play，但是就是没办法下载软件。Debug 的过程中，我发现应该不是我网络的问题，但是也发现了这系统，平时请求的奇奇怪怪的域名也太多了吧。隐私安全上，还真的是很不让人放心啊。</p>
<p>一周以后，就是端午三天假期了，开始折腾。解锁过程有官方教程，按下不表。刷机最开始选择的是国际版（其实感觉就是美国版），但是后来发现 MIUI 还没更新到 12.5，所以又换成了欧洲版，据说欧洲版更新也更快一些，而且隐私上面也更收敛一些，没那么多广告。具体的刷机教程我也按下不表，值得提一点的就是需要下载完整的刷机包，也有<a href="https://c.mi.com/oc/miuidownload/detail?guide=2" target="_blank" rel="noopener noreferrer">官方教程</a>。解锁的工具要用 Windows，刷机我实测也可以在 macOS 上搞定，需要稍微修改一下脚本。注意不要重新锁了机器就好了。</p>
<p>既然都刷机了，肯定还是要玩 root 的啦，现在流行的是使用面具（<a href="https://github.com/topjohnwu/Magisk" target="_blank" rel="noopener noreferrer">Magisk</a>）这个工具，注意，Google 搜索出来那个 <code>.com</code> 的并非作者所有的网页，作者只有 Github 上的那个页面，不过好像下载的还是 Github 的，但是还是自己去 Github 比较保险。过程 <a href="https://topjohnwu.github.io/Magisk/install.html" target="_blank" rel="noopener noreferrer">Magisk 上面的文档</a>也说的很清楚了，我就不翻译了。特别提醒一下，安装 Magisk 模块的时候，记得先把 <code>adb</code> 打开，而且要用电脑先连一次让手机信任了，折腾死了有时候可以救命。</p>
<p>欧洲版的 MIUI 其实少了很多实用的功能，比如：</p>
<ul>
<li>公交卡、门禁卡</li>
<li>小米应用商店</li>
<li>照明弹等等高级权限控制</li>
</ul>
<p>理论上是可以通过 Magisk 来恢复的，我为此也折腾了一番，不过结果我也就搞定了公交卡、门禁和小米应用商店，别的都没搞定。不清楚是不是因为目前欧洲版是 <code>12.5.3</code> ，大陆版是 <code>12.5.4</code> 的原因，还是别的。特别是我想装回权限控制的时候，直接就无法开机了，而且 <code>adb</code> 上去卸载掉模块也不行。</p>
<p>网上有好几篇写如何恢复公交卡和门禁卡的，我测试了一下，可能因为现在是新版本了，我按照上面的操作也并不能使用，打开小米钱包以后，点击门禁、公交卡没有反应，所以我自己折腾了一番。</p>
<p>网上介绍的，都是针对老版本的 Magisk 制作方法，新版的其实很简单的，不需要那么多文件。详细可以参考<a href="https://topjohnwu.github.io/Magisk/guides.html" target="_blank" rel="noopener noreferrer">文档</a>，这里简单描述一下。</p>
<p>随便弄一个文件夹，新建一个文件 <code>module.prop</code> ，例如：</p>
<pre><code>id=mi_smart_card
name=Xiaomi Smart Card
version=v0.0.1
versionCode=1
author=Yingyu
description=Add MIUI CN Features to 11 pro
</code></pre>
<p>找对应的大陆版 MIUI，提取 <code>/system/app/</code> 对应的 App，我对在我这个手机上使用银联卡并不感兴趣，所以我觉得我并不需要恢复银联卡的功能。试了一下，如果只是要公交卡和门禁，我只需要恢复 <code>TSMClient</code> 就可以了，值得注意的是，<code>/system/app/TSMClient/lib/arm64</code> 里头是两个符号连接，也要把他们对应的文件复制好，具体地就是 <code>/system/lib64</code> 的 <code>libentryexpro.so</code>和<code>libuptsmaddonmi.so</code>两个文件。当然，因为考虑到有些软件 Google Play 上没有，我还是恢复了小米应用商店 <code>MiuiSuperMarket</code>。</p>
<p>公交和门禁，需要将系统设置里头，NFC <code>安全模块设置</code>改成<code>内置安全模块</code>，然而，欧洲版并没有这个选项，恢复这个选项，需要修改系统的 prop。具体是，根目录新建<code>system.prop</code>，内容如下：</p>
<pre><code>ro.se.type=eSE,HCE,UICC
</code></pre>
<p>将上述文件夹里的内容打一个<code>zip</code>包，下载到手机，在 Magisk 上<code>从本地安装</code>即可。</p>
<p>安装上以后，这个模块，桌面只会多一个小米应用商店。然而并不能看到门禁、公交卡的影子。我们需要给他们建一个快捷方式，这里用到 <a href="https://play.google.com/store/apps/details?id=rk.android.app.shortcutmaker" target="_blank" rel="noopener noreferrer">Shortcut 这个 App</a>，下载后，在 Activity 里头，给小米智能卡这个 App 创建几个快捷方式即可，分别是：</p>
<ul>
<li>门卡：<code>com.miui.tsmclient.ui.MifareCardListActivity</code></li>
<li>公交卡：<code>com.miui.tsmmclient.ui.introduction.CheckServiceActivity</code></li>
<li>双击电源界面：<code>com.miui.tsmclient.ui.quick.DoubleClickActivity</code></li>
</ul>
<p>其中，双击电源那个，只有打开了，才能注册上锁屏界面双击使用。其他界面可以照常使用即可。公交卡需要在系统设置里头登陆了小米账户，绑定了公交卡才能拥有两张以上门禁卡。</p>
<p>小米的云服务我都换掉了，查找手机的功能也关掉了，但是我尝试去用 adb 卸载这个应用，结果就无法开机了，但是这玩意不厌其烦地在后台活跃，还给我推送通知，我也没啥办法，只能把它通知关掉。。至于这通知，竟然冒充起别人了（微信安装好以后就出现这个通知）：</p>
<p><img src="https://wingu.se/_file/images/2021-06-14-xiaomi-find-device.9cc31083.jpeg" alt="微信安装好以后就出现小米查找手机通知"></p>
<p>没办法，只能躺平。。</p>
<h2 id="notes-for-extract-img" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/06/14/xiaomi.html#notes-for-extract-img">Notes for extract img</a></h2>
<ol>
<li>download and extra from <a href="https://www.xiaomi.cn/post/25769526" target="_blank" rel="noopener noreferrer">https://www.xiaomi.cn/post/25769526</a></li>
<li><code>brew install simg2img</code> and <code>simg2img images/super.img out_super.img</code></li>
<li><a href="http://newandroidbook.com/tools/imjtool.html" target="_blank" rel="noopener noreferrer">http://newandroidbook.com/tools/imjtool.html</a> <code>imjtool/imjtool out_super.img extract</code></li>
<li><code>ext4fuse extracted/system_a.img sysa -o allow_other</code></li>
</ol>
<p><a href="https://medium.com/@chmodxx/extracting-android-factory-images-on-macos-cc61e45139d1" target="_blank" rel="noopener noreferrer">https://medium.com/@chmodxx/extracting-android-factory-images-on-macos-cc61e45139d1</a></p>
<p>also see: <a href="https://blog.minamigo.moe/archives/184" target="_blank" rel="noopener noreferrer">https://blog.minamigo.moe/archives/184</a></p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[Clubhouse 体验]]></title>
        <id>https://wingu.se/2021/02/06/clubhouse.html</id>
        <link href="https://wingu.se/2021/02/06/clubhouse.html"/>
        <updated>2021-02-03T12:00:00.000Z</updated>
        <summary type="html"><![CDATA[
最近因为 Elon Musk 的一个推，这个 App 在我的圈子里也火起来了，主要是中文推特、科技圈。当然，这个 Clubhouse 不是我司用的那个工单系统。经过同事邀请，我也终于用上了，开始体验...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2021/02/06/clubhouse&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>最近因为 <a href="https://twitter.com/elonmusk/status/1355983231988862978?s=20" target="_blank" rel="noopener noreferrer">Elon Musk</a> 的一个推，这个 App 在我的圈子里也火起来了，主要是中文推特、科技圈。当然，这个 Clubhouse 不是我司用的那个<a href="https://clubhouse.io/" target="_blank" rel="noopener noreferrer">工单系统</a>。经过同事邀请，我也终于用上了，开始体验。</p>
<h2 id="%E4%BA%A7%E5%93%81%E5%BD%A2%E6%80%81" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse.html#%E4%BA%A7%E5%93%81%E5%BD%A2%E6%80%81">产品形态</a></h2>
<p>这个 App 目前是邀请机制的，一个人注册后，可以立刻获得 2 个邀请机会（后面好像不是每个人都有了）。而且会显示邀请的 credit，他们还把这个放在了每个人的 profile 里头，所以就在全网公开了这样一颗关系链二叉树，每棵树的树根都是某一个最原始的种子用户。</p>
<p>Clubhouse 的每个人用户，有独立的 ID，名字，头像，可以写自己的一段 Profile 介绍，可以链接 Twitter，Instagram，可以关注别人也可以被关注。</p>
<p>Clubhouse 的帮助文档是放到了 Notion 上面的。</p>
<p>用户注册 Clubhouse 以后，可以选择感兴趣的领域，一个个领域里头一个个 Club，用户可以选择 Follow 某一个 Club。这部分基本上是中文社区好像还没开始认真体验的。好像是 host 几次 room 才可以创建 Club。</p>
<p>每个人都是 Host Room，可以是 Private 的，可以是允许 Follow 的人加入，也可以完全公开。在一个 Room 里头，可以有主人和管理员（Moderator）。房间的人可以举手发言，房主可以决定什么人可以举手，比如所有人、关注的、都不可以。</p>
<p>用户的首屏就是正在发生的 Room 根据推荐、关注、或者关注的人在里头。还有日历，显示即将发生的活动。</p>
<p>用户的通知管理还是很完整的，比如被关注、发布的活动、有通讯录的朋友加入等等。</p>
<p>产品除了语音，没有别的沟通方式，比如文字图片都是没有做的。而且所以用户也没办法在 App 内并发沟通。但是在某个房间的时候还可以出去闲逛。</p>
<p>内容上，这个都是实时的，比较随意，所以不会有很高质量内容（至少目前如此）。对我而言，我很多时候都觉得别人说话太慢了，没有倍速比较浪费时间。同时，因为没有具体的文本介绍，中间进去以后，不会立刻知道话题，要很长时间 bootstrap。内容本身也不会有录制，所以很难形成沉淀。更多就是一种讨论工具，社交属性。或许类似于头脑风暴是一个不错的场景。</p>
<p>技术上还是很厉害的，声音质量很好，切换网络的时候也可以快速重新上线。用的是<a href="https://www.agora.io/" target="_blank" rel="noopener noreferrer">声网</a>的技术，然后声网一天之内就股票翻翻。所以，Clubhouse 的公司本身并不掌握核心技术。</p>
<h2 id="clubhouse-%E8%A7%A3%E5%86%B3%E4%BA%86%E4%BB%80%E4%B9%88%E9%97%AE%E9%A2%98%E5%88%AB%E4%BA%BA%E6%B2%A1%E8%A7%A3%E5%86%B3%E7%9A%84%E9%97%AE%E9%A2%98" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse.html#clubhouse-%E8%A7%A3%E5%86%B3%E4%BA%86%E4%BB%80%E4%B9%88%E9%97%AE%E9%A2%98%E5%88%AB%E4%BA%BA%E6%B2%A1%E8%A7%A3%E5%86%B3%E7%9A%84%E9%97%AE%E9%A2%98">Clubhouse 解决了什么问题别人没解决的问题</a></h2>
<p><strong>视频化的空白区域</strong>。我觉得解决了其中一个点是，说是现在 5G 了，都视频化了，但是视频很多场景是不合适的，比如我们开会，就不会开视频。这种语音的降维攻击把剩下的不想视频的收割了。同时，听东西的时候，人是可以继续做很多事情的，比如走路、做饭、开车。这种产品形态，有点像多年以前电台节目，你可以打电话进去跟主播聊天。</p>
<p><strong>实时沟通的社交网络</strong>。这里我们可以先对比一下现存的在线声音视频方案，他们要么是点对点的私密会议、在线教学，要么就是点对多的直播。前者是私密的，非公开的，实时的；后者是多数是公开的，几乎实时的（目前的直播技术，至少会有秒级延迟）。我们或许可以把 Clubhouse 跟 YY 之类的游戏语音平台进行比较，但是这二者还是有点区别的，就是两个产品面对的用户群不一样。YY 那种产品，对于游戏用户的渗透是可以的，但是作为普通社交用户的渗透就会很差，这个跟产品形态还是有区别的。Cloud house 一开始就是奔着社交去做的。</p>
<p><strong>更加难以信息污染</strong>。相较于文本，它有很强的发声成本，需要真人实时发言。相较于视频，普通人参与成本更低。所以如果想要使用水军，这种地方就很难了。</p>
<h2 id="%E4%B8%AD%E6%96%87%E5%9C%88%E7%9A%84%E7%89%B9%E6%AE%8A%E4%B9%8B%E5%A4%84" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse.html#%E4%B8%AD%E6%96%87%E5%9C%88%E7%9A%84%E7%89%B9%E6%AE%8A%E4%B9%8B%E5%A4%84">中文圈的特殊之处</a></h2>
<p>中国大陆用户想用这个还是有门槛的，需要用的 iOS 并且有一个境外的 AppleID，所以先进去的，都是科技圈的人物。也正因为这样，把我这种 nobody 跟大佬的距离拉近了。</p>
<p>第一个晚上，我听了几个圈：一个主体是中国的投资圈、产品经理，他们主要探讨这个产品在中国区怎么运营，怎么下沉到三四线城市。不过必可避免谈到可能性，这个产品做监管太难了，所以几个产品都不看好。也提到了商业化问题，另外一些投资人还在观察，觉得这个破圈太快了，所以可以先培养土壤，说不从后面有更大可能。另外一个就是飞猪（Flypig），此人网红，去哪都自带流量，我也听了第一个卖货（义务的），分享 3000 块可以买什么快乐，其中我买了个 App AutoSleep，还真的挺好用的。飞猪有句话很有意思：该死的邀请码，还要一个 iOS，还要没区账户，他的 follower 大堆大堆的 VC，一夜之间从华为换成了 iphone。还有一个香港的产品经理圈，当然这里就只谈产品了，相对境内的圈子，没有聊监管的问题。中文圈感觉最火的还是政治 Room，表示长了三十多年，第一次看出好几千人的社会化大讨论到凌晨三点。</p>
<p>因为监管，所以国内已经开始有人做 Copy Cat 的工作了。但是产品可以复制，但是这群人很难复制的。这很对，因为有些人是为了无国界来的，如果国内互联网来一个，我觉得他们就不回来了。</p>
<h2 id="%E6%89%80%E4%BB%A5%EF%BC%8C%E5%AE%83%E6%9C%89%E4%BB%80%E4%B9%88%E7%94%A8%EF%BC%9F" tabindex="-1"><a class="observablehq-header-anchor" href="https://wingu.se/2021/02/06/clubhouse.html#%E6%89%80%E4%BB%A5%EF%BC%8C%E5%AE%83%E6%9C%89%E4%BB%80%E4%B9%88%E7%94%A8%EF%BC%9F">所以，它有什么用？</a></h2>
<p>我也不知道这个产品会演绎到什么形态。我觉得，最后应该都是内容为王，应该还是要有一些用户定期地分享一些内容。但是，这个也可以是陌生人社交的机会，就比如说，随便路过一个咖啡店，遇到了聊两句，寻找共同爱好，或者是就是缓解寂寞。或者是城市论坛。</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
    <entry>
        <title type="html"><![CDATA[日常唠叨]]></title>
        <id>https://wingu.se/2020/06/13/some-random-words.html</id>
        <link href="https://wingu.se/2020/06/13/some-random-words.html"/>
        <updated>2020-06-10T12:00:00.000Z</updated>
        <summary type="html"><![CDATA[
说起来，2020 年是我写博客的第十年了。可惜，没写成什么有用的东西。今天算起来也很久没有更新了，所以唠叨几句流水账。
早上起来，持续关注着北京新增的肺炎病例。感觉这次很有危机感啊，毕竟这回离我很近...]]></summary>
        <content type="html"><![CDATA[<p><img src="https://winguse.com/view-counter?r=wingu.se/2020/06/13/some-random-words&from=feed" style="vertical-align: middle; height: 1em;"/></p>
<p>说起来，2020 年是我写博客的第十年了。可惜，没写成什么有用的东西。今天算起来也很久没有更新了，所以唠叨几句流水账。</p>
<p>早上起来，持续关注着北京新增的肺炎病例。感觉这次很有危机感啊，毕竟这回离我很近了。特别是，昨天做了 1900 人的检测，早上公布了 500 多个样本的数据，里面有 45 个阳性结果，这将近 10% 的比例，表示吓人。而且，全国没有本土病例已经有一段时间了，最后居然是在首都翻车。。当然，该出门的还得出门，我还是要去吃个饭的。你要说担心，好像街上的样子好像也没啥变化，除了偶尔一个保安又开始弱弱地查出入证和体温也没啥变化。毕竟，北京也是很大的，哪怕朝阳区也是很大的，而且当下世局，啊 Q 一下，你看看境外的情况，北京这里个位数好像差了好几个数量级。。</p>
<p>说起这次疫情，坦白说对我的中短期影响还是很大的，最近好长一段时间，我心里面都有对未来的极度不确定感，并且多少有点焦虑——不知道应该去哪。只是我不是很喜欢啊 Q 精神，我说的这些不确定性，只是工作地点、生活上的变化，相较于其他很多人，这就算不上什么东西了。当然，另外一方面就是，我内心对于世界的糟糕状态的焦虑：疫情全球蔓延，很多人都身处困难；全球政治环境的变糟，中美从合作走向对抗，民粹盛行，而且还有些人无脑奉行「加速主义」。世界处于这种糟糕的状态，然而我一点办法都没有，这就更让无力感在我心中肆虐了。。</p>
<p>今天下午的时候，在 B 站看了个 UP 主分享，讲美国在日韩的军事基地的，后来我详细查了一下，发现美军在全世界基本都有驻扎啊。然后顺便去看了一些美国的海外领地的词条，最后发现这个：<a href="https://zh.wikipedia.org/wiki/%E7%BE%8E%E5%9C%8B%E7%AC%AC51%E5%B7%9E" target="_blank" rel="noopener noreferrer">美國第 51 州</a>很有意思，原来这世界上这么多地方想成为美国的第 51 个州的啊。当然啦，这个词条里面，也有讽刺的意思，讽刺当地被美国化太严重。不过其中<a href="https://zh.wikipedia.org/wiki/%E6%B3%A2%E5%A4%9A%E9%BB%8E%E5%90%84" target="_blank" rel="noopener noreferrer">波多黎各</a>引起了我的注意，这个地方的现状就是一个美国下面的自治邦，要说想独立成为一个国家，他们也可以，但是进行了几次公投，想加入美国成为一个州的支持率在持续上升。但是美国的国会呢，又不想它加入，因为多一个州，就会分薄了其它州的投票权。这倒是给我一种感觉，有种真正意义上的万国来朝的意味，这才是一个国家强大的体现啊。或许天朝在唐朝的时候，就是这种感觉？我这里不想展开讨论中美这方面的差异，单纯提供今天这个发现。。</p>
<p>毕业以后，我的工作都是在美国企业在中国的全资公司工作。过去两年，在中美对抗的日子里，我们同事多多少少都会问总部的老大关于对我们北京团队的影响。当然，因为目前我们的公司，在境内没有业务，只是单纯的研发团队，而且我们公司也的确很小，所以一句「We are too small to mater」通常是可以糊弄过去的。我倒是会建议我们在一些词汇上面用温和的词语，比如说「北京」、「华盛顿」要好于「中国」、「美国」。我们 SFO 同事对于 PEK 的同事，其实都是非常礼貌、友好的。有时候我会觉得，我们都只是两个大国之下的小民而已，但是我们在这之间却有太多微妙的影响，或者说我们有一些责任。所谓「歧视源于偏见、偏见源于无知」，我们这种小民，其实对于两边互相增进了解，进行交流，最后减少对抗是多少有义务的。可惜，很多人都没办法洞察到这一点。特别是最近些年，民粹盛行，头脑一热就开始上纲上线、战狼外交、大国崛起、虽远必诛；又或者图一时嘴快乱斗机灵去讽刺对面、制造误解和对抗。这时代啊，真的需要「越是觉得要激动的时候，越要保持冷静」。。</p>
<p>Anyway，这个世界，最终和平和发展才是主题，愿周遭的世界早日康复。。</p>
]]></content>
        <author>
            <name>Yingyu Cheng</name>
            <email>emerald_cahoots0j@icloud.com</email>
            <uri>https://wingu.se</uri>
        </author>
    </entry>
</feed>