From 931c863b4f4cbd37147448712d774de426c40882 Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 12:01:25 +0800 Subject: [PATCH 01/14] Sanitize browser runner errors --- dialtesting/browser.go | 89 +++++++++++++++++++++++++++-- dialtesting/browser_error_test.go | 94 +++++++++++++++++++++++++++++++ dialtesting/browser_test.go | 3 +- 3 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 dialtesting/browser_error_test.go diff --git a/dialtesting/browser.go b/dialtesting/browser.go index 8e86b6a2..b801e385 100644 --- a/dialtesting/browser.go +++ b/dialtesting/browser.go @@ -42,6 +42,8 @@ const ( optionLightpandaPathCamel = "lightpandaPath" optionChromePath = "chrome_path" optionChromePathCamel = "chromePath" + + browserSystemErrorMessage = "Browser dial testing system error, please check DataKit logs" ) type BrowserTask struct { @@ -597,8 +599,9 @@ func (t *BrowserTask) getResults() (tags map[string]string, fields map[string]in fields["message"] = "success" } else { reasons, _ := t.checkResult() - fields["fail_reason"] = strings.Join(reasons, ";") - fields["message"] = strings.Join(reasons, ";") + displayReasons := t.displayReasons(reasons) + fields["fail_reason"] = strings.Join(displayReasons, ";") + fields["message"] = strings.Join(displayReasons, ";") fields["failure_type"] = t.result.FailureType if fields["failure_type"] == "" && t.reqError != "" { fields["failure_type"] = "config_error" @@ -607,15 +610,16 @@ func (t *BrowserTask) getResults() (tags map[string]string, fields map[string]in if len(t.result.TraceIDs) > 0 { fields["trace_id"] = t.result.TraceIDs[0] } - if steps, err := json.Marshal(compactBrowserSteps(t.result.Steps)); err == nil { + displayError := t.displayRunnerError() + if steps, err := json.Marshal(sanitizeBrowserSteps(compactBrowserSteps(t.result.Steps), displayError)); err == nil { fields["steps"] = string(steps) } if len(result.retryRecords) > 0 { - if data, err := json.Marshal(result.retryRecords); err == nil { + if data, err := json.Marshal(sanitizeBrowserRetryRecords(result.retryRecords, displayError)); err == nil { fields["retry_records"] = string(data) } } else if len(t.result.RetryRecords) > 0 { - if data, err := json.Marshal(t.result.RetryRecords); err == nil { + if data, err := json.Marshal(sanitizeBrowserRetryRecords(t.result.RetryRecords, displayError)); err == nil { fields["retry_records"] = string(data) } } @@ -683,6 +687,81 @@ func compactBrowserSteps(steps []browserDialStep) []browserDialStep { return compact } +func (t *BrowserTask) displayReasons(reasons []string) []string { + if displayError := t.displayRunnerError(); displayError != "" { + raw := strings.Join(reasons, ";") + if raw == "" { + raw = t.runnerErrorText() + } + logger.Warnf("browser runner error sanitized for dialtesting result: %s", raw) + return []string{displayError} + } + return reasons +} + +func (t *BrowserTask) displayRunnerError() string { + if !t.isRunnerError() { + return "" + } + return browserSystemErrorMessage +} + +func (t *BrowserTask) isRunnerError() bool { + return strings.EqualFold(t.result.FailReason, "runner_error") || + (t.reqError != "" && strings.Contains(strings.ToLower(t.reqError), "lightpanda")) +} + +func (t *BrowserTask) runnerErrorText() string { + parts := []string{t.reqError, t.result.FailReason} + if t.result.Error != nil { + parts = append(parts, t.result.Error.Message) + } + for _, step := range t.result.Steps { + if step.Error != nil { + parts = append(parts, step.Error.Message) + } + } + for _, record := range t.result.RetryRecords { + parts = append(parts, record.Message) + } + return strings.Join(parts, "\n") +} + +func sanitizeBrowserSteps(steps []browserDialStep, displayError string) []browserDialStep { + if displayError == "" || len(steps) == 0 { + return steps + } + + out := make([]browserDialStep, len(steps)) + copy(out, steps) + for i := range out { + if out[i].Error == nil { + continue + } + errInfo := *out[i].Error + errInfo.Message = displayError + errInfo.Stack = "" + out[i].Error = &errInfo + } + return out +} + +func sanitizeBrowserRetryRecords(records []browserRetryRecord, displayError string) []browserRetryRecord { + if displayError == "" || len(records) == 0 { + return records + } + + out := make([]browserRetryRecord, len(records)) + copy(out, records) + for i := range out { + if out[i].Message == "" && !strings.EqualFold(out[i].FailureType, "runner_error") { + continue + } + out[i].Message = displayError + } + return out +} + func (t *BrowserTask) check() error { if strings.TrimSpace(t.BrowserConfig) == "" { return errors.New("browser_config should not be empty") diff --git a/dialtesting/browser_error_test.go b/dialtesting/browser_error_test.go new file mode 100644 index 00000000..43eb2656 --- /dev/null +++ b/dialtesting/browser_error_test.go @@ -0,0 +1,94 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package dialtesting + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBrowserTaskDisplayRunnerError(t *testing.T) { + raw := "lightpanda exited before CDP was ready: exit status 1\n" + + "/root/.local/bin/lightpanda: /lib64/libm.so.6: version GLIBC_2.27 not found\n" + + "/root/.local/bin/lightpanda: /lib64/libc.so.6: version GLIBC_2.34 not found" + task := &BrowserTask{ + result: browserDialRun{ + FailReason: "runner_error", + FailureType: "browser_error", + Error: &browserDialError{Name: "Error", Message: raw, Stack: "/root/.local/bin/lightpanda"}, + Steps: []browserDialStep{ + { + Seq: 1, + Status: "FAIL", + Error: &browserDialError{Name: "Error", Message: raw, Stack: "/root/.local/bin/lightpanda"}, + }, + }, + RetryRecords: []browserRetryRecord{ + {Attempt: 1, Status: "FAIL", FailureType: "runner_error", Message: raw}, + }, + }, + } + + display := task.displayRunnerError() + assert.Equal(t, browserSystemErrorMessage, display) + assert.Equal(t, []string{display}, task.displayReasons([]string{"runner_error", raw})) + + steps := sanitizeBrowserSteps(task.result.Steps, display) + assert.Len(t, steps, 1) + assert.Equal(t, display, steps[0].Error.Message) + assert.Empty(t, steps[0].Error.Stack) + + records := sanitizeBrowserRetryRecords(task.result.RetryRecords, display) + assert.Len(t, records, 1) + assert.Equal(t, display, records[0].Message) + + assertNoLeak(t, steps[0].Error.Message, "/root/.local/bin/lightpanda", "GLIBC_") + assertNoLeak(t, records[0].Message, "/root/.local/bin/lightpanda", "GLIBC_") +} + +func TestBrowserTaskDisplayRunnerErrorUsesUnifiedMessage(t *testing.T) { + rawMessages := []string{ + "no lightpanda executable found; set LIGHTPANDA_EXECUTABLE_PATH or install lightpanda in PATH", + "fork/exec /opt/lightpanda: permission denied", + "/lib64/libc.so.6: version GLIBC_2.34 not found", + "lightpanda CDP server did not become ready at http://127.0.0.1:9222", + } + + for _, raw := range rawMessages { + t.Run(raw, func(t *testing.T) { + task := &BrowserTask{ + result: browserDialRun{ + FailReason: "runner_error", + Error: &browserDialError{Message: raw}, + }, + } + + assert.Equal(t, browserSystemErrorMessage, task.displayRunnerError()) + }) + } +} + +func TestBrowserTaskDisplayRunnerErrorKeepsNonRunnerError(t *testing.T) { + task := &BrowserTask{ + result: browserDialRun{ + FailReason: "selector_not_found", + FailureType: "selector_not_found", + Error: &browserDialError{Message: "selector not found: #login"}, + }, + } + + assert.Empty(t, task.displayRunnerError()) + assert.Equal(t, []string{"selector not found: #login"}, task.displayReasons([]string{"selector not found: #login"})) +} + +func assertNoLeak(t *testing.T, value string, leaks ...string) { + t.Helper() + for _, leak := range leaks { + assert.False(t, strings.Contains(value, leak)) + } +} diff --git a/dialtesting/browser_test.go b/dialtesting/browser_test.go index ba983356..a083f81d 100644 --- a/dialtesting/browser_test.go +++ b/dialtesting/browser_test.go @@ -125,7 +125,8 @@ func TestBrowserTaskRunEmbeddedEngineError(t *testing.T) { require.NoError(t, task.Run()) _, fields := task.GetResults() assert.Equal(t, int64(-1), fields["success"]) - assert.Contains(t, fields["message"], "start chrome failed") + assert.Equal(t, browserSystemErrorMessage, fields["message"]) + assert.NotContains(t, fields["message"], "start chrome failed") } func TestBrowserTaskRenderTemplate(t *testing.T) { From 5a5a630c109e124fa544ec973f6db342c847061f Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 12:03:47 +0800 Subject: [PATCH 02/14] Use generic browser system error message --- dialtesting/browser.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dialtesting/browser.go b/dialtesting/browser.go index b801e385..9039b71d 100644 --- a/dialtesting/browser.go +++ b/dialtesting/browser.go @@ -43,7 +43,7 @@ const ( optionChromePath = "chrome_path" optionChromePathCamel = "chromePath" - browserSystemErrorMessage = "Browser dial testing system error, please check DataKit logs" + browserSystemErrorMessage = "Browser dial testing system error, please check agent logs" ) type BrowserTask struct { From 34cc5317f2e60537e4880e6262a854ba3a4b5aef Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 12:12:37 +0800 Subject: [PATCH 03/14] Cover browser script errors --- dialtesting/browser_test.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/dialtesting/browser_test.go b/dialtesting/browser_test.go index a083f81d..3c8611ca 100644 --- a/dialtesting/browser_test.go +++ b/dialtesting/browser_test.go @@ -109,6 +109,25 @@ func TestBrowserTaskRunEmbeddedFailure(t *testing.T) { assert.Contains(t, fields["steps"], "title") } +func TestBrowserTaskRunEmbeddedScriptError(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.BrowserConfig = "name: browser\nsteps:\n - action: eval\n text: window.fail()\n" + task, err := NewTask("", browserTask) + require.NoError(t, err) + + stubBrowserEngineWithFactory(t, func(context.Context, browserrunner.EngineOptions) (browserrunner.Engine, error) { + return &fakeBrowserEngine{evalErr: errors.New("eval boom")}, nil + }) + + require.NoError(t, task.Run()) + _, fields := task.GetResults() + assert.Equal(t, int64(-1), fields["success"]) + assert.Contains(t, fields["fail_reason"], "step_error") + assert.Equal(t, "script_error", fields["failure_type"]) + assert.Contains(t, fields["message"], "eval boom") + assert.NotEqual(t, browserSystemErrorMessage, fields["message"]) +} + func TestBrowserTaskRunEmbeddedEngineError(t *testing.T) { browserTask := newBrowserTaskForTest() task, err := NewTask("", browserTask) @@ -866,8 +885,9 @@ func TestBrowserTaskRunEmbedded(t *testing.T) { } type fakeBrowserEngine struct { - config *browserrunner.BrowserConfig - title string + config *browserrunner.BrowserConfig + title string + evalErr error } func (e *fakeBrowserEngine) Close(context.Context) error { return nil } @@ -893,7 +913,12 @@ func (e *fakeBrowserEngine) Text(context.Context, string) (string, error) { return "Example Domain", nil } -func (e *fakeBrowserEngine) Eval(context.Context, string) (string, error) { return "", nil } +func (e *fakeBrowserEngine) Eval(context.Context, string) (string, error) { + if e.evalErr != nil { + return "", e.evalErr + } + return "", nil +} func (e *fakeBrowserEngine) CaptureDOM(context.Context) (browserevidence.DomSnapshot, error) { return browserevidence.DomSnapshot{}, nil From ee93867681b23f74dc04bd85553633b7daadeced Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 12:19:02 +0800 Subject: [PATCH 04/14] Use dialtesting default logger name --- dialtesting/task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dialtesting/task.go b/dialtesting/task.go index 5f25c1d6..ccf5b556 100644 --- a/dialtesting/task.go +++ b/dialtesting/task.go @@ -40,7 +40,7 @@ const ( ScheduleTypeFrequency = "frequency" ) -var logger = log.DefaultSLogger("icmp") +var logger = log.DefaultSLogger("dialtesting") var ( setupLock sync.Mutex // setup global variable From 0f372ab5de7be1c3b7a532703ca24b65a44b90af Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 12:28:15 +0800 Subject: [PATCH 05/14] Avoid masking browser config errors --- dialtesting/browser.go | 3 +-- dialtesting/browser_error_test.go | 15 +++++++++++++++ dialtesting/browser_test.go | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dialtesting/browser.go b/dialtesting/browser.go index 9039b71d..bd89f1f7 100644 --- a/dialtesting/browser.go +++ b/dialtesting/browser.go @@ -707,8 +707,7 @@ func (t *BrowserTask) displayRunnerError() string { } func (t *BrowserTask) isRunnerError() bool { - return strings.EqualFold(t.result.FailReason, "runner_error") || - (t.reqError != "" && strings.Contains(strings.ToLower(t.reqError), "lightpanda")) + return strings.EqualFold(t.result.FailReason, "runner_error") } func (t *BrowserTask) runnerErrorText() string { diff --git a/dialtesting/browser_error_test.go b/dialtesting/browser_error_test.go index 43eb2656..fb8afd08 100644 --- a/dialtesting/browser_error_test.go +++ b/dialtesting/browser_error_test.go @@ -86,6 +86,21 @@ func TestBrowserTaskDisplayRunnerErrorKeepsNonRunnerError(t *testing.T) { assert.Equal(t, []string{"selector not found: #login"}, task.displayReasons([]string{"selector not found: #login"})) } +func TestBrowserTaskDisplayRunnerErrorKeepsConfigErrorMentioningLightpanda(t *testing.T) { + task := &BrowserTask{ + reqError: "advance_options engine should be chrome or lightpanda", + result: browserDialRun{ + FailureType: "config_error", + }, + } + + assert.Empty(t, task.displayRunnerError()) + assert.Equal(t, + []string{"advance_options engine should be chrome or lightpanda"}, + task.displayReasons([]string{"advance_options engine should be chrome or lightpanda"}), + ) +} + func assertNoLeak(t *testing.T, value string, leaks ...string) { t.Helper() for _, leak := range leaks { diff --git a/dialtesting/browser_test.go b/dialtesting/browser_test.go index 3c8611ca..df20a9e8 100644 --- a/dialtesting/browser_test.go +++ b/dialtesting/browser_test.go @@ -148,6 +148,20 @@ func TestBrowserTaskRunEmbeddedEngineError(t *testing.T) { assert.NotContains(t, fields["message"], "start chrome failed") } +func TestBrowserTaskRunInvalidEngineKeepsConfigError(t *testing.T) { + browserTask := newBrowserTaskForTest() + browserTask.AdvanceOptions = &BrowserAdvanceOption{Engine: "firefox"} + task, err := NewTask("", browserTask) + require.NoError(t, err) + + require.NoError(t, task.Run()) + _, fields := task.GetResults() + assert.Equal(t, int64(-1), fields["success"]) + assert.Equal(t, "config_error", fields["failure_type"]) + assert.Contains(t, fields["message"], "advance_options engine should be chrome or lightpanda") + assert.NotEqual(t, browserSystemErrorMessage, fields["message"]) +} + func TestBrowserTaskRenderTemplate(t *testing.T) { task := &BrowserTask{ Task: &Task{ From 7706a7fa6c2597deabf71fe0f215dfd6876bfff7 Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 12:42:50 +0800 Subject: [PATCH 06/14] Preserve non-runner retry messages --- dialtesting/browser.go | 7 ++++++- dialtesting/browser_error_test.go | 32 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/dialtesting/browser.go b/dialtesting/browser.go index bd89f1f7..465ea2a5 100644 --- a/dialtesting/browser.go +++ b/dialtesting/browser.go @@ -753,7 +753,7 @@ func sanitizeBrowserRetryRecords(records []browserRetryRecord, displayError stri out := make([]browserRetryRecord, len(records)) copy(out, records) for i := range out { - if out[i].Message == "" && !strings.EqualFold(out[i].FailureType, "runner_error") { + if !isBrowserRunnerRetryRecord(out[i]) { continue } out[i].Message = displayError @@ -761,6 +761,11 @@ func sanitizeBrowserRetryRecords(records []browserRetryRecord, displayError stri return out } +func isBrowserRunnerRetryRecord(record browserRetryRecord) bool { + return strings.EqualFold(record.FailReason, "runner_error") || + strings.EqualFold(record.FailureType, "runner_error") +} + func (t *BrowserTask) check() error { if strings.TrimSpace(t.BrowserConfig) == "" { return errors.New("browser_config should not be empty") diff --git a/dialtesting/browser_error_test.go b/dialtesting/browser_error_test.go index fb8afd08..f8eff77f 100644 --- a/dialtesting/browser_error_test.go +++ b/dialtesting/browser_error_test.go @@ -51,6 +51,38 @@ func TestBrowserTaskDisplayRunnerError(t *testing.T) { assertNoLeak(t, records[0].Message, "/root/.local/bin/lightpanda", "GLIBC_") } +func TestSanitizeBrowserRetryRecordsKeepsNonRunnerMessages(t *testing.T) { + records := []browserRetryRecord{ + { + Attempt: 1, + Status: "FAIL", + FailReason: "step_error", + FailureType: "assertion_failed", + Message: "title assertion failed: expected Home", + }, + { + Attempt: 2, + Status: "FAIL", + FailReason: "runner_error", + FailureType: "browser_error", + Message: "lightpanda exited before CDP was ready: exit status 1", + }, + { + Attempt: 3, + Status: "FAIL", + FailureType: "runner_error", + Message: "runner engine factory is not configured", + }, + } + + got := sanitizeBrowserRetryRecords(records, browserSystemErrorMessage) + + assert.Len(t, got, 3) + assert.Equal(t, "title assertion failed: expected Home", got[0].Message) + assert.Equal(t, browserSystemErrorMessage, got[1].Message) + assert.Equal(t, browserSystemErrorMessage, got[2].Message) +} + func TestBrowserTaskDisplayRunnerErrorUsesUnifiedMessage(t *testing.T) { rawMessages := []string{ "no lightpanda executable found; set LIGHTPANDA_EXECUTABLE_PATH or install lightpanda in PATH", From 1015676c9a9055351dbdcbb19182ba0e402a12c5 Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 16:23:46 +0800 Subject: [PATCH 07/14] feat(dialtesting): add ssl task --- dialtesting/ssl.go | 408 ++++++++++++++++++++++++++++++++++++++++ dialtesting/ssl_test.go | 84 +++++++++ dialtesting/task.go | 4 + 3 files changed, 496 insertions(+) create mode 100644 dialtesting/ssl.go create mode 100644 dialtesting/ssl_test.go diff --git a/dialtesting/ssl.go b/dialtesting/ssl.go new file mode 100644 index 00000000..ba36473b --- /dev/null +++ b/dialtesting/ssl.go @@ -0,0 +1,408 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + +package dialtesting + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "text/template" + "time" +) + +var ( + _ TaskChild = (*SSLTask)(nil) + _ ITask = (*SSLTask)(nil) +) + +type SSLSuccess struct { + ResponseTime string `json:"response_time,omitempty"` + CertificateExpiresInDays []*ValueSuccess `json:"ssl_cert_expires_in_days,omitempty"` + CertificateNotAfter []*ValueSuccess `json:"ssl_cert_not_after,omitempty"` + Subject []*SuccessOption `json:"subject,omitempty"` + Issuer []*SuccessOption `json:"issuer,omitempty"` + TLSVersion []*SuccessOption `json:"tls_version,omitempty"` + + respTime time.Duration +} + +type SSLTask struct { + *Task + Host string `json:"host"` + Port string `json:"port"` + ServerName string `json:"server_name,omitempty"` + Timeout string `json:"timeout,omitempty"` + SuccessWhen []*SSLSuccess `json:"success_when"` + SuccessWhenLogic string `json:"success_when_logic"` + + reqCost time.Duration + reqError string + destIP string + timeout time.Duration + tlsVersion string + sslCertNotBefore int64 + sslCertNotAfter int64 + sslCertExpiresInDays int64 + certSubject string + certIssuer string + + rawTask *SSLTask +} + +func (t *SSLTask) init() error { + if t.Timeout == "" { + t.timeout = 10 * time.Second + } else { + timeout, err := time.ParseDuration(t.Timeout) + if err != nil { + return err + } + t.timeout = timeout + } + + if len(t.SuccessWhen) == 0 { + return errors.New(`no any check rule`) + } + + for _, checker := range t.SuccessWhen { + if checker.ResponseTime != "" { + du, err := time.ParseDuration(checker.ResponseTime) + if err != nil { + return err + } + checker.respTime = du + } + + for _, v := range checker.Subject { + if err := genReg(v); err != nil { + return err + } + } + for _, v := range checker.Issuer { + if err := genReg(v); err != nil { + return err + } + } + for _, v := range checker.TLSVersion { + if err := genReg(v); err != nil { + return err + } + } + } + + return nil +} + +func (t *SSLTask) check() error { + if t.Host == "" { + return errors.New("host should not be empty") + } + if t.Port == "" { + return errors.New("port should not be empty") + } + return nil +} + +func (t *SSLTask) checkResult() (reasons []string, succFlag bool) { + for _, chk := range t.SuccessWhen { + if chk.respTime > 0 { + if t.reqCost >= chk.respTime { + reasons = append(reasons, fmt.Sprintf("SSL response time(%v) larger equal than %v", t.reqCost, chk.respTime)) + } else { + succFlag = true + } + } + + for _, v := range chk.CertificateExpiresInDays { + if err := v.check(float64(t.sslCertExpiresInDays)); err != nil { + reasons = append(reasons, fmt.Sprintf("SSL certificate expires in days check failed: %s", err.Error())) + } else { + succFlag = true + } + } + + for _, v := range chk.CertificateNotAfter { + if err := v.check(float64(t.sslCertNotAfter)); err != nil { + reasons = append(reasons, fmt.Sprintf("SSL certificate not after check failed: %s", err.Error())) + } else { + succFlag = true + } + } + + for _, v := range chk.Subject { + if err := v.check(t.certSubject, "SSL certificate subject"); err != nil { + reasons = append(reasons, err.Error()) + } else { + succFlag = true + } + } + + for _, v := range chk.Issuer { + if err := v.check(t.certIssuer, "SSL certificate issuer"); err != nil { + reasons = append(reasons, err.Error()) + } else { + succFlag = true + } + } + + for _, v := range chk.TLSVersion { + if err := v.check(t.tlsVersion, "TLS version"); err != nil { + reasons = append(reasons, err.Error()) + } else { + succFlag = true + } + } + } + + return reasons, succFlag +} + +func (t *SSLTask) getResults() (tags map[string]string, fields map[string]interface{}) { + tags = map[string]string{ + "name": t.Name, + "dest_host": t.Host, + "dest_port": t.Port, + "dest_ip": t.destIP, + "status": "FAIL", + "proto": "ssl", + } + + if t.ServerName != "" { + tags["server_name"] = t.ServerName + } + + for k, v := range t.Tags { + tags[k] = v + } + + responseTime := int64(t.reqCost) / 1000 + fields = map[string]interface{}{ + "response_time": responseTime, + "success": int64(-1), + } + + if t.tlsVersion != "" { + fields["tls_version"] = t.tlsVersion + } + if t.sslCertNotBefore > 0 { + fields["ssl_cert_not_before"] = t.sslCertNotBefore + } + if t.sslCertNotAfter > 0 { + fields["ssl_cert_not_after"] = t.sslCertNotAfter + fields["ssl_cert_expires_in_days"] = t.sslCertExpiresInDays + } + if t.certSubject != "" { + fields["ssl_cert_subject"] = t.certSubject + } + if t.certIssuer != "" { + fields["ssl_cert_issuer"] = t.certIssuer + } + + message := map[string]interface{}{} + reasons, succFlag := t.checkResult() + if t.reqError != "" { + reasons = append(reasons, t.reqError) + } + + switch t.SuccessWhenLogic { + case "or": + if succFlag && t.reqError == "" { + tags["status"] = "OK" + fields["success"] = int64(1) + message["response_time"] = responseTime + } else { + message[`fail_reason`] = strings.Join(reasons, `;`) + fields[`fail_reason`] = strings.Join(reasons, `;`) + } + default: + if len(reasons) != 0 { + message[`fail_reason`] = strings.Join(reasons, `;`) + fields[`fail_reason`] = strings.Join(reasons, `;`) + } else { + message["response_time"] = responseTime + } + + if t.reqError == "" && len(reasons) == 0 { + tags["status"] = "OK" + fields["success"] = int64(1) + } + } + + message["status"] = tags["status"] + data, err := json.Marshal(message) + if err != nil { + fields[`message`] = err.Error() + } else if len(data) > MaxMsgSize { + fields[`message`] = string(data[:MaxMsgSize]) + } else { + fields[`message`] = string(data) + } + + return tags, fields +} + +func (t *SSLTask) metricName() string { + return `ssl_dial_testing` +} + +func (t *SSLTask) clear() { + t.reqCost = 0 + t.reqError = "" + t.destIP = "" + t.tlsVersion = "" + t.sslCertNotBefore = 0 + t.sslCertNotAfter = 0 + t.sslCertExpiresInDays = 0 + t.certSubject = "" + t.certIssuer = "" +} + +func (t *SSLTask) run() error { + d := net.Dialer{Timeout: t.timeout} + ctx, cancel := context.WithTimeout(context.Background(), t.timeout) + defer cancel() + + addr := net.JoinHostPort(t.Host, t.Port) + serverName := t.ServerName + if serverName == "" { + serverName = t.Host + } + + cfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: serverName, + } + + start := time.Now() + rawConn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + t.reqError = err.Error() + return nil + } + conn := tls.Client(rawConn, cfg) + if err := conn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + t.reqError = err.Error() + return nil + } + defer conn.Close() //nolint:errcheck + + t.reqCost = time.Since(start) + if remote := conn.RemoteAddr(); remote != nil { + if host, _, err := net.SplitHostPort(remote.String()); err == nil { + t.destIP = host + } + } + + cs := conn.ConnectionState() + t.tlsVersion = tlsVersionString(cs.Version) + t.extractCertificateInfo(cs) + return nil +} + +func (t *SSLTask) stop() {} + +func (t *SSLTask) class() string { + return ClassSSL +} + +func (t *SSLTask) getHostName() ([]string, error) { + return []string{t.Host}, nil +} + +func (t *SSLTask) getVariableValue(variable Variable) (string, error) { + return "", errors.New("not support") +} + +func (t *SSLTask) getRawTask(taskString string) (string, error) { + task := SSLTask{} + + if err := json.Unmarshal([]byte(taskString), &task); err != nil { + return "", fmt.Errorf("unmarshal ssl task failed: %w", err) + } + + task.Task = nil + + bytes, _ := json.Marshal(task) + return string(bytes), nil +} + +func (t *SSLTask) initTask() { + if t.Task == nil { + t.Task = &Task{} + } +} + +func (t *SSLTask) setReqError(err string) { + t.reqError = err +} + +func (t *SSLTask) renderTemplate(fm template.FuncMap) error { + if t.rawTask == nil { + task := &SSLTask{} + if err := t.NewRawTask(task); err != nil { + return fmt.Errorf("new raw task failed: %w", err) + } + t.rawTask = task + } + + task := t.rawTask + if task == nil { + return errors.New("raw task is nil") + } + + if text, err := t.GetParsedString(task.Host, fm); err != nil { + return fmt.Errorf("render host failed: %w", err) + } else { + t.Host = text + } + + if text, err := t.GetParsedString(task.Port, fm); err != nil { + return fmt.Errorf("render port failed: %w", err) + } else { + t.Port = text + } + + if text, err := t.GetParsedString(task.ServerName, fm); err != nil { + return fmt.Errorf("render server name failed: %w", err) + } else { + t.ServerName = text + } + + return nil +} + +func (t *SSLTask) extractCertificateInfo(cs tls.ConnectionState) { + if len(cs.PeerCertificates) == 0 { + return + } + + cert := cs.PeerCertificates[0] + t.sslCertNotBefore = cert.NotBefore.UnixMicro() + t.sslCertNotAfter = cert.NotAfter.UnixMicro() + t.sslCertExpiresInDays = (t.sslCertNotAfter - time.Now().UnixMicro()) / (24 * time.Hour).Microseconds() + t.certSubject = cert.Subject.String() + t.certIssuer = cert.Issuer.String() +} + +func tlsVersionString(version uint16) string { + switch version { + case tls.VersionTLS10: + return "TLS1.0" + case tls.VersionTLS11: + return "TLS1.1" + case tls.VersionTLS12: + return "TLS1.2" + case tls.VersionTLS13: + return "TLS1.3" + default: + return fmt.Sprintf("0x%x", version) + } +} diff --git a/dialtesting/ssl_test.go b/dialtesting/ssl_test.go new file mode 100644 index 00000000..17290b94 --- /dev/null +++ b/dialtesting/ssl_test.go @@ -0,0 +1,84 @@ +package dialtesting + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateSSLTaskChild(t *testing.T) { + child, err := CreateTaskChild("ssl") + require.NoError(t, err) + assert.IsType(t, &SSLTask{}, child) + + child, err = CreateTaskChild(ClassSSL) + require.NoError(t, err) + assert.IsType(t, &SSLTask{}, child) +} + +func TestSSLTaskCheckAndResult(t *testing.T) { + task, err := NewTask("", &SSLTask{ + Task: &Task{ + ExternalID: "ssl-task", + Name: "ssl-task", + Frequency: "1m", + }, + Host: "example.com", + Port: "443", + SuccessWhen: []*SSLSuccess{ + { + ResponseTime: "1s", + CertificateExpiresInDays: []*ValueSuccess{ + {Op: "gt", Target: 7}, + }, + TLSVersion: []*SuccessOption{ + {Is: "TLS1.3"}, + }, + Subject: []*SuccessOption{ + {Contains: "example.com"}, + }, + }, + }, + }) + require.NoError(t, err) + require.NoError(t, task.Check()) + + sslTask := task.(*SSLTask) + sslTask.reqCost = 100 * time.Millisecond + sslTask.sslCertExpiresInDays = 30 + sslTask.sslCertNotAfter = time.Now().Add(30 * 24 * time.Hour).UnixMicro() + sslTask.tlsVersion = "TLS1.3" + sslTask.certSubject = "CN=example.com" + sslTask.certIssuer = "CN=Example CA" + + reasons, ok := sslTask.CheckResult() + assert.True(t, ok) + assert.Empty(t, reasons) + + tags, fields := sslTask.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, int64(1), fields["success"]) + assert.Equal(t, "TLS1.3", fields["tls_version"]) + assert.Equal(t, int64(30), fields["ssl_cert_expires_in_days"]) +} + +func TestSSLTaskCheckResultCertificateExpiresSoon(t *testing.T) { + task := &SSLTask{ + Task: &Task{Name: "ssl-task"}, + SuccessWhen: []*SSLSuccess{ + { + CertificateExpiresInDays: []*ValueSuccess{ + {Op: "gt", Target: 7}, + }, + }, + }, + sslCertExpiresInDays: 3, + } + + reasons, ok := task.checkResult() + assert.False(t, ok) + require.Len(t, reasons, 1) + assert.Contains(t, reasons[0], "SSL certificate expires in days check failed") +} diff --git a/dialtesting/task.go b/dialtesting/task.go index ccf5b556..a00d5a1e 100644 --- a/dialtesting/task.go +++ b/dialtesting/task.go @@ -30,6 +30,7 @@ const ( ClassWebsocket = "WEBSOCKET" ClassICMP = "ICMP" ClassGRPC = "GRPC" + ClassSSL = "SSL" ClassDNS = "DNS" ClassHeadless = "BROWSER" ClassOther = "OTHER" @@ -244,6 +245,9 @@ func CreateTaskChild(taskType string) (TaskChild, error) { case "grpc", ClassGRPC: ct = &GRPCTask{} + case "ssl", ClassSSL: + ct = &SSLTask{} + default: return nil, fmt.Errorf("unknown task type %s", taskType) } From 69e11dfbc06489d6c657414f19794ce8ff00c898 Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 17:00:11 +0800 Subject: [PATCH 08/14] test(dialtesting): cover ssl task --- dialtesting/ssl.go | 18 +-- dialtesting/ssl_test.go | 288 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 8 deletions(-) diff --git a/dialtesting/ssl.go b/dialtesting/ssl.go index ba36473b..ebfae699 100644 --- a/dialtesting/ssl.go +++ b/dialtesting/ssl.go @@ -35,12 +35,13 @@ type SSLSuccess struct { type SSLTask struct { *Task - Host string `json:"host"` - Port string `json:"port"` - ServerName string `json:"server_name,omitempty"` - Timeout string `json:"timeout,omitempty"` - SuccessWhen []*SSLSuccess `json:"success_when"` - SuccessWhenLogic string `json:"success_when_logic"` + Host string `json:"host"` + Port string `json:"port"` + ServerName string `json:"server_name,omitempty"` + Timeout string `json:"timeout,omitempty"` + IgnoreServerCertificateError bool `json:"ignore_server_certificate_error,omitempty"` + SuccessWhen []*SSLSuccess `json:"success_when"` + SuccessWhenLogic string `json:"success_when_logic"` reqCost time.Duration reqError string @@ -276,8 +277,9 @@ func (t *SSLTask) run() error { } cfg := &tls.Config{ - MinVersion: tls.VersionTLS12, - ServerName: serverName, + MinVersion: tls.VersionTLS12, + ServerName: serverName, + InsecureSkipVerify: t.IgnoreServerCertificateError, //nolint:gosec } start := time.Now() diff --git a/dialtesting/ssl_test.go b/dialtesting/ssl_test.go index 17290b94..1c2020ca 100644 --- a/dialtesting/ssl_test.go +++ b/dialtesting/ssl_test.go @@ -1,6 +1,12 @@ package dialtesting import ( + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "net" + "net/http" + "net/http/httptest" "testing" "time" @@ -82,3 +88,285 @@ func TestSSLTaskCheckResultCertificateExpiresSoon(t *testing.T) { require.Len(t, reasons, 1) assert.Contains(t, reasons[0], "SSL certificate expires in days check failed") } + +func TestSSLTaskRunSuccess(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + host, port, err := net.SplitHostPort(server.Listener.Addr().String()) + require.NoError(t, err) + + task, err := NewTask("", &SSLTask{ + Task: &Task{ + ExternalID: "ssl-run-success", + Name: "ssl-run-success", + Frequency: "1m", + }, + Host: host, + Port: port, + ServerName: "example.test", + Timeout: "3s", + IgnoreServerCertificateError: true, + SuccessWhen: []*SSLSuccess{ + { + ResponseTime: "3s", + CertificateExpiresInDays: []*ValueSuccess{ + {Op: "gt", Target: 0}, + }, + }, + }, + }) + require.NoError(t, err) + require.NoError(t, task.Check()) + require.NoError(t, task.Run()) + + sslTask := task.(*SSLTask) + assert.Empty(t, sslTask.reqError) + assert.NotZero(t, sslTask.reqCost) + assert.Equal(t, host, sslTask.destIP) + assert.NotEmpty(t, sslTask.tlsVersion) + + tags, fields := task.GetResults() + assert.Equal(t, "OK", tags["status"]) + assert.Equal(t, "example.test", tags["server_name"]) + assert.Equal(t, int64(1), fields["success"]) + assert.NotEmpty(t, fields["task"]) + assert.NotEmpty(t, fields["config_vars"]) +} + +func TestSSLTaskRunFailure(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := listener.Addr().String() + require.NoError(t, listener.Close()) + + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + + task := &SSLTask{ + Task: &Task{Name: "ssl-run-failure"}, + Host: host, + Port: port, + timeout: time.Second, + SuccessWhen: []*SSLSuccess{ + {ResponseTime: "1s"}, + }, + } + + require.NoError(t, task.run()) + assert.NotEmpty(t, task.reqError) + + tags, fields := task.getResults() + assert.Equal(t, "FAIL", tags["status"]) + assert.Equal(t, int64(-1), fields["success"]) + assert.Contains(t, fields["fail_reason"], "connect") +} + +func TestSSLTaskCheckErrors(t *testing.T) { + assert.EqualError(t, (&SSLTask{}).check(), "host should not be empty") + assert.EqualError(t, (&SSLTask{Host: "example.com"}).check(), "port should not be empty") +} + +func TestSSLTaskInitErrors(t *testing.T) { + err := (&SSLTask{Timeout: "bad", SuccessWhen: []*SSLSuccess{{}}}).init() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid duration") + + err = (&SSLTask{}).init() + require.Error(t, err) + assert.Contains(t, err.Error(), "no any check rule") + + err = (&SSLTask{SuccessWhen: []*SSLSuccess{{ResponseTime: "bad"}}}).init() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid duration") + + err = (&SSLTask{SuccessWhen: []*SSLSuccess{{Subject: []*SuccessOption{{MatchRegex: "["}}}}}).init() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing closing") + + err = (&SSLTask{SuccessWhen: []*SSLSuccess{{Issuer: []*SuccessOption{{MatchRegex: "["}}}}}).init() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing closing") + + err = (&SSLTask{SuccessWhen: []*SSLSuccess{{TLSVersion: []*SuccessOption{{MatchRegex: "["}}}}}).init() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing closing") +} + +func TestSSLTaskCheckResultFailures(t *testing.T) { + task := &SSLTask{ + reqCost: 2 * time.Second, + sslCertExpiresInDays: 3, + sslCertNotAfter: 100, + certSubject: "CN=unexpected", + certIssuer: "CN=Unexpected CA", + tlsVersion: "TLS1.2", + SuccessWhen: []*SSLSuccess{ + { + respTime: time.Second, + CertificateExpiresInDays: []*ValueSuccess{{Op: "gt", Target: 7}}, + CertificateNotAfter: []*ValueSuccess{{Op: "gt", Target: 200}}, + Subject: []*SuccessOption{{Contains: "example.com"}}, + Issuer: []*SuccessOption{{Contains: "Expected CA"}}, + TLSVersion: []*SuccessOption{{Is: "TLS1.3"}}, + }, + }, + } + + reasons, ok := task.checkResult() + assert.False(t, ok) + require.Len(t, reasons, 6) + assert.Contains(t, reasons[0], "SSL response time") + assert.Contains(t, reasons[1], "SSL certificate expires in days") + assert.Contains(t, reasons[2], "SSL certificate not after") + assert.Contains(t, reasons[3], "SSL certificate subject") + assert.Contains(t, reasons[4], "SSL certificate issuer") + assert.Contains(t, reasons[5], "TLS version") +} + +func TestSSLTaskGetResultsOrLogicFailure(t *testing.T) { + task := &SSLTask{ + Task: &Task{Name: "ssl-task", Tags: map[string]string{"env": "test"}}, + Host: "example.com", + Port: "443", + ServerName: "sni.example.com", + SuccessWhenLogic: "or", + SuccessWhen: []*SSLSuccess{{respTime: time.Second}}, + reqCost: 2 * time.Second, + reqError: "handshake failed", + } + + tags, fields := task.getResults() + assert.Equal(t, "FAIL", tags["status"]) + assert.Equal(t, "test", tags["env"]) + assert.Equal(t, "sni.example.com", tags["server_name"]) + assert.Equal(t, int64(-1), fields["success"]) + assert.Contains(t, fields["fail_reason"], "handshake failed") + assert.Contains(t, fields["message"], "FAIL") +} + +func TestSSLTaskHelpers(t *testing.T) { + task := &SSLTask{ + Task: &Task{Name: "ssl-task"}, + Host: "example.com", + Port: "443", + reqCost: time.Second, + reqError: "err", + destIP: "127.0.0.1", + tlsVersion: "TLS1.3", + sslCertNotBefore: 1, + sslCertNotAfter: 2, + sslCertExpiresInDays: 3, + certSubject: "subject", + certIssuer: "issuer", + } + + assert.Equal(t, "ssl_dial_testing", task.metricName()) + assert.Equal(t, ClassSSL, task.class()) + hosts, err := task.getHostName() + require.NoError(t, err) + assert.Equal(t, []string{"example.com"}, hosts) + assert.EqualError(t, func() error { + _, err := task.getVariableValue(Variable{}) + return err + }(), "not support") + task.setReqError("new error") + assert.Equal(t, "new error", task.reqError) + task.stop() + + task.clear() + assert.Zero(t, task.reqCost) + assert.Empty(t, task.reqError) + assert.Empty(t, task.destIP) + assert.Empty(t, task.tlsVersion) + assert.Zero(t, task.sslCertNotBefore) + assert.Zero(t, task.sslCertNotAfter) + assert.Zero(t, task.sslCertExpiresInDays) + assert.Empty(t, task.certSubject) + assert.Empty(t, task.certIssuer) + + task.Task = nil + task.initTask() + assert.NotNil(t, task.Task) + + raw, err := task.getRawTask(`{"host":"example.com","port":"443"}`) + require.NoError(t, err) + assert.Contains(t, raw, `"host":"example.com"`) + + _, err = task.getRawTask(`{`) + require.Error(t, err) + assert.Contains(t, err.Error(), "unmarshal ssl task failed") +} + +func TestSSLTaskRenderTemplate(t *testing.T) { + task, err := NewTask("", &SSLTask{ + Task: &Task{ConfigVars: []*ConfigVar{{Name: "host", Value: "example.com"}}}, + Host: "{{ host }}", + Port: "{{ port }}", + ServerName: "{{ server }}", + SuccessWhen: []*SSLSuccess{ + {ResponseTime: "1s"}, + }, + }) + require.NoError(t, err) + + sslTask := task.(*SSLTask) + err = sslTask.renderTemplate(map[string]interface{}{ + "host": func() string { return "example.org" }, + "port": func() string { return "443" }, + "server": func() string { return "sni.example.org" }, + }) + require.NoError(t, err) + assert.Equal(t, "example.org", sslTask.Host) + assert.Equal(t, "443", sslTask.Port) + assert.Equal(t, "sni.example.org", sslTask.ServerName) + + sslTask.rawTask = nil + sslTask.SetTaskJSONString("") + err = sslTask.renderTemplate(map[string]interface{}{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "new raw task failed") + + err = (&SSLTask{Task: &Task{}, rawTask: nil}).renderTemplate(map[string]interface{}{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "new raw task failed") +} + +func TestSSLTaskExtractCertificateInfoEmpty(t *testing.T) { + task := &SSLTask{} + task.extractCertificateInfo(tls.ConnectionState{}) + assert.Zero(t, task.sslCertNotAfter) +} + +func TestSSLTaskExtractCertificateInfo(t *testing.T) { + notBefore := time.Now().Add(-time.Hour).Truncate(time.Microsecond) + notAfter := time.Now().Add(48 * time.Hour).Truncate(time.Microsecond) + task := &SSLTask{} + + task.extractCertificateInfo(tls.ConnectionState{ + PeerCertificates: []*x509.Certificate{ + { + Subject: pkix.Name{CommonName: "example.com"}, + Issuer: pkix.Name{CommonName: "Example CA"}, + NotBefore: notBefore, + NotAfter: notAfter, + }, + }, + }) + + assert.Equal(t, notBefore.UnixMicro(), task.sslCertNotBefore) + assert.Equal(t, notAfter.UnixMicro(), task.sslCertNotAfter) + assert.GreaterOrEqual(t, task.sslCertExpiresInDays, int64(1)) + assert.Contains(t, task.certSubject, "example.com") + assert.Contains(t, task.certIssuer, "Example CA") +} + +func TestTLSVersionString(t *testing.T) { + assert.Equal(t, "TLS1.0", tlsVersionString(tls.VersionTLS10)) + assert.Equal(t, "TLS1.1", tlsVersionString(tls.VersionTLS11)) + assert.Equal(t, "TLS1.2", tlsVersionString(tls.VersionTLS12)) + assert.Equal(t, "TLS1.3", tlsVersionString(tls.VersionTLS13)) + assert.Equal(t, "0x1234", tlsVersionString(0x1234)) +} From ebfcb009f5b0005d98be4ba4077be6d8c2440228 Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 17:16:01 +0800 Subject: [PATCH 09/14] fix(dialtesting): render ssl success templates --- dialtesting/ssl.go | 38 ++++++++++++++++++++++++++ dialtesting/ssl_test.go | 59 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/dialtesting/ssl.go b/dialtesting/ssl.go index ebfae699..94217bd1 100644 --- a/dialtesting/ssl.go +++ b/dialtesting/ssl.go @@ -378,6 +378,44 @@ func (t *SSLTask) renderTemplate(fm template.FuncMap) error { t.ServerName = text } + if err := t.renderSuccessWhen(task, fm); err != nil { + return err + } + + return nil +} + +func (t *SSLTask) renderSuccessWhen(task *SSLTask, fm template.FuncMap) error { + if task == nil { + return nil + } + + for index, checker := range task.SuccessWhen { + if text, err := t.GetParsedString(checker.ResponseTime, fm); err != nil { + return fmt.Errorf("render response time failed: %w", err) + } else { + t.SuccessWhen[index].ResponseTime = text + } + + for optionIndex, option := range checker.Subject { + if err := t.renderSuccessOption(option, t.SuccessWhen[index].Subject[optionIndex], fm); err != nil { + return fmt.Errorf("render subject failed: %w", err) + } + } + + for optionIndex, option := range checker.Issuer { + if err := t.renderSuccessOption(option, t.SuccessWhen[index].Issuer[optionIndex], fm); err != nil { + return fmt.Errorf("render issuer failed: %w", err) + } + } + + for optionIndex, option := range checker.TLSVersion { + if err := t.renderSuccessOption(option, t.SuccessWhen[index].TLSVersion[optionIndex], fm); err != nil { + return fmt.Errorf("render tls version failed: %w", err) + } + } + } + return nil } diff --git a/dialtesting/ssl_test.go b/dialtesting/ssl_test.go index 1c2020ca..62309a2f 100644 --- a/dialtesting/ssl_test.go +++ b/dialtesting/ssl_test.go @@ -307,7 +307,21 @@ func TestSSLTaskRenderTemplate(t *testing.T) { Port: "{{ port }}", ServerName: "{{ server }}", SuccessWhen: []*SSLSuccess{ - {ResponseTime: "1s"}, + { + ResponseTime: "{{ timeout }}", + Subject: []*SuccessOption{ + {Contains: "{{ subject }}"}, + {IsNot: "{{ subject_is_not }}"}, + }, + Issuer: []*SuccessOption{ + {MatchRegex: "{{ issuer_regex }}"}, + {NotContains: "{{ issuer_not_contains }}"}, + }, + TLSVersion: []*SuccessOption{ + {Is: "{{ tls_version }}"}, + {NotMatchRegex: "{{ tls_not_regex }}"}, + }, + }, }, }) require.NoError(t, err) @@ -317,11 +331,41 @@ func TestSSLTaskRenderTemplate(t *testing.T) { "host": func() string { return "example.org" }, "port": func() string { return "443" }, "server": func() string { return "sni.example.org" }, + "timeout": func() string { + return "2s" + }, + "subject": func() string { + return "example.org" + }, + "subject_is_not": func() string { + return "bad.example.org" + }, + "issuer_regex": func() string { + return "Example.*CA" + }, + "issuer_not_contains": func() string { + return "Bad CA" + }, + "tls_version": func() string { + return "TLS1.3" + }, + "tls_not_regex": func() string { + return "TLS1\\.0" + }, }) require.NoError(t, err) assert.Equal(t, "example.org", sslTask.Host) assert.Equal(t, "443", sslTask.Port) assert.Equal(t, "sni.example.org", sslTask.ServerName) + require.NoError(t, sslTask.init()) + assert.Equal(t, 2*time.Second, sslTask.SuccessWhen[0].respTime) + assert.Equal(t, "example.org", sslTask.SuccessWhen[0].Subject[0].Contains) + assert.Equal(t, "bad.example.org", sslTask.SuccessWhen[0].Subject[1].IsNot) + assert.Equal(t, "Example.*CA", sslTask.SuccessWhen[0].Issuer[0].MatchRegex) + assert.Equal(t, "Bad CA", sslTask.SuccessWhen[0].Issuer[1].NotContains) + assert.Equal(t, "TLS1.3", sslTask.SuccessWhen[0].TLSVersion[0].Is) + assert.Equal(t, "TLS1\\.0", sslTask.SuccessWhen[0].TLSVersion[1].NotMatchRegex) + require.NoError(t, sslTask.renderSuccessWhen(nil, map[string]interface{}{})) sslTask.rawTask = nil sslTask.SetTaskJSONString("") @@ -332,6 +376,19 @@ func TestSSLTaskRenderTemplate(t *testing.T) { err = (&SSLTask{Task: &Task{}, rawTask: nil}).renderTemplate(map[string]interface{}{}) require.Error(t, err) assert.Contains(t, err.Error(), "new raw task failed") + + badTask, err := NewTask("", &SSLTask{ + Task: &Task{}, + Host: "example.com", + Port: "443", + SuccessWhen: []*SSLSuccess{ + {ResponseTime: "{{"}, + }, + }) + require.NoError(t, err) + err = badTask.(*SSLTask).renderTemplate(map[string]interface{}{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "render response time failed") } func TestSSLTaskExtractCertificateInfoEmpty(t *testing.T) { From e52259fd737ad4b07e9cc072cd4180f2bdd1dde4 Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 17:41:09 +0800 Subject: [PATCH 10/14] chore(dialtesting): add ssl test license header --- dialtesting/ssl_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dialtesting/ssl_test.go b/dialtesting/ssl_test.go index 62309a2f..e27b32ab 100644 --- a/dialtesting/ssl_test.go +++ b/dialtesting/ssl_test.go @@ -1,3 +1,8 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the MIT License. +// This product includes software developed at Guance Cloud (https://www.guance.com/). +// Copyright 2021-present Guance, Inc. + package dialtesting import ( From 6265227bbf1679f6c123f9b8dc31100769efae3e Mon Sep 17 00:00:00 2001 From: zhengb Date: Mon, 8 Jun 2026 17:54:31 +0800 Subject: [PATCH 11/14] fix(dialtesting): skip ssl checks on request error --- dialtesting/ssl.go | 7 ++++++- dialtesting/ssl_test.go | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dialtesting/ssl.go b/dialtesting/ssl.go index 94217bd1..f4821c57 100644 --- a/dialtesting/ssl.go +++ b/dialtesting/ssl.go @@ -207,9 +207,14 @@ func (t *SSLTask) getResults() (tags map[string]string, fields map[string]interf } message := map[string]interface{}{} - reasons, succFlag := t.checkResult() + var ( + reasons []string + succFlag bool + ) if t.reqError != "" { reasons = append(reasons, t.reqError) + } else { + reasons, succFlag = t.checkResult() } switch t.SuccessWhenLogic { diff --git a/dialtesting/ssl_test.go b/dialtesting/ssl_test.go index e27b32ab..10a80807 100644 --- a/dialtesting/ssl_test.go +++ b/dialtesting/ssl_test.go @@ -156,7 +156,12 @@ func TestSSLTaskRunFailure(t *testing.T) { Port: port, timeout: time.Second, SuccessWhen: []*SSLSuccess{ - {ResponseTime: "1s"}, + { + ResponseTime: "1s", + CertificateExpiresInDays: []*ValueSuccess{ + {Op: "gt", Target: 7}, + }, + }, }, } @@ -167,6 +172,7 @@ func TestSSLTaskRunFailure(t *testing.T) { assert.Equal(t, "FAIL", tags["status"]) assert.Equal(t, int64(-1), fields["success"]) assert.Contains(t, fields["fail_reason"], "connect") + assert.NotContains(t, fields["fail_reason"], "SSL certificate expires in days") } func TestSSLTaskCheckErrors(t *testing.T) { From 22f75531c8b6dddb348174e50add38c69d61e09f Mon Sep 17 00:00:00 2001 From: zhengb Date: Wed, 10 Jun 2026 13:44:13 +0800 Subject: [PATCH 12/14] fix(dialtesting): sanitize browser retry runner errors --- dialtesting/browser.go | 8 ++++++-- dialtesting/browser_error_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dialtesting/browser.go b/dialtesting/browser.go index 465ea2a5..379c6eb4 100644 --- a/dialtesting/browser.go +++ b/dialtesting/browser.go @@ -746,7 +746,7 @@ func sanitizeBrowserSteps(steps []browserDialStep, displayError string) []browse } func sanitizeBrowserRetryRecords(records []browserRetryRecord, displayError string) []browserRetryRecord { - if displayError == "" || len(records) == 0 { + if len(records) == 0 { return records } @@ -756,7 +756,11 @@ func sanitizeBrowserRetryRecords(records []browserRetryRecord, displayError stri if !isBrowserRunnerRetryRecord(out[i]) { continue } - out[i].Message = displayError + if displayError != "" { + out[i].Message = displayError + } else { + out[i].Message = browserSystemErrorMessage + } } return out } diff --git a/dialtesting/browser_error_test.go b/dialtesting/browser_error_test.go index f8eff77f..0ca0a539 100644 --- a/dialtesting/browser_error_test.go +++ b/dialtesting/browser_error_test.go @@ -83,6 +83,30 @@ func TestSanitizeBrowserRetryRecordsKeepsNonRunnerMessages(t *testing.T) { assert.Equal(t, browserSystemErrorMessage, got[2].Message) } +func TestSanitizeBrowserRetryRecordsHidesRunnerErrorWithoutFinalRunnerError(t *testing.T) { + records := []browserRetryRecord{ + { + Attempt: 1, + Status: "FAIL", + FailureType: "runner_error", + Message: "/opt/lightpanda: /lib64/libc.so.6: version GLIBC_2.34 not found", + }, + { + Attempt: 2, + Status: "OK", + Success: true, + Message: "success", + }, + } + + got := sanitizeBrowserRetryRecords(records, "") + + assert.Len(t, got, 2) + assert.Equal(t, browserSystemErrorMessage, got[0].Message) + assert.Equal(t, "success", got[1].Message) + assertNoLeak(t, got[0].Message, "/opt/lightpanda", "GLIBC_") +} + func TestBrowserTaskDisplayRunnerErrorUsesUnifiedMessage(t *testing.T) { rawMessages := []string{ "no lightpanda executable found; set LIGHTPANDA_EXECUTABLE_PATH or install lightpanda in PATH", From f1128994c5a7b6fae80e0a2f2fa6d3c062f98f13 Mon Sep 17 00:00:00 2001 From: zhengb Date: Thu, 11 Jun 2026 11:00:22 +0800 Subject: [PATCH 13/14] feat(dialtesting): report ssl handshake time --- dialtesting/ssl.go | 10 ++++++++-- dialtesting/ssl_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/dialtesting/ssl.go b/dialtesting/ssl.go index f4821c57..5bfb296f 100644 --- a/dialtesting/ssl.go +++ b/dialtesting/ssl.go @@ -44,6 +44,7 @@ type SSLTask struct { SuccessWhenLogic string `json:"success_when_logic"` reqCost time.Duration + tlsHandshakeCost time.Duration reqError string destIP string timeout time.Duration @@ -184,9 +185,11 @@ func (t *SSLTask) getResults() (tags map[string]string, fields map[string]interf } responseTime := int64(t.reqCost) / 1000 + tlsHandshakeTime := int64(t.tlsHandshakeCost) / 1000 fields = map[string]interface{}{ - "response_time": responseTime, - "success": int64(-1), + "response_time": responseTime, + "tls_handshake_time": tlsHandshakeTime, + "success": int64(-1), } if t.tlsVersion != "" { @@ -260,6 +263,7 @@ func (t *SSLTask) metricName() string { func (t *SSLTask) clear() { t.reqCost = 0 + t.tlsHandshakeCost = 0 t.reqError = "" t.destIP = "" t.tlsVersion = "" @@ -294,11 +298,13 @@ func (t *SSLTask) run() error { return nil } conn := tls.Client(rawConn, cfg) + handshakeStart := time.Now() if err := conn.HandshakeContext(ctx); err != nil { _ = conn.Close() t.reqError = err.Error() return nil } + t.tlsHandshakeCost = time.Since(handshakeStart) defer conn.Close() //nolint:errcheck t.reqCost = time.Since(start) diff --git a/dialtesting/ssl_test.go b/dialtesting/ssl_test.go index 10a80807..10e57925 100644 --- a/dialtesting/ssl_test.go +++ b/dialtesting/ssl_test.go @@ -58,6 +58,7 @@ func TestSSLTaskCheckAndResult(t *testing.T) { sslTask := task.(*SSLTask) sslTask.reqCost = 100 * time.Millisecond + sslTask.tlsHandshakeCost = 80 * time.Millisecond sslTask.sslCertExpiresInDays = 30 sslTask.sslCertNotAfter = time.Now().Add(30 * 24 * time.Hour).UnixMicro() sslTask.tlsVersion = "TLS1.3" @@ -72,6 +73,7 @@ func TestSSLTaskCheckAndResult(t *testing.T) { assert.Equal(t, "OK", tags["status"]) assert.Equal(t, int64(1), fields["success"]) assert.Equal(t, "TLS1.3", fields["tls_version"]) + assert.Equal(t, int64(80000), fields["tls_handshake_time"]) assert.Equal(t, int64(30), fields["ssl_cert_expires_in_days"]) } @@ -130,6 +132,8 @@ func TestSSLTaskRunSuccess(t *testing.T) { sslTask := task.(*SSLTask) assert.Empty(t, sslTask.reqError) assert.NotZero(t, sslTask.reqCost) + assert.NotZero(t, sslTask.tlsHandshakeCost) + assert.LessOrEqual(t, sslTask.tlsHandshakeCost, sslTask.reqCost) assert.Equal(t, host, sslTask.destIP) assert.NotEmpty(t, sslTask.tlsVersion) @@ -141,6 +145,24 @@ func TestSSLTaskRunSuccess(t *testing.T) { assert.NotEmpty(t, fields["config_vars"]) } +func TestSSLTaskClearResetsTimingFields(t *testing.T) { + task := &SSLTask{ + reqCost: time.Second, + tlsHandshakeCost: 500 * time.Millisecond, + reqError: "failed", + destIP: "127.0.0.1", + tlsVersion: "TLS1.3", + } + + task.clear() + + assert.Zero(t, task.reqCost) + assert.Zero(t, task.tlsHandshakeCost) + assert.Empty(t, task.reqError) + assert.Empty(t, task.destIP) + assert.Empty(t, task.tlsVersion) +} + func TestSSLTaskRunFailure(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) From a8ea00cf245a4393b2016a0b2f986026a88d8642 Mon Sep 17 00:00:00 2001 From: zhengb Date: Tue, 16 Jun 2026 16:52:10 +0800 Subject: [PATCH 14/14] fix(dialtesting): handle zero icmp rtt --- dialtesting/icmp.go | 36 ++++++++++++++++++++++-------------- dialtesting/icmp_test.go | 7 +++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/dialtesting/icmp.go b/dialtesting/icmp.go index a1e9e163..19cfac42 100644 --- a/dialtesting/icmp.go +++ b/dialtesting/icmp.go @@ -460,7 +460,7 @@ func getICMPSequence() uint16 { return icmpSequence } -func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) { +func doPing(timeout time.Duration, target string) (rtt time.Duration, received bool, err error) { // limit icmp concurrent isReturnCh := false if ICMPConcurrentCh != nil { @@ -471,7 +471,7 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) isReturnCh = true case <-waitCtx.Done(): logger.Errorf("exceed max icmp concurrent %d", len(ICMPConcurrentCh)) - return 0, nil + return 0, false, nil } defer func() { @@ -498,7 +498,7 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) { dstIPAddr, _, err = chooseProtocol(timeout, "ip4", true, target) if err != nil { - return 0, fmt.Errorf("error resolving address: %w", err) + return 0, false, fmt.Errorf("error resolving address: %w", err) } srcIP := net.ParseIP("::") privileged := true @@ -520,7 +520,7 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) if privileged { icmpConn, err = icmp.ListenPacket("ip6:ipv6-icmp", srcIP.String()) if err != nil { - return 0, fmt.Errorf("error listening to socket: %w", err) + return 0, false, fmt.Errorf("error listening to socket: %w", err) } } defer icmpConn.Close() // nolint: errcheck @@ -542,7 +542,7 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) if privileged { icmpConn, err = icmp.ListenPacket("ip4:icmp", srcIP.String()) if err != nil { - return 0, fmt.Errorf("error listening to socket: %w", err) + return 0, false, fmt.Errorf("error listening to socket: %w", err) } } defer icmpConn.Close() // nolint: errcheck @@ -572,14 +572,14 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) wb, err = wm.Marshal(nil) if err != nil { - return 0, fmt.Errorf("error marshaling packet: %w", err) + return 0, false, fmt.Errorf("error marshaling packet: %w", err) } rttStart = time.Now() _, err = icmpConn.WriteTo(wb, dst) if err != nil { - return 0, fmt.Errorf("error writing to socket: %w", err) + return 0, false, fmt.Errorf("error writing to socket: %w", err) } // Reply should be the same except for the message type and ID if @@ -592,7 +592,7 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) } wb, err = wm.Marshal(nil) if err != nil { - return 0, fmt.Errorf("error marshaling packet: %w", err) + return 0, false, fmt.Errorf("error marshaling packet: %w", err) } if idUnknown { @@ -606,7 +606,7 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) deadline := time.Now().Add(timeout) err = icmpConn.SetReadDeadline(deadline) if err != nil { - return 0, fmt.Errorf("error setting socket deadline: %w", err) + return 0, false, fmt.Errorf("error setting socket deadline: %w", err) } } @@ -621,9 +621,9 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) } if err != nil { if nerr, ok := err.(net.Error); ok && nerr.Timeout() { // nolint: errorlint - return 0, nil + return 0, false, nil } - return 0, fmt.Errorf("error reading from socket: %w", err) + return 0, false, fmt.Errorf("error reading from socket: %w", err) } if peer.String() != dst.String() { // if read time is too big, then return concurrent channel @@ -649,11 +649,19 @@ func doPing(timeout time.Duration, target string) (rtt time.Duration, err error) } if bytes.Equal(rb[:n], wb) { rtt = time.Since(rttStart) - return rtt, nil + rtt = normalizeICMPRTT(rtt) + return rtt, true, nil } } } +func normalizeICMPRTT(rtt time.Duration) time.Duration { + if rtt <= 0 { + return time.Microsecond + } + return rtt +} + // Returns the IP for the ipproto and lookup time. func chooseProtocol(timeout time.Duration, ipproto string, fallbackIPProtocol bool, target string) (ip *net.IPAddr, lookupTime float64, err error) { if ipproto == "ip6" || ipproto == "" { @@ -742,12 +750,12 @@ func pingTarget(target string, count int, interval, timeout time.Duration) (stat rtts := []time.Duration{} for i := 0; i < count; i++ { err = func() error { - rtt, err := doPing(timeout, target) + rtt, received, err := doPing(timeout, target) if err != nil { return fmt.Errorf("ping failed: %w", err) } stat.PacketsSent++ - if rtt > 0 { + if received { stat.PacketsRecv++ rtts = append(rtts, rtt) } else { diff --git a/dialtesting/icmp_test.go b/dialtesting/icmp_test.go index 822702fc..a835934c 100644 --- a/dialtesting/icmp_test.go +++ b/dialtesting/icmp_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" "text/template" + "time" "github.com/stretchr/testify/assert" ) @@ -128,3 +129,9 @@ func TestICMPRenderTemplate(t *testing.T) { assert.NoError(t, ct.renderTemplate(fm)) assert.Equal(t, "localhost", ct.Host) } + +func TestNormalizeICMPRTT(t *testing.T) { + assert.Equal(t, time.Microsecond, normalizeICMPRTT(0)) + assert.Equal(t, time.Microsecond, normalizeICMPRTT(-time.Nanosecond)) + assert.Equal(t, 2*time.Microsecond, normalizeICMPRTT(2*time.Microsecond)) +}