前阵子公司新园区上线,网络拓扑图定稿之后,分到我手上的是一张四百多行的 Excel 表:VLAN 编号、名称、归属区域、网关、IP 子网、每个终端固定的地址……摊开看一目了然,但要把这些数据真正灌进 phpipam,靠鼠标在 Web 界面里一个个点,那我加班加到几点是小问题,点错一个、前后对不上,上线之后排查能让人崩溃。所以我花了一个下午把 phpipam 的 API 从认证到写接口完整摸了一遍,写了套批量导入脚本,把 VLAN 和 IP 一次性灌进去,后续几个月的网络变更也都是同一套流程在跑。
这篇就聊聊这段实操里最有价值的部分:phpipam 的 API 是怎么认证的、VLAN 批量创建怎么写、IP 批量导入为什么必须先搞定 subnet、以及我在踩坑过程中总结出的一套排查思路。适合正在用 phpipam、被初始数据折磨的运维,也适合想把网络资产纳入 CMDB 自动同步体系的开发同学。
1. 为什么我放弃了 Web 界面手工录入:VLAN 和 IP 的初始化场景
1.1 手工录入的隐性成本:我在一次大规模上线中踩的坑
第一次需要往 phpipam 里灌大量 VLAN 和 IP 的时候,我最初的方案确实是在界面上手工录。因为那时候觉得 phpipam 的表单很友好,创建 VLAN 也就是填个编号和名称,创建 IP 也就是在子网页面点两下"添加地址",看起来并不复杂。
但实际录到一百多个的时候,问题就全暴露出来了。首先是操作次数太多,每个 IP 都要经历"打开子网页面 -> 点添加地址 -> 填 IP、主机名、说明 -> 保存"这条链路,四百个地址就是两千多次页面切换,中间还得穿插 VLAN 创建和子网创建,手一快字段就串了。其次是出错之后极难定位,有一次我把某个 VLAN 的 number 填成了 4096 之外的非法值,页面当时没报错,等到把子网关联上去之后才发现整个子网列表里那条记录是残缺的,排查花了一个多小时。最让人头疼的是重复性劳动带来的麻木感,录到后面真的会看错行。
那次之后我做了个对比记录,同样一份 400 条地址的数据,手工录入了大概三个多小时,中途错了好几条,而且没有任何可追溯的日志。用 API 脚本跑的话,从准备 CSV 到灌完数据,总共不到十分钟,导入结果和失败记录全部打在本地日志里。这个差距在一次性初始化的时候还不算致命,但如果每周围绕工单系统新增几十条 IP,手工方式的成本就是持续累积的。
1.2 批量导入真正解决的三个问题
API 批量导入不是炫技,它解决的是几个非常实际的问题。
第一是效率。脚本遍历 CSV 里的每一行,自动完成 VLAN 创建、子网创建、IP 创建,速度取决于你设不设请求延迟。我在内网环境实测,逐条 POST 一个地址大约几十毫秒,跑完几百条不过是喝完一杯水的时间。
第二是一致性。数据全部来自预先整理好的 CSV 或 CMDB 导出,而不是人手工在表单里敲,天然规避了手滑填错、字段串位这类问题。只要是源表正确的数据,落到 phpipam 里就是一致的。
第三是可重复、可审计。脚本执行完,哪些 VLAN 成功了,哪些 IP 因为重复被跳过,全部有日志。哪天数据被误删,或者换了套测试环境要重新灌,同一套命令再跑一遍就行,不用重新在界面里点一遍。
顺便说一句,phpipam 官方其实有 CSV 导入模块,需要额外装扩展,但它对 CSV 格式要求比较死板,出错的时候你只能在界面上看到一个笼统的提示,配合不了复杂的数据清洗逻辑。API 方式的好处是逻辑完全掌握在自己手里,数据清洗、字段映射、失败重试都可以用代码精确控制。
1.3 什么时候不该用 API 批量导入
这里说点反的。不是所有场景都值得写脚本。
如果只是临时加三五个 VLAN、十几条 IP,那直接在 Web 界面点,反而比写脚本快。如果源数据的质量本身很差——Excel 里一堆空行、IP 格式五花八门、VLAN 编号和实际需求都对不上——那第一步应该是跟数据负责人把表格理干净,而不是急着写导入脚本,否则脚本只是把垃圾数据搬进 phpipam,后面的维护成本只会更高。
还有一个容易被忽略的问题:脚本是需要人维护的。如果你们团队没有人能长期维护这套脚本,只是上线期间拿它应付一次,那这个脚本很可能变成半年后没人敢碰的僵尸代码。所以我在写导入脚本的时候会刻意保持结构简单,注释写清楚,让后来的人看一眼就能改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. phpipam API 认证机制与调用规范:一次拿稳 token
2.1 开启 API 服务与创建应用:三个你必须明白的选项
phpipam 的 API 是 REST 风格,资源用 URL 路径表达,POST 负责新增,GET 负责查询,语义很直观。但真正用起来之前,卡住大部分人的其实是认证这一关。
首先要确认你的 phpipam 版本开启了 API 功能。一般在管理后台的设置页面里会有一个 API 相关的开关,把它打开之后,在 API 管理区域里就能创建应用。创建应用的时候有几个字段需要留心:
| 字段 | 说明 | 备注 |
|---|---|---|
| App ID | API 调用时 URL 里的应用标识 | 需要全小写且唯一 |
| App Code | 相当于这个应用的密钥 | 创建后只显示一次,务必保存 |
| Permissions | Read / Write | 只做查询选 Read,要批量写入必须 Write |
| Authentication Type | None / SSL / Token 等 | 选 None 或 Token 比较常见,SSL 需要额外配置证书 |
创建完应用,页面上会给你一组 app_id 和 app_code。很多第一次接触的朋友以为 app_code 可以直接拿来当请求头里的固定 token 用,实际上很多时候你需要先拿它换一个会话 token,再用 token 去访问业务接口。这个流程不搞清楚,后面全是 401 和乱七八糟的报错。
2.2 获取 token 的两步请求与 header 规范
我这边实践下来最稳妥的认证流程是这样:先用 app_id 和 app_code 拼成 Basic Auth 请求头,去调一次用户认证接口,拿到一个 token;之后所有业务请求都在请求头里带这个 token。
用 curl 表示大概是这样的:
bash复制APP_ID="your_app_id"
APP_CODE="your_app_code"
BASE="https://phpipam.example.com/api"
# 生成 Basic Auth 头部
AUTH=$(printf '%s:%s' "$APP_ID" "$APP_CODE" | base64)
# 用 phpipam 的有效账户换取 token
curl -s -X POST \
-H "Authorization: Basic $AUTH" \
-H "Content-Type: application/json" \
-d '{"username":"api_user","password":"api_password"}' \
"$BASE/$APP_ID/user/"
返回的 JSON 里会带一个 token 字段,类似:
json复制{
"code": 200,
"success": true,
"data": {
"token": "abcdef123456",
"expires": "2025-..."
}
}
拿到这个 token 之后,后面所有 POST / GET 请求都在请求头里加一行:
text复制token: abcdef123456
也就是 Python 版的:
python复制HEADERS = {
"token": token,
"Content-Type": "application/json",
"accept": "application/json"
}
这里要提醒一下,不同 phpipam 版本的认证细节有差异,有的版本允许直接用 app_id 和 app_code 做 Basic Auth 访问业务接口,不强制换 token;有的版本则要求必须走用户 token 流程。写脚本之前先去你部署版本的 API 文档页面对一下,避免踩版本差异的坑。
2.3 token 过期、权限边界与 login failed 类报错的排查顺序
实际使用中,认证环节最常见的报错就是 login failed、invalid token 这一挂。这类报错反馈的信息很少,不会直接告诉你"app code 错了"或者"token 过期了",所以排查顺序很重要。
我的排查顺序通常是这样:
- 先在管理后台确认 API 功能开关确实打开了,且 App 状态是启用。
- 确认请求里的 app_id 跟 URL 路径里的 app_id 完全一致,大小写和拼写都不能错。
- 确认 app_code 是从创建时保存下来的那串,不是后来又去后台重新生成的。
- 确认换取 token 时用的 phpipam 账户没有被禁用,密码没被改过。
- 确认业务请求头里带的是从认证接口拿到的 token,而不是把 app_code 直接塞进去。
- 最后才考虑权限边界:如果 App 权限是 Read,那 POST 创建 VLAN 返回 403 就很正常,去后台改成 Write 再说。
我还见过一种特别典型的场景,就是同事在做 GitLab CI 集成的时候,把 GitLab API 的 token 当成 phpipam 的 token 塞进了请求头,两边都报 login failed 之类的错,第一反应都是密码被改了,找了半天才发现是 token 来源就错了。所以只要是报认证类错误,先冷静下来核对 token 是从哪里来的,再去想别的可能。
3. 批量导入 VLAN:从 CSV 到 phpipam 的自动映射
3.1 VLAN 数据结构:编号、名称、域与 section 的关系
VLAN 在 phpipam 里的结构比想象中稍微复杂一点,不是一个名字就能创建的。一个 VLAN 对象通常包含编号(number)、名称(name)、描述(description)、所属区域(sectionId),以及 L2 域(l2DomainId)等字段。
这里最重要的认知是:VLAN 的 number(比如 100、200)和 VLAN 对象的数据库 ID(比如 3、7)是完全不同的两回事。你创建子网的时候绑定的是 VLAN 的数据库 ID,而不是网络里说的 VLAN 编号。很多脚本第一次跑出来的数据一团乱,就是因为在绑定 VLAN 时把 number 当成了 id 传给接口。
section 可以理解为一个逻辑分区,比如按机房、按项目、按业务线来划分。VLAN 和 subnet 都要归属于某个 section,跨 section 的数据在 phpipam 里默认是互相隔离的,并不方便直接关联。所以批量导入之前,先把 section 的规划定好,让数据分布符合你的管理习惯,比写脚本本身更重要。
3.2 创建 VLAN 的 API 调用与字段说明
VLAN 的创建端点是 POST /api/{app_id}/vlan/,一个典型的请求体:
json复制{
"number": 100,
"name": "web_vlan",
"description": "Web server segment",
"sectionId": 2,
"l2DomainId": 1
}
实际写批量脚本的时候,我不会逐条在脚本里硬编码这些字段,而是把 VLAN 信息放在 CSV 里,脚本负责读取、映射字段、调用接口。下面这个例子我加了点幂等处理,避免重复执行时产生一堆重复 VLAN:
python复制import csv
import json
import requests
BASE = "https://phpipam.example.com/api/your_app"
HEADERS = {"token": "your_token", "Content-Type": "application/json"}
def get_existing_vlans():
r = requests.get(f"{BASE}/vlan/", headers=HEADERS, timeout=10)
r.raise_for_status()
# 用 number 做 key,方便后续判断是否存在
return {int(item["number"]): item["id"] for item in r.json().get("data", [])}
def create_vlan(vlans, number, name, description, section_id):
if number in vlans:
print(f"[skip] VLAN {number} 已存在")
return vlans[number]
payload = {
"number": number,
"name": name,
"description": description,
"sectionId": section_id,
}
r = requests.post(f"{BASE}/vlan/", headers=HEADERS, data=json.dumps(payload), timeout=10)
if r.json().get("success"):
print(f"[created] VLAN {number} name={name}")
else:
print(f"[failed] VLAN {number} 返回: {r.text}")
with open("vlans.csv", newline="", encoding="utf-8") as f:
vlans = get_existing_vlans()
section_id = 2 # 从配置或前面查 section 获得
for row in csv.DictReader(f):
create_vlan(
vlans,
int(row["number"]),
row["name"],
row.get("description", ""),
section_id,
)
注意我第一步先把现有 VLAN 全量拉出来,在内存里建成一个以 number 为 key 的字典,之后每一行先查字典,存在就跳过。这样脚本整体是幂等的,你跑一遍和跑十遍,最终库里的数据是一致的,不会产生一堆重复的 VLAN 记录。
3.3 幂等处理:重复执行不产生脏 VLAN
幂等这个概念听起来有点开发味,但放在 VLAN 导入里特别实用。我在做批量导入的时候,最怕的不是数据写不进去,而是写进去之后你忘了,又跑了一遍脚本,结果库里出现两条一模一样的 VLAN,子网关联的时候分不清该绑哪条。
我推荐的方案就是上面代码里的思路:写之前先查。先把 phpipam 里的 VLAN 全量拉出来,脚本在内存里比对,存在就跳过,不存在才调用 POST。对于几百条数据来说,全量拉取的开销几乎可以忽略,但带来的幂等保障是实打实的。
如果你不想每次执行都先全量拉取一遍,也可以反过来:先直接 POST,捕获接口返回的错误信息,如果提示是重复创建之类的错误就跳过,把其他错误记录下来。这种方案的请求次数更少,但对错误码的解析要更细致,否则会把真正的写入失败也一起吞掉。我个人的建议是,批量导入这种低频操作,宁可多一次 GET,也要保证数据绝对干净。
4. 批量导入 IP 的完整链路:subnetId 是把万能钥匙
4.1 为什么先有 section 和 subnet,IP 才有地方放
IP 在 phpipam 里不是孤立存在的,一个 IP 必须挂在一个 subnet 下面,而 subnet 又必须挂在某个 section 下面。VLAN 则是作为 subnet 的一个属性存在,用来标注这个子网逻辑上属于哪个二层网络。
打个比方:section 是小区,subnet 是楼栋,IP 是门牌号,VLAN 就是楼栋外墙贴的标签。你不可能在小区还没规划的情况下先给门牌号挂牌,所以用 API 创建 IP 之前,必须确保 subnet 已经存在。这是整个批量导入链路里最核心的前置条件,也是很多脚本第一次跑失败的根本原因。
在 phpipam 里创建 subnet 的端点是 POST /api/{app_id}/subnets/,请求体里可以直接传 CIDR:
json复制{
"subnet": "192.168.100.0/24",
"description": "Web server subnet",
"sectionId": 2,
"vlanId": 3
}
这里的 vlanId 就是前面反复强调的 VLAN 对象数据库 ID,不是 VLAN 编号 100。如果你导入 VLAN 的时候把每个 VLAN 的数据库 ID 记下来,这里就能直接对上。
4.2 subnet 的创建与查询:CIDR 格式与 vlanId 绑定
写批量导入脚本的时候,我一般会做一个名为 ensure_subnet 的函数:先查 subnet 是否存在,存在就返回它的 ID,不存在就创建一个新的并返回 ID。这样 IP 导入脚本就可以放心地对每一行调用这个函数,不管 CSV 里的子网是否已经在 phpipam 里建过,都能拿到正确的 subnetId。
python复制def get_subnet_map():
r = requests.get(f"{BASE}/subnets/", headers=HEADERS, timeout=10)
r.raise_for_status()
mapping = {}
for item in r.json().get("data", []):
cidr = item.get("subnet") # 有的版本值是 "192.168.100.0/24"
if not cidr:
cidr = f"{item['subnet']}/{item['mask']}"
mapping[cidr] = item["id"]
return mapping
def ensure_subnet(subnet_map, cidr, section_id, vlan_id, description=""):
if cidr in subnet_map:
return subnet_map[cidr], False
payload = {
"subnet": cidr,
"sectionId": section_id,
"vlanId": vlan_id,
"description": description,
}
# 部分版本要求显式传 permissions,例如 "1;2" 表示对哪些组可见
# payload["permissions"] = "1"
r = requests.post(f"{BASE}/subnets/", headers=HEADERS, data=json.dumps(payload), timeout=10)
if not r.json().get("success"):
raise RuntimeError(f"创建 subnet {cidr} 失败: {r.text}")
# 创建完后重新拉取一次映射,保证拿到真实的 id
new_map = get_subnet_map()
return new_map[cidr], True
这里有个值得注意的点:把 subnet 创建好之后,不要依赖接口返回值里的 id 去继续操作,而是重新拉一次全量子网列表来更新内存映射。这样即使接口返回的字段结构跟预期不符,也不会影响后续 IP 创建的准确性。我吃过一次这样的亏,当时某个版本的返回体里 id 字段
