From bf614594494b61195b8e7d649df4dbe136cde373 Mon Sep 17 00:00:00 2001 From: Baoyuan Date: Thu, 16 Mar 2023 16:54:06 +0800 Subject: [PATCH] feat: support ipv6 in upstream nodes (#2766) --- api/internal/core/entity/format.go | 23 +++-- api/internal/core/entity/format_test.go | 92 +++++++++++++++++++ .../create-edit-duplicate-delete-route.cy.js | 79 ++++++++++++++-- .../components/Upstream/components/Nodes.tsx | 5 +- web/src/components/Upstream/service.ts | 52 +++++++++-- 5 files changed, 224 insertions(+), 27 deletions(-) diff --git a/api/internal/core/entity/format.go b/api/internal/core/entity/format.go index c13bb6cb91..dcc50c4806 100644 --- a/api/internal/core/entity/format.go +++ b/api/internal/core/entity/format.go @@ -18,6 +18,7 @@ package entity import ( "errors" + "net" "strconv" "strings" @@ -25,15 +26,21 @@ import ( ) func mapKV2Node(key string, val float64) (*Node, error) { - hp := strings.Split(key, ":") - host := hp[0] - // according to APISIX upstream nodes policy, port is optional - port := "0" + host, port, err := net.SplitHostPort(key) - if len(hp) > 2 { - return nil, errors.New("invalid upstream node") - } else if len(hp) == 2 { - port = hp[1] + // ipv6 address + if strings.Count(host, ":") >= 2 { + host = "[" + host + "]" + } + + if err != nil { + if strings.Contains(err.Error(), "missing port in address") { + // according to APISIX upstream nodes policy, port is optional + host = key + port = "0" + } else { + return nil, errors.New("invalid upstream node") + } } portInt, err := strconv.Atoi(port) diff --git a/api/internal/core/entity/format_test.go b/api/internal/core/entity/format_test.go index 109aa384e8..b3b5d299ef 100644 --- a/api/internal/core/entity/format_test.go +++ b/api/internal/core/entity/format_test.go @@ -56,6 +56,39 @@ func TestNodesFormat(t *testing.T) { assert.Contains(t, jsonStr, `"priority":10`) } +func TestNodesFormat_ipv6(t *testing.T) { + // route data saved in ETCD + routeStr := `{ + "uris": ["/*"], + "upstream": { + "type": "roundrobin", + "nodes": [{ + "host": "::1", + "port": 80, + "weight": 0, + "priority":10 + }] + } + }` + + // bind struct + var route Route + err := json.Unmarshal([]byte(routeStr), &route) + assert.Nil(t, err) + + // nodes format + nodes := NodesFormat(route.Upstream.Nodes) + + // json encode for client + res, err := json.Marshal(nodes) + assert.Nil(t, err) + jsonStr := string(res) + assert.Contains(t, jsonStr, `"weight":0`) + assert.Contains(t, jsonStr, `"port":80`) + assert.Contains(t, jsonStr, `"host":"::1"`) + assert.Contains(t, jsonStr, `"priority":10`) +} + func TestNodesFormat_struct(t *testing.T) { // route data saved in ETCD var route Route @@ -77,6 +110,27 @@ func TestNodesFormat_struct(t *testing.T) { assert.Contains(t, jsonStr, `"host":"127.0.0.1"`) } +func TestNodesFormat_struct_ipv6(t *testing.T) { + // route data saved in ETCD + var route Route + route.Uris = []string{"/*"} + route.Upstream = &UpstreamDef{} + route.Upstream.Type = "roundrobin" + var nodes = []*Node{{Host: "::1", Port: 80, Weight: 0}} + route.Upstream.Nodes = nodes + + // nodes format + formattedNodes := NodesFormat(route.Upstream.Nodes) + + // json encode for client + res, err := json.Marshal(formattedNodes) + assert.Nil(t, err) + jsonStr := string(res) + assert.Contains(t, jsonStr, `"weight":0`) + assert.Contains(t, jsonStr, `"port":80`) + assert.Contains(t, jsonStr, `"host":"::1"`) +} + func TestNodesFormat_Map(t *testing.T) { // route data saved in ETCD routeStr := `{ @@ -104,6 +158,33 @@ func TestNodesFormat_Map(t *testing.T) { assert.Contains(t, jsonStr, `"host":"127.0.0.1"`) } +func TestNodesFormat_Map_ipv6(t *testing.T) { + // route data saved in ETCD + routeStr := `{ + "uris": ["/*"], + "upstream": { + "type": "roundrobin", + "nodes": {"[::1]:8080": 0} + } + }` + + // bind struct + var route Route + err := json.Unmarshal([]byte(routeStr), &route) + assert.Nil(t, err) + + // nodes format + nodes := NodesFormat(route.Upstream.Nodes) + + // json encode for client + res, err := json.Marshal(nodes) + assert.Nil(t, err) + jsonStr := string(res) + assert.Contains(t, jsonStr, `"weight":0`) + assert.Contains(t, jsonStr, `"port":8080`) + assert.Contains(t, jsonStr, `"host":"[::1]"`) +} + func TestNodesFormat_empty_struct(t *testing.T) { // route data saved in ETCD routeStr := `{ @@ -277,6 +358,17 @@ func TestMapKV2Node(t *testing.T) { Weight: 0, }, }, + { + name: "address with ipv6", + key: "[::1]:443", + value: 100, + wantErr: false, + wantRes: &Node{ + Host: "[::1]", + Port: 443, + Weight: 100, + }, + }, } for _, tc := range testCases { diff --git a/web/cypress/e2e/route/create-edit-duplicate-delete-route.cy.js b/web/cypress/e2e/route/create-edit-duplicate-delete-route.cy.js index ba33c31717..62e2a5e00d 100755 --- a/web/cypress/e2e/route/create-edit-duplicate-delete-route.cy.js +++ b/web/cypress/e2e/route/create-edit-duplicate-delete-route.cy.js @@ -36,6 +36,7 @@ context('Create and Delete Route', () => { operator: '#operator', value: '#value', nodes_0_host: '#submitNodes_0_host', + nodes_1_host: '#submitNodes_1_host', nodes_0_port: '#submitNodes_0_port', nodes_0_weight: '#submitNodes_0_weight', pluginCardBordered: '.ant-card-bordered', @@ -52,6 +53,7 @@ context('Create and Delete Route', () => { notificationCloseIcon: '.ant-notification-close-icon', notification: '.ant-notification-notice-message', addHost: '[data-cy=addHost]', + addNode: '[data-cy=add-node]', schemaErrorMessage: '.ant-form-item-explain.ant-form-item-explain-error', stepCheck: '.ant-steps-finish-icon', advancedMatchingTable: '.ant-table-row.ant-table-row-level-0', @@ -66,6 +68,8 @@ context('Create and Delete Route', () => { host3: '10.10.10.10', host4: '@', host5: '*1', + host_ipv6: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + host_ipv6_2: '::1', port: '80', weight: 1, basicAuthPlugin: 'basic-auth', @@ -74,12 +78,7 @@ context('Create and Delete Route', () => { deleteRouteSuccess: 'Delete Route Successfully', }; - const opreatorList = [ - 'Equal(==)', - 'Case insensitive regular match(~*)', - 'HAS', - 'Reverse the result(!)', - ]; + const opreatorList = ['Equal(==)', 'Case insensitive regular match(~*)', 'HAS']; before(() => { cy.clearLocalStorageSnapshot(); @@ -92,7 +91,7 @@ context('Create and Delete Route', () => { cy.visit('/'); }); - it.only('should not create route with name above 100 characters', function () { + it('should not create route with name above 100 characters', function () { cy.visit('/'); cy.contains('Route').click(); cy.get(selector.empty).should('be.visible'); @@ -325,4 +324,70 @@ context('Create and Delete Route', () => { cy.get(selector.notificationCloseIcon).click(); }); }); + + it('should create route with ipv6 upstream node', () => { + cy.visit('/'); + cy.contains('Route').click(); + cy.get(selector.empty).should('be.visible'); + cy.contains('Create').click(); + + // step 1 + cy.get(selector.name).type(name); + cy.get(selector.description).type(data.description); + cy.contains('Next').click(); + + // step2 + cy.get(selector.nodes_0_host).type(data.host_ipv6); + cy.get(selector.nodes_0_port).type(80); + cy.get(selector.addNode).click(); + cy.get(selector.nodes_1_host).type(data.host_ipv6_2); + cy.contains('Next').click(); + cy.contains('Next').click(); + cy.contains('button', 'Submit').click(); + cy.contains(data.submitSuccess); + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + + cy.get(selector.nameSelector).type(name); + cy.contains('Search').click(); + cy.contains(name).siblings().contains('Configure').click(); + cy.get('#status').should('have.class', 'ant-switch-checked'); + + cy.contains('Next').click(); + cy.get(selector.nodes_0_host).should('have.value', data.host_ipv6); + cy.get(selector.nodes_0_port).should('have.value', 80); + cy.get(selector.nodes_1_host).should('have.value', data.host_ipv6_2); + + cy.contains('Next').click(); + cy.contains('Next').click(); + cy.contains('Submit').click(); + cy.contains(data.submitSuccess); + cy.contains('Goto List').click(); + cy.url().should('contains', 'routes/list'); + cy.contains(name).siblings().contains('More').click(); + cy.contains('View').click(); + cy.get(selector.drawer).should('be.visible'); + + cy.get(selector.monacoScroll).within(() => { + cy.contains(name).should('exist'); + cy.contains(`[${data.host_ipv6}]`).should('exist'); + cy.contains(`[${data.host_ipv6_2}]`).should('exist'); + }); + + cy.visit('/routes/list'); + cy.get(selector.name).clear().type(name); + cy.contains('Search').click(); + cy.contains(name).siblings().contains('More').click(); + cy.contains('Delete').click(); + cy.get(selector.deleteAlert) + .should('be.visible') + .within(() => { + cy.contains('OK').click(); + }); + cy.get(selector.deleteAlert).within(() => { + cy.get('.ant-btn-loading-icon').should('be.visible'); + }); + cy.get(selector.notification).should('contain', data.deleteRouteSuccess); + cy.get(selector.notificationCloseIcon).click(); + }); }); diff --git a/web/src/components/Upstream/components/Nodes.tsx b/web/src/components/Upstream/components/Nodes.tsx index 40122545d9..63d09e9f14 100644 --- a/web/src/components/Upstream/components/Nodes.tsx +++ b/web/src/components/Upstream/components/Nodes.tsx @@ -54,7 +54,8 @@ const Component: React.FC = ({ readonly }) => { }), }, { - pattern: new RegExp(/^\*?[0-9a-zA-Z-._]+$/, 'g'), + // eslint-disable-next-line no-useless-escape + pattern: new RegExp(/^\*?[0-9a-zA-Z-._\[\]:]+$/), message: formatMessage({ id: 'page.route.form.itemRulesPatternMessage.domain', }), @@ -115,7 +116,7 @@ const Component: React.FC = ({ readonly }) => { {!readonly && ( - diff --git a/web/src/components/Upstream/service.ts b/web/src/components/Upstream/service.ts index c7587d7ddc..acb5f88f8f 100644 --- a/web/src/components/Upstream/service.ts +++ b/web/src/components/Upstream/service.ts @@ -18,6 +18,10 @@ import { notification } from 'antd'; import { cloneDeep, isNil, omit, omitBy } from 'lodash'; import { formatMessage, request } from 'umi'; +const ipv6RegexExp = new RegExp( + /(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/, +); + /** * Because we have some `custom` field in Upstream Form, like custom.tls/custom.checks.active etc, * we need to transform data that doesn't have `custom` field to data contains `custom` field @@ -58,13 +62,32 @@ export const convertToFormData = (originData: UpstreamComponent.ResponseData) => // nodes have two types // https://github.com/apache/apisix-dashboard/issues/2080 if (data.nodes instanceof Array) { - data.submitNodes = data.nodes; + data.submitNodes = data.nodes.map((key) => { + if (key.host.indexOf(']') !== -1) { + // handle ipv6 address + return { + ...key, + host: key.host.match(/\[(.*?)\]/)?.[1] || '', + }; + } + return key; + }); } else if (data.nodes) { - data.submitNodes = Object.keys(data.nodes as Object).map((key) => ({ - host: key.split(':')[0], - port: key.split(':')[1], - weight: (data.nodes as Object)[key], - })); + data.submitNodes = Object.keys(data.nodes as Object).map((key) => { + if (key.indexOf(']') !== -1) { + // handle ipv6 address + return { + host: key.match(/\[(.*?)\]/)?.[1] || '', + port: key.split(']:')[1], + weight: (data.nodes as Object)[key], + }; + } + return { + host: key.split(':')[0], + port: key.split(':')[1], + weight: (data.nodes as Object)[key], + }; + }); } if (data.discovery_type && data.service_name) { @@ -135,10 +158,19 @@ export const convertToRequestData = ( data.nodes = {}; submitNodes?.forEach((item) => { const port = item.port ? `:${item.port}` : ''; - data.nodes = { - ...data.nodes, - [`${item.host}${port}`]: item.weight as number, - }; + if (ipv6RegexExp.test(item.host as string)) { + // ipv6 host need add [] on host + // like [::1]:80 + data.nodes = { + ...data.nodes, + [`[${item.host}]${port}`]: item.weight as number, + }; + } else { + data.nodes = { + ...data.nodes, + [`${item.host}${port}`]: item.weight as number, + }; + } }); return omit(data, ['upstream_type', 'submitNodes']); }