diff --git a/internal/clab/service.go b/internal/clab/service.go index 17a2f78..9a95320 100644 --- a/internal/clab/service.go +++ b/internal/clab/service.go @@ -531,6 +531,16 @@ func (s *Service) CreateCA(ctx context.Context, opts CACreateOptions) (*clabcert ca := clabcert.NewCA() + // Go crypto/rsa rejects insecure key sizes; containerlab cert helpers + // accept 0 which used to imply a default. Enforce a safe default here. + keySize := opts.KeySize + if keySize == 0 { + keySize = 2048 + } + if keySize < 2048 { + keySize = 2048 + } + input := &clabcert.CACSRInput{ CommonName: opts.CommonName, Country: opts.Country, @@ -538,13 +548,14 @@ func (s *Service) CreateCA(ctx context.Context, opts CACreateOptions) (*clabcert Organization: opts.Organization, OrganizationUnit: opts.OrgUnit, Expiry: opts.Expiry, - KeySize: opts.KeySize, + KeySize: keySize, } log.Info("Generating CA certificate", "name", opts.Name, "commonName", opts.CommonName, "expiry", opts.Expiry, + "keySize", keySize, ) cert, err := ca.GenerateCACert(input) diff --git a/tests_go/docs_suite_test.go b/tests_go/docs_suite_test.go new file mode 100644 index 0000000..f99e937 --- /dev/null +++ b/tests_go/docs_suite_test.go @@ -0,0 +1,66 @@ +// tests_go/docs_suite_test.go +package tests_go + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/suite" +) + +// DocsSuite tests public documentation endpoints (swagger/redoc). +type DocsSuite struct { + BaseSuite +} + +func TestDocsSuite(t *testing.T) { + suite.Run(t, new(DocsSuite)) +} + +func (s *DocsSuite) SetupSuite() { + s.BaseSuite.SetupSuite() +} + +func (s *DocsSuite) TestSwaggerUIIndexHTML() { + s.logTest("Fetching swagger UI index page") + + url := fmt.Sprintf("%s/swagger/index.html", s.cfg.APIURL) + bodyBytes, statusCode, err := s.doRequest("GET", url, nil, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected 200 for swagger UI. Body: %s", string(bodyBytes)) + + s.Assert().NotEmpty(bodyBytes, "Expected swagger UI page content to be non-empty") + // Avoid strict string matching; just sanity-check it's HTML-ish. + s.Assert().True(strings.Contains(strings.ToLower(string(bodyBytes)), "`, "Expected redoc HTML to reference swagger spec URL") +} diff --git a/tests_go/lab_archive_suite_test.go b/tests_go/lab_archive_suite_test.go new file mode 100644 index 0000000..15afe36 --- /dev/null +++ b/tests_go/lab_archive_suite_test.go @@ -0,0 +1,104 @@ +// tests_go/lab_archive_suite_test.go +package tests_go + +import ( + "archive/zip" + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + "gopkg.in/yaml.v3" +) + +// LabArchiveSuite tests deploying labs from an uploaded archive. +type LabArchiveSuite struct { + BaseSuite + apiUserToken string + apiUserHeaders http.Header +} + +func TestLabArchiveSuite(t *testing.T) { + suite.Run(t, new(LabArchiveSuite)) +} + +func (s *LabArchiveSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + s.apiUserToken = s.login(s.cfg.APIUserUser, s.cfg.APIUserPass) + s.apiUserHeaders = s.getAuthHeaders(s.apiUserToken) + s.Require().NotEmpty(s.apiUserToken) +} + +func (s *LabArchiveSuite) TestDeployLabArchiveMissingLabNameQueryParam() { + s.logTest("Deploying lab archive without labName query param (expecting 400)") + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + s.Require().NoError(writer.Close()) + + headers := s.getAuthHeaders(s.apiUserToken) + headers.Set("Content-Type", writer.FormDataContentType()) + + url := fmt.Sprintf("%s/api/v1/labs/archive", s.cfg.APIURL) + respBody, statusCode, err := s.doRequest("POST", url, headers, &body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusBadRequest, statusCode, "Expected 400 for missing labName. Body: %s", string(respBody)) +} + +func (s *LabArchiveSuite) TestDeployLabArchiveZipSuccess() { + labName := fmt.Sprintf("%s-arch-%s", s.cfg.LabNamePrefix, s.randomSuffix(5)) + defer s.cleanupLab(labName, true) + + s.logTest("Deploying lab '%s' via /api/v1/labs/archive (zip upload)", labName) + + // Build YAML topology from the same JSON content used by other tests. + topologyJSON := strings.ReplaceAll(s.cfg.SimpleTopologyContent, "{lab_name}", labName) + var topoData map[string]interface{} + s.Require().NoError(json.Unmarshal([]byte(topologyJSON), &topoData), "Failed to parse test topology JSON") + + yamlBytes, err := yaml.Marshal(topoData) + s.Require().NoError(err, "Failed to marshal topology to YAML") + + // Create a zip archive in memory containing ".clab.yml" at the archive root. + var zipBuf bytes.Buffer + zw := zip.NewWriter(&zipBuf) + fw, err := zw.Create(labName + ".clab.yml") + s.Require().NoError(err, "Failed to create zip entry") + _, err = fw.Write(yamlBytes) + s.Require().NoError(err, "Failed to write zip entry data") + s.Require().NoError(zw.Close(), "Failed to close zip writer") + + // Create multipart/form-data request body. + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("labArchive", labName+".zip") + s.Require().NoError(err, "Failed to create multipart file part") + _, err = part.Write(zipBuf.Bytes()) + s.Require().NoError(err, "Failed to write multipart file bytes") + + s.Require().NoError(writer.Close(), "Failed to close multipart writer") + + headers := s.getAuthHeaders(s.apiUserToken) + headers.Set("Content-Type", writer.FormDataContentType()) + + url := fmt.Sprintf("%s/api/v1/labs/archive?labName=%s", s.cfg.APIURL, labName) + respBody, statusCode, err := s.doRequest("POST", url, headers, &body, s.cfg.DeployTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected 200 deploying lab from archive. Body: %s", string(respBody)) + + var out ClabInspectOutput + s.Require().NoError(json.Unmarshal(respBody, &out), "Failed to unmarshal archive deploy response. Body: %s", string(respBody)) + s.Assert().Contains(out, labName, "Expected deployed lab '%s' to be present in response", labName) + if nodes, ok := out[labName]; ok { + s.Assert().NotEmpty(nodes, "Expected deployed lab to have container entries in response") + } + + if !s.T().Failed() { + s.logSuccess("Archive deploy succeeded for lab '%s'", labName) + } +} diff --git a/tests_go/ssh_suite_test.go b/tests_go/ssh_suite_test.go new file mode 100644 index 0000000..9ac11f0 --- /dev/null +++ b/tests_go/ssh_suite_test.go @@ -0,0 +1,321 @@ +// tests_go/ssh_suite_test.go +package tests_go + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "golang.org/x/crypto/ssh" +) + +// SSHSuite tests SSH access/session management endpoints. +type SSHSuite struct { + BaseSuite + + apiUserToken string + apiUserHeaders http.Header + superuserToken string + superuserHeaders http.Header + + labName string + containerName string + + createdPorts []int +} + +func TestSSHSuite(t *testing.T) { + suite.Run(t, new(SSHSuite)) +} + +func (s *SSHSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + + s.apiUserToken = s.login(s.cfg.APIUserUser, s.cfg.APIUserPass) + s.apiUserHeaders = s.getAuthHeaders(s.apiUserToken) + s.superuserToken = s.login(s.cfg.SuperuserUser, s.cfg.SuperuserPass) + s.superuserHeaders = s.getAuthHeaders(s.superuserToken) + s.Require().NotEmpty(s.apiUserToken) + s.Require().NotEmpty(s.superuserToken) + + // Create a shared lab owned by the API user for SSH tests. + // Use a Nokia SR Linux node so the SSH proxy can be validated end-to-end. + s.labName = fmt.Sprintf("%s-ssh-%s", s.cfg.LabNamePrefix, s.randomSuffix(5)) + topologyObj := map[string]interface{}{ + "name": s.labName, + "topology": map[string]interface{}{ + "nodes": map[string]interface{}{ + "srl1": map[string]interface{}{ + "kind": "nokia_srlinux", + "type": "ixr-d2l", + "image": "ghcr.io/nokia/srlinux:latest", + }, + }, + }, + } + topology := string(s.mustMarshal(topologyObj)) + + s.logSetup("Creating shared SSH test lab: %s", s.labName) + deployTimeout := s.cfg.DeployTimeout + if deployTimeout < 10*time.Minute { + deployTimeout = 10 * time.Minute + } + bodyBytes, statusCode, err := s.createLab(s.apiUserHeaders, s.labName, topology, false, deployTimeout) + s.Require().NoError(err, "SETUP Failed: Could not create SSH test lab") + s.Require().Equal(http.StatusOK, statusCode, "SETUP Failed: Could not create SSH test lab. Body: %s", string(bodyBytes)) + + // Resolve first container name in the lab to use for SSH access requests. + // SR Linux boot can take time; wait until inspect provides a container with an IPv4 address. + container := s.waitForFirstContainerWithIPv4(s.labName, s.apiUserHeaders, 3*time.Minute) + s.containerName = container.Name + s.Require().NotEmpty(s.containerName, "SETUP Failed: Container name is empty") + + s.logSetup("Using container '%s' for SSH access tests", s.containerName) +} + +func (s *SSHSuite) TearDownSuite() { + // Best-effort cleanup of any sessions created during the suite. + for _, port := range s.createdPorts { + terminateURL := fmt.Sprintf("%s/api/v1/ssh/sessions/%d", s.cfg.APIURL, port) + _, _, _ = s.doRequest("DELETE", terminateURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) + } + + if s.labName != "" { + s.logTeardown("Cleaning up SSH test lab: %s", s.labName) + s.cleanupLab(s.labName, true) + } + + s.BaseSuite.TearDownSuite() +} + +func (s *SSHSuite) waitForFirstContainerWithIPv4(labName string, headers http.Header, timeout time.Duration) ClabContainerInfo { + s.T().Helper() + + inspectURL := fmt.Sprintf("%s/api/v1/labs/%s", s.cfg.APIURL, labName) + deadline := time.Now().Add(timeout) + + var lastBody []byte + var lastStatus int + var lastErr error + + for time.Now().Before(deadline) { + bodyBytes, statusCode, err := s.doRequest("GET", inspectURL, headers, nil, s.cfg.RequestTimeout) + lastBody = bodyBytes + lastStatus = statusCode + lastErr = err + + if err == nil && statusCode == http.StatusOK { + var containers []ClabContainerInfo + if json.Unmarshal(bodyBytes, &containers) == nil && len(containers) > 0 { + for _, c := range containers { + if c.Name != "" && c.IPv4Address != "" { + return c + } + } + } + } + + time.Sleep(2 * time.Second) + } + + s.Require().FailNowf("Timed out waiting for lab container IPv4", "lab=%s status=%d err=%v body=%s", + labName, lastStatus, lastErr, string(lastBody)) + return ClabContainerInfo{} +} + +func (s *SSHSuite) requireSRLSSHHandshake(port int, timeout time.Duration) { + s.T().Helper() + + addr := fmt.Sprintf("127.0.0.1:%d", port) + deadline := time.Now().Add(timeout) + var lastErr error + + cfg := &ssh.ClientConfig{ + User: "admin", + Auth: []ssh.AuthMethod{ssh.Password("NokiaSrl1!")}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + for time.Now().Before(deadline) { + // Fast check: port is accepting TCP connections. + conn, dialErr := net.DialTimeout("tcp", addr, 2*time.Second) + if dialErr != nil { + lastErr = dialErr + time.Sleep(2 * time.Second) + continue + } + _ = conn.Close() + + client, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + lastErr = err + time.Sleep(2 * time.Second) + continue + } + _ = client.Close() + return + } + + s.Require().FailNowf("SSH handshake failed", "addr=%s lastErr=%v", addr, lastErr) +} + +func (s *SSHSuite) requestSSH(headers http.Header, duration string) (port int) { + s.T().Helper() + + req := map[string]string{ + "sshUsername": "admin", + } + if duration != "" { + req["duration"] = duration + } + + reqBody := bytes.NewBuffer(s.mustMarshal(req)) + reqURL := fmt.Sprintf("%s/api/v1/labs/%s/nodes/%s/ssh", s.cfg.APIURL, s.labName, url.PathEscape(s.containerName)) + + respBody, statusCode, err := s.doRequest("POST", reqURL, headers, reqBody, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected 200 for SSH access request. Body: %s", string(respBody)) + + var resp struct { + Port int `json:"port"` + Host string `json:"host"` + Username string `json:"username"` + Expiration time.Time `json:"expiration"` + Command string `json:"command"` + } + s.Require().NoError(json.Unmarshal(respBody, &resp), "Failed to unmarshal SSH access response. Body: %s", string(respBody)) + s.Require().Greater(resp.Port, 0, "Expected port > 0 in SSH access response") + s.Require().NotEmpty(resp.Host, "Expected host in SSH access response") + s.Require().NotEmpty(resp.Username, "Expected username in SSH access response") + s.Require().NotZero(resp.Expiration, "Expected expiration in SSH access response") + s.Require().Contains(resp.Command, fmt.Sprintf("-p %d", resp.Port), "Expected command to reference allocated port") + + s.createdPorts = append(s.createdPorts, resp.Port) + return resp.Port +} + +func (s *SSHSuite) TestRequestSSHAccessCreatesSessionAndListsForUser() { + s.logTest("Requesting SSH access as API user and listing sessions") + + port := s.requestSSH(s.apiUserHeaders, "10m") + + // Validate that we can actually SSH (handshake + auth) to the SR Linux node via the proxy. + s.logTest("Attempting SSH handshake to SR Linux node via proxy port %d", port) + s.requireSRLSSHHandshake(port, 2*time.Minute) + + listURL := fmt.Sprintf("%s/api/v1/ssh/sessions", s.cfg.APIURL) + bodyBytes, statusCode, err := s.doRequest("GET", listURL, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected 200 listing SSH sessions. Body: %s", string(bodyBytes)) + + var sessions []struct { + Port int `json:"port"` + LabName string `json:"labName"` + } + s.Require().NoError(json.Unmarshal(bodyBytes, &sessions), "Failed to unmarshal sessions list. Body: %s", string(bodyBytes)) + + found := false + for _, sess := range sessions { + if sess.Port == port && sess.LabName == s.labName { + found = true + break + } + } + s.Assert().True(found, "Expected sessions list to include created session (port=%d, lab=%s). Body: %s", port, s.labName, string(bodyBytes)) + + terminateURL := fmt.Sprintf("%s/api/v1/ssh/sessions/%d", s.cfg.APIURL, port) + termBody, termStatus, termErr := s.doRequest("DELETE", terminateURL, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(termErr) + s.Assert().Equal(http.StatusOK, termStatus, "Expected 200 terminating own SSH session. Body: %s", string(termBody)) +} + +func (s *SSHSuite) TestListSessionsAllForbiddenForAPIUser() { + s.logTest("Listing all sessions as API user with all=true (expecting 403)") + + port := s.requestSSH(s.apiUserHeaders, "10m") + defer func() { + terminateURL := fmt.Sprintf("%s/api/v1/ssh/sessions/%d", s.cfg.APIURL, port) + _, _, _ = s.doRequest("DELETE", terminateURL, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + }() + + listAllURL := fmt.Sprintf("%s/api/v1/ssh/sessions?all=true", s.cfg.APIURL) + bodyBytes, statusCode, err := s.doRequest("GET", listAllURL, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 when API user requests all sessions. Body: %s", string(bodyBytes)) +} + +func (s *SSHSuite) TestSuperuserAllSeesOtherUsersSessions() { + s.logTest("Creating sessions as api user + superuser and listing as superuser with all=true") + + apiPort := s.requestSSH(s.apiUserHeaders, "10m") + suPort := s.requestSSH(s.superuserHeaders, "10m") + + // Superuser listing without all=true should only show sessions owned by superuser. + listURL := fmt.Sprintf("%s/api/v1/ssh/sessions", s.cfg.APIURL) + bodyBytes, statusCode, err := s.doRequest("GET", listURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected 200 listing sessions as superuser. Body: %s", string(bodyBytes)) + + var sessions []struct { + Port int `json:"port"` + } + s.Require().NoError(json.Unmarshal(bodyBytes, &sessions), "Failed to unmarshal sessions list. Body: %s", string(bodyBytes)) + + seenSU := false + seenAPI := false + for _, sess := range sessions { + if sess.Port == suPort { + seenSU = true + } + if sess.Port == apiPort { + seenAPI = true + } + } + s.Assert().True(seenSU, "Expected superuser list (all=false) to include superuser-created session") + s.Assert().False(seenAPI, "Expected superuser list (all=false) to NOT include api user session") + + // Superuser listing with all=true should include both. + listAllURL := fmt.Sprintf("%s/api/v1/ssh/sessions?all=true", s.cfg.APIURL) + bodyBytes2, statusCode2, err2 := s.doRequest("GET", listAllURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err2) + s.Require().Equal(http.StatusOK, statusCode2, "Expected 200 listing all sessions as superuser. Body: %s", string(bodyBytes2)) + + var sessions2 []struct { + Port int `json:"port"` + } + s.Require().NoError(json.Unmarshal(bodyBytes2, &sessions2), "Failed to unmarshal sessions list (all=true). Body: %s", string(bodyBytes2)) + + seenSU = false + seenAPI = false + for _, sess := range sessions2 { + if sess.Port == suPort { + seenSU = true + } + if sess.Port == apiPort { + seenAPI = true + } + } + s.Assert().True(seenSU, "Expected superuser list (all=true) to include superuser-created session") + s.Assert().True(seenAPI, "Expected superuser list (all=true) to include api user session") +} + +func (s *SSHSuite) TestTerminateForbiddenForNonOwner() { + s.logTest("Terminating superuser session as API user (expecting 403)") + + suPort := s.requestSSH(s.superuserHeaders, "10m") + + terminateURL := fmt.Sprintf("%s/api/v1/ssh/sessions/%d", s.cfg.APIURL, suPort) + bodyBytes, statusCode, err := s.doRequest("DELETE", terminateURL, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 when non-owner terminates SSH session. Body: %s", string(bodyBytes)) + + // Cleanup. + _, _, _ = s.doRequest("DELETE", terminateURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) +} diff --git a/tests_go/tools_certs_suite_test.go b/tests_go/tools_certs_suite_test.go new file mode 100644 index 0000000..2218c08 --- /dev/null +++ b/tests_go/tools_certs_suite_test.go @@ -0,0 +1,144 @@ +// tests_go/tools_certs_suite_test.go +package tests_go + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" +) + +// ToolsCertsSuite tests certificate tool endpoints (CA create, cert sign). +type ToolsCertsSuite struct { + BaseSuite + apiUserToken string + apiUserHeaders http.Header + superuserToken string + superuserHeaders http.Header +} + +func TestToolsCertsSuite(t *testing.T) { + suite.Run(t, new(ToolsCertsSuite)) +} + +func (s *ToolsCertsSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + s.apiUserToken = s.login(s.cfg.APIUserUser, s.cfg.APIUserPass) + s.apiUserHeaders = s.getAuthHeaders(s.apiUserToken) + s.superuserToken = s.login(s.cfg.SuperuserUser, s.cfg.SuperuserPass) + s.superuserHeaders = s.getAuthHeaders(s.superuserToken) + s.Require().NotEmpty(s.apiUserToken) + s.Require().NotEmpty(s.superuserToken) +} + +func (s *ToolsCertsSuite) TestCreateCAForbiddenForAPIUser() { + s.logTest("Creating CA as API user (expecting 403)") + + url := fmt.Sprintf("%s/api/v1/tools/certs/ca", s.cfg.APIURL) + payload := map[string]interface{}{"name": "gotest-ca-" + s.randomSuffix(5)} + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.apiUserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 for non-superuser CA create. Body: %s", string(respBody)) +} + +func (s *ToolsCertsSuite) TestSignCertForbiddenForAPIUser() { + s.logTest("Signing cert as API user (expecting 403)") + + url := fmt.Sprintf("%s/api/v1/tools/certs/sign", s.cfg.APIURL) + payload := map[string]interface{}{ + "name": "gotest-node-" + s.randomSuffix(5), + "hosts": []string{"127.0.0.1"}, + "caName": "does-not-matter", + } + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.apiUserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 for non-superuser cert sign. Body: %s", string(respBody)) +} + +func (s *ToolsCertsSuite) TestSignCertNotFoundWhenCAIsMissing() { + s.logTest("Signing cert with missing CA (expecting 404)") + + url := fmt.Sprintf("%s/api/v1/tools/certs/sign", s.cfg.APIURL) + payload := map[string]interface{}{ + "name": "gotest-node-" + s.randomSuffix(5), + "hosts": []string{"127.0.0.1"}, + "caName": "gotest-missing-ca-" + s.randomSuffix(5), + } + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.superuserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusNotFound, statusCode, "Expected 404 when CA does not exist. Body: %s", string(respBody)) +} + +func (s *ToolsCertsSuite) TestCreateCAAndSignCertAsSuperuser() { + caName := "gotest-ca-" + s.randomSuffix(6) + certName := "gotest-node-" + s.randomSuffix(6) + + s.logTest("Creating CA '%s' and signing cert '%s' as superuser", caName, certName) + + createURL := fmt.Sprintf("%s/api/v1/tools/certs/ca", s.cfg.APIURL) + createPayload := map[string]interface{}{ + "name": caName, + } + + createBody := bytes.NewBuffer(s.mustMarshal(createPayload)) + createRespBody, createStatus, createErr := s.doRequest("POST", createURL, s.superuserHeaders, createBody, s.cfg.RequestTimeout) + s.Require().NoError(createErr) + s.Require().Equal(http.StatusOK, createStatus, "Expected 200 for CA create. Body: %s", string(createRespBody)) + + var caResp struct { + Message string `json:"message"` + CertPath string `json:"certPath"` + KeyPath string `json:"keyPath"` + CSRPath string `json:"csrPath"` + } + s.Require().NoError(json.Unmarshal(createRespBody, &caResp), "Failed to unmarshal CA response. Body: %s", string(createRespBody)) + s.Assert().NotEmpty(caResp.Message) + s.Assert().Contains(caResp.CertPath, caName) + s.Assert().Contains(caResp.KeyPath, caName) + + signURL := fmt.Sprintf("%s/api/v1/tools/certs/sign", s.cfg.APIURL) + signPayload := map[string]interface{}{ + "name": certName, + "hosts": []string{"127.0.0.1", "localhost"}, + "caName": caName, + } + signBody := bytes.NewBuffer(s.mustMarshal(signPayload)) + signRespBody, signStatus, signErr := s.doRequest("POST", signURL, s.superuserHeaders, signBody, s.cfg.RequestTimeout) + s.Require().NoError(signErr) + s.Require().Equal(http.StatusOK, signStatus, "Expected 200 for cert sign. Body: %s", string(signRespBody)) + + var signResp struct { + Message string `json:"message"` + CertPath string `json:"certPath"` + KeyPath string `json:"keyPath"` + CSRPath string `json:"csrPath"` + } + s.Require().NoError(json.Unmarshal(signRespBody, &signResp), "Failed to unmarshal sign response. Body: %s", string(signRespBody)) + s.Assert().NotEmpty(signResp.Message) + s.Assert().Contains(signResp.CertPath, caName) + s.Assert().Contains(signResp.CertPath, certName) + s.Assert().Contains(signResp.KeyPath, caName) + s.Assert().Contains(signResp.KeyPath, certName) + + // Best-effort cleanup of generated cert files on disk (under the current user's home). + homeDir, _ := os.UserHomeDir() + if homeDir != "" { + caDir := filepath.Join(homeDir, ".clab", "certs", caName) + _ = os.RemoveAll(caDir) + } + + if !s.T().Failed() { + s.logSuccess("Created CA '%s' and signed cert '%s' successfully", caName, certName) + } +} diff --git a/tests_go/tools_network_suite_test.go b/tests_go/tools_network_suite_test.go new file mode 100644 index 0000000..21b09f6 --- /dev/null +++ b/tests_go/tools_network_suite_test.go @@ -0,0 +1,189 @@ +// tests_go/tools_network_suite_test.go +package tests_go + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +// ToolsNetworkSuite tests privileged network/tool endpoints (tx-offload, veth, vxlan). +type ToolsNetworkSuite struct { + BaseSuite + + apiUserToken string + apiUserHeaders http.Header + superuserToken string + superuserHeaders http.Header + + labName string + containerName string +} + +func TestToolsNetworkSuite(t *testing.T) { + suite.Run(t, new(ToolsNetworkSuite)) +} + +func (s *ToolsNetworkSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + + s.apiUserToken = s.login(s.cfg.APIUserUser, s.cfg.APIUserPass) + s.apiUserHeaders = s.getAuthHeaders(s.apiUserToken) + s.superuserToken = s.login(s.cfg.SuperuserUser, s.cfg.SuperuserPass) + s.superuserHeaders = s.getAuthHeaders(s.superuserToken) + s.Require().NotEmpty(s.apiUserToken) + s.Require().NotEmpty(s.superuserToken) + + // Create an API-user owned lab to target container-based tools. + s.labName = fmt.Sprintf("%s-tools-%s", s.cfg.LabNamePrefix, s.randomSuffix(5)) + topology := strings.ReplaceAll(s.cfg.SimpleTopologyContent, "{lab_name}", s.labName) + s.logSetup("Creating tools test lab: %s", s.labName) + bodyBytes, statusCode, err := s.createLab(s.apiUserHeaders, s.labName, topology, false, s.cfg.DeployTimeout) + s.Require().NoError(err, "SETUP Failed: Could not create tools test lab") + s.Require().Equal(http.StatusOK, statusCode, "SETUP Failed: Could not create tools test lab. Body: %s", string(bodyBytes)) + + time.Sleep(s.cfg.StabilizePause) + + // Resolve a container name to use for tool calls. + inspectURL := fmt.Sprintf("%s/api/v1/labs/%s", s.cfg.APIURL, s.labName) + inspectBytes, inspectStatus, inspectErr := s.doRequest("GET", inspectURL, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(inspectErr, "SETUP Failed: Could not inspect lab '%s'", s.labName) + s.Require().Equal(http.StatusOK, inspectStatus, "SETUP Failed: Inspect lab returned non-OK. Body: %s", string(inspectBytes)) + + var containers []ClabContainerInfo + s.Require().NoError(json.Unmarshal(inspectBytes, &containers), "SETUP Failed: Could not decode inspect output. Body: %s", string(inspectBytes)) + s.Require().NotEmpty(containers, "SETUP Failed: No containers returned for lab '%s'", s.labName) + s.containerName = containers[0].Name + s.Require().NotEmpty(s.containerName) + + s.logSetup("Using container '%s' for tools tests", s.containerName) +} + +func (s *ToolsNetworkSuite) TearDownSuite() { + if s.labName != "" { + s.logTeardown("Cleaning up tools test lab: %s", s.labName) + s.cleanupLab(s.labName, true) + } + s.BaseSuite.TearDownSuite() +} + +func (s *ToolsNetworkSuite) TestDisableTxOffloadForbiddenForAPIUser() { + s.logTest("Calling disable-tx-offload as API user (expecting 403)") + + url := fmt.Sprintf("%s/api/v1/tools/disable-tx-offload", s.cfg.APIURL) + payload := map[string]string{"containerName": s.containerName} + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.apiUserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 for non-superuser disable-tx-offload. Body: %s", string(respBody)) +} + +func (s *ToolsNetworkSuite) TestDisableTxOffloadAsSuperuser() { + s.logTest("Calling disable-tx-offload as superuser") + + url := fmt.Sprintf("%s/api/v1/tools/disable-tx-offload", s.cfg.APIURL) + payload := map[string]string{"containerName": s.containerName} + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.superuserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, statusCode, "Expected 200 for disable-tx-offload as superuser. Body: %s", string(respBody)) +} + +func (s *ToolsNetworkSuite) TestVethForbiddenForAPIUser() { + s.logTest("Calling veth create as API user (expecting 403)") + + url := fmt.Sprintf("%s/api/v1/tools/veth", s.cfg.APIURL) + payload := map[string]interface{}{ + "aEndpoint": "host:dummy0", + "bEndpoint": s.containerName + ":eth10", + "mtu": 1500, + } + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.apiUserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 for non-superuser veth create. Body: %s", string(respBody)) +} + +func (s *ToolsNetworkSuite) TestVxlanCreateForbiddenForAPIUser() { + s.logTest("Calling vxlan create as API user (expecting 403)") + + url := fmt.Sprintf("%s/api/v1/tools/vxlan", s.cfg.APIURL) + payload := map[string]interface{}{ + "remote": "127.0.0.1", + "link": "dummy0", + "id": 10, + "port": 14789, + } + body := bytes.NewBuffer(s.mustMarshal(payload)) + + respBody, statusCode, err := s.doRequest("POST", url, s.apiUserHeaders, body, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 for non-superuser vxlan create. Body: %s", string(respBody)) +} + +func (s *ToolsNetworkSuite) TestVxlanDeleteForbiddenForAPIUser() { + s.logTest("Calling vxlan delete as API user (expecting 403)") + + url := fmt.Sprintf("%s/api/v1/tools/vxlan?prefix=vx-", s.cfg.APIURL) + respBody, statusCode, err := s.doRequest("DELETE", url, s.apiUserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(err) + s.Assert().Equal(http.StatusForbidden, statusCode, "Expected 403 for non-superuser vxlan delete. Body: %s", string(respBody)) +} + +func (s *ToolsNetworkSuite) TestVethAndVxlanLifecycleAsSuperuser() { + s.logTest("Creating veth (host<->container), creating vxlan, then deleting vxlan as superuser") + + linkName := "gtvx" + s.randomSuffix(5) // ensure <= 15 chars (vx- must also fit) + containerIface := "eth10" + + // 1) Create veth pair between host and container. The host-side iface will be "linkName". + vethURL := fmt.Sprintf("%s/api/v1/tools/veth", s.cfg.APIURL) + vethPayload := map[string]interface{}{ + "aEndpoint": fmt.Sprintf("host:%s", linkName), + "bEndpoint": fmt.Sprintf("%s:%s", s.containerName, containerIface), + "mtu": 1500, + } + vethBody := bytes.NewBuffer(s.mustMarshal(vethPayload)) + vethRespBody, vethStatus, vethErr := s.doRequest("POST", vethURL, s.superuserHeaders, vethBody, s.cfg.RequestTimeout) + s.Require().NoError(vethErr) + s.Require().Equal(http.StatusOK, vethStatus, "Expected 200 for veth create. Body: %s", string(vethRespBody)) + + // 2) Create vxlan stitched to the host interface. + vxlanURL := fmt.Sprintf("%s/api/v1/tools/vxlan", s.cfg.APIURL) + vxlanPayload := map[string]interface{}{ + "remote": "127.0.0.1", + "link": linkName, + "id": 10, + "port": 14789, + } + vxlanBody := bytes.NewBuffer(s.mustMarshal(vxlanPayload)) + vxlanRespBody, vxlanStatus, vxlanErr := s.doRequest("POST", vxlanURL, s.superuserHeaders, vxlanBody, s.cfg.RequestTimeout) + s.Require().NoError(vxlanErr) + s.Require().Equal(http.StatusOK, vxlanStatus, "Expected 200 for vxlan create. Body: %s", string(vxlanRespBody)) + + // 3) Delete the created vxlan interface (named "vx-"). + deleteURL := fmt.Sprintf("%s/api/v1/tools/vxlan?prefix=%s", s.cfg.APIURL, "vx-"+linkName) + deleteRespBody, deleteStatus, deleteErr := s.doRequest("DELETE", deleteURL, s.superuserHeaders, nil, s.cfg.RequestTimeout) + s.Require().NoError(deleteErr) + s.Require().Equal(http.StatusOK, deleteStatus, "Expected 200 for vxlan delete. Body: %s", string(deleteRespBody)) + + var resp struct { + Message string `json:"message"` + } + if err := json.Unmarshal(deleteRespBody, &resp); err == nil && resp.Message != "" { + s.Assert().Contains(resp.Message, "vx-"+linkName, "Expected delete response to reference deleted vxlan interface name") + } + + if !s.T().Failed() { + s.logSuccess("veth + vxlan lifecycle completed successfully (link=%s)", linkName) + } +}