上一篇:09 智能分析与自定义大盘 | 目录:README | 下一篇:11 测试与 CI
平台的"观测"已经完整,这一篇补齐"处置":拨测(黑盒视角)、故障单(告警聚合与 复盘)、巡检(定期体检)、SLO(错误预算)、自愈(告警后自动处置)、通知渠道(真实 邮件/企微/钉钉)、审计(谁在什么时候干了什么),以及全平台唯一"真删文件"的 清理加速中心——它的安全边界设计单独一节讲。
本篇是"概述 + 四个精讲":精讲 Incident 聚合逻辑、SSRF 闸门、自愈白名单、
演示账号只读机制(这四块是运维中心的安全灵魂);其余功能给出模型与要点,
完整实现对照仓库 ops/ 各文件(每个文件头部都有完整的设计注释)。
一、模型总览
ops/models.py 一共 10 个模型(完整定义对照仓库,此处列职责):
| 模型 | 职责 | 关键字段 |
|---|---|---|
ProbeTask |
拨测任务:URL 可用性/延迟/证书到期 | url, interval_sec, enabled |
ProbeResult |
每次探测结果 | ok, latency_ms, cert_days |
Incident |
故障单:时间相近的告警聚合 | title, level, status, services, root_cause, lessons |
InspectionRun |
巡检报告(每 12 小时自动一次) | score, findings(JSON), trigger |
SLO |
服务等级目标 + 错误预算 | availability_target, p95_target |
Asset |
资产台账:Agent 主机自动登记 | hostname, owner, env, last_seen |
HealAction |
自愈动作定义 | action_type, param, cooldown_sec |
HealRun |
自愈执行留痕 | output, ok, duration_ms |
AuditLog |
操作审计 | actor, action, target, detail |
NotifyConfig |
通知渠道(SMTP/企微/钉钉/Webhook) | 加密落库的授权码 |
Asset.auto_register(hostname) 是个类方法——06 篇的采集线程调它自动登记资产
(5 分钟节流)。NotifyConfig 的密码字段用 Fernet 加密(ops/crypto.py,
密钥从 SECRET_KEY 派生)——密文落库、页面不回显。
二、精讲 1:故障单聚合(ops/incidents.py)
凌晨 3 点,一个磁盘告警触发后拖出 5 条连锁告警——是 5 个事件还是 1 个故障? 答案是 1 个故障单 + 5 条告警时间线。聚合规则(与仓库一致):
"""
ops/incidents.py — 故障事件管理:告警自动聚合为 Incident
"""
from datetime import timedelta
from django.utils import timezone
LEVEL_ORDER = {'P0': 0, 'P1': 1, 'P2': 2, '提示': 3}
def attach_or_create(event):
"""把一条新触发的告警并入现有故障单(30 分钟窗口)或新建故障单"""
from .models import Incident
if event.policy_id:
recent = Incident.objects.filter(
status='open',
started_at__gte=timezone.now() - timedelta(minutes=30),
).order_by('-started_at').first()
if recent:
event.incident = recent
event.save(update_fields=['incident'])
# 级别取更高者(P0 < P1 < P2 < 提示 的字典序即严重序)
if LEVEL_ORDER.get(event.level, 9) < LEVEL_ORDER.get(recent.level, 9):
Incident.objects.filter(pk=recent.pk).update(level=event.level)
# 并入时补全涉及服务(首次创建只取了首条策略的域)
svc = event.policy.metric_key.split('.')[0] if event.policy else ''
if svc and svc not in recent.services.split(','):
new_services = f'{recent.services},{svc}'.strip(',')[:200]
Incident.objects.filter(pk=recent.pk).update(services=new_services)
return recent
incident = Incident.objects.create(
title=event.summary[:150],
level=event.level,
started_at=event.started_at,
services=event.policy.metric_key.split('.')[0] if event.policy else '',
)
event.incident = incident
event.save(update_fields=['incident'])
return incident
def maybe_resolve(incident):
"""故障单下所有告警恢复后,自动标记恢复"""
firing = incident.alerts.filter(status='firing').count()
if firing == 0 and incident.status == 'open':
incident.status = 'resolved'
incident.resolved_at = timezone.now()
incident.save(update_fields=['status', 'resolved_at'])
return True
return False
为什么是"30 分钟窗口内的 open 单":太短会把一次故障拆成多单,太长会把 两次独立故障粘成一单。30 分钟是"同一个故障的连锁反应"的常见跨度——这是 可调参数,不是真理。
maybe_resolve 在 08 篇告警恢复时已被调用——单据的状态跟着告警走:
最后一条告警恢复 → 故障单自动 resolved。人工复盘(root_cause/lessons 两个字段)
之后可关闭并导出 Markdown 复盘报告(incident_markdown() 生成
"时间线 + 根因 + 改进措施"的标准结构)。
08 篇的两处注释现在兑现:alerts/engine.py 里
from ops.incidents import attach_or_create, maybe_resolve # 取消注释
...
attach_or_create(event) # 并入/创建故障单(取消注释)
process_heal_for_event(event) # 自愈动作(取消注释)
三、精讲 2:SSRF 闸门(ops/urlsafe.py)
拨测要"从平台访问任意 URL"、自愈要"GET 一个回调"、通知要"POST 一个 webhook"—— 这三件事给了使用者一个能力:让服务器向任意地址发请求。攻击者的经典玩法 (SSRF,服务端请求伪造):
- 提交拨测目标
http://169.254.169.254/latest/meta-data/——云主机的元数据接口 (能拿到临时凭据); - 提交
http://10.0.0.1:6379/——探测内网 Redis; - 提交一个外网 URL,它 302 跳到
http://192.168.1.1/admin——绕过 IP 黑名单。
ops/urlsafe.py 是全部出站请求的唯一闸门(与仓库一致):
"""
ops/urlsafe.py — 出站 URL 统一安全校验(拨测 / 自愈回调 / 通知 Webhook 共用)
规则:
- 仅允许 http/https 且必须带主机名;
- 解析域名后逐 IP 检查:链路本地(含 169.254.169.254 云元数据)、保留、
组播地址始终阻断;环回与私网地址默认允许(自用/内网拨测的合法目标),
可分别用环境变量关闭:OBS_ALLOW_LOOPBACK_URL=0 / OBS_ALLOW_PRIVATE_URL=0;
- 所有出站请求必须经 open_no_redirect() 发出:禁止 30x 自动重定向
(否则攻击者用外网跳板 302 到内网即可绕过全部 IP 黑名单)。
已知边界:校验与请求之间存在 DNS rebinding TOCTOU 窗口(校验时解析一次、
连接时再解析一次)。内网部署如需彻底封堵,可固定解析结果直连 IP。
"""
import ipaddress
import os
import socket
import urllib.error
import urllib.request
from urllib.parse import urlsplit
_ALLOW_LOOPBACK = os.environ.get('OBS_ALLOW_LOOPBACK_URL', '1') == '1'
_ALLOW_PRIVATE = os.environ.get('OBS_ALLOW_PRIVATE_URL', '1') == '1'
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""30x 一律不跟随:redirect_request 返回 None 会让 urlopen 抛 HTTPError"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_OPENER = urllib.request.build_opener(_NoRedirect)
def check_ip(ip):
"""单 IP 分类检查:返回错误信息或 None"""
ip = ipaddress.ip_address(ip)
if ip.is_link_local or ip.is_reserved or ip.is_multicast:
return f'目标地址被禁止: {ip}(链路本地/保留/组播地址)'
if ip.is_loopback and not _ALLOW_LOOPBACK:
return f'目标为环回地址 {ip},且已禁用环回 URL'
if ip.is_private and not _ALLOW_PRIVATE:
return f'目标为私网地址 {ip},且已禁用私网 URL'
return None
def validate_url(url):
"""出站 URL 校验:返回错误信息或 None"""
parts = urlsplit(url or '')
if parts.scheme not in ('http', 'https'):
return 'URL 仅允许 http/https'
if not parts.hostname:
return 'URL 缺少主机名'
if parts.username or parts.password:
return 'URL 不应包含用户名/密码'
try:
infos = socket.getaddrinfo(parts.hostname, None)
except OSError as e:
return f'域名解析失败: {e}'
for info in infos:
err = check_ip(info[4][0])
if err:
return err
return None
def open_no_redirect(url, timeout=10, headers=None):
"""校验 URL 并以"禁止重定向"的 opener 发起 GET。
返回 (response, None);校验失败返回 (None, 错误信息);
3xx 重定向会作为 HTTPError 抛出(不跟随),由调用方按失败处理。
"""
err = validate_url(url)
if err:
return None, err
req = urllib.request.Request(url, headers=headers or {})
try:
resp = _OPENER.open(req, timeout=timeout)
return resp, None
except urllib.error.HTTPError:
raise # 含 3xx:让调用方与 4xx/5xx 同样按失败处理
except OSError as e:
return None, f'请求失败: {e}'
三道闸门各自挡什么:
- 协议 + 凭据白名单:
file://、gopher://出局;URL 里带用户名密码出局 (防把凭据泄给出站日志); - 逐 IP 黑名单:域名解析出几个 IP 就查几个(解析出公网+内网双 A 记录也逃不掉); 云元数据地址属于链路本地段,始终阻断,环境开关都打不开——多租户部署 再关掉环回/私网两个开关,就是"只许打公网"的对外形态;
- 禁重定向:302 不跟随——跳转目标没有经过校验,等价于绕过闸门。 附带一个诚实的"已知边界":DNS rebinding 的 TOCTOU 窗口(校验时解析一次、 连接时再解析一次)——内网要彻底封堵就固定解析结果直连 IP。承认边界 比假装无懈可击更专业。
四、精讲 3:自愈执行器(ops/heal.py)
告警触发后自动处置——这是全平台危险等级最高的功能:一旦配置错误, 它会自动地、无人值守地"做事"。所以它的每个设计都以"怎么防自己闯祸"展开:
三种动作类型:
cleanup_tmp:清理临时目录过期文件——自定义目录必须位于系统临时目录之下 (防止误删业务目录),仅删普通文件;http_callback:GET 一个回调 URL——必须走 3 节的 urlsafe 闸门;command:执行白名单内的命令——约束最多,逐条看:
# 可执行文件白名单(绝对路径):未配置时 command 类型禁用。
# 例:Windows set OBS_HEAL_CMD_ALLOWLIST=C:\ops\clean.exe;C:\ops\restartsvc.exe
# Linux export OBS_HEAL_CMD_ALLOWLIST=/usr/local/bin/clean-tmp.sh:/usr/bin/systemctl
_CMD_ALLOWLIST = [
str(Path(p).resolve())
for p in (__import__('os').environ.get('OBS_HEAL_CMD_ALLOWLIST') or '').split(
';' if sys.platform == 'win32' else ':')
if p.strip()
]
- 未配置白名单 → command 类型整体禁用:默认安全,而不是默认可用;
- argv[0] 必须命中登记的绝对路径:
../绕不过、sh包装绕不过; - 拒绝 .bat/.cmd:Windows 下批处理会经 cmd.exe 重新解析参数,
& echo hacked这样的参数注入成为可能——直接不收; shell=False+ 参数列表执行:命令永不进 shell 解析;- 30 秒超时 + 输出留痕(HealRun 落库 + 审计)。
冷却期防重复执行:告警每 30 秒评估一轮,事件 firing 期间每轮都会调
process_heal_for_event——冷却期用一个条件 UPDATE 原子占位(抢占成功才执行),
并发评估不会把同一个动作跑两遍。
def process_heal_for_event(event):
"""告警事件触发时调用:找该策略绑定的启用动作,冷却期外执行"""
actions = HealAction.objects.filter(policy=event.policy, enabled=True)
for action in actions:
# 冷却期原子占位:条件 UPDATE 抢占,抢到的才执行
deadline = timezone.now() - timedelta(seconds=action.cooldown_sec)
claimed = HealRun.objects.filter(
action=action, started_at__gte=deadline,
).exists() is False
if not claimed:
continue
run = run_action(action, reason=f'告警:{event.summary}')
...
(教学版示意;仓库版用 get_or_create + 唯一约束实现原子抢占,见 ops/heal.py。)
五、精讲 4:演示账号只读(03 篇占位的兑现)
01 篇用户故事里的"只读展示账号",实现全在 monitor/security.py。补上三段:
# ---------------- 演示模式(只读展示账号) ----------------
DEMO_GROUP_NAME = '演示访客'
# 演示账号禁止访问的路径前缀:管理入口 / 含令牌或密钥的页面 / 会写数据的页面
DEMO_BLOCKED_PREFIXES = (
'/ops/notify/', # 通知渠道配置(含 SMTP 密码)
'/ops/audit/', # 操作审计
'/cleaner/', # 清理加速(高危维护操作)
'/ops/heal/', # 自愈动作管理
'/ops/probe/', # 拨测管理(任务 URL 可能含接入令牌)
'/alerts/policies/', # 告警策略管理
'/integration/', # 接入中心(含接入令牌明文)
'/forum/chaos/', # 故障演练(会写数据)
'/metrics', # Prometheus 全量指标
'/admin/', # 后台
)
LOGOUT_PATH = '/accounts/logout/'
def is_demo_user(user):
"""判断是否演示账号:属于"演示访客"组即可(结果缓存在 user 对象上)"""
if user is None or not getattr(user, 'is_authenticated', False):
return False
cached = getattr(user, '_obs_is_demo', None)
if cached is not None:
return cached
try:
result = user.groups.filter(name=DEMO_GROUP_NAME).exists()
except Exception:
# 判定失败时按"非演示"放行会绕过写保护,这里保守地按演示账号处理(fail-closed)
return True
try:
user._obs_is_demo = result
except AttributeError:
pass
return result
def _demo_403(request):
from django.shortcuts import render
return render(request, 'demo_403.html', status=403)
AuthRequiredMiddleware.__call__ 里已登录分支改为:
user = getattr(request, 'user', None)
if user is not None and user.is_authenticated:
# ---- 演示模式:只读展示账号 ----
if is_demo_user(user):
# 写保护:除退出登录外,任何非 GET 请求一律 403
if request.method not in ('GET', 'HEAD', 'OPTIONS') \
and path != LOGOUT_PATH:
return _demo_403(request)
# 敏感页面拦截:管理入口 / 含令牌密钥的页面
if any(path.startswith(p) for p in DEMO_BLOCKED_PREFIXES):
return _demo_403(request)
return self.get_response(request)
return self.get_response(request)
注意两个安全细节:
- fail-closed:组判定抛异常时按"是演示账号"处理(宁可误拦不可漏放)—— 与直觉相反但正确:这里是安全边界,方向必须是"保守侧";
- 写保护 = 方法级(非 GET 一律 403),敏感页 = 路径级(连看都不行)—— 两层正交,读类页面随便看,写类操作全封死。
同时 06 篇的 ingest_allowed() 里演示账号被显式拒绝(不能借演示会话上报数据)。
创建演示账号用管理命令(对照仓库 monitor/management/commands/create_demo_account.py,
76 行:建组、建账号、拒绝重置管理员、凭据写 demo_credentials.txt、
支持 --revoke 停用)。
六、其余五块:模型与要点
6.1 拨测(probing.py + workers.py)
- ProbeTask 每
interval_sec秒探测一次(open_no_redirect闸门): 记 ok/latency_ms/HTTP 状态码;HTTPS 目标顺带读证书剩余天数; - 结果写 ProbeResult;
task_series()按probe.<task_id>.<field>接进注册表 (字段白名单 ok_rate/latency/duration_ms/cert_days)——拨测指标直接可告警; probe_loop()是第五条租约线程('probe',5 秒 tick)。
6.2 巡检中心(inspection.py)
- 检查清单:资源水位、磁盘/内存耗尽预测(近期增长速率线性外推"还剩几天")、 接口质量、拨测证书、日志突增、SLO 预算余量;
- 每项产出 finding(level/标题/建议),汇总健康评分(0-100)落 InspectionRun;
inspect_loop()第六条线程('inspect',12 小时一轮;页面上可手动触发)。
6.3 SLO(slo.py)
- 定义可用性目标(如 99.9%)与 P95 目标;
- 错误预算 = (1−目标) × 时间窗——预算燃尽图(剩余百分比随时间下降);
- 预算烧穿提示冻结发布——这是 SRE 的经典管理语义:错误预算用完就别再发版。
6.4 通知渠道(notify.py + crypto.py)
- NotifyConfig 支持 SMTP / 企业微信 / 钉钉 / 通用 Webhook;
- SMTP 授权码 Fernet 加密落库(
crypto.py:SECRET_KEY 派生密钥,密文落库、 页面不回显、换密钥时优雅返回空而不是炸链路); send_notify(title, content)被告警引擎_notify()调用(08 篇已接), 各渠道发送结果写回 NotificationRecord。
6.5 审计(audit.py)
audit(request, action, target, detail=''):记录操作人/IP/动作/对象/详情;- 覆盖:策略变更、大盘编辑、自愈执行、登录成败——运维动作必须可回溯;
- 写失败记日志不静默(审计系统坏了要让人知道);保留 90 天。
ops/urls.py 把上面所有页面挂到 /ops/(拨测/故障单/巡检/SLO/资产/自愈/通知/审计
八个路由组),config/urls.py 里 path('ops/', include('ops.urls'));
页面模板对照仓库 templates/ops/(14 个,套路与 08 篇的策略页一致)。
最后把 ops.workers 的两条线程加进 monitor/workers.py(10 篇注释取消):
from ops.workers import probe_loop, inspect_loop
threads = (
('obs-host-collector', host_collector_loop, 'host-collector'),
('obs-alert-engine', alert_engine_loop, 'alert-engine'),
('obs-probe', probe_loop, 'probe'),
('obs-inspect', inspect_loop, 'inspect'),
)
七、清理加速中心:全平台唯一真删文件的地方
cleaner/(models/services/views/tests 四个文件,434 行 services 是主体)。
功能三层:
- 磁盘空间分析:目录/大文件 Top20(整盘可扫,25 秒时限 + 6 次/分钟限速);
- 垃圾清理:四类白名单项——系统临时文件 /
__pycache__/ pip 缓存 / 平台过期数据(+ VACUUM 真回收);先预估再执行,全部留痕审计; - 内存整理(仅 Windows):工作集修剪,跳过系统进程。
它为什么值得单讲:删除是"风险不对称"操作——少删 1 万个文件没人在意,
误删 1 个业务文件就是事故。cleaner/services.py 的边界设计(对应仓库 253 行
测试里的"逃逸用例"):
- 白名单封闭:只有四类清理项,不提供"任意目录删除"——运维级工具宁可少删 不可误删;
- 剪枝符号链接与 Windows Junction:遍历时遇到链接一律不进入—— 否则"临时目录里塞一个指向 C:\Users 的 Junction"就能把删除引出白名单根 (仓库测试里专门有"伪装成 Junction 逃逸"的用例);
- 进程级互斥:清理 / VACUUM / 内存整理三者互斥——VACUUM 持写锁期间 在线请求会被阻塞;
- 只删超龄期文件(如 7 天前的临时文件)+ 未知清理项一律拒绝;
- staff-only:清理中心不在演示账号的可访问清单里。
八、geoip:可插拔的 IP 归属地(顺手补完)
09 篇总览提过的 IP 访问地图,解析器是三级可插拔(monitor/geoip.py,224 行):
- GeoLite2(
pip install geoip2+ mmdb 文件);2. 纯真库(qqwry-py3+ dat); - 内置演示解析(IP 哈希确定性映射到省份,带 8.5% 权重海外访客)。
演示解析必须"同一 IP 结果恒定"(哈希映射不是随机数)——否则同一访客
一会儿北京一会儿上海,页面数据没法看;也必须显式标注演示后端
(地图页标题栏显示当前生效解析器),不冒充真实归属地。04 篇中间件里的
geoip.resolve(ip) 就在这里落地。
本篇检查点
- [ ] 故障演练触发多条告警后,故障单页出现一条 Incident(多告警聚合), 全部恢复后自动 resolved;
- [ ] 建一个
http_callback自愈动作绑在耗时策略上(指向http://127.0.0.1:8014/api/health/), 触发告警后 HealRun 有留痕;把它改成http://169.254.169.254/——被闸门拒绝; - [ ] 建一个
command自愈动作(未配置白名单环境变量)——保存后类型整体禁用; - [ ]
create_demo_account建只读账号,登录后:可看大盘/APM,点任何按钮 403, 访问/integration/403; - [ ] 拨测任务建一条指向健康检查端点,
probe.*指标出现在策略下拉里; - [ ] 清理中心能列出
__pycache__并清理,操作审计出现对应记录。
常见报错速查
| 报错 | 原因 | 解法 |
|---|---|---|
| 告警引擎报 ImportError: ops | ops app 未建/未挂载 | 对照第一节补齐 models 迁移 |
| 自愈回调 403/报错 | urlsafe 闸门(元数据/私网开关) | 看返回的错误信息,确认环境开关 |
| VACUUM 报错 in transaction | SQLite 的 VACUUM 不能在事务里 | services.py 的 autocommit 约束(对照仓库) |
| 演示账号还能点按钮 | 中间件分支没改对 | 对照第五节 __call__ 的顺序 |
功能全部就位。下一篇给整个项目上"质量闸门":125 个测试用例的分层写法、ruff、 GitHub Actions——11 · 测试与 CI。