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']);
   }