netbirdexport 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Younes Z.
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,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbirdexport
3
+ Version: 0.1.0
4
+ Summary: Export a NetBird account inventory (peers, users, setup keys, groups) to CSV and JSON in one command, using your own access token. Standard library only.
5
+ Author: Younes Z.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Rezarys/netbirdexport
8
+ Project-URL: Issues, https://github.com/Rezarys/netbirdexport/issues
9
+ Keywords: netbird,wireguard,mesh vpn,inventory,export,csv,audit,backup,self hosted
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: System :: Networking
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # netbirdexport
25
+
26
+ ```
27
+ pip install netbirdexport
28
+ ```
29
+
30
+ Export your NetBird account inventory to CSV and JSON in one command, using your own access token. Peers, users, setup keys and groups, one CSV and one JSON file each, plus a manifest. Standard library only, no dependencies.
31
+
32
+ Not affiliated with NetBird. "NetBird" is used here only to say which API this tool reads.
33
+
34
+ ## Why this exists
35
+
36
+ In [netbirdio/netbird#4437](https://github.com/netbirdio/netbird/issues/4437), open since September 2025, `chirag-rastogi` wrote:
37
+
38
+ > Currently, there's no way to download or back up information like the list of users, peers, or setup keys.
39
+
40
+ The management API already exposes every one of those objects: `/api/users`, `/api/peers` and `/api/setup-keys` all exist and are read by the project's own REST client. The missing part was the one command that reads them and writes them out for an audit or a migration. That is all this tool is.
41
+
42
+ ## Use
43
+
44
+ Create a personal access token in the management dashboard, then:
45
+
46
+ ```
47
+ export NETBIRD_TOKEN=nbp_xxxxxxxxxxxxxxxx
48
+ netbirdexport
49
+ ```
50
+
51
+ ```
52
+ netbirdexport 0.1.0
53
+ source https://api.netbird.io
54
+ output netbird-export
55
+
56
+ resource rows files
57
+ peers 2 peers.csv, peers.json
58
+ users 2 users.csv, users.json
59
+ setup-keys 1 setup-keys.csv, setup-keys.json
60
+ groups 2 groups.csv, groups.json
61
+
62
+ never written, matched by field name: setup-keys.key
63
+ wrote 9 files including manifest.json
64
+ ```
65
+
66
+ The token is read from `NETBIRD_TOKEN` and is never accepted as a command line argument, so it does not appear in the argument list of the running process. It is still in that process's environment, and typing `export NETBIRD_TOKEN=...` at a prompt does put it in your shell history, so read it from a secret store or from a file you control if that matters to you.
67
+
68
+ Self hosted, and more resources:
69
+
70
+ ```
71
+ netbirdexport --url https://netbird.example.org --resources peers,users,setup-keys,groups,policies,routes,nameservers --out audit-2026-09
72
+ ```
73
+
74
+ Options:
75
+
76
+ - `--url` management API base URL, default `https://api.netbird.io`.
77
+ - `--out` directory to write into, created if missing, default `netbird-export`.
78
+ - `--resources` comma separated, in output order. Available: `peers`, `users`, `setup-keys`, `groups`, `policies`, `routes`, `nameservers`.
79
+ - `--format` comma separated, `csv` and `json`, default both.
80
+ - `--bearer` send the token as `Bearer` instead of `Token`, for an OAuth access token rather than a personal access token.
81
+ - `--version` print the version and exit.
82
+
83
+ Nothing is written until every request has succeeded, so a failure halfway through leaves no half exported directory behind.
84
+
85
+ ## What the two formats are for
86
+
87
+ `<resource>.json` keeps the nested shape the API returned, which is what you want for a diff between two dates. `<resource>.csv` flattens the same objects to one row each: a nested object becomes a `parent.child` column, and every list gets a `<field>_count` column, whatever it holds and even when it is empty, so that a column never appears or disappears just because a list happened to be empty on one run. A list of values also gets `<field>` with the values joined by a vertical bar; a list of objects also gets `<field>_ids` when every object in the list carries a string `id`. Columns are the union of the keys seen across all rows, so a field that only some objects carry still gets a column.
88
+
89
+ Columns are not a fixed list. This tool writes whatever the API returns, minus the redacted fields below, which means it does not silently drop a field that a newer server version added.
90
+
91
+ `manifest.json` records the tool version, the source URL, the UTC time of the run, and for each resource its API path, its row count and the names of the fields that were dropped.
92
+
93
+ ## Secrets: what is dropped, and what that does not promise
94
+
95
+ A field is never written when its own name, lowercased, is one of `key`, `token`, `secret`, `password`, `passphrase`, `private_key`, `privatekey`, `client_secret`, `api_key`, `access_token`, `refresh_token`, or ends with `_secret`, `_password`, `_token` or `_private_key`. This applies at every depth, inside nested objects and inside lists. Every dropped field is named on the console and in `manifest.json`, so nothing is removed silently.
96
+
97
+ Read this next part before you share an export. **This is a match on field names, not a classification of content.** It drops the setup key field, which is named `key`, and it deliberately keeps fields like `setup_key_id` or `public_key`, which identify rather than authenticate. It cannot know that some future field with an innocent name holds something sensitive. An export of an administration inventory contains user emails, host names and internal addresses in any case: treat the output directory as sensitive, and read it before sending it anywhere.
98
+
99
+ ## Honest limits
100
+
101
+ **This tool has never been run against a real NetBird instance.** It has no test deployment and no token behind it. Its test suite runs against example API responses written for those tests, and the shape of those responses follows the upstream project's public REST client and API specification, read on 2026-09-30. If you run it against a real account and something is wrong, open an issue and it will be fixed.
102
+
103
+ It issues one GET per resource and sends nothing else of its own. It does not follow redirects: a redirect is reported as an error rather than followed, because following one would resend your `Authorization` header to whatever host the server pointed at. It never asks the API to change anything, because every request it makes is a GET.
104
+
105
+ It does not paginate: it reads each list endpoint once and writes what came back. The project's own REST client takes no page parameter on these list calls, but if some server version paginates one of them, this tool would write only the first page, and that has not been tested against a real instance.
106
+
107
+ It reads only a whole account: there is no filter by group, by name or by date. An export is the inventory as it stands at the moment of the run.
108
+
109
+ An export is not a restore file. Nothing here writes back, the default set covers four resources out of seven, and a setup key cannot be recreated from an export because its value is deliberately absent. Treat the output as an audit and migration record, not as a backup you could restore from.
110
+
111
+ `policies`, `routes` and `nameservers` are available but not exported by default. The default set is peers, users and setup keys, the three the request above named, plus groups, because a peer row carries group ids and those ids mean nothing without the group names.
112
+
113
+ Python 3.9 or newer.
114
+
115
+ ## Upstream licence
116
+
117
+ NetBird is published by NetBird GmbH and its authors. Its repository is BSD 3-Clause except the `management/`, `signal/`, `relay/` and `combined/` directories, which are GNU AGPL version 3. This package is MIT and is not derived from it: no upstream line is copied here. What was read is the public shape of its HTTP interface, and this tool is an outside client that speaks to that interface over the network.
118
+
119
+ ## Licence
120
+
121
+ MIT, Younes Z. See `LICENSE`.
122
+
123
+ Built with AI assistance, reviewed and tested by me.
@@ -0,0 +1,100 @@
1
+ # netbirdexport
2
+
3
+ ```
4
+ pip install netbirdexport
5
+ ```
6
+
7
+ Export your NetBird account inventory to CSV and JSON in one command, using your own access token. Peers, users, setup keys and groups, one CSV and one JSON file each, plus a manifest. Standard library only, no dependencies.
8
+
9
+ Not affiliated with NetBird. "NetBird" is used here only to say which API this tool reads.
10
+
11
+ ## Why this exists
12
+
13
+ In [netbirdio/netbird#4437](https://github.com/netbirdio/netbird/issues/4437), open since September 2025, `chirag-rastogi` wrote:
14
+
15
+ > Currently, there's no way to download or back up information like the list of users, peers, or setup keys.
16
+
17
+ The management API already exposes every one of those objects: `/api/users`, `/api/peers` and `/api/setup-keys` all exist and are read by the project's own REST client. The missing part was the one command that reads them and writes them out for an audit or a migration. That is all this tool is.
18
+
19
+ ## Use
20
+
21
+ Create a personal access token in the management dashboard, then:
22
+
23
+ ```
24
+ export NETBIRD_TOKEN=nbp_xxxxxxxxxxxxxxxx
25
+ netbirdexport
26
+ ```
27
+
28
+ ```
29
+ netbirdexport 0.1.0
30
+ source https://api.netbird.io
31
+ output netbird-export
32
+
33
+ resource rows files
34
+ peers 2 peers.csv, peers.json
35
+ users 2 users.csv, users.json
36
+ setup-keys 1 setup-keys.csv, setup-keys.json
37
+ groups 2 groups.csv, groups.json
38
+
39
+ never written, matched by field name: setup-keys.key
40
+ wrote 9 files including manifest.json
41
+ ```
42
+
43
+ The token is read from `NETBIRD_TOKEN` and is never accepted as a command line argument, so it does not appear in the argument list of the running process. It is still in that process's environment, and typing `export NETBIRD_TOKEN=...` at a prompt does put it in your shell history, so read it from a secret store or from a file you control if that matters to you.
44
+
45
+ Self hosted, and more resources:
46
+
47
+ ```
48
+ netbirdexport --url https://netbird.example.org --resources peers,users,setup-keys,groups,policies,routes,nameservers --out audit-2026-09
49
+ ```
50
+
51
+ Options:
52
+
53
+ - `--url` management API base URL, default `https://api.netbird.io`.
54
+ - `--out` directory to write into, created if missing, default `netbird-export`.
55
+ - `--resources` comma separated, in output order. Available: `peers`, `users`, `setup-keys`, `groups`, `policies`, `routes`, `nameservers`.
56
+ - `--format` comma separated, `csv` and `json`, default both.
57
+ - `--bearer` send the token as `Bearer` instead of `Token`, for an OAuth access token rather than a personal access token.
58
+ - `--version` print the version and exit.
59
+
60
+ Nothing is written until every request has succeeded, so a failure halfway through leaves no half exported directory behind.
61
+
62
+ ## What the two formats are for
63
+
64
+ `<resource>.json` keeps the nested shape the API returned, which is what you want for a diff between two dates. `<resource>.csv` flattens the same objects to one row each: a nested object becomes a `parent.child` column, and every list gets a `<field>_count` column, whatever it holds and even when it is empty, so that a column never appears or disappears just because a list happened to be empty on one run. A list of values also gets `<field>` with the values joined by a vertical bar; a list of objects also gets `<field>_ids` when every object in the list carries a string `id`. Columns are the union of the keys seen across all rows, so a field that only some objects carry still gets a column.
65
+
66
+ Columns are not a fixed list. This tool writes whatever the API returns, minus the redacted fields below, which means it does not silently drop a field that a newer server version added.
67
+
68
+ `manifest.json` records the tool version, the source URL, the UTC time of the run, and for each resource its API path, its row count and the names of the fields that were dropped.
69
+
70
+ ## Secrets: what is dropped, and what that does not promise
71
+
72
+ A field is never written when its own name, lowercased, is one of `key`, `token`, `secret`, `password`, `passphrase`, `private_key`, `privatekey`, `client_secret`, `api_key`, `access_token`, `refresh_token`, or ends with `_secret`, `_password`, `_token` or `_private_key`. This applies at every depth, inside nested objects and inside lists. Every dropped field is named on the console and in `manifest.json`, so nothing is removed silently.
73
+
74
+ Read this next part before you share an export. **This is a match on field names, not a classification of content.** It drops the setup key field, which is named `key`, and it deliberately keeps fields like `setup_key_id` or `public_key`, which identify rather than authenticate. It cannot know that some future field with an innocent name holds something sensitive. An export of an administration inventory contains user emails, host names and internal addresses in any case: treat the output directory as sensitive, and read it before sending it anywhere.
75
+
76
+ ## Honest limits
77
+
78
+ **This tool has never been run against a real NetBird instance.** It has no test deployment and no token behind it. Its test suite runs against example API responses written for those tests, and the shape of those responses follows the upstream project's public REST client and API specification, read on 2026-09-30. If you run it against a real account and something is wrong, open an issue and it will be fixed.
79
+
80
+ It issues one GET per resource and sends nothing else of its own. It does not follow redirects: a redirect is reported as an error rather than followed, because following one would resend your `Authorization` header to whatever host the server pointed at. It never asks the API to change anything, because every request it makes is a GET.
81
+
82
+ It does not paginate: it reads each list endpoint once and writes what came back. The project's own REST client takes no page parameter on these list calls, but if some server version paginates one of them, this tool would write only the first page, and that has not been tested against a real instance.
83
+
84
+ It reads only a whole account: there is no filter by group, by name or by date. An export is the inventory as it stands at the moment of the run.
85
+
86
+ An export is not a restore file. Nothing here writes back, the default set covers four resources out of seven, and a setup key cannot be recreated from an export because its value is deliberately absent. Treat the output as an audit and migration record, not as a backup you could restore from.
87
+
88
+ `policies`, `routes` and `nameservers` are available but not exported by default. The default set is peers, users and setup keys, the three the request above named, plus groups, because a peer row carries group ids and those ids mean nothing without the group names.
89
+
90
+ Python 3.9 or newer.
91
+
92
+ ## Upstream licence
93
+
94
+ NetBird is published by NetBird GmbH and its authors. Its repository is BSD 3-Clause except the `management/`, `signal/`, `relay/` and `combined/` directories, which are GNU AGPL version 3. This package is MIT and is not derived from it: no upstream line is copied here. What was read is the public shape of its HTTP interface, and this tool is an outside client that speaks to that interface over the network.
95
+
96
+ ## Licence
97
+
98
+ MIT, Younes Z. See `LICENSE`.
99
+
100
+ Built with AI assistance, reviewed and tested by me.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "netbirdexport"
7
+ version = "0.1.0"
8
+ description = "Export a NetBird account inventory (peers, users, setup keys, groups) to CSV and JSON in one command, using your own access token. Standard library only."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Younes Z." }]
13
+ keywords = [
14
+ "netbird",
15
+ "wireguard",
16
+ "mesh vpn",
17
+ "inventory",
18
+ "export",
19
+ "csv",
20
+ "audit",
21
+ "backup",
22
+ "self hosted",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Environment :: Console",
27
+ "Intended Audience :: System Administrators",
28
+ "Intended Audience :: Developers",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3",
31
+ "Topic :: System :: Networking",
32
+ "Topic :: System :: Systems Administration",
33
+ "Topic :: Utilities",
34
+ ]
35
+ dependencies = []
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/Rezarys/netbirdexport"
39
+ Issues = "https://github.com/Rezarys/netbirdexport/issues"
40
+
41
+ [project.scripts]
42
+ netbirdexport = "netbirdexport.cli:main"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Export a NetBird account inventory to CSV and JSON in one command."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,186 @@
1
+ """Command line entry point."""
2
+
3
+ import argparse
4
+ import datetime
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .core import (
11
+ DEFAULT_RESOURCES,
12
+ DEFAULT_URL,
13
+ RESOURCES,
14
+ ExportError,
15
+ auth_header,
16
+ export,
17
+ flatten,
18
+ http_fetch,
19
+ to_csv,
20
+ to_json,
21
+ )
22
+
23
+ TOKEN_ENV = "NETBIRD_TOKEN"
24
+
25
+
26
+ def build_parser():
27
+ parser = argparse.ArgumentParser(
28
+ prog="netbirdexport",
29
+ description=(
30
+ "Export a NetBird account inventory to CSV and JSON in one command. "
31
+ "The token is read from the %s environment variable and is never "
32
+ "accepted as an argument, so it does not appear in the argument list of "
33
+ "the running process. It is still in that process's environment, and "
34
+ "typing it at a prompt puts it in your shell history." % TOKEN_ENV
35
+ ),
36
+ )
37
+ parser.add_argument(
38
+ "--url",
39
+ default=DEFAULT_URL,
40
+ help="management API base URL (default: %s)" % DEFAULT_URL,
41
+ )
42
+ parser.add_argument(
43
+ "--out",
44
+ default="netbird-export",
45
+ help="directory to write into, created if missing (default: netbird-export)",
46
+ )
47
+ parser.add_argument(
48
+ "--resources",
49
+ default=",".join(DEFAULT_RESOURCES),
50
+ help=(
51
+ "comma separated list, in output order (default: %s; available: %s)"
52
+ % (",".join(DEFAULT_RESOURCES), ",".join(sorted(RESOURCES)))
53
+ ),
54
+ )
55
+ parser.add_argument(
56
+ "--format",
57
+ default="csv,json",
58
+ help="comma separated, csv and json (default: csv,json)",
59
+ )
60
+ parser.add_argument(
61
+ "--bearer",
62
+ action="store_true",
63
+ help="send the token as Bearer instead of Token, for an OAuth access token",
64
+ )
65
+ parser.add_argument("--version", action="version", version=__version__)
66
+ return parser
67
+
68
+
69
+ def parse_list(raw, allowed, label):
70
+ names = [one.strip() for one in raw.split(",") if one.strip()]
71
+ if not names:
72
+ raise ExportError("--%s got an empty list." % label)
73
+ unknown = [one for one in names if one not in allowed]
74
+ if unknown:
75
+ raise ExportError(
76
+ "unknown %s: %s. Available: %s."
77
+ % (label, ", ".join(unknown), ", ".join(sorted(allowed)))
78
+ )
79
+ ordered = []
80
+ for one in names:
81
+ if one not in ordered:
82
+ ordered.append(one)
83
+ return ordered
84
+
85
+
86
+ def render_report(url, out, written, dropped):
87
+ """The exact text the tool prints on success.
88
+
89
+ Kept free of anything that changes between two identical runs, so that the
90
+ sample in the README can be checked character by character.
91
+ """
92
+ lines = [
93
+ "netbirdexport " + __version__,
94
+ "source " + url,
95
+ "output " + out,
96
+ "",
97
+ ]
98
+ head = ("resource", "rows", "files")
99
+ rows = [(name, str(count), ", ".join(files)) for name, count, files in written]
100
+ first = max([len(head[0])] + [len(row[0]) for row in rows])
101
+ second = max([len(head[1])] + [len(row[1]) for row in rows])
102
+ lines.append("%-*s %*s %s" % (first, head[0], second, head[1], head[2]))
103
+ for name, count, files in rows:
104
+ lines.append("%-*s %*s %s" % (first, name, second, count, files))
105
+ lines.append("")
106
+ if dropped:
107
+ lines.append("never written, matched by field name: " + ", ".join(dropped))
108
+ else:
109
+ lines.append("never written, matched by field name: none found")
110
+ total = sum(len(files) for _, _, files in written) + 1
111
+ lines.append("wrote %d files including manifest.json" % total)
112
+ return "\n".join(lines) + "\n"
113
+
114
+
115
+ def run(argv, fetch=http_fetch, env=None, stdout=None):
116
+ env = os.environ if env is None else env
117
+ stdout = sys.stdout if stdout is None else stdout
118
+ args = build_parser().parse_args(argv)
119
+
120
+ token = env.get(TOKEN_ENV, "").strip()
121
+ if not token:
122
+ raise ExportError(
123
+ "%s is not set. Create a personal access token in the management "
124
+ "dashboard, then export %s=... before running this." % (TOKEN_ENV, TOKEN_ENV)
125
+ )
126
+
127
+ resources = parse_list(args.resources, set(RESOURCES), "resources")
128
+ formats = parse_list(args.format, {"csv", "json"}, "format")
129
+
130
+ result = export(resources, args.url, auth_header(token, args.bearer), fetch=fetch)
131
+
132
+ os.makedirs(args.out, exist_ok=True)
133
+ written = []
134
+ all_dropped = []
135
+ manifest = {
136
+ "tool": "netbirdexport",
137
+ "version": __version__,
138
+ "source": args.url,
139
+ "generated_utc": datetime.datetime.now(datetime.timezone.utc).strftime(
140
+ "%Y-%m-%dT%H:%M:%SZ"
141
+ ),
142
+ "resources": {},
143
+ }
144
+
145
+ for name in resources:
146
+ items, dropped = result[name]
147
+ all_dropped.extend(name + "." + one for one in dropped)
148
+ files = []
149
+ if "csv" in formats:
150
+ rows = [flatten(item) for item in items]
151
+ files.append(name + ".csv")
152
+ write_text(os.path.join(args.out, name + ".csv"), to_csv(rows))
153
+ if "json" in formats:
154
+ files.append(name + ".json")
155
+ write_text(os.path.join(args.out, name + ".json"), to_json(items))
156
+ written.append((name, len(items), files))
157
+ manifest["resources"][name] = {
158
+ "path": RESOURCES[name],
159
+ "rows": len(items),
160
+ "files": files,
161
+ "redacted_fields": dropped,
162
+ }
163
+
164
+ write_text(
165
+ os.path.join(args.out, "manifest.json"),
166
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
167
+ )
168
+ stdout.write(render_report(args.url, args.out, written, all_dropped))
169
+ return 0
170
+
171
+
172
+ def write_text(path, text):
173
+ with open(path, "w", encoding="utf-8", newline="") as handle:
174
+ handle.write(text)
175
+
176
+
177
+ def main(argv=None):
178
+ try:
179
+ return run(sys.argv[1:] if argv is None else argv)
180
+ except ExportError as err:
181
+ sys.stderr.write("netbirdexport: %s\n" % err)
182
+ return 1
183
+
184
+
185
+ if __name__ == "__main__":
186
+ sys.exit(main())
@@ -0,0 +1,218 @@
1
+ """Fetching, redaction and rendering.
2
+
3
+ The HTTP layer is kept behind a single callable so that every other part of this
4
+ package is pure and testable without a network.
5
+ """
6
+
7
+ import csv
8
+ import io
9
+ import json
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ # Resource name as written on the command line, mapped to its API path.
14
+ # Every path here is read from the upstream REST client at
15
+ # shared/management/client/rest/ and from its own documentation links, not
16
+ # guessed from the resource name.
17
+ RESOURCES = {
18
+ "peers": "/api/peers",
19
+ "users": "/api/users",
20
+ "setup-keys": "/api/setup-keys",
21
+ "groups": "/api/groups",
22
+ "policies": "/api/policies",
23
+ "routes": "/api/routes",
24
+ "nameservers": "/api/dns/nameservers",
25
+ }
26
+
27
+ DEFAULT_RESOURCES = ("peers", "users", "setup-keys", "groups")
28
+
29
+ DEFAULT_URL = "https://api.netbird.io"
30
+
31
+ # A field is never written when its own name, lowercased, is listed here or ends
32
+ # with one of REDACT_SUFFIXES. This is a name match, not a classification: see
33
+ # the README for what that does and does not guarantee.
34
+ REDACT_NAMES = frozenset(
35
+ (
36
+ "key",
37
+ "token",
38
+ "secret",
39
+ "password",
40
+ "passphrase",
41
+ "private_key",
42
+ "privatekey",
43
+ "client_secret",
44
+ "api_key",
45
+ "access_token",
46
+ "refresh_token",
47
+ )
48
+ )
49
+
50
+ REDACT_SUFFIXES = ("_secret", "_password", "_token", "_private_key")
51
+
52
+
53
+ class ExportError(Exception):
54
+ """Anything that should end the run with a readable message."""
55
+
56
+
57
+ def is_redacted(name):
58
+ """True when a field with this name must never be written."""
59
+ low = name.lower()
60
+ if low in REDACT_NAMES:
61
+ return True
62
+ return any(low.endswith(suffix) for suffix in REDACT_SUFFIXES)
63
+
64
+
65
+ def redact(value, path=(), dropped=None):
66
+ """Return value with every redacted field removed, structure preserved.
67
+
68
+ Collects the dotted path of each removed field into dropped.
69
+ """
70
+ if dropped is None:
71
+ dropped = set()
72
+ if isinstance(value, dict):
73
+ out = {}
74
+ for name in value:
75
+ here = path + (name,)
76
+ if is_redacted(name):
77
+ dropped.add(".".join(here))
78
+ continue
79
+ out[name] = redact(value[name], here, dropped)
80
+ return out
81
+ if isinstance(value, list):
82
+ return [redact(item, path, dropped) for item in value]
83
+ return value
84
+
85
+
86
+ def flatten(value, prefix=""):
87
+ """Flatten one already redacted object into a single level of scalars.
88
+
89
+ A nested object becomes parent.child. Every list gets a <field>_count,
90
+ whatever it holds and even when it is empty, so that a column never appears
91
+ or disappears just because a list happened to be empty on one run. A list of
92
+ values also gets <field> with the values joined by a vertical bar; a list of
93
+ objects also gets <field>_ids when every object carries a string id.
94
+ """
95
+ flat = {}
96
+ if isinstance(value, dict):
97
+ for name in value:
98
+ key = prefix + "." + name if prefix else name
99
+ flat.update(flatten(value[name], key))
100
+ return flat
101
+ if isinstance(value, list):
102
+ flat[prefix + "_count"] = len(value)
103
+ if not value:
104
+ return flat
105
+ if all(isinstance(item, dict) for item in value):
106
+ ids = [item.get("id") for item in value]
107
+ if all(isinstance(one, str) for one in ids):
108
+ flat[prefix + "_ids"] = "|".join(ids)
109
+ return flat
110
+ flat[prefix] = "|".join("" if item is None else str(item) for item in value)
111
+ return flat
112
+ if isinstance(value, bool):
113
+ flat[prefix] = "true" if value else "false"
114
+ return flat
115
+ flat[prefix] = "" if value is None else value
116
+ return flat
117
+
118
+
119
+ def columns_of(rows):
120
+ """The union of the keys of every row, id and name first, then sorted."""
121
+ seen = set()
122
+ for row in rows:
123
+ seen.update(row)
124
+ lead = [name for name in ("id", "name", "email") if name in seen]
125
+ rest = sorted(name for name in seen if name not in lead)
126
+ return lead + rest
127
+
128
+
129
+ def to_csv(rows):
130
+ """Render rows as CSV text with a header line, newline terminated."""
131
+ cols = columns_of(rows)
132
+ buf = io.StringIO()
133
+ writer = csv.DictWriter(buf, fieldnames=cols, lineterminator="\n", restval="")
134
+ writer.writeheader()
135
+ for row in rows:
136
+ writer.writerow(row)
137
+ return buf.getvalue()
138
+
139
+
140
+ def to_json(items):
141
+ """Render already redacted items as indented JSON text."""
142
+ return json.dumps(items, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
143
+
144
+
145
+ def auth_header(token, bearer=False):
146
+ """The Authorization header value.
147
+
148
+ Read from the upstream REST client: a personal access token created in the
149
+ management dashboard is sent as Token, an OAuth access token as Bearer.
150
+ """
151
+ return ("Bearer " if bearer else "Token ") + token
152
+
153
+
154
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
155
+ """Refuses every redirect instead of following it.
156
+
157
+ The default opener would follow up to ten redirects and, doing so, would
158
+ copy the Authorization header onto the target, which can be another host.
159
+ An administration token is not something to hand to whatever a server
160
+ points at, so a redirect is an error here and says so.
161
+ """
162
+
163
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
164
+ raise ExportError(
165
+ "%s answered HTTP %s, a redirect to %s. This tool does not follow "
166
+ "redirects, because following one would send your token to the "
167
+ "target. Point --url at the final address instead."
168
+ % (req.selector, code, newurl)
169
+ )
170
+
171
+
172
+ _OPENER = urllib.request.build_opener(_NoRedirect)
173
+
174
+
175
+ def http_fetch(url, path, header, timeout=30):
176
+ """GET one API path and decode its JSON body.
177
+
178
+ This is the only function in the package that touches the network. It sends
179
+ exactly one GET per call, never follows a redirect, and sends the token only
180
+ in the Authorization header of that one request.
181
+ """
182
+ request = urllib.request.Request(
183
+ url.rstrip("/") + path,
184
+ headers={"Authorization": header, "Accept": "application/json"},
185
+ method="GET",
186
+ )
187
+ try:
188
+ with _OPENER.open(request, timeout=timeout) as response:
189
+ raw = response.read()
190
+ except urllib.error.HTTPError as err:
191
+ raise ExportError(
192
+ "%s returned HTTP %s. A 401 or 403 usually means the token is wrong "
193
+ "or lacks the admin role." % (path, err.code)
194
+ )
195
+ except urllib.error.URLError as err:
196
+ raise ExportError("%s could not be reached: %s" % (path, err.reason))
197
+ try:
198
+ body = json.loads(raw.decode("utf-8"))
199
+ except (UnicodeDecodeError, ValueError):
200
+ raise ExportError("%s did not return JSON." % path)
201
+ if not isinstance(body, list):
202
+ raise ExportError("%s did not return a list of objects." % path)
203
+ return body
204
+
205
+
206
+ def export(resources, url, header, fetch=http_fetch):
207
+ """Fetch and redact each resource.
208
+
209
+ Returns a mapping of resource name to a pair of the redacted items and the
210
+ sorted dotted paths of the fields that were dropped from them.
211
+ """
212
+ result = {}
213
+ for name in resources:
214
+ items = fetch(url, RESOURCES[name], header)
215
+ dropped = set()
216
+ clean = [redact(item, (), dropped) for item in items]
217
+ result[name] = (clean, sorted(dropped))
218
+ return result
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: netbirdexport
3
+ Version: 0.1.0
4
+ Summary: Export a NetBird account inventory (peers, users, setup keys, groups) to CSV and JSON in one command, using your own access token. Standard library only.
5
+ Author: Younes Z.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Rezarys/netbirdexport
8
+ Project-URL: Issues, https://github.com/Rezarys/netbirdexport/issues
9
+ Keywords: netbird,wireguard,mesh vpn,inventory,export,csv,audit,backup,self hosted
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: System :: Networking
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # netbirdexport
25
+
26
+ ```
27
+ pip install netbirdexport
28
+ ```
29
+
30
+ Export your NetBird account inventory to CSV and JSON in one command, using your own access token. Peers, users, setup keys and groups, one CSV and one JSON file each, plus a manifest. Standard library only, no dependencies.
31
+
32
+ Not affiliated with NetBird. "NetBird" is used here only to say which API this tool reads.
33
+
34
+ ## Why this exists
35
+
36
+ In [netbirdio/netbird#4437](https://github.com/netbirdio/netbird/issues/4437), open since September 2025, `chirag-rastogi` wrote:
37
+
38
+ > Currently, there's no way to download or back up information like the list of users, peers, or setup keys.
39
+
40
+ The management API already exposes every one of those objects: `/api/users`, `/api/peers` and `/api/setup-keys` all exist and are read by the project's own REST client. The missing part was the one command that reads them and writes them out for an audit or a migration. That is all this tool is.
41
+
42
+ ## Use
43
+
44
+ Create a personal access token in the management dashboard, then:
45
+
46
+ ```
47
+ export NETBIRD_TOKEN=nbp_xxxxxxxxxxxxxxxx
48
+ netbirdexport
49
+ ```
50
+
51
+ ```
52
+ netbirdexport 0.1.0
53
+ source https://api.netbird.io
54
+ output netbird-export
55
+
56
+ resource rows files
57
+ peers 2 peers.csv, peers.json
58
+ users 2 users.csv, users.json
59
+ setup-keys 1 setup-keys.csv, setup-keys.json
60
+ groups 2 groups.csv, groups.json
61
+
62
+ never written, matched by field name: setup-keys.key
63
+ wrote 9 files including manifest.json
64
+ ```
65
+
66
+ The token is read from `NETBIRD_TOKEN` and is never accepted as a command line argument, so it does not appear in the argument list of the running process. It is still in that process's environment, and typing `export NETBIRD_TOKEN=...` at a prompt does put it in your shell history, so read it from a secret store or from a file you control if that matters to you.
67
+
68
+ Self hosted, and more resources:
69
+
70
+ ```
71
+ netbirdexport --url https://netbird.example.org --resources peers,users,setup-keys,groups,policies,routes,nameservers --out audit-2026-09
72
+ ```
73
+
74
+ Options:
75
+
76
+ - `--url` management API base URL, default `https://api.netbird.io`.
77
+ - `--out` directory to write into, created if missing, default `netbird-export`.
78
+ - `--resources` comma separated, in output order. Available: `peers`, `users`, `setup-keys`, `groups`, `policies`, `routes`, `nameservers`.
79
+ - `--format` comma separated, `csv` and `json`, default both.
80
+ - `--bearer` send the token as `Bearer` instead of `Token`, for an OAuth access token rather than a personal access token.
81
+ - `--version` print the version and exit.
82
+
83
+ Nothing is written until every request has succeeded, so a failure halfway through leaves no half exported directory behind.
84
+
85
+ ## What the two formats are for
86
+
87
+ `<resource>.json` keeps the nested shape the API returned, which is what you want for a diff between two dates. `<resource>.csv` flattens the same objects to one row each: a nested object becomes a `parent.child` column, and every list gets a `<field>_count` column, whatever it holds and even when it is empty, so that a column never appears or disappears just because a list happened to be empty on one run. A list of values also gets `<field>` with the values joined by a vertical bar; a list of objects also gets `<field>_ids` when every object in the list carries a string `id`. Columns are the union of the keys seen across all rows, so a field that only some objects carry still gets a column.
88
+
89
+ Columns are not a fixed list. This tool writes whatever the API returns, minus the redacted fields below, which means it does not silently drop a field that a newer server version added.
90
+
91
+ `manifest.json` records the tool version, the source URL, the UTC time of the run, and for each resource its API path, its row count and the names of the fields that were dropped.
92
+
93
+ ## Secrets: what is dropped, and what that does not promise
94
+
95
+ A field is never written when its own name, lowercased, is one of `key`, `token`, `secret`, `password`, `passphrase`, `private_key`, `privatekey`, `client_secret`, `api_key`, `access_token`, `refresh_token`, or ends with `_secret`, `_password`, `_token` or `_private_key`. This applies at every depth, inside nested objects and inside lists. Every dropped field is named on the console and in `manifest.json`, so nothing is removed silently.
96
+
97
+ Read this next part before you share an export. **This is a match on field names, not a classification of content.** It drops the setup key field, which is named `key`, and it deliberately keeps fields like `setup_key_id` or `public_key`, which identify rather than authenticate. It cannot know that some future field with an innocent name holds something sensitive. An export of an administration inventory contains user emails, host names and internal addresses in any case: treat the output directory as sensitive, and read it before sending it anywhere.
98
+
99
+ ## Honest limits
100
+
101
+ **This tool has never been run against a real NetBird instance.** It has no test deployment and no token behind it. Its test suite runs against example API responses written for those tests, and the shape of those responses follows the upstream project's public REST client and API specification, read on 2026-09-30. If you run it against a real account and something is wrong, open an issue and it will be fixed.
102
+
103
+ It issues one GET per resource and sends nothing else of its own. It does not follow redirects: a redirect is reported as an error rather than followed, because following one would resend your `Authorization` header to whatever host the server pointed at. It never asks the API to change anything, because every request it makes is a GET.
104
+
105
+ It does not paginate: it reads each list endpoint once and writes what came back. The project's own REST client takes no page parameter on these list calls, but if some server version paginates one of them, this tool would write only the first page, and that has not been tested against a real instance.
106
+
107
+ It reads only a whole account: there is no filter by group, by name or by date. An export is the inventory as it stands at the moment of the run.
108
+
109
+ An export is not a restore file. Nothing here writes back, the default set covers four resources out of seven, and a setup key cannot be recreated from an export because its value is deliberately absent. Treat the output as an audit and migration record, not as a backup you could restore from.
110
+
111
+ `policies`, `routes` and `nameservers` are available but not exported by default. The default set is peers, users and setup keys, the three the request above named, plus groups, because a peer row carries group ids and those ids mean nothing without the group names.
112
+
113
+ Python 3.9 or newer.
114
+
115
+ ## Upstream licence
116
+
117
+ NetBird is published by NetBird GmbH and its authors. Its repository is BSD 3-Clause except the `management/`, `signal/`, `relay/` and `combined/` directories, which are GNU AGPL version 3. This package is MIT and is not derived from it: no upstream line is copied here. What was read is the public shape of its HTTP interface, and this tool is an outside client that speaks to that interface over the network.
118
+
119
+ ## Licence
120
+
121
+ MIT, Younes Z. See `LICENSE`.
122
+
123
+ Built with AI assistance, reviewed and tested by me.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/netbirdexport/__init__.py
5
+ src/netbirdexport/__main__.py
6
+ src/netbirdexport/cli.py
7
+ src/netbirdexport/core.py
8
+ src/netbirdexport.egg-info/PKG-INFO
9
+ src/netbirdexport.egg-info/SOURCES.txt
10
+ src/netbirdexport.egg-info/dependency_links.txt
11
+ src/netbirdexport.egg-info/entry_points.txt
12
+ src/netbirdexport.egg-info/top_level.txt
13
+ tests/test_cli.py
14
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ netbirdexport = netbirdexport.cli:main
@@ -0,0 +1 @@
1
+ netbirdexport
@@ -0,0 +1,166 @@
1
+ import csv
2
+ import io
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ import unittest
8
+
9
+ from fixtures import fake_fetch
10
+ from netbirdexport.cli import TOKEN_ENV, run
11
+ from netbirdexport.core import ExportError
12
+
13
+ # The exact text the tool prints, also recopied by hand into the README. The
14
+ # test below compares this constant to the tool's real output, so the tool
15
+ # cannot change its output without turning this suite red. It does not read
16
+ # README.md, so keeping the README in step with this constant stays a manual
17
+ # step; what the suite guarantees is that this constant is not stale.
18
+ README_SAMPLE = """netbirdexport 0.1.0
19
+ source https://api.netbird.io
20
+ output netbird-export
21
+
22
+ resource rows files
23
+ peers 2 peers.csv, peers.json
24
+ users 2 users.csv, users.json
25
+ setup-keys 1 setup-keys.csv, setup-keys.json
26
+ groups 2 groups.csv, groups.json
27
+
28
+ never written, matched by field name: setup-keys.key
29
+ wrote 9 files including manifest.json
30
+ """
31
+
32
+
33
+ class CliTest(unittest.TestCase):
34
+ def setUp(self):
35
+ self.tmp = tempfile.mkdtemp()
36
+ self.out = os.path.join(self.tmp, "netbird-export")
37
+ self.env = {TOKEN_ENV: "pat-example"}
38
+
39
+ def tearDown(self):
40
+ shutil.rmtree(self.tmp, ignore_errors=True)
41
+
42
+ def invoke(self, *extra):
43
+ stdout = io.StringIO()
44
+ code = run(
45
+ ["--out", self.out] + list(extra),
46
+ fetch=fake_fetch,
47
+ env=self.env,
48
+ stdout=stdout,
49
+ )
50
+ return code, stdout.getvalue()
51
+
52
+ def read(self, name):
53
+ with open(os.path.join(self.out, name), encoding="utf-8") as handle:
54
+ return handle.read()
55
+
56
+ def test_the_whole_rendering_is_the_one_shown_in_the_readme(self):
57
+ stdout = io.StringIO()
58
+ code = run([], fetch=fake_fetch, env=self.env, stdout=stdout)
59
+ try:
60
+ self.assertEqual(0, code)
61
+ self.assertEqual(README_SAMPLE, stdout.getvalue())
62
+ finally:
63
+ shutil.rmtree("netbird-export", ignore_errors=True)
64
+
65
+ def test_it_writes_one_csv_and_one_json_per_resource_plus_a_manifest(self):
66
+ code, _ = self.invoke()
67
+ self.assertEqual(0, code)
68
+ self.assertEqual(
69
+ sorted(
70
+ [
71
+ "groups.csv",
72
+ "groups.json",
73
+ "manifest.json",
74
+ "peers.csv",
75
+ "peers.json",
76
+ "setup-keys.csv",
77
+ "setup-keys.json",
78
+ "users.csv",
79
+ "users.json",
80
+ ]
81
+ ),
82
+ sorted(os.listdir(self.out)),
83
+ )
84
+
85
+ def test_the_setup_key_value_is_in_no_written_byte_anywhere(self):
86
+ self.invoke()
87
+ for name in os.listdir(self.out):
88
+ self.assertNotIn("A616097E", self.read(name), name)
89
+
90
+ def test_the_manifest_names_the_fields_it_dropped(self):
91
+ self.invoke()
92
+ manifest = json.loads(self.read("manifest.json"))
93
+ self.assertEqual(["key"], manifest["resources"]["setup-keys"]["redacted_fields"])
94
+ self.assertEqual([], manifest["resources"]["peers"]["redacted_fields"])
95
+
96
+ def test_the_manifest_records_the_source_and_the_row_counts(self):
97
+ self.invoke("--url", "https://nb.example.org")
98
+ manifest = json.loads(self.read("manifest.json"))
99
+ self.assertEqual("https://nb.example.org", manifest["source"])
100
+ self.assertEqual(2, manifest["resources"]["peers"]["rows"])
101
+ self.assertEqual("/api/peers", manifest["resources"]["peers"]["path"])
102
+
103
+ def test_the_csv_has_a_header_and_one_line_per_object(self):
104
+ self.invoke()
105
+ rows = list(csv.DictReader(io.StringIO(self.read("peers.csv"))))
106
+ self.assertEqual(2, len(rows))
107
+ self.assertEqual("laptop-01", rows[0]["name"])
108
+ self.assertEqual("100.72.0.2", rows[1]["ip"])
109
+
110
+ def test_the_json_keeps_the_nested_shape_the_api_returned(self):
111
+ self.invoke()
112
+ peers = json.loads(self.read("peers.json"))
113
+ self.assertEqual("All", peers[0]["groups"][0]["name"])
114
+
115
+ def test_only_the_requested_resources_are_fetched_and_in_that_order(self):
116
+ code, text = self.invoke("--resources", "users,peers")
117
+ self.assertEqual(0, code)
118
+ self.assertEqual(["manifest.json", "peers.csv", "peers.json", "users.csv", "users.json"], sorted(os.listdir(self.out)))
119
+ self.assertLess(text.index("users "), text.index("peers "))
120
+
121
+ def test_asking_for_csv_only_writes_no_json_except_the_manifest(self):
122
+ self.invoke("--format", "csv")
123
+ self.assertEqual(
124
+ ["groups.csv", "manifest.json", "peers.csv", "setup-keys.csv", "users.csv"],
125
+ sorted(os.listdir(self.out)),
126
+ )
127
+
128
+ def test_a_repeated_resource_is_exported_once(self):
129
+ _, text = self.invoke("--resources", "peers,peers")
130
+ self.assertEqual(1, text.count("peers.csv"))
131
+
132
+ def test_a_missing_token_is_an_error_that_names_the_variable(self):
133
+ self.env = {}
134
+ with self.assertRaises(ExportError) as caught:
135
+ self.invoke()
136
+ self.assertIn(TOKEN_ENV, str(caught.exception))
137
+
138
+ def test_a_blank_token_counts_as_missing(self):
139
+ self.env = {TOKEN_ENV: " "}
140
+ with self.assertRaises(ExportError):
141
+ self.invoke()
142
+
143
+ def test_an_unknown_resource_is_refused_and_the_list_is_shown(self):
144
+ with self.assertRaises(ExportError) as caught:
145
+ self.invoke("--resources", "peers,widgets")
146
+ self.assertIn("widgets", str(caught.exception))
147
+ self.assertIn("setup-keys", str(caught.exception))
148
+
149
+ def test_an_unknown_format_is_refused(self):
150
+ with self.assertRaises(ExportError):
151
+ self.invoke("--format", "xlsx")
152
+
153
+ def test_nothing_is_written_before_every_fetch_has_succeeded(self):
154
+ def failing(url, path, header, timeout=30):
155
+ if path == "/api/groups":
156
+ raise ExportError("boom")
157
+ return fake_fetch(url, path, header)
158
+
159
+ stdout = io.StringIO()
160
+ with self.assertRaises(ExportError):
161
+ run(["--out", self.out], fetch=failing, env=self.env, stdout=stdout)
162
+ self.assertFalse(os.path.exists(self.out))
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
@@ -0,0 +1,142 @@
1
+ import unittest
2
+ import urllib.request
3
+
4
+ from fixtures import GROUPS, PEERS, SETUP_KEYS, USERS
5
+ from netbirdexport.core import (
6
+ ExportError,
7
+ _NoRedirect,
8
+ auth_header,
9
+ columns_of,
10
+ flatten,
11
+ is_redacted,
12
+ redact,
13
+ to_csv,
14
+ )
15
+
16
+
17
+ class RedactionTest(unittest.TestCase):
18
+ def test_a_field_named_key_is_redacted_and_reported(self):
19
+ dropped = set()
20
+ clean = redact(SETUP_KEYS[0], (), dropped)
21
+ self.assertNotIn("key", clean)
22
+ self.assertEqual({"key"}, dropped)
23
+
24
+ def test_every_other_setup_key_field_survives(self):
25
+ clean = redact(SETUP_KEYS[0], (), set())
26
+ expected = set(SETUP_KEYS[0]) - {"key"}
27
+ self.assertEqual(expected, set(clean))
28
+
29
+ def test_the_name_match_is_case_insensitive_and_covers_suffixes(self):
30
+ for name in ("Key", "TOKEN", "client_secret", "db_password", "x_private_key"):
31
+ self.assertTrue(is_redacted(name), name)
32
+
33
+ def test_an_identifier_that_merely_mentions_a_key_is_kept(self):
34
+ for name in ("setup_key_id", "public_key", "keys_count", "monkey"):
35
+ self.assertFalse(is_redacted(name), name)
36
+
37
+ def test_redaction_reaches_inside_nested_objects_and_lists(self):
38
+ dropped = set()
39
+ clean = redact(
40
+ {"a": {"token": "t", "b": 1}, "c": [{"secret": "s", "d": 2}]}, (), dropped
41
+ )
42
+ self.assertEqual({"a": {"b": 1}, "c": [{"d": 2}]}, clean)
43
+ self.assertEqual({"a.token", "c.secret"}, dropped)
44
+
45
+ def test_redaction_does_not_mutate_its_input(self):
46
+ redact(SETUP_KEYS[0], (), set())
47
+ self.assertIn("key", SETUP_KEYS[0])
48
+
49
+
50
+ class FlattenTest(unittest.TestCase):
51
+ def test_a_list_of_objects_becomes_a_count_and_the_joined_ids(self):
52
+ flat = flatten(PEERS[0])
53
+ self.assertEqual(2, flat["groups_count"])
54
+ self.assertEqual("ch8i4ug6lnn4g9hqv7m1|ch8i4ug6lnn4g9hqv7m2", flat["groups_ids"])
55
+
56
+ def test_an_empty_list_of_objects_is_a_zero_count_not_a_missing_column(self):
57
+ self.assertEqual({"auto_groups_count": 0}, flatten({"auto_groups": []}))
58
+
59
+ def test_a_list_of_scalars_is_joined_with_a_bar_and_still_counted(self):
60
+ self.assertEqual({"g": "a|b", "g_count": 2}, flatten({"g": ["a", "b"]}))
61
+
62
+ def test_every_list_gets_a_count_whether_it_holds_values_or_objects(self):
63
+ # Otherwise a column would appear or disappear between two runs only
64
+ # because a list happened to be empty, which reads as a server change.
65
+ for value in ([], ["a"], [{"id": "x"}]):
66
+ self.assertIn("g_count", flatten({"g": value}), value)
67
+
68
+ def test_booleans_are_written_as_words_not_as_python_repr(self):
69
+ flat = flatten(PEERS[0])
70
+ self.assertEqual("true", flat["connected"])
71
+ self.assertEqual("false", flat["ssh_enabled"])
72
+
73
+ def test_a_nested_object_is_dotted(self):
74
+ self.assertEqual({"a.b": 1}, flatten({"a": {"b": 1}}))
75
+
76
+ def test_none_becomes_an_empty_cell(self):
77
+ self.assertEqual({"a": ""}, flatten({"a": None}))
78
+
79
+ def test_no_flattened_column_of_a_peer_is_a_container(self):
80
+ for key, value in flatten(PEERS[1]).items():
81
+ self.assertNotIsInstance(value, (dict, list), key)
82
+
83
+
84
+ class ColumnsTest(unittest.TestCase):
85
+ def test_identifying_columns_come_first_then_the_rest_sorted(self):
86
+ cols = columns_of([{"z": 1, "name": 2, "id": 3, "a": 4}])
87
+ self.assertEqual(["id", "name", "a", "z"], cols)
88
+
89
+ def test_columns_are_the_union_over_rows_so_a_sparse_row_is_not_dropped(self):
90
+ self.assertEqual(["id", "a", "b"], columns_of([{"id": 1, "a": 2}, {"b": 3}]))
91
+
92
+ def test_a_missing_value_is_written_as_an_empty_cell(self):
93
+ text = to_csv([{"id": "1", "a": "x"}, {"id": "2"}])
94
+ self.assertEqual("id,a\n1,x\n2,\n", text)
95
+
96
+
97
+ class CsvTest(unittest.TestCase):
98
+ def test_the_header_is_present_even_with_no_rows(self):
99
+ self.assertEqual("\n", to_csv([]))
100
+
101
+ def test_a_group_renders_its_member_ids_in_one_cell(self):
102
+ rows = [flatten(redact(item, (), set())) for item in GROUPS]
103
+ text = to_csv(rows)
104
+ self.assertIn("ch8i4ug6lnn4g9hqv7m0|ch8i4ug6lnn4g9hqv7m5", text)
105
+
106
+ def test_a_user_email_is_exported_because_it_identifies_not_authenticates(self):
107
+ rows = [flatten(redact(item, (), set())) for item in USERS]
108
+ self.assertIn("ada@example.org", to_csv(rows))
109
+
110
+
111
+ class RedirectTest(unittest.TestCase):
112
+ def test_a_redirect_is_refused_rather_than_followed(self):
113
+ # Following one would resend the Authorization header to the target,
114
+ # which the standard library opener does by default and this one must not.
115
+ request = urllib.request.Request("https://nb.example.org/api/peers")
116
+ with self.assertRaises(ExportError) as caught:
117
+ _NoRedirect().redirect_request(
118
+ request, None, 302, "Found", {}, "https://elsewhere.example/api/peers"
119
+ )
120
+ self.assertIn("elsewhere.example", str(caught.exception))
121
+
122
+ def test_the_opener_used_for_fetching_carries_no_redirect_handler(self):
123
+ from netbirdexport.core import _OPENER
124
+
125
+ followers = [
126
+ handler
127
+ for handler in _OPENER.handlers
128
+ if type(handler) is urllib.request.HTTPRedirectHandler
129
+ ]
130
+ self.assertEqual([], followers)
131
+
132
+
133
+ class AuthHeaderTest(unittest.TestCase):
134
+ def test_a_personal_access_token_is_sent_as_token(self):
135
+ self.assertEqual("Token abc", auth_header("abc"))
136
+
137
+ def test_an_oauth_access_token_is_sent_as_bearer(self):
138
+ self.assertEqual("Bearer abc", auth_header("abc", bearer=True))
139
+
140
+
141
+ if __name__ == "__main__":
142
+ unittest.main()