@highstate/common 0.14.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-WFWXDYUX.js → chunk-X5BK6JSN.js} +877 -194
- package/dist/chunk-X5BK6JSN.js.map +1 -0
- package/dist/highstate.manifest.json +12 -2
- package/dist/index.js +1 -1
- package/dist/units/databases/etcd-patch/index.js +20 -0
- package/dist/units/databases/etcd-patch/index.js.map +1 -0
- package/dist/units/databases/existing-etcd/index.js +14 -0
- package/dist/units/databases/existing-etcd/index.js.map +1 -0
- package/dist/units/databases/existing-mariadb/index.js +2 -2
- package/dist/units/databases/existing-mariadb/index.js.map +1 -1
- package/dist/units/databases/existing-mongodb/index.js +2 -2
- package/dist/units/databases/existing-mongodb/index.js.map +1 -1
- package/dist/units/databases/existing-postgresql/index.js +2 -2
- package/dist/units/databases/existing-postgresql/index.js.map +1 -1
- package/dist/units/databases/existing-redis/index.js +2 -2
- package/dist/units/databases/existing-redis/index.js.map +1 -1
- package/dist/units/databases/existing-s3/index.js +18 -0
- package/dist/units/databases/existing-s3/index.js.map +1 -0
- package/dist/units/databases/mariadb-patch/index.js +24 -0
- package/dist/units/databases/mariadb-patch/index.js.map +1 -0
- package/dist/units/databases/mongodb-patch/index.js +24 -0
- package/dist/units/databases/mongodb-patch/index.js.map +1 -0
- package/dist/units/databases/postgresql-patch/index.js +24 -0
- package/dist/units/databases/postgresql-patch/index.js.map +1 -0
- package/dist/units/databases/redis-patch/index.js +27 -0
- package/dist/units/databases/redis-patch/index.js.map +1 -0
- package/dist/units/databases/s3-patch/index.js +25 -0
- package/dist/units/databases/s3-patch/index.js.map +1 -0
- package/dist/units/dns/record-set/index.js +14 -20
- package/dist/units/dns/record-set/index.js.map +1 -1
- package/dist/units/existing-server/index.js +3 -4
- package/dist/units/existing-server/index.js.map +1 -1
- package/dist/units/network/address-space/index.js +20 -0
- package/dist/units/network/address-space/index.js.map +1 -0
- package/dist/units/network/endpoint-filter/index.js +15 -0
- package/dist/units/network/endpoint-filter/index.js.map +1 -0
- package/dist/units/network/l3-endpoint/index.js +2 -2
- package/dist/units/network/l3-endpoint/index.js.map +1 -1
- package/dist/units/network/l4-endpoint/index.js +2 -2
- package/dist/units/network/l4-endpoint/index.js.map +1 -1
- package/dist/units/network/l7-endpoint/index.js +12 -0
- package/dist/units/network/l7-endpoint/index.js.map +1 -0
- package/dist/units/script/index.js +1 -1
- package/dist/units/server-patch/index.js +9 -12
- package/dist/units/server-patch/index.js.map +1 -1
- package/dist/units/ssh/key-pair/index.js +1 -1
- package/package.json +64 -10
- package/src/shared/command.ts +1 -1
- package/src/shared/dns.ts +11 -93
- package/src/shared/files.ts +3 -3
- package/src/shared/impl-ref.ts +4 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network/address-space.spec.ts +114 -0
- package/src/shared/network/address-space.ts +364 -0
- package/src/shared/network/address.spec.ts +109 -0
- package/src/shared/network/address.ts +119 -0
- package/src/shared/network/endpoints.spec.ts +249 -0
- package/src/shared/network/endpoints.ts +608 -0
- package/src/shared/network/index.ts +4 -0
- package/src/shared/network/ip.ts +236 -0
- package/src/shared/network/subnet.spec.ts +62 -0
- package/src/shared/network/subnet.ts +137 -0
- package/src/shared/ssh.ts +1 -1
- package/src/shared/tls.ts +21 -5
- package/src/shared/utils.ts +93 -0
- package/src/units/databases/etcd-patch/index.ts +23 -0
- package/src/units/databases/existing-etcd/index.ts +11 -0
- package/src/units/databases/existing-mariadb/index.ts +1 -1
- package/src/units/databases/existing-mongodb/index.ts +1 -1
- package/src/units/databases/existing-postgresql/index.ts +1 -1
- package/src/units/databases/existing-redis/index.ts +1 -1
- package/src/units/databases/existing-s3/index.ts +1 -1
- package/src/units/databases/mariadb-patch/index.ts +27 -0
- package/src/units/databases/mongodb-patch/index.ts +27 -0
- package/src/units/databases/postgresql-patch/index.ts +27 -0
- package/src/units/databases/redis-patch/index.ts +32 -0
- package/src/units/databases/s3-patch/index.ts +28 -0
- package/src/units/dns/record-set/index.ts +15 -20
- package/src/units/existing-server/index.ts +3 -4
- package/src/units/network/address-space/index.ts +20 -0
- package/src/units/network/endpoint-filter/index.ts +5 -5
- package/src/units/network/l3-endpoint/index.ts +2 -2
- package/src/units/network/l4-endpoint/index.ts +2 -2
- package/src/units/network/l7-endpoint/index.ts +2 -2
- package/src/units/remote-file/index.ts +12 -5
- package/src/units/server-patch/index.ts +10 -13
- package/dist/chunk-WFWXDYUX.js.map +0 -1
- package/dist/units/server-dns/index.js +0 -26
- package/dist/units/server-dns/index.js.map +0 -1
- package/src/shared/network.ts +0 -413
- package/src/units/server-dns/index.ts +0 -26
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { check } from "@highstate/contract"
|
|
2
|
+
import { network } from "@highstate/library"
|
|
3
|
+
import { addressToCidr } from "./address"
|
|
4
|
+
import { cidrBlockSize, ipToString, parseCidr, parseIp, subnetBaseFromCidr } from "./ip"
|
|
5
|
+
import { subnetToString } from "./subnet"
|
|
6
|
+
|
|
7
|
+
export type AddressSpaceArgs = {
|
|
8
|
+
/**
|
|
9
|
+
* The list of addresses to include in the address space.
|
|
10
|
+
*
|
|
11
|
+
* Supports:
|
|
12
|
+
* - Other address space entities;
|
|
13
|
+
* - Address entities;
|
|
14
|
+
* - Subnet entities;
|
|
15
|
+
* - L3 endpoint entities;
|
|
16
|
+
* - String representations of addresses, subnets, or ranges.
|
|
17
|
+
*
|
|
18
|
+
* The supported formats for strings are:
|
|
19
|
+
* - Single IP address (e.g., `192.168.1.1`);
|
|
20
|
+
* - CIDR notation (e.g., `192.168.1.1/24`);
|
|
21
|
+
* - Dash notation (e.g., `192.168.1.1-192.168.1.254`).
|
|
22
|
+
*
|
|
23
|
+
* The addresses can be a mix of IPv4 and IPv6.
|
|
24
|
+
*/
|
|
25
|
+
included: InputAddressSpace[]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The list of addresses to exclude from the `addresses` list.
|
|
29
|
+
*
|
|
30
|
+
* The supported formats are the same as in `addresses`.
|
|
31
|
+
*/
|
|
32
|
+
excluded?: InputAddressSpace[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type InputAddressSpace =
|
|
36
|
+
| network.AddressSpace
|
|
37
|
+
| network.Address
|
|
38
|
+
| network.Subnet
|
|
39
|
+
| network.L3Endpoint
|
|
40
|
+
| string
|
|
41
|
+
|
|
42
|
+
type IpFamily = network.AddressType
|
|
43
|
+
|
|
44
|
+
type IpRange = {
|
|
45
|
+
family: IpFamily
|
|
46
|
+
start: bigint
|
|
47
|
+
endExclusive: bigint
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates an address space from a list of addresses/subnets/ranges.
|
|
52
|
+
*
|
|
53
|
+
* @param included The list of addresses to include in the address space.
|
|
54
|
+
* @param excluded The list of addresses to exclude from the `included` list.
|
|
55
|
+
* @returns The created address space entity.
|
|
56
|
+
*/
|
|
57
|
+
export function createAddressSpace({
|
|
58
|
+
included,
|
|
59
|
+
excluded = [],
|
|
60
|
+
}: AddressSpaceArgs): network.AddressSpace {
|
|
61
|
+
const includedRanges = included.flatMap(resolveInputToRanges)
|
|
62
|
+
const excludedRanges = excluded.flatMap(resolveInputToRanges)
|
|
63
|
+
|
|
64
|
+
const normalized = normalizeRanges(includedRanges, excludedRanges)
|
|
65
|
+
const subnets = rangesToCanonicalSubnets(normalized)
|
|
66
|
+
|
|
67
|
+
return { subnets }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveInputToRanges(input: InputAddressSpace): IpRange[] {
|
|
71
|
+
if (typeof input === "string") {
|
|
72
|
+
return [rangeFromString(input)]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (check(network.addressSpaceEntity.schema, input)) {
|
|
76
|
+
return input.subnets.flatMap(subnet => [rangeFromCidr(subnetToString(subnet))])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (check(network.subnetEntity.schema, input)) {
|
|
80
|
+
return [rangeFromCidr(subnetToString(input))]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (check(network.addressEntity.schema, input)) {
|
|
84
|
+
return [rangeFromCidr(addressToCidr(input))]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (check(network.l3EndpointEntity.schema, input)) {
|
|
88
|
+
if (input.type === "hostname") {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const cidr = (input as unknown as { cidr?: unknown }).cidr
|
|
93
|
+
if (typeof cidr === "string") {
|
|
94
|
+
return [rangeFromCidr(cidr)]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const address = (input as unknown as { address?: unknown }).address
|
|
98
|
+
if (typeof address === "string") {
|
|
99
|
+
const parsed = parseIp(address)
|
|
100
|
+
const prefixLength = parsed.type === "ipv4" ? 32 : 128
|
|
101
|
+
return [
|
|
102
|
+
rangeFromParsedCidr({ family: parsed.type, base: parsed.value, prefix: prefixLength }),
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (check(network.addressEntity.schema, address)) {
|
|
107
|
+
return [rangeFromCidr(addressToCidr(address))]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error("Invalid L3 endpoint: missing address information")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
throw new Error("Unsupported address space input")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rangeFromString(value: string): IpRange {
|
|
117
|
+
const trimmed = value.trim()
|
|
118
|
+
if (!trimmed) {
|
|
119
|
+
throw new Error("Empty address string")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (trimmed.includes("-")) {
|
|
123
|
+
const [left, right, ...rest] = trimmed.split("-")
|
|
124
|
+
if (!left || !right || rest.length > 0) {
|
|
125
|
+
throw new Error(`Invalid range: "${value}"`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const start = parseIp(left.trim())
|
|
129
|
+
const end = parseIp(right.trim())
|
|
130
|
+
|
|
131
|
+
if (start.type !== end.type) {
|
|
132
|
+
throw new Error(`Range must not mix IPv4 and IPv6: "${value}"`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const min = start.value <= end.value ? start.value : end.value
|
|
136
|
+
const max = start.value <= end.value ? end.value : start.value
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
family: start.type,
|
|
140
|
+
start: min,
|
|
141
|
+
endExclusive: max + 1n,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (trimmed.includes("/")) {
|
|
146
|
+
return rangeFromCidr(trimmed)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const parsed = parseIp(trimmed)
|
|
150
|
+
return {
|
|
151
|
+
family: parsed.type,
|
|
152
|
+
start: parsed.value,
|
|
153
|
+
endExclusive: parsed.value + 1n,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rangeFromCidr(cidr: string): IpRange {
|
|
158
|
+
const parsed = parseCidr(cidr)
|
|
159
|
+
return rangeFromParsedCidr({
|
|
160
|
+
family: parsed.type,
|
|
161
|
+
base: subnetBaseFromCidr(parsed),
|
|
162
|
+
prefix: parsed.prefixLength,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function rangeFromParsedCidr(parsed: { family: IpFamily; base: bigint; prefix: number }): IpRange {
|
|
167
|
+
const size = cidrBlockSize(parsed.family, parsed.prefix)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
family: parsed.family,
|
|
171
|
+
start: parsed.base,
|
|
172
|
+
endExclusive: parsed.base + size,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function normalizeRanges(included: IpRange[], excluded: IpRange[]): IpRange[] {
|
|
177
|
+
const includedByFamily = splitByFamily(included)
|
|
178
|
+
const excludedByFamily = splitByFamily(excluded)
|
|
179
|
+
|
|
180
|
+
const normalizedV4 = subtractMergedRanges(
|
|
181
|
+
mergeRanges(includedByFamily.v4),
|
|
182
|
+
mergeRanges(excludedByFamily.v4),
|
|
183
|
+
)
|
|
184
|
+
const normalizedV6 = subtractMergedRanges(
|
|
185
|
+
mergeRanges(includedByFamily.v6),
|
|
186
|
+
mergeRanges(excludedByFamily.v6),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return [...normalizedV4, ...normalizedV6]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function splitByFamily(ranges: IpRange[]): { v4: IpRange[]; v6: IpRange[] } {
|
|
193
|
+
const v4: IpRange[] = []
|
|
194
|
+
const v6: IpRange[] = []
|
|
195
|
+
|
|
196
|
+
for (const range of ranges) {
|
|
197
|
+
if (range.start >= range.endExclusive) continue
|
|
198
|
+
|
|
199
|
+
if (range.family === "ipv4") {
|
|
200
|
+
v4.push(range)
|
|
201
|
+
} else {
|
|
202
|
+
v6.push(range)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { v4, v6 }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mergeRanges(ranges: IpRange[]): IpRange[] {
|
|
210
|
+
const sorted = [...ranges].sort((a, b) => {
|
|
211
|
+
if (a.start === b.start) {
|
|
212
|
+
return a.endExclusive < b.endExclusive ? -1 : a.endExclusive > b.endExclusive ? 1 : 0
|
|
213
|
+
}
|
|
214
|
+
return a.start < b.start ? -1 : 1
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const merged: IpRange[] = []
|
|
218
|
+
|
|
219
|
+
for (const current of sorted) {
|
|
220
|
+
const last = merged.at(-1)
|
|
221
|
+
if (!last) {
|
|
222
|
+
merged.push({ ...current })
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (current.start <= last.endExclusive) {
|
|
227
|
+
if (current.endExclusive > last.endExclusive) {
|
|
228
|
+
last.endExclusive = current.endExclusive
|
|
229
|
+
}
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
merged.push({ ...current })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return merged
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function subtractMergedRanges(included: IpRange[], excluded: IpRange[]): IpRange[] {
|
|
240
|
+
if (included.length === 0) return []
|
|
241
|
+
if (excluded.length === 0) return included
|
|
242
|
+
|
|
243
|
+
const result: IpRange[] = []
|
|
244
|
+
let j = 0
|
|
245
|
+
|
|
246
|
+
for (const incOriginal of included) {
|
|
247
|
+
const inc: IpRange = { ...incOriginal }
|
|
248
|
+
|
|
249
|
+
while (j < excluded.length && excluded[j]!.endExclusive <= inc.start) {
|
|
250
|
+
j++
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
while (j < excluded.length) {
|
|
254
|
+
const exc = excluded[j]!
|
|
255
|
+
|
|
256
|
+
if (exc.start >= inc.endExclusive) {
|
|
257
|
+
break
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (exc.start <= inc.start) {
|
|
261
|
+
inc.start = inc.start < exc.endExclusive ? exc.endExclusive : inc.start
|
|
262
|
+
if (inc.start >= inc.endExclusive) {
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (exc.endExclusive <= inc.start) {
|
|
267
|
+
j++
|
|
268
|
+
}
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
result.push({
|
|
273
|
+
family: inc.family,
|
|
274
|
+
start: inc.start,
|
|
275
|
+
endExclusive: exc.start,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
inc.start = exc.endExclusive
|
|
279
|
+
if (inc.start >= inc.endExclusive) {
|
|
280
|
+
break
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (inc.start < inc.endExclusive) {
|
|
285
|
+
result.push(inc)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function rangesToCanonicalSubnets(ranges: IpRange[]): network.Subnet[] {
|
|
293
|
+
const subnets: network.Subnet[] = []
|
|
294
|
+
|
|
295
|
+
for (const range of ranges) {
|
|
296
|
+
for (const cidr of rangeToCidrs(range)) {
|
|
297
|
+
const baseAddress = ipToString(cidr.family, cidr.base)
|
|
298
|
+
|
|
299
|
+
subnets.push({
|
|
300
|
+
type: cidr.family,
|
|
301
|
+
baseAddress,
|
|
302
|
+
prefixLength: cidr.prefix,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return sortSubnetsCanonical(subnets)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sortSubnetsCanonical(subnets: network.Subnet[]): network.Subnet[] {
|
|
311
|
+
return [...subnets].sort((a, b) => {
|
|
312
|
+
const aFamily = a.type
|
|
313
|
+
const bFamily = b.type
|
|
314
|
+
if (aFamily !== bFamily) {
|
|
315
|
+
return aFamily === "ipv4" ? -1 : 1
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const aBase = parseIp(a.baseAddress).value
|
|
319
|
+
const bBase = parseIp(b.baseAddress).value
|
|
320
|
+
|
|
321
|
+
if (aBase !== bBase) {
|
|
322
|
+
return aBase < bBase ? -1 : 1
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return a.prefixLength - b.prefixLength
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function rangeToCidrs(range: IpRange): Array<{ family: IpFamily; base: bigint; prefix: number }> {
|
|
330
|
+
const bits = range.family === "ipv4" ? 32 : 128
|
|
331
|
+
const result: Array<{ family: IpFamily; base: bigint; prefix: number }> = []
|
|
332
|
+
|
|
333
|
+
let current = range.start
|
|
334
|
+
while (current < range.endExclusive) {
|
|
335
|
+
const remaining = range.endExclusive - current
|
|
336
|
+
const maxAligned = current === 0n ? 1n << BigInt(bits) : current & -current
|
|
337
|
+
const maxByRemaining = highestPowerOfTwoAtMost(remaining)
|
|
338
|
+
|
|
339
|
+
const blockSize = maxAligned < maxByRemaining ? maxAligned : maxByRemaining
|
|
340
|
+
const prefix = bits - bitLength(blockSize) + 1
|
|
341
|
+
|
|
342
|
+
result.push({
|
|
343
|
+
family: range.family,
|
|
344
|
+
base: current,
|
|
345
|
+
prefix,
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
current += blockSize
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return result
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function highestPowerOfTwoAtMost(value: bigint): bigint {
|
|
355
|
+
if (value <= 0n) {
|
|
356
|
+
throw new Error("Value must be positive")
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return 1n << BigInt(bitLength(value) - 1)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function bitLength(value: bigint): number {
|
|
363
|
+
return value.toString(2).length
|
|
364
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { network } from "@highstate/library"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { addressToCidr, doesAddressBelongToSubnet, mergeAddresses, parseAddress } from "./address"
|
|
4
|
+
import { subnetToString } from "./subnet"
|
|
5
|
+
|
|
6
|
+
describe("parseAddress", () => {
|
|
7
|
+
it("parses an IPv4 address string", () => {
|
|
8
|
+
const result = parseAddress("10.0.0.1")
|
|
9
|
+
|
|
10
|
+
expect(result.type).toBe("ipv4")
|
|
11
|
+
expect(result.value).toBe("10.0.0.1")
|
|
12
|
+
expect(addressToCidr(result)).toBe("10.0.0.1/32")
|
|
13
|
+
expect(subnetToString(result.subnet)).toBe("10.0.0.1/32")
|
|
14
|
+
expect(result.subnet.prefixLength).toBe(32)
|
|
15
|
+
|
|
16
|
+
expect(network.addressEntity.schema.safeParse(result).success).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("parses an IPv4 CIDR string as address/prefix", () => {
|
|
20
|
+
const result = parseAddress("10.0.0.7/24")
|
|
21
|
+
|
|
22
|
+
expect(result.type).toBe("ipv4")
|
|
23
|
+
expect(result.value).toBe("10.0.0.7")
|
|
24
|
+
expect(addressToCidr(result)).toBe("10.0.0.7/24")
|
|
25
|
+
expect(subnetToString(result.subnet)).toBe("10.0.0.0/24")
|
|
26
|
+
expect(result.subnet.baseAddress).toBe("10.0.0.0")
|
|
27
|
+
expect(result.subnet.prefixLength).toBe(24)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("canonicalizes IPv6 address strings", () => {
|
|
31
|
+
const result = parseAddress("2001:db8:0:0:0:0:0:1")
|
|
32
|
+
|
|
33
|
+
expect(result.type).toBe("ipv6")
|
|
34
|
+
expect(result.value).toBe("2001:db8::1")
|
|
35
|
+
expect(addressToCidr(result)).toBe("2001:db8::1/128")
|
|
36
|
+
expect(subnetToString(result.subnet)).toBe("2001:db8::1/128")
|
|
37
|
+
expect(result.subnet.prefixLength).toBe(128)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("throws on invalid address", () => {
|
|
41
|
+
expect(() => parseAddress("not-an-ip")).toThrow(/Invalid/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("throws on invalid prefix", () => {
|
|
45
|
+
expect(() => parseAddress("10.0.0.1/33")).toThrow(/Invalid CIDR prefix length/)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("doesAddressBelongToSubnet", () => {
|
|
50
|
+
it("returns true when an IPv4 address belongs to the subnet", () => {
|
|
51
|
+
const address = parseAddress("10.0.0.5")
|
|
52
|
+
const subnet = parseAddress("10.0.0.0/24").subnet
|
|
53
|
+
|
|
54
|
+
expect(doesAddressBelongToSubnet(address, subnet)).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("returns false when an IPv4 address does not belong to the subnet", () => {
|
|
58
|
+
const address = parseAddress("10.0.1.5")
|
|
59
|
+
const subnet = parseAddress("10.0.0.0/24").subnet
|
|
60
|
+
|
|
61
|
+
expect(doesAddressBelongToSubnet(address, subnet)).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("treats /0 as containing every address", () => {
|
|
65
|
+
const address = parseAddress("10.123.45.67")
|
|
66
|
+
const subnet = parseAddress("0.0.0.0/0").subnet
|
|
67
|
+
|
|
68
|
+
expect(doesAddressBelongToSubnet(address, subnet)).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("returns true when an IPv6 address belongs to the subnet", () => {
|
|
72
|
+
const address = parseAddress("2001:db8::1")
|
|
73
|
+
const subnet = parseAddress("2001:db8::/64").subnet
|
|
74
|
+
|
|
75
|
+
expect(doesAddressBelongToSubnet(address, subnet)).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("returns false when an IPv6 address does not belong to the subnet", () => {
|
|
79
|
+
const address = parseAddress("2001:db9::1")
|
|
80
|
+
const subnet = parseAddress("2001:db8::/64").subnet
|
|
81
|
+
|
|
82
|
+
expect(doesAddressBelongToSubnet(address, subnet)).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("returns false for mismatched address and subnet types", () => {
|
|
86
|
+
const address = parseAddress("10.0.0.1")
|
|
87
|
+
const ipv6Subnet = parseAddress("2001:db8::/64").subnet
|
|
88
|
+
|
|
89
|
+
expect(doesAddressBelongToSubnet(address, ipv6Subnet)).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("mergeAddresses", () => {
|
|
94
|
+
it("dedupes by cidr", () => {
|
|
95
|
+
const a = parseAddress("10.0.0.1")
|
|
96
|
+
const b = parseAddress("10.0.0.1")
|
|
97
|
+
|
|
98
|
+
expect(mergeAddresses([a, b])).toEqual([b])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("keeps stable order of last occurrences", () => {
|
|
102
|
+
const a1 = parseAddress("10.0.0.1")
|
|
103
|
+
const b1 = parseAddress("10.0.0.2")
|
|
104
|
+
const a2 = parseAddress("10.0.0.1")
|
|
105
|
+
const c1 = parseAddress("10.0.0.3")
|
|
106
|
+
|
|
107
|
+
expect(mergeAddresses([a1, b1, a2, c1])).toEqual([a2, b1, c1])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { check } from "@highstate/contract"
|
|
2
|
+
import { network } from "@highstate/library"
|
|
3
|
+
import { ipToString, parseCidr, parseIp, subnetBaseFromCidr } from "./ip"
|
|
4
|
+
|
|
5
|
+
export type InputAddress = network.Address | string
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parses and normalizes the given address string.
|
|
9
|
+
* If Address entity is given, it is returned as-is.
|
|
10
|
+
*
|
|
11
|
+
* @param address The address to parse.
|
|
12
|
+
* @returns The normalized Address entity.
|
|
13
|
+
*/
|
|
14
|
+
export function parseAddress(address: InputAddress): network.Address {
|
|
15
|
+
if (check(network.addressEntity.schema, address)) {
|
|
16
|
+
return address
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const input = address.trim()
|
|
20
|
+
if (!input) {
|
|
21
|
+
throw new Error("Empty address string")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parsed = input.includes("/") ? parseCidr(input) : parseCidrFromIp(input)
|
|
25
|
+
const canonicalAddress = ipToString(parsed.type, parsed.ip)
|
|
26
|
+
|
|
27
|
+
const subnetBase = subnetBaseFromCidr(parsed)
|
|
28
|
+
const subnetBaseAddress = ipToString(parsed.type, subnetBase)
|
|
29
|
+
|
|
30
|
+
const result: network.Address = {
|
|
31
|
+
type: parsed.type,
|
|
32
|
+
value: canonicalAddress,
|
|
33
|
+
subnet: {
|
|
34
|
+
type: parsed.type,
|
|
35
|
+
baseAddress: subnetBaseAddress,
|
|
36
|
+
prefixLength: parsed.prefixLength,
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validated = network.addressEntity.schema.safeParse(result)
|
|
41
|
+
if (!validated.success) {
|
|
42
|
+
throw new Error(`Invalid address "${input}": ${validated.error.message}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return validated.data
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Converts an address entity to its full CIDR string representation.
|
|
50
|
+
*
|
|
51
|
+
* The result format is `<address>/<prefix-length>`.
|
|
52
|
+
*
|
|
53
|
+
* @param address The address entity.
|
|
54
|
+
* @returns The CIDR string representation.
|
|
55
|
+
*/
|
|
56
|
+
export function addressToCidr(address: network.Address): string {
|
|
57
|
+
return `${address.value}/${address.subnet.prefixLength}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Checks whether the given address belongs to the specified subnet.
|
|
62
|
+
*
|
|
63
|
+
* @param address The address to check.
|
|
64
|
+
* @param subnet The subnet to check against.
|
|
65
|
+
* @returns True if the address belongs to the subnet, false otherwise.
|
|
66
|
+
*/
|
|
67
|
+
export function doesAddressBelongToSubnet(
|
|
68
|
+
address: network.Address,
|
|
69
|
+
subnet: network.Subnet,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (address.type !== subnet.type) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const addressIp = parseIp(address.value)
|
|
76
|
+
const subnetBaseIp = parseIp(subnet.baseAddress)
|
|
77
|
+
|
|
78
|
+
if (addressIp.type !== subnet.type || subnetBaseIp.type !== subnet.type) {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bits = subnet.type === "ipv4" ? 32 : 128
|
|
83
|
+
if (subnet.prefixLength === 0) {
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mask = ((1n << BigInt(subnet.prefixLength)) - 1n) << BigInt(bits - subnet.prefixLength)
|
|
88
|
+
|
|
89
|
+
return (addressIp.value & mask) === (subnetBaseIp.value & mask)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Merges duplicate addresses by their canonical CIDR key.
|
|
94
|
+
*
|
|
95
|
+
* If multiple addresses share the same `cidr`, the last one wins.
|
|
96
|
+
*
|
|
97
|
+
* @param addresses The list of addresses to merge.
|
|
98
|
+
* @returns The merged list of addresses with duplicates removed.
|
|
99
|
+
*/
|
|
100
|
+
export function mergeAddresses(addresses: network.Address[]): network.Address[] {
|
|
101
|
+
const mergedMap = new Map<string, network.Address>()
|
|
102
|
+
|
|
103
|
+
for (const address of addresses) {
|
|
104
|
+
mergedMap.set(addressToCidr(address), address)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Array.from(mergedMap.values())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseCidrFromIp(value: string) {
|
|
111
|
+
const parsed = parseIp(value)
|
|
112
|
+
const prefixLength = parsed.type === "ipv4" ? 32 : 128
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
type: parsed.type,
|
|
116
|
+
ip: parsed.value,
|
|
117
|
+
prefixLength,
|
|
118
|
+
}
|
|
119
|
+
}
|