🍊 UglyOrange
🛠️ 技术

如何搭建个人 CardDAV 通讯录服务器

frigidpluto Views: ...

这个想法主要受到 vCards 中国黄页 这个项目的启发,vCards 是由 metowolf 发起的黄页项目,维护了国内众多平台(机构、银行、医院等)的联系信息,本质上是维护了一大本通讯录,目前常用联系人有 300 个左右,每周还在持续更新。

vCards 中国黄页
vCards 中国黄页

开发者还部署并维护了一个公开的 CardDAV 服务 vcards.metowolf.com,支持 CardDAV 协议,通过手机直接订阅,如果有新的联系人信息,会自动同步到订阅者的通讯录中。

为什么需要自己的通讯录服务器

上面说的这个订阅服务我在 iPhone 上用了有两年多,非常实用。

平日里有一些陌生电话打进来,很难确定哪些是骚扰电话,哪些是正常电话。但又不敢轻易挂断,怕错过了重要消息,比如信用卡还款提醒、快递上门取件电话,错过了可能还要重新约时间,麻烦费力。

但是推销电话我是不想接的,比如阿里云、腾讯云的销售电话,不用接我就知道是日常 social,问一些关于域名、云服务器使用上的问题,电话那头的小哥为了 KPI 还会多跟你聊几句,聊到一半你又不好意思直接挂掉,尴尬又浪费时间。

这种情况下 vCards 就派上用场了,如果之前已经收录过电话号码,来电时看到联系人信息,就可以提前决定要不要接。

但现在销售部门也变得鸡贼了,开始用私人手机号或者虚拟号码来打电话,乍一看就是普通的电话号码,想着是什么快递到了或者有什么紧急事情,接了才发现是推销电话。 这也是 vCards 的局限性所在,毕竟联系人信息太多,且维护者精力有限,不可能面面俱到,很多号码收录相关的 pull request 也没有合并。

为了补齐这一点,我就有了自己搭建一个通讯录服务器的想法,收录一些我常用的联系方式,比如家人、朋友、同事的电话、邮箱、地址等,也收录一些不在 vCards 订阅中的保险、快递等机构的号码,方便日后查询。

我的使用场景

我的主要使用场景是,我在多台设备(iPad x 2,手机 x 2)上需要同步通讯录,但每台设备上 Apple 账号又不同,所以用 iCloud 同步不太可行,因此需要有一个集中的通讯录管理平台。未来不论更换或添加什么设备,都能做到一键同步。

另一方面是个人有点通讯录“洁癖”,希望保持联系人信息整洁。很多联系人都只在某一段时间内联系,比如中介、销售,时间长了忘了删除,就会长时间“霸占”通讯录。而自己搭建一个前台服务,就可以方便分组管理,日后集中清理。

前置要求

  • 一台服务器(VPS 或本地,推荐 1GB 内存以上)
  • Docker 环境
  • 一个域名(用于 HTTPS 访问)
  • Cloudflare 账号(可选,用于反向代理)

如何部署

Radicale

部署 CardDav 最方便的方式应该就是 Radicale 了,轻量、支持 CardDAV 协议、支持 Docker 部署,非常方便。

docker-compose 文件:

services:
  radicale:
    image: tomsquest/docker-radicale
    container_name: radicale
    ports:
      - 5232:5232
    init: true
    read_only: true
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
      - CHOWN
      - KILL
    deploy:
      resources:
        limits:
          memory: 512M
    healthcheck:
      test: curl -f http://127.0.0.1:5232 || exit 1
      interval: 30s
      retries: 3
    restart: unless-stopped
    volumes:
      - ./data:/data
      - ./config:/config:ro

配置文件

创建 config/config 文件(注意没有扩展名),添加以下内容:

[auth]
type = htpasswd
htpasswd_filename = /config/users
htpasswd_encryption = bcrypt

[storage]
filesystem_folder = /data/collections

[server]
hosts = 0.0.0.0:5232

创建用户密码文件(需要安装 htpasswd 工具):

# 在 config 目录下创建用户
htpasswd -Bc config/users yourname

如果没有 htpasswd 工具,可以使用 Docker 临时运行:

docker run --rm -it httpd:alpine htpasswd -Bnb yourname yourpassword > config/users

启动服务

docker-compose up -d

部署完服务可以通过 Cloudflare Tunnel 反代到公网,方便访问。也可以使用 Nginx 配合 Let’s Encrypt 证书。

创建地址簿

访问 https://vcards.yourdomain.com,使用创建的账号密码登录后,创建一个新的地址簿,命名为 yourname(与用户名相同),这个名称后面会用到。

创建新的地址簿
创建新的地址簿

之后就可以得到一个 URL,这个 URL 就是 CardDAV 服务的地址,可以用来同步到 iOS 设备。

CardDAV 服务地址
CardDAV 服务地址

操作平台

  • 功能:主要用来创建、编辑、删除联系人。
  • 技术栈:NextJS + drizzle + hono + tailwindcss

前端没什么好说的,用 Cursor vibe 一个展示页、搜索框及一个编辑界面,都是一些很基本的功能,总耗时也就不到一个小时。

操作界面
联系展示界面
创建新的联系人
创建新的联系人

数据同步

联系人信息存储在 PostgreSQL 数据库中,增删改操作都会同步到数据库中,主要字段如下:

interface Contact {
  id: string; // uuid,唯一标识一个联系人
  organization: string; // 组织
  fullName: string; // 全名
  givenName: string; // 名
  familyName: string; // 姓
  title: string; // 职位
  phones: string[]; // 电话号码数组
  email: string; // 邮箱
  url: string; // 网站
  imageUrl: string; // 头像 URL
  address: string; // 地址
  note: string; // 备注
  createdAt: Date; // 创建时间
  updatedAt: Date; // 更新时间
}

一开始比较困惑的点在于,在网页上更改数据后,怎么同步到 Radicale 数据库中。

稍微观察了一下 Radicale 的存储方式,发现其实还挺简单,只需要把数据库里的信息包装成 Vcard 格式,然后通过 PUT 请求发送到 CardDAV 服务中。

Vcard 格式如下:

BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//iOS 18.6.2//EN
UID:5833faf1-774b-46bb-8369-c0686330c983
FN:张三
N:张三;张三;;;
PHOTO;VALUE=uri;X-ABCROP-RECTANGLE=ABClipRect_1&0&0&1024&1024&sMz40ONwnYKy407Yrz6veQ==:https://i.pinimg.com/1200x/bf/37/89/bf37890103f17074d4848ea14be6aea5.jpg
REV:2025-11-29T14:28:56Z
item1.URL;TYPE=pref:https://t.me/@i1234567
TEL;TYPE=CELL:18899887766
END:VCARD

以上联系人的 ID 为 5833faf1-774b-46bb-8369-c0686330c983,要同步这条联系人信息到 Radicale 服务器上,只需通过以下 API 进行更新:

# - yourdomain.com: 你的域名
# - yourname: 你的用户名
# - yourpassword: 你的密码

curl -X PUT -d @card.vcf \
  https://vcards.yourdomain.com/yourname/5833faf1-774b-46bb-8369-c0686330c983.vcf \
  -H "Content-Type: text/vcard" \
  -H "Authorization: Basic $(echo -n "yourname:yourpassword" | base64)"

Radicale 服务器收到请求后,会把联系人信息以 5833faf1-774b-46bb-8369-c0686330c983.vcf 为文件名存储到 data/collections/yourname/5833faf1-774b-46bb-8369-c0686330c983.vcf 文件中。

这条数据会被实时同步到 Radicale 的 CardDAV 服务中,iPhone 设备会定时拉取最新数据,同步到本地。

Radicale 服务器存储的联系人结构
Radicale 服务器存储的联系人结构

更新联系人信息的操作本质上没有什么不同,添加新手机号、改完姓名后,用同样的方式把新的 card.vcf 发送过去后,旧的数据会被覆盖。

这里需要注意的一点是,在 Radicale 服务器里,vcf 叫什么名称不重要,这只是一个文件名,最重要的是里面的联系人信息。但为了避免联系人重复、冲突,我们需要一个唯一的 ID 来关联每一个联系人。身份证号是一个不错的选择,但我们没有,而用 UUID 也是可以的。

这个 ID 不仅是数据库需要一个主 key 那么简单,它还充当我们更新 Radicale 上联系人文件的唯一标识符。在后续更新 Radicale 上联系人时,增、删、改 API 调用时都要以这个 ID 作为文件名,这样才能避免联系人信息重复或者误删。

删除联系人

删除联系人也是类似的:

# 替换 yourname 和联系人 ID
curl -X DELETE \
  https://vcards.yourdomain.com/yourname/5833faf1-774b-46bb-8369-c0686330c983.vcf \
  -H "Authorization: Basic $(echo -n "yourname:yourpassword" | base64)"

而查询就简单多了,数据量不大的情况下,直接从数据库里读取数据就可以了。

query = query.where(
  or(
    sql`${vCards.fullName} ILIKE ${`%${search}%`}`,
    sql`${vCards.organization} ILIKE ${`%${search}%`}`,
    sql`${vCards.email} ILIKE ${`%${search}%`}`,
    sql`${vCards.cellphone} ILIKE ${`%${search}%`}`
  )
) as any;

这种部署方式相当于维护了两份数据,一份在数据库(例如 PostgreSQL)里,为了增删改查。另一份在 CardDAV 服务里,为了同步到 iOS 设备。

iOS 设备配置

配置好服务器后,需要在 iOS 设备上添加 CardDAV 账户才能同步通讯录。

添加 CardDAV 账户

  1. 打开 iPhone 的”设置”应用
  2. 找到”账户”->“添加账户”->选择”其他”
  3. 选择”添加 CardDAV 账户”

填写账户信息

在弹出的表单中填写以下信息:

  • 服务器vcards.yourdomain.com(你的域名,不用带 https://)
  • 用户名yourname(你在 Radicale 中创建的用户名)
  • 密码yourpassword(你设置的密码)
  • 描述我的通讯录服务器(任意名称,方便识别)

点击”下一步”,系统会验证账户信息。验证成功后,点击”存储”完成配置。

使用体验

一段时间体验下来,非常方便,毕竟在电脑端管理联系人,比在手机上的小屏幕点来点去快捷太多了。

比较常用的联系人在逐步添加到网站后,多设备上同步非常便利。比如添加了新联系人(e.g., 快递小哥),改完之后,每个设备都会定时自动拉取最新数据,不用再自己手动逐个添加。

同步体验
new contact

另外一个是头像配置丝滑多了,很多常用的联系人我都搭配了头像,这样来电时瞄一眼就知道是谁。 当然,很多图片都是网图,之前在手机上操作时需要先下载好图片,然后导到手机里,再去联系人那里修改。现在只需要在网站上更新一下图片链接,保存即可。后面设备上自动拉取完数据之后就是最新头像了。

通讯录里之前有很多冲突、重复的号码,一个联系人记了多次或者只有名字没有信息的,种种类似问题。改成中心化统一管理后,清爽了很多。

实在不想折腾怎么办?

当然,也不是每个人都愿意为了存个电话号码,特意去买台服务器、还去写代码。如果你只是想找个现成的、能用的方案,Google Contacts 其实是个不错的选择。

现在还在坚持原生支持 CardDAV 协议的大厂真的不多了,Google 算是一个。用法也简单:你在网页版上把联系人理顺,然后在手机设置里登录 Google 账号,勾选“通讯录同步”,就齐活了。

Google Contacts
Google Contacts

不过,既然把数据交给了别人,就得提前考虑清楚两件事:

  • 众所周知,Google 有非常知名的“墓地”文化(Killed by Google),万一哪天它觉得这业务不赚钱,手起刀落,迁移数据又是一通麻烦。
  • 二是国内这个网络环境,如果直连,数据能否成功同步全看运气,急用的时候如果拉取不到最新号码,也挺糟心。

最后碎碎念

说回自建这事儿。

可能有人会觉得,存个电话而已,至于这么大动干戈吗? 其实折腾到最后,早已不是为了“防骚扰电话”这么具体的功能了。更多的是一种数字生活的“洁癖”,和一种对数据的掌控欲。

在这个数据碎片化的时代,我们的社交关系被切碎在微信、抖音和小红书等各种 App 里。能亲手把属于自己的“人际关系网”从这些平台里收拢回来,洗干净,整整齐齐地码在自己的数据库里。

这种踏实感,可能才是每一个 Self-hosted 玩家最大的乐趣吧。