You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by ba...@apache.org on 2023/03/16 08:54:14 UTC
[apisix-dashboard] branch master updated: feat: support ipv6 in upstream nodes (#2766)
This is an automated email from the ASF dual-hosted git repository.
baoyuan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new bf6145944 feat: support ipv6 in upstream nodes (#2766)
bf6145944 is described below
commit bf614594494b61195b8e7d649df4dbe136cde373
Author: Baoyuan <ba...@gmail.com>
AuthorDate: Thu Mar 16 16:54:06 2023 +0800
feat: support ipv6 in upstream nodes (#2766)
---
api/internal/core/entity/format.go | 23 ++++--
api/internal/core/entity/format_test.go | 92 ++++++++++++++++++++++
.../route/create-edit-duplicate-delete-route.cy.js | 79 +++++++++++++++++--
web/src/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 c13bb6cb9..dcc50c480 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 109aa384e..b3b5d299e 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 ba33c3171..62e2a5e00 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 40122545d..63d09e9f1 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<Props> = ({ 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<Props> = ({ readonly }) => {
</Form.Item>
{!readonly && (
<Form.Item wrapperCol={{ offset: 3 }}>
- <Button type="dashed" onClick={add}>
+ <Button type="dashed" onClick={add} data-cy="add-node">
<PlusOutlined />
{formatMessage({ id: 'component.global.add' })}
</Button>
diff --git a/web/src/components/Upstream/service.ts b/web/src/components/Upstream/service.ts
index c7587d7dd..acb5f88f8 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} [...]
+);
+
/**
* 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']);
}