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

Reply via email to