fix: distinguish TLS faults and require active dispatcher retry
This commit is contained in:
@@ -81,7 +81,7 @@
|
||||
|
||||
- **当前预生产目标授权(`muzr4gzv-jzx8pg`)**:先核验、合入 main 并推送,再完善一键部署脚本,在登记测试机 `server.sip` 使用根目录 `saas.env`、`rabbitmq.env`、`aliyun-oss.env` 和真实 SaaS 配置,以现有 `nonprod-real` 启动固定的 `go-sip-agent.service`、`go-sip-dispatcher.service`;原生 `go-sip-asterisk.service` 保留。配置固定在 `~/.config/go-sip/`,业务数据固定在 `~/.local/share/go-sip/`,制品在 `~/.local/opt/go-sip/releases/`,不再用每次试验的新业务目录或 Mock 代替预生产验证。下方日期记录是历史事实,其旧授权限制不覆盖本条新授权。
|
||||
- **本轮外呼授权**:真实 SaaS 下发的全部获批外呼任务允许按正式规则执行,不再逐通询问;仍核对归属、号码、时段、额度、并发、有效授权与幂等,不自行生成外呼消息、不盲重拨或换线重拨。独立工具试拨及超出上述真实 SaaS 任务范围的操作仍需另行确认。没有任务或未接通时如实标记未验证,不以 Mock 代签真实通话或生产。
|
||||
- **部署与数据重置**:普通重部署不清数据;仅显式 `--reset-state` 可处理本项目非生产数据。本次首次部署已获准,后续重置须再次确认;必须先停止相关服务、核实零活动通话、完成私密备份与清单,再重置,不把重置当成代码问题修复,不伪造旧通话终结或结果,不清外部 SaaS/MQ/OSS。清理限于两端确认退役的部署制品、临时文件及自有 Mock 设施;不得直接删仍用凭据、SSH 登记、原始证据、录音或恢复,不删除整个用户 `~/.local` 或 Asterisk/其他项目依赖。退役数据按确认范围保留或备份后处理。SaaS 尚未启动不阻断部署:严格保留输入校验与关新准入,允许 Dispatcher 由 systemd 明确等待/重试,不用 Mock 或旧快照;SaaS/依赖业务队列/会话/SIP加载及通话验证延期,独立配置、MQ登录、证书或Asterisk故障仍须修复。2026-10-08 固定环境首次部署 `e5f46ba` 已完成:Asterisk/Agent enabled+active,Dispatcher enabled且因SaaS业务拓扑尚未供给而auto-restart,业务未ready。旧20个临时服务及自有本地RabbitMQ已私密归档退役;12份配置/证书0600,固定目录0700、linger已开启,但未实际重启验收。历史数据库/恢复事实保留于受控备份,不伪造旧终结;首次固定目录为空,已显式执行本次获批reset,之后普通重部署不reset。部署说明见 [`deploys/preprod/README.md`](deploys/preprod/README.md),证据见 [`docs/evidence/fixed-preprod-systemd-20261008.md`](docs/evidence/fixed-preprod-systemd-20261008.md)。
|
||||
- **部署与数据重置**:普通重部署不清数据;仅显式 `--reset-state` 可处理本项目非生产数据。本次首次部署已获准,后续重置须再次确认;必须先停止相关服务、核实零活动通话、完成私密备份与清单,再重置,不把重置当成代码问题修复,不伪造旧通话终结或结果,不清外部 SaaS/MQ/OSS。清理限于两端确认退役的部署制品、临时文件及自有 Mock 设施;不得直接删仍用凭据、SSH 登记、原始证据、录音或恢复,不删除整个用户 `~/.local` 或 Asterisk/其他项目依赖。退役数据按确认范围保留或备份后处理。SaaS 尚未启动不阻断部署:严格保留输入校验与关新准入,允许 Dispatcher 由 systemd 明确等待/重试,不用 Mock 或旧快照;SaaS/依赖业务队列/会话/SIP加载及通话验证延期,独立配置、MQ登录、证书或Asterisk故障仍须修复。2026-10-08 固定环境首次部署 `e5f46ba` 已完成:Asterisk/Agent enabled+active,Dispatcher enabled且因SaaS业务拓扑尚未供给而auto-restart,业务未ready。旧20个临时服务及自有本地RabbitMQ已私密归档退役;12份配置/证书0600,固定目录0700、linger已开启,但未实际重启验收。复核后5个旧unit drop-in目录/6份配置已私密归档移除,旧发布根目录原文件整体保留在go-sip-backups而非运行位置;证书/未知传输错误不归为SaaS等待,D须实际activating/auto-restart且有对应最新错误,failed不算完成。历史数据库/恢复事实保留于受控备份,不伪造旧终结;首次固定目录为空,已显式执行本次获批reset,之后普通重部署不reset。部署说明见 [`deploys/preprod/README.md`](deploys/preprod/README.md),证据见 [`docs/evidence/fixed-preprod-systemd-20261008.md`](docs/evidence/fixed-preprod-systemd-20261008.md)。
|
||||
|
||||
- P01–P08 及 K01–K16 已完成**项目内隔离 Mock** 核验;本轮把分散的人类可读契约、文档与第三方对接合为唯一当前规范,不重审已确认规则。若未来另获启动开发/审查子 Agent 授权,必须按使用者指定的 `gpt-5.6-luna`、`max` 思考和 `fast: true` 逐项核验并显式配置,不静默换模型、降档或关闭 fast。
|
||||
- 唯一现行 SaaS↔Dispatcher 业务规范、Schema、拓扑和正常示例由共享仓库统一管理;现行合同只由 Git submodule 提交号固定,不维护重复逐文件或整包 hash;历史来源证据、验证脚本及错误示例放在 `contracts/`,不提交到共享仓库或嵌入运行契约。在本项目的共享入口为 [`contracts/schema/`](contracts/schema/README.md);[`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md) 只保留导航,不维护另一份规范。内部 Agent RPC 仍在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
用户已授权本次目标中的真实 SaaS 全部获批任务,不再逐通询问。程序仍核对任务归属、时段、线路、额度、并发、签发期限和消息身份;不自行生成外呼指令,不盲重拨或换线重拨。
|
||||
|
||||
验收包括三项服务安装、启用并启动,Asterisk/Agent `enabled+active`,外部 RabbitMQ 登录/通道、Agent监听、Asterisk基础运行及版本/hash。SaaS 未启动不阻断部署:Dispatcher可明确等待或自动重试,新执行准入保持关闭;SaaS读取、其业务队列供给、业务会话及新SIP加载延期,不把此状态写成业务已就绪。证书、配置、MQ登录及Asterisk故障不得归为SaaS不可用。无获批任务、未接通或AI未调用时如实记录未验证。
|
||||
验收包括三项服务安装、启用并启动,Asterisk/Agent `enabled+active`,外部 RabbitMQ 登录/通道、Agent监听、Asterisk基础运行及版本/hash。SaaS 未启动不阻断部署:Dispatcher可明确等待或自动重试,新执行准入保持关闭;SaaS读取、其业务队列供给、业务会话及新SIP加载延期,不把此状态写成业务已就绪。证书、配置、MQ登录及Asterisk故障不得归为SaaS不可用。没有HTTP响应须按真实错误类型判定,证书或未知传输错误阻断部署;等待状态必须是有最新对应错误的 `activating/auto-restart`,`failed` 不算成功。无获批任务、未接通或AI未调用时如实记录未验证。
|
||||
|
||||
## 一键入口
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -9,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/config"
|
||||
@@ -36,6 +39,7 @@ type report struct {
|
||||
Phase string `json:"phase"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
TransportFailure string `json:"transport_failure,omitempty"`
|
||||
SchemaLocations []string `json:"schema_locations,omitempty"`
|
||||
Trunks int `json:"trunks"`
|
||||
Providers int `json:"providers"`
|
||||
@@ -53,8 +57,26 @@ func (t transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if response != nil {
|
||||
t.report.HTTPStatus = response.StatusCode
|
||||
}
|
||||
if err != nil {
|
||||
t.report.TransportFailure = transportFailure(err)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
func transportFailure(err error) string {
|
||||
var verification *tls.CertificateVerificationError
|
||||
var authority x509.UnknownAuthorityError
|
||||
var hostname x509.HostnameError
|
||||
var invalid x509.CertificateInvalidError
|
||||
var roots x509.SystemRootsError
|
||||
if errors.As(err, &verification) || errors.As(err, &authority) || errors.As(err, &hostname) || errors.As(err, &invalid) || errors.As(err, &roots) {
|
||||
return "tls_failed"
|
||||
}
|
||||
var network net.Error
|
||||
if errors.Is(err, syscall.ECONNREFUSED) || (errors.As(err, &network) && network.Timeout()) {
|
||||
return "unavailable"
|
||||
}
|
||||
return "transport_failed"
|
||||
}
|
||||
func rejected(r *report, err error) {
|
||||
r.Success = false
|
||||
r.ErrorClass = fmt.Sprintf("%T", err)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -17,6 +22,23 @@ func TestEnvironmentRejectsMissingConfigurationWithoutNetworking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestTransportFailureDistinguishesCertificateAndAvailability(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{fmt.Errorf("wrapped: %w", x509.UnknownAuthorityError{}), "tls_failed"},
|
||||
{&tls.CertificateVerificationError{Err: x509.HostnameError{}}, "tls_failed"},
|
||||
{&net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}, "unavailable"},
|
||||
{&net.DNSError{IsTimeout: true}, "unavailable"},
|
||||
{errors.New("unclassified transport issue"), "transport_failed"},
|
||||
} {
|
||||
if got := transportFailure(test.err); got != test.want {
|
||||
t.Fatalf("got %s want %s", got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticNeverSerializesErrorContents(t *testing.T) {
|
||||
r := report{Phase: "sip"}
|
||||
rejected(&r, errors.New("synthetic-sensitive-value"))
|
||||
|
||||
@@ -119,8 +119,11 @@ def require_reset(requested, confirmed, channels):
|
||||
def dependency_state(report):
|
||||
if report.get('success'):
|
||||
return 'verified'
|
||||
if report.get('phase') in ('sip', 'providers', 'tasks', 'task_and_quota') and report.get('http_status', 0) in (0, 404, 502, 503, 504):
|
||||
return 'saas_pending'
|
||||
if report.get('phase') in ('sip', 'providers', 'tasks', 'task_and_quota'):
|
||||
if report.get('http_status') in (404, 502, 503, 504):
|
||||
return 'saas_pending'
|
||||
if report.get('http_status') == 0 and report.get('transport_failure') == 'unavailable':
|
||||
return 'saas_pending'
|
||||
if report.get('phase', '').startswith('mq_'):
|
||||
return 'mq_failed'
|
||||
return 'dependency_failed'
|
||||
@@ -338,6 +341,10 @@ def retire(p, payload):
|
||||
shutil.copy2(fragment, unit_backup / fragment.name)
|
||||
(unit_backup / fragment.name).chmod(0o600)
|
||||
fragment.unlink()
|
||||
dropin = p['units'] / (unit + '.d')
|
||||
if dropin.exists():
|
||||
backup_dir(p, dropin, 'legacy-dropins')
|
||||
shutil.rmtree(dropin)
|
||||
run(['systemctl', '--user', 'daemon-reload'])
|
||||
evidence = {'ok': True, 'legacy_units_removed': len(units), 'legacy_binaries_removed': len(obsolete_binaries), 'legacy_directories_archived': len(archived), 'archive_hashes': archived, 'mock_broker_removed': local_mq, 'active_channels': 0, 'external_services_modified': False}
|
||||
write_private(p['backups'] / 'retirement.json', json.dumps(evidence, indent=2))
|
||||
@@ -424,6 +431,14 @@ def install(payload, p):
|
||||
return result
|
||||
|
||||
|
||||
def dispatcher_acceptable(dispatcher, waiting_reason):
|
||||
if dispatcher.get('UnitFileState') != 'enabled':
|
||||
return False
|
||||
if dispatcher.get('ActiveState') == 'active':
|
||||
return True
|
||||
return bool(waiting_reason) and dispatcher.get('ActiveState') == 'activating' and dispatcher.get('SubState') == 'auto-restart'
|
||||
|
||||
|
||||
def status(p):
|
||||
units = {u: {k: v for k, v in unit_properties(u).items() if k in ('ActiveState', 'SubState', 'UnitFileState', 'ExecMainStatus')} for u in UNITS}
|
||||
manifest = p['current'] / 'manifest.json'
|
||||
@@ -450,7 +465,7 @@ def status(p):
|
||||
waiting_reason = 'saas_topology_pending'
|
||||
elif '/internal/v1/dispatcher/' in logs and any(v in logs.lower() for v in ('timeout', 'connection refused', 'no such host')):
|
||||
waiting_reason = 'saas_network_unavailable'
|
||||
acceptable = dispatcher['UnitFileState'] == 'enabled' and (dispatcher['ActiveState'] == 'active' or (waiting_reason is not None and dispatcher['ActiveState'] in ('activating', 'failed')))
|
||||
acceptable = dispatcher_acceptable(dispatcher, waiting_reason)
|
||||
return {'ok': healthy and acceptable, 'services': units, 'artifact_hashes_match': matches, 'agent_listener': listen, 'active_channels': len(ari(p['home'])), 'dependency_state': dependency, 'business_ready': dependency == 'verified' and dispatcher['ActiveState'] == 'active', 'commit': release['commit'] if release else None, 'waiting_reason': waiting_reason, 'real_reboot_verified': False}
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ class SafetyTest(unittest.TestCase):
|
||||
self.assertEqual(status, 'saas_pending')
|
||||
self.assertEqual(self.m.dependency_state({'success': True, 'phase': 'complete'}), 'verified')
|
||||
self.assertEqual(self.m.dependency_state({'success': False, 'phase': 'mq_connect'}), 'mq_failed')
|
||||
for kind in ('tls_failed', 'transport_failed', ''):
|
||||
self.assertEqual(self.m.dependency_state({'success': False, 'phase': 'sip', 'http_status': 0, 'transport_failure': kind}), 'dependency_failed')
|
||||
self.assertEqual(self.m.dependency_state({'success': False, 'phase': 'sip', 'http_status': 0, 'transport_failure': 'unavailable'}), 'saas_pending')
|
||||
|
||||
def test_role_rendering_overrides_old_mock_business_source(self):
|
||||
source = {'dispatcher_id': 'new-owner', 'saas_url': 'https://real.example.invalid', 'secret': 'new-secret', 'mq_url': 'amqp://synthetic:placeholder@mq.example.invalid/test'}
|
||||
@@ -121,6 +124,12 @@ class SafetyTest(unittest.TestCase):
|
||||
self.assertTrue(any('go-sip-nonprod-siponly-test.service' in a and 'stop' in a for a in calls))
|
||||
self.assertFalse(any('go-sip-asterisk.service' in a for a in calls))
|
||||
|
||||
def test_dispatcher_failed_is_never_accepted_as_waiting(self):
|
||||
for state, substate in (('failed', 'failed'), ('activating', 'start'), ('inactive', 'dead')):
|
||||
self.assertFalse(self.m.dispatcher_acceptable({'UnitFileState': 'enabled', 'ActiveState': state, 'SubState': substate}, 'saas_topology_pending'))
|
||||
self.assertTrue(self.m.dispatcher_acceptable({'UnitFileState': 'enabled', 'ActiveState': 'activating', 'SubState': 'auto-restart'}, 'saas_topology_pending'))
|
||||
self.assertFalse(self.m.dispatcher_acceptable({'UnitFileState': 'enabled', 'ActiveState': 'activating', 'SubState': 'auto-restart'}, None))
|
||||
|
||||
def test_normal_deployment_never_selects_reset(self):
|
||||
self.assertFalse(self.m.reset_requested({}))
|
||||
self.assertFalse(self.m.reset_requested({'reset_state': False}))
|
||||
|
||||
@@ -20,6 +20,8 @@ SaaS 尚未启动,三个正式入口当前均为 HTTP 404;部署不等待 Sa
|
||||
- 开发机 6 个确认无进程使用的旧 ELF 程序先逐文件备份和核对 SHA-256,再删除;不删除整个 `.local`。
|
||||
- 保留 Asterisk、其配置及依赖、当前所需凭据、SSH 主机登记、原始证据、录音与恢复的私密备份。没有清理外部 SaaS/RabbitMQ/OSS 数据或其他项目资源。
|
||||
|
||||
复核额外发现的 5 个旧 unit drop-in 目录、6 份配置(含 SaaS Mock)已先冷归档、记录 SHA-256 后移除,当前用户服务目录没有任何 `go-sip-nonprod-*` 残留。旧 RabbitMQ 连通性临时工具目录也已归档移除。旧 `.local/share/sip-go-agent` 已核对无活跃服务引用,整树冷归档后,将原文件保留到受控 `go-sip-backups/retained-historical-tree-*`;其中 backups/certs/env/results/staging 的原证据、凭据和结果全部保留,不作为现行配置或运行入口。私有保留清单为 `retained-originals-manifest.json`,补充移除清单为 `audit-cleanup-verification.json`。
|
||||
|
||||
原始清单、数据库/消息和抓包等仅存在权限受限位置,不提交完整原始内容。
|
||||
|
||||
## 验证与边界
|
||||
@@ -28,7 +30,7 @@ SaaS 尚未启动,三个正式入口当前均为 HTTP 404;部署不等待 Sa
|
||||
|
||||
首次部署 `e5f46ba` 已实测:Asterisk/Agent enabled+active,Dispatcher enabled、auto-restart,最新应用错误为 SaaS 尚未供给业务拓扑;hash 匹配、Agent 监听正常、原生通道/bridge 均 0。12 份私有文件为 0600,5 个受控目录为 0700,linger=yes。首次显式 reset 已执行,固定运行目录原为空;历史状态已另行私密冷归档,没有改写历史结果。后续部署不再携带 reset 参数。
|
||||
|
||||
正式服务版本/hash、私有权限、systemd 状态、Agent 监听和原生 Asterisk 状态由脚本实测。Dispatcher 等待 SaaS 时必须由最新应用日志说明 HTTP 不可用或 SaaS 未供给业务拓扑,不能把其他启动错误算作正常。自动重试不是业务 ready。停/启和重复部署检查不得重新使用首次重置授权。
|
||||
正式服务版本/hash、私有权限、systemd 状态、Agent 监听和原生 Asterisk 状态由脚本实测。Dispatcher 等待 SaaS 时必须由最新应用日志说明 HTTP 不可用或 SaaS 未供给业务拓扑,同时实际状态须为 `activating/auto-restart`;`failed` 和未重试状态均不算部署成功。无 HTTP 响应时由 Go 按真实错误类型区分证书/链/主机名失败、连接拒绝/超时和其他传输错误;不以 HTTP 0 推断 SaaS 未启动。证书及未知传输错误明确阻断部署,不能把其他启动错误算作正常。自动重试不是业务 ready。停/启和重复部署检查不得重新使用首次重置授权。
|
||||
|
||||
测试机已实际执行脚本 stop/start:原固定目录中的现存状态文件 SHA-256 不变,Asterisk/Agent 恢复 enabled+active,Dispatcher 保持明确的 SaaS 拓扑等待状态、零活动通话。普通重复部署同样不传 reset 参数,并按私有 before-repeat-deployment 清单核对原文件保留;最终结果存私有 last-deployment/repeat-deployment-verification 清单,不公开其中原始数据。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user