ipsubnet 1.0.1__py3-none-any.whl
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.
- ipsubnet/__init__.py +3 -0
- ipsubnet/__main__.py +5 -0
- ipsubnet/cli.py +298 -0
- ipsubnet/core.py +244 -0
- ipsubnet-1.0.1.dist-info/METADATA +137 -0
- ipsubnet-1.0.1.dist-info/RECORD +10 -0
- ipsubnet-1.0.1.dist-info/WHEEL +5 -0
- ipsubnet-1.0.1.dist-info/entry_points.txt +2 -0
- ipsubnet-1.0.1.dist-info/licenses/LICENSE +21 -0
- ipsubnet-1.0.1.dist-info/top_level.txt +1 -0
ipsubnet/__init__.py
ADDED
ipsubnet/__main__.py
ADDED
ipsubnet/cli.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Command-line interface for ipsubnet."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .core import (SubnetError, describe, parse, resolve_new_prefix, subnet_count,
|
|
11
|
+
subnet_rows)
|
|
12
|
+
|
|
13
|
+
DEFAULT_LIMIT = 64
|
|
14
|
+
|
|
15
|
+
EXAMPLES = """\
|
|
16
|
+
examples:
|
|
17
|
+
ipsubnet 192.168.1.10/24
|
|
18
|
+
ipsubnet 192.168.1.10 255.255.255.0
|
|
19
|
+
ipsubnet 10.0.0.0/16 --subnets 8
|
|
20
|
+
ipsubnet 172.16.0.0/22 --hosts 100
|
|
21
|
+
ipsubnet 192.168.0.0/24 --prefix 27
|
|
22
|
+
ipsubnet 2001:db8::1/48 --prefix 52
|
|
23
|
+
ipsubnet 192.168.1.10/24 --json
|
|
24
|
+
|
|
25
|
+
Run without arguments for interactive mode.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Style:
|
|
30
|
+
"""ANSI colours, switched off when output isn't a terminal or NO_COLOR is set."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, enabled: bool):
|
|
33
|
+
self.enabled = enabled
|
|
34
|
+
|
|
35
|
+
def _wrap(self, code: str, text: str) -> str:
|
|
36
|
+
return "\033[{}m{}\033[0m".format(code, text) if self.enabled else text
|
|
37
|
+
|
|
38
|
+
def bold(self, t): return self._wrap("1", t)
|
|
39
|
+
def dim(self, t): return self._wrap("2", t)
|
|
40
|
+
def cyan(self, t): return self._wrap("36", t)
|
|
41
|
+
def green(self, t): return self._wrap("32", t)
|
|
42
|
+
def yellow(self, t): return self._wrap("33", t)
|
|
43
|
+
def red(self, t): return self._wrap("31", t)
|
|
44
|
+
def magenta(self, t): return self._wrap("35", t)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _enable_windows_ansi() -> bool:
|
|
48
|
+
if os.name != "nt":
|
|
49
|
+
return True
|
|
50
|
+
try:
|
|
51
|
+
import ctypes
|
|
52
|
+
kernel32 = ctypes.windll.kernel32
|
|
53
|
+
handle = kernel32.GetStdHandle(-11)
|
|
54
|
+
mode = ctypes.c_uint32()
|
|
55
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
56
|
+
return False
|
|
57
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
|
|
58
|
+
except Exception:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_style(no_color: bool) -> Style:
|
|
63
|
+
if no_color or os.environ.get("NO_COLOR") or not sys.stdout.isatty():
|
|
64
|
+
return Style(False)
|
|
65
|
+
return Style(_enable_windows_ansi())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fmt_int(n: int) -> str:
|
|
69
|
+
return "{:,}".format(n)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_binary(bits: str, prefix: int, s: Style) -> str:
|
|
73
|
+
"""Colour network bits and host bits differently."""
|
|
74
|
+
out, seen = [], 0
|
|
75
|
+
for ch in bits:
|
|
76
|
+
if ch == ".":
|
|
77
|
+
out.append(s.dim("."))
|
|
78
|
+
continue
|
|
79
|
+
out.append(s.cyan(ch) if seen < prefix else s.yellow(ch))
|
|
80
|
+
seen += 1
|
|
81
|
+
return "".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_summary(info: dict, s: Style) -> str:
|
|
85
|
+
rows = [("Address", info["address"])]
|
|
86
|
+
rows.append(("Network", s.green(info["cidr"])))
|
|
87
|
+
if info["version"] == 4:
|
|
88
|
+
rows += [
|
|
89
|
+
("Netmask", "{} (/{})".format(info["netmask"], info["prefix"])),
|
|
90
|
+
("Wildcard", info["wildcard"]),
|
|
91
|
+
("Network address", info["network"]),
|
|
92
|
+
("Broadcast", info["broadcast"]),
|
|
93
|
+
]
|
|
94
|
+
else:
|
|
95
|
+
rows += [
|
|
96
|
+
("Expanded", info["expanded"]),
|
|
97
|
+
("Prefix length", "/{}".format(info["prefix"])),
|
|
98
|
+
("Network address", info["network"]),
|
|
99
|
+
]
|
|
100
|
+
rows += [
|
|
101
|
+
("Usable range", s.green("{} - {}".format(info["first_host"], info["last_host"]))),
|
|
102
|
+
("Usable hosts", fmt_int(info["usable_hosts"])),
|
|
103
|
+
("Total addresses", fmt_int(info["total_addresses"])),
|
|
104
|
+
]
|
|
105
|
+
if info["version"] == 4:
|
|
106
|
+
rows.append(("Class", info["class"]))
|
|
107
|
+
rows.append(("Type", info["scope"]))
|
|
108
|
+
|
|
109
|
+
width = max(len(k) for k, _ in rows)
|
|
110
|
+
lines = [" {} {}".format(s.bold(k.ljust(width)), v) for k, v in rows]
|
|
111
|
+
|
|
112
|
+
if info["version"] == 4:
|
|
113
|
+
b = info["binary"]
|
|
114
|
+
lines += ["", " " + s.bold("Binary") + s.dim(" (network bits | host bits)")]
|
|
115
|
+
for label in ("address", "netmask", "network", "broadcast"):
|
|
116
|
+
lines.append(" {} {}".format(
|
|
117
|
+
label.capitalize().ljust(width),
|
|
118
|
+
render_binary(b[label], info["prefix"], s)))
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_table(rows: List[dict], s: Style) -> str:
|
|
123
|
+
ipv4 = rows and rows[0]["broadcast"] is not None
|
|
124
|
+
headers = ["#", "Subnet", "First host", "Last host"]
|
|
125
|
+
if ipv4:
|
|
126
|
+
headers.append("Broadcast")
|
|
127
|
+
headers.append("Usable")
|
|
128
|
+
|
|
129
|
+
data = []
|
|
130
|
+
for r in rows:
|
|
131
|
+
line = [str(r["index"]), r["network"], r["first_host"], r["last_host"]]
|
|
132
|
+
if ipv4:
|
|
133
|
+
line.append(r["broadcast"])
|
|
134
|
+
line.append(fmt_int(r["usable_hosts"]))
|
|
135
|
+
data.append(line)
|
|
136
|
+
|
|
137
|
+
widths = [max(len(h), *(len(d[i]) for d in data)) for i, h in enumerate(headers)]
|
|
138
|
+
|
|
139
|
+
def fmt(cells, color=None):
|
|
140
|
+
parts = []
|
|
141
|
+
for i, (c, w) in enumerate(zip(cells, widths)):
|
|
142
|
+
# Right-align numeric columns.
|
|
143
|
+
cell = c.rjust(w) if i in (0, len(cells) - 1) else c.ljust(w)
|
|
144
|
+
parts.append(cell)
|
|
145
|
+
text = " ".join(parts)
|
|
146
|
+
return color(text) if color else text
|
|
147
|
+
|
|
148
|
+
out = [" " + fmt(headers, s.bold), " " + s.dim(" ".join("-" * w for w in widths))]
|
|
149
|
+
out += [" " + fmt(d) for d in data]
|
|
150
|
+
return "\n".join(out)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run(address: str, mask: Optional[str], new_prefix: Optional[int], subnets: Optional[int],
|
|
154
|
+
hosts: Optional[int], limit: Optional[int], as_json: bool, s: Style) -> None:
|
|
155
|
+
iface, note = parse(address, mask)
|
|
156
|
+
info = describe(iface)
|
|
157
|
+
net = iface.network
|
|
158
|
+
|
|
159
|
+
split_wanted = any(x is not None for x in (new_prefix, subnets, hosts))
|
|
160
|
+
target = resolve_new_prefix(net, new_prefix, subnets, hosts) if split_wanted else None
|
|
161
|
+
|
|
162
|
+
if as_json:
|
|
163
|
+
payload = dict(info)
|
|
164
|
+
if note:
|
|
165
|
+
payload["note"] = note
|
|
166
|
+
if target is not None:
|
|
167
|
+
payload["subnetting"] = {
|
|
168
|
+
"new_prefix": target,
|
|
169
|
+
"subnet_count": subnet_count(net, target),
|
|
170
|
+
"subnets": subnet_rows(net, target, limit),
|
|
171
|
+
"truncated": limit is not None and subnet_count(net, target) > limit,
|
|
172
|
+
}
|
|
173
|
+
print(json.dumps(payload, indent=2))
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
print()
|
|
177
|
+
if note:
|
|
178
|
+
print(" " + s.yellow(note))
|
|
179
|
+
print()
|
|
180
|
+
print(render_summary(info, s))
|
|
181
|
+
|
|
182
|
+
if target is not None:
|
|
183
|
+
count = subnet_count(net, target)
|
|
184
|
+
rows = subnet_rows(net, target, limit)
|
|
185
|
+
per = rows[0]["usable_hosts"]
|
|
186
|
+
print()
|
|
187
|
+
print(" {} {} into /{}: {} subnet{}, {} usable host{} each".format(
|
|
188
|
+
s.bold("Subnets"), info["cidr"], target, fmt_int(count), "" if count == 1 else "s",
|
|
189
|
+
fmt_int(per), "" if per == 1 else "s"))
|
|
190
|
+
print()
|
|
191
|
+
print(render_table(rows, s))
|
|
192
|
+
if count > len(rows):
|
|
193
|
+
print()
|
|
194
|
+
print(" " + s.dim("... showing {} of {} subnets. Use --limit N or --all to see more."
|
|
195
|
+
.format(fmt_int(len(rows)), fmt_int(count))))
|
|
196
|
+
print()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _ask(prompt: str, s: Style) -> str:
|
|
200
|
+
try:
|
|
201
|
+
return input(s.magenta("? ") + prompt).strip()
|
|
202
|
+
except EOFError:
|
|
203
|
+
raise KeyboardInterrupt from None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def interactive(s: Style, limit: Optional[int]) -> int:
|
|
207
|
+
print()
|
|
208
|
+
print(" " + s.bold("ipsubnet {}".format(__version__)) + " - IP subnetting calculator")
|
|
209
|
+
print(" " + s.dim("Press Enter on an empty line or Ctrl+C to quit."))
|
|
210
|
+
while True:
|
|
211
|
+
print()
|
|
212
|
+
try:
|
|
213
|
+
address = _ask("IP address (e.g. 192.168.1.10 or 192.168.1.10/24): ", s)
|
|
214
|
+
if not address:
|
|
215
|
+
return 0
|
|
216
|
+
mask = None
|
|
217
|
+
if "/" not in address:
|
|
218
|
+
mask = _ask("Subnet mask or CIDR (e.g. 255.255.255.0 or 24, Enter for default): ", s) or None
|
|
219
|
+
split = _ask("Split into subnets? Enter /prefix, N subnets ('8s'), N hosts ('50h'), "
|
|
220
|
+
"or Enter to skip: ", s).lower()
|
|
221
|
+
except KeyboardInterrupt:
|
|
222
|
+
print()
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
new_prefix = subnets = hosts = None
|
|
226
|
+
try:
|
|
227
|
+
if split:
|
|
228
|
+
if split.endswith("s") and split[:-1].isdigit():
|
|
229
|
+
subnets = int(split[:-1])
|
|
230
|
+
elif split.endswith("h") and split[:-1].isdigit():
|
|
231
|
+
hosts = int(split[:-1])
|
|
232
|
+
elif split.lstrip("/").isdigit():
|
|
233
|
+
new_prefix = int(split.lstrip("/"))
|
|
234
|
+
else:
|
|
235
|
+
raise SubnetError("didn't understand '{}'".format(split))
|
|
236
|
+
run(address, mask, new_prefix, subnets, hosts, limit, False, s)
|
|
237
|
+
except SubnetError as e:
|
|
238
|
+
print(" " + s.red("error: ") + str(e))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
242
|
+
p = argparse.ArgumentParser(
|
|
243
|
+
prog="ipsubnet",
|
|
244
|
+
description="IP subnetting and addressing calculator for IPv4 and IPv6. "
|
|
245
|
+
"Shows the network address, broadcast, usable host range and "
|
|
246
|
+
"can split a network into subnets.",
|
|
247
|
+
epilog=EXAMPLES,
|
|
248
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
249
|
+
)
|
|
250
|
+
p.add_argument("address", nargs="?",
|
|
251
|
+
help="IP address, optionally with CIDR (192.168.1.10/24) or mask (192.168.1.10/255.255.255.0)")
|
|
252
|
+
p.add_argument("mask", nargs="?",
|
|
253
|
+
help="subnet mask or prefix if not included in the address (255.255.255.0 or 24)")
|
|
254
|
+
|
|
255
|
+
g = p.add_argument_group("subnetting (pick one)")
|
|
256
|
+
ex = g.add_mutually_exclusive_group()
|
|
257
|
+
ex.add_argument("-p", "--prefix", type=int, metavar="N", help="split into subnets of prefix /N")
|
|
258
|
+
ex.add_argument("-s", "--subnets", type=int, metavar="N", help="split into at least N equal subnets")
|
|
259
|
+
ex.add_argument("-H", "--hosts", type=int, metavar="N",
|
|
260
|
+
help="split into the smallest subnets that fit N usable hosts each")
|
|
261
|
+
|
|
262
|
+
o = p.add_argument_group("output")
|
|
263
|
+
o.add_argument("-l", "--limit", type=int, default=DEFAULT_LIMIT, metavar="N",
|
|
264
|
+
help="max subnets to list (default {})".format(DEFAULT_LIMIT))
|
|
265
|
+
o.add_argument("-a", "--all", action="store_true", help="list every subnet (can be huge)")
|
|
266
|
+
o.add_argument("-j", "--json", action="store_true", help="print JSON instead of a table")
|
|
267
|
+
o.add_argument("--no-color", action="store_true", help="disable coloured output")
|
|
268
|
+
p.add_argument("-V", "--version", action="version", version="ipsubnet " + __version__)
|
|
269
|
+
return p
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
273
|
+
args = build_parser().parse_args(argv)
|
|
274
|
+
s = make_style(args.no_color or args.json)
|
|
275
|
+
limit = None if args.all else max(args.limit, 1)
|
|
276
|
+
|
|
277
|
+
if args.address is None:
|
|
278
|
+
if sys.stdin.isatty():
|
|
279
|
+
return interactive(s, limit)
|
|
280
|
+
build_parser().print_help()
|
|
281
|
+
return 2
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
run(args.address, args.mask, args.prefix, args.subnets, args.hosts, limit, args.json, s)
|
|
285
|
+
except SubnetError as e:
|
|
286
|
+
print("ipsubnet: error: {}".format(e), file=sys.stderr)
|
|
287
|
+
return 1
|
|
288
|
+
except KeyboardInterrupt:
|
|
289
|
+
return 130
|
|
290
|
+
except BrokenPipeError:
|
|
291
|
+
# e.g. `ipsubnet ... --all | head`
|
|
292
|
+
sys.stderr.close()
|
|
293
|
+
return 0
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
sys.exit(main())
|
ipsubnet/core.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Subnet math: parsing input, describing a network, and splitting it into subnets."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import math
|
|
5
|
+
from typing import Iterator, List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
Interface = Union[ipaddress.IPv4Interface, ipaddress.IPv6Interface]
|
|
8
|
+
Network = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SubnetError(ValueError):
|
|
12
|
+
"""Raised for any input the calculator cannot work with."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def classful_prefix(ip: ipaddress.IPv4Address) -> Optional[int]:
|
|
16
|
+
"""Default prefix for the IPv4 address class (A=/8, B=/16, C=/24), or None for D/E."""
|
|
17
|
+
first = int(ip) >> 24
|
|
18
|
+
if first < 128:
|
|
19
|
+
return 8
|
|
20
|
+
if first < 192:
|
|
21
|
+
return 16
|
|
22
|
+
if first < 224:
|
|
23
|
+
return 24
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_mask(mask: str, version: int) -> int:
|
|
28
|
+
"""Turn '24', '/24', '255.255.255.0' or a wildcard like '0.0.0.255' into a prefix length."""
|
|
29
|
+
mask = mask.strip().lstrip("/")
|
|
30
|
+
if not mask:
|
|
31
|
+
raise SubnetError("empty subnet mask")
|
|
32
|
+
if mask.isdigit():
|
|
33
|
+
prefix = int(mask)
|
|
34
|
+
limit = 32 if version == 4 else 128
|
|
35
|
+
if not 0 <= prefix <= limit:
|
|
36
|
+
raise SubnetError("prefix /{} is out of range (0-{})".format(prefix, limit))
|
|
37
|
+
return prefix
|
|
38
|
+
if version == 6:
|
|
39
|
+
raise SubnetError("IPv6 needs a prefix length such as /64, not a dotted mask")
|
|
40
|
+
try:
|
|
41
|
+
# ipaddress accepts both netmasks and wildcard (host) masks here.
|
|
42
|
+
return ipaddress.IPv4Network("0.0.0.0/" + mask).prefixlen
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise SubnetError("'{}' is not a valid subnet mask".format(mask)) from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse(address: str, mask: Optional[str] = None) -> Tuple[Interface, Optional[str]]:
|
|
48
|
+
"""Parse user input into an interface (address + prefix).
|
|
49
|
+
|
|
50
|
+
Returns the interface and an optional note to show the user (e.g. when a
|
|
51
|
+
default mask was assumed).
|
|
52
|
+
"""
|
|
53
|
+
address = address.strip()
|
|
54
|
+
if "/" in address:
|
|
55
|
+
if mask:
|
|
56
|
+
raise SubnetError("give the mask either as /CIDR or as a separate argument, not both")
|
|
57
|
+
address, mask = address.split("/", 1)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
ip = ipaddress.ip_address(address)
|
|
61
|
+
except ValueError:
|
|
62
|
+
raise SubnetError("'{}' is not a valid IPv4 or IPv6 address".format(address)) from None
|
|
63
|
+
|
|
64
|
+
note = None
|
|
65
|
+
if mask is None:
|
|
66
|
+
if ip.version == 4:
|
|
67
|
+
prefix = classful_prefix(ip)
|
|
68
|
+
if prefix is None:
|
|
69
|
+
raise SubnetError(
|
|
70
|
+
"{} is a class D/E address and has no default mask; please give one".format(ip)
|
|
71
|
+
)
|
|
72
|
+
note = "No mask given; assumed the classful default /{}.".format(prefix)
|
|
73
|
+
else:
|
|
74
|
+
prefix = 64
|
|
75
|
+
note = "No prefix given; assumed /64."
|
|
76
|
+
else:
|
|
77
|
+
prefix = _parse_mask(mask, ip.version)
|
|
78
|
+
|
|
79
|
+
return ipaddress.ip_interface("{}/{}".format(ip, prefix)), note
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ip_class(ip: ipaddress.IPv4Address) -> str:
|
|
83
|
+
first = int(ip) >> 24
|
|
84
|
+
if first < 128:
|
|
85
|
+
return "A"
|
|
86
|
+
if first < 192:
|
|
87
|
+
return "B"
|
|
88
|
+
if first < 224:
|
|
89
|
+
return "C"
|
|
90
|
+
if first < 240:
|
|
91
|
+
return "D (multicast)"
|
|
92
|
+
return "E (reserved)"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
DOCUMENTATION_NETS = [ipaddress.ip_network(n) for n in (
|
|
96
|
+
"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "2001:db8::/32")]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ip_scope(ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]) -> str:
|
|
100
|
+
if ip.is_loopback:
|
|
101
|
+
return "Loopback"
|
|
102
|
+
if ip.is_link_local:
|
|
103
|
+
return "Link-local"
|
|
104
|
+
if ip.is_multicast:
|
|
105
|
+
return "Multicast"
|
|
106
|
+
if ip.is_unspecified:
|
|
107
|
+
return "Unspecified"
|
|
108
|
+
if any(ip.version == n.version and ip in n for n in DOCUMENTATION_NETS):
|
|
109
|
+
return "Documentation (example range)"
|
|
110
|
+
if ip.version == 4 and ip in ipaddress.IPv4Network("100.64.0.0/10"):
|
|
111
|
+
return "Shared address space (CGNAT)"
|
|
112
|
+
if ip.is_private:
|
|
113
|
+
return "Private"
|
|
114
|
+
if ip.is_reserved:
|
|
115
|
+
return "Reserved"
|
|
116
|
+
if ip.is_global:
|
|
117
|
+
return "Public"
|
|
118
|
+
return "Special-purpose"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def host_range(net: Network) -> Tuple[object, object, int]:
|
|
122
|
+
"""First usable, last usable and usable count for a network.
|
|
123
|
+
|
|
124
|
+
IPv4 /31 links use both addresses (RFC 3021); /32 is a single host.
|
|
125
|
+
IPv6 has no broadcast address, so every address is usable.
|
|
126
|
+
"""
|
|
127
|
+
total = net.num_addresses
|
|
128
|
+
if net.version == 6 or net.prefixlen >= net.max_prefixlen - 1:
|
|
129
|
+
return net.network_address, net.broadcast_address, total
|
|
130
|
+
return net.network_address + 1, net.broadcast_address - 1, total - 2
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def to_binary(ip: ipaddress.IPv4Address) -> str:
|
|
134
|
+
bits = format(int(ip), "032b")
|
|
135
|
+
return ".".join(bits[i:i + 8] for i in range(0, 32, 8))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def describe(iface: Interface) -> dict:
|
|
139
|
+
"""All the facts about an address and the network it sits in."""
|
|
140
|
+
net = iface.network
|
|
141
|
+
ip = iface.ip
|
|
142
|
+
first, last, usable = host_range(net)
|
|
143
|
+
info = {
|
|
144
|
+
"version": ip.version,
|
|
145
|
+
"address": str(ip),
|
|
146
|
+
"cidr": "{}/{}".format(net.network_address, net.prefixlen),
|
|
147
|
+
"prefix": net.prefixlen,
|
|
148
|
+
"network": str(net.network_address),
|
|
149
|
+
"first_host": str(first),
|
|
150
|
+
"last_host": str(last),
|
|
151
|
+
"total_addresses": net.num_addresses,
|
|
152
|
+
"usable_hosts": usable,
|
|
153
|
+
"scope": ip_scope(ip),
|
|
154
|
+
}
|
|
155
|
+
if ip.version == 4:
|
|
156
|
+
info.update({
|
|
157
|
+
"netmask": str(net.netmask),
|
|
158
|
+
"wildcard": str(net.hostmask),
|
|
159
|
+
"broadcast": str(net.broadcast_address),
|
|
160
|
+
"class": ip_class(ip),
|
|
161
|
+
"binary": {
|
|
162
|
+
"address": to_binary(ip),
|
|
163
|
+
"netmask": to_binary(net.netmask),
|
|
164
|
+
"network": to_binary(net.network_address),
|
|
165
|
+
"broadcast": to_binary(net.broadcast_address),
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
else:
|
|
169
|
+
info.update({
|
|
170
|
+
"netmask": None,
|
|
171
|
+
"wildcard": None,
|
|
172
|
+
"broadcast": None,
|
|
173
|
+
"expanded": ip.exploded,
|
|
174
|
+
})
|
|
175
|
+
return info
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def resolve_new_prefix(net: Network, new_prefix: Optional[int] = None,
|
|
179
|
+
subnets: Optional[int] = None, hosts: Optional[int] = None) -> int:
|
|
180
|
+
"""Work out the target prefix from exactly one of: new prefix, subnet count, hosts per subnet."""
|
|
181
|
+
given = [x is not None for x in (new_prefix, subnets, hosts)]
|
|
182
|
+
if sum(given) != 1:
|
|
183
|
+
raise SubnetError("choose exactly one of --prefix, --subnets or --hosts")
|
|
184
|
+
|
|
185
|
+
max_len = net.max_prefixlen
|
|
186
|
+
if new_prefix is not None:
|
|
187
|
+
if not net.prefixlen <= new_prefix <= max_len:
|
|
188
|
+
raise SubnetError(
|
|
189
|
+
"new prefix must be between /{} and /{}".format(net.prefixlen, max_len)
|
|
190
|
+
)
|
|
191
|
+
return new_prefix
|
|
192
|
+
|
|
193
|
+
if subnets is not None:
|
|
194
|
+
if subnets < 1:
|
|
195
|
+
raise SubnetError("number of subnets must be at least 1")
|
|
196
|
+
borrowed = math.ceil(math.log2(subnets)) if subnets > 1 else 0
|
|
197
|
+
result = net.prefixlen + borrowed
|
|
198
|
+
if result > max_len:
|
|
199
|
+
raise SubnetError(
|
|
200
|
+
"/{} cannot be split into {} subnets (at most {})".format(
|
|
201
|
+
net.prefixlen, subnets, 2 ** (max_len - net.prefixlen))
|
|
202
|
+
)
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
if hosts < 1:
|
|
206
|
+
raise SubnetError("hosts per subnet must be at least 1")
|
|
207
|
+
# IPv4 subnets reserve network + broadcast (textbook rule; a single host gets /32).
|
|
208
|
+
if net.version == 4 and hosts > 1:
|
|
209
|
+
needed = hosts + 2
|
|
210
|
+
else:
|
|
211
|
+
needed = hosts
|
|
212
|
+
host_bits = math.ceil(math.log2(needed)) if needed > 1 else 0
|
|
213
|
+
result = max_len - host_bits
|
|
214
|
+
if result < net.prefixlen:
|
|
215
|
+
raise SubnetError(
|
|
216
|
+
"/{} is too small for {} hosts per subnet (it has {} usable)".format(
|
|
217
|
+
net.prefixlen, hosts, host_range(net)[2])
|
|
218
|
+
)
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def split(net: Network, new_prefix: int) -> Iterator[Network]:
|
|
223
|
+
return net.subnets(new_prefix=new_prefix)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def subnet_count(net: Network, new_prefix: int) -> int:
|
|
227
|
+
return 2 ** (new_prefix - net.prefixlen)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def subnet_rows(net: Network, new_prefix: int, limit: Optional[int]) -> List[dict]:
|
|
231
|
+
rows = []
|
|
232
|
+
for i, sub in enumerate(split(net, new_prefix), 1):
|
|
233
|
+
if limit is not None and i > limit:
|
|
234
|
+
break
|
|
235
|
+
first, last, usable = host_range(sub)
|
|
236
|
+
rows.append({
|
|
237
|
+
"index": i,
|
|
238
|
+
"network": str(sub),
|
|
239
|
+
"first_host": str(first),
|
|
240
|
+
"last_host": str(last),
|
|
241
|
+
"broadcast": str(sub.broadcast_address) if sub.version == 4 else None,
|
|
242
|
+
"usable_hosts": usable,
|
|
243
|
+
})
|
|
244
|
+
return rows
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ipsubnet
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: IP subnetting and addressing calculator for the command line (IPv4 and IPv6)
|
|
5
|
+
Author: Shazan Hussain Zaidi, Harshit Agrawal, Prabhat Bhati
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ShazanZaidii/ipsubnet
|
|
8
|
+
Keywords: subnet,cidr,ip,network,calculator,cli
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: System :: Networking
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# ipsubnet
|
|
20
|
+
|
|
21
|
+
An IP subnetting and addressing calculator for the command line. Give it an IP address with a subnet mask or CIDR prefix. It prints the network address, broadcast address, usable host range and host counts, and it can split the network into a list of subnets. It works with IPv4 and IPv6 on Windows, macOS and Linux.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
**Windows** (PowerShell):
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
irm https://raw.githubusercontent.com/ShazanZaidii/ipsubnet/main/scripts/install.ps1 | iex
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**macOS / Linux**:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
curl -fsSL https://raw.githubusercontent.com/ShazanZaidii/ipsubnet/main/scripts/install.sh | sh
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**With Python** (any OS, Python 3.8+):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pip install ipsubnet
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
or, to keep it isolated from other packages, `pipx install ipsubnet`.
|
|
44
|
+
|
|
45
|
+
You can also download a ready-to-run binary from the [Releases page](https://github.com/ShazanZaidii/ipsubnet/releases/latest):
|
|
46
|
+
|
|
47
|
+
| OS | File |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Windows | `ipsubnet-windows-x64.exe` |
|
|
50
|
+
| macOS (Apple Silicon) | `ipsubnet-macos-arm64` |
|
|
51
|
+
| macOS (Intel) | `ipsubnet-macos-x64` |
|
|
52
|
+
| Linux (x64) | `ipsubnet-linux-x64` |
|
|
53
|
+
| Linux (ARM64) | `ipsubnet-linux-arm64` |
|
|
54
|
+
|
|
55
|
+
On macOS and Linux, run `chmod +x` on the file first. On macOS, if you downloaded it with a browser, also run `xattr -d com.apple.quarantine <file>`.
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
ipsubnet ADDRESS[/PREFIX] [MASK] [--prefix N | --subnets N | --hosts N] [options]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
| Input | Meaning |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `192.168.1.10/24` | Address with a CIDR prefix |
|
|
66
|
+
| `192.168.1.10 255.255.255.0` | Address and a dotted subnet mask |
|
|
67
|
+
| `192.168.1.10 24` | Address and a prefix length |
|
|
68
|
+
| `192.168.1.10` | Uses the classful default mask (A=/8, B=/16, C=/24) |
|
|
69
|
+
| `2001:db8::1/48` | IPv6 |
|
|
70
|
+
|
|
71
|
+
| Option | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `-p, --prefix N` | Split into subnets of size /N |
|
|
74
|
+
| `-s, --subnets N` | Split into at least N equal subnets |
|
|
75
|
+
| `-H, --hosts N` | Split into the smallest subnets that fit N usable hosts each |
|
|
76
|
+
| `-l, --limit N` | Show at most N subnets (default 64) |
|
|
77
|
+
| `-a, --all` | Show every subnet |
|
|
78
|
+
| `-j, --json` | Output JSON |
|
|
79
|
+
| `--no-color` | Turn off colours |
|
|
80
|
+
|
|
81
|
+
If you run `ipsubnet` with no arguments, it starts in interactive mode and asks for each value. This is also what happens when you double-click the Windows `.exe`.
|
|
82
|
+
|
|
83
|
+
### Example
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
$ ipsubnet 192.168.1.10/24 --hosts 50
|
|
87
|
+
|
|
88
|
+
Address 192.168.1.10
|
|
89
|
+
Network 192.168.1.0/24
|
|
90
|
+
Netmask 255.255.255.0 (/24)
|
|
91
|
+
Wildcard 0.0.0.255
|
|
92
|
+
Network address 192.168.1.0
|
|
93
|
+
Broadcast 192.168.1.255
|
|
94
|
+
Usable range 192.168.1.1 - 192.168.1.254
|
|
95
|
+
Usable hosts 254
|
|
96
|
+
Total addresses 256
|
|
97
|
+
Class C
|
|
98
|
+
Type Private
|
|
99
|
+
|
|
100
|
+
Binary (network bits | host bits)
|
|
101
|
+
Address 11000000.10101000.00000001.00001010
|
|
102
|
+
Netmask 11111111.11111111.11111111.00000000
|
|
103
|
+
Network 11000000.10101000.00000001.00000000
|
|
104
|
+
Broadcast 11000000.10101000.00000001.11111111
|
|
105
|
+
|
|
106
|
+
Subnets 192.168.1.0/24 into /26: 4 subnets, 62 usable hosts each
|
|
107
|
+
|
|
108
|
+
# Subnet First host Last host Broadcast Usable
|
|
109
|
+
- ---------------- ------------- ------------- ------------- ------
|
|
110
|
+
1 192.168.1.0/26 192.168.1.1 192.168.1.62 192.168.1.63 62
|
|
111
|
+
2 192.168.1.64/26 192.168.1.65 192.168.1.126 192.168.1.127 62
|
|
112
|
+
3 192.168.1.128/26 192.168.1.129 192.168.1.190 192.168.1.191 62
|
|
113
|
+
4 192.168.1.192/26 192.168.1.193 192.168.1.254 192.168.1.255 62
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## How it works
|
|
117
|
+
|
|
118
|
+
- **Network address**: the IP AND the subnet mask (all host bits set to 0).
|
|
119
|
+
- **Broadcast address**: the network address with all host bits set to 1.
|
|
120
|
+
- **Usable hosts**: 2^(32 − prefix) − 2 for IPv4, because the network and broadcast addresses can't be assigned to hosts. A /31 has 2 usable addresses (point-to-point links, RFC 3021) and a /32 has 1.
|
|
121
|
+
- **Subnetting**: borrowing *b* host bits creates 2^b subnets of prefix + *b*.
|
|
122
|
+
- `--subnets N` borrows ⌈log₂ N⌉ bits.
|
|
123
|
+
- `--hosts N` keeps ⌈log₂(N + 2)⌉ host bits.
|
|
124
|
+
- **IPv6** has no broadcast address, so every address in the prefix is counted as usable.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
pip install -e .
|
|
130
|
+
python -m unittest discover -s tests -v
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Pushing a tag like `v1.0.1` makes GitHub Actions run the tests on all three operating systems, build the binaries and publish a release.
|
|
134
|
+
|
|
135
|
+
## Authors
|
|
136
|
+
|
|
137
|
+
Group 4: Shazan Hussain Zaidi, Harshit Agrawal, Prabhat Bhati
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ipsubnet/__init__.py,sha256=CPSYTvFM_bjsaEJ5Vc3gqyKJC4rAMwOA2WdxODyxl_s,70
|
|
2
|
+
ipsubnet/__main__.py,sha256=VDDt6PjW8FfNVs0yz8nIRMQ5bhcldgv9menjS59yQsA,60
|
|
3
|
+
ipsubnet/cli.py,sha256=p5Ehgn8jQfdpt5kuJgBxE4JDnSF9SGZuIjQfJMNyeSk,10775
|
|
4
|
+
ipsubnet/core.py,sha256=xElqJyUEVyuAa3BlONamKqhtXFIV_AvNIy2YyDwCnfg,8409
|
|
5
|
+
ipsubnet-1.0.1.dist-info/licenses/LICENSE,sha256=nwyiEOEuxSwucb81U5Pf-swWbfXtX6AWrGp9WlIKebY,1109
|
|
6
|
+
ipsubnet-1.0.1.dist-info/METADATA,sha256=61l1JrbBQKwAs9wVjFHetgV1r3hybPlZ18-pn9GUuRo,5003
|
|
7
|
+
ipsubnet-1.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
ipsubnet-1.0.1.dist-info/entry_points.txt,sha256=L9vLkbQeV78mwA1uSunCoPJ7rgtCVJ5xIBAGbbOLzAQ,47
|
|
9
|
+
ipsubnet-1.0.1.dist-info/top_level.txt,sha256=4sv85yzG4QCydDa5rhkKnHcPNVngGRBhkxlfJyDpsu8,9
|
|
10
|
+
ipsubnet-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shazan Hussain Zaidi, Harshit Agrawal, Prabhat Bhati
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ipsubnet
|