fix: distinguish TLS faults and require active dispatcher retry

This commit is contained in:
2026-10-09 02:24:28 +08:00
parent ba53ad308f
commit 4b27ef31ca
7 changed files with 76 additions and 6 deletions
+1 -1
View File
@@ -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未调用时如实记录未验证。
## 一键入口
+22
View File
@@ -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)
+22
View File
@@ -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"))
+18 -3
View File
@@ -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}
+9
View File
@@ -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}))