Skip to content

Commit

Permalink
feat: support ipv6 in upstream nodes (#2766)
Browse files Browse the repository at this point in the history
  • Loading branch information
Baoyuantop authored Mar 16, 2023
1 parent 85fadb6 commit bf61459
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 27 deletions.
23 changes: 15 additions & 8 deletions api/internal/core/entity/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,29 @@ package entity

import (
"errors"
"net"
"strconv"
"strings"

"github.com/apisix/manager-api/internal/log"
)

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)
Expand Down
92 changes: 92 additions & 0 deletions api/internal/core/entity/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 := `{
Expand Down Expand Up @@ -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 := `{
Expand Down Expand Up @@ -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 {
Expand Down
79 changes: 72 additions & 7 deletions web/cypress/e2e/route/create-edit-duplicate-delete-route.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -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();
});
});
5 changes: 3 additions & 2 deletions web/src/components/Upstream/components/Nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand Down Expand Up @@ -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>
Expand Down
52 changes: 42 additions & 10 deletions web/src/components/Upstream/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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']);
}
Expand Down

0 comments on commit bf61459

Please sign in to comment.