├── tomato-nvram.sln
├── config.ini
├── tomato-nvram.pyproj
├── example-output.sh
├── readme.md
└── tomato-nvram.py
/tomato-nvram.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 16
4 | VisualStudioVersion = 16.0.28531.58
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "tomato-nvram", "tomato-nvram.pyproj", "{9A97D858-F246-437E-BCA8-5337449D4062}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Release|Any CPU = Release|Any CPU
12 | EndGlobalSection
13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
14 | {9A97D858-F246-437E-BCA8-5337449D4062}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15 | {9A97D858-F246-437E-BCA8-5337449D4062}.Release|Any CPU.ActiveCfg = Release|Any CPU
16 | EndGlobalSection
17 | GlobalSection(SolutionProperties) = preSolution
18 | HideSolutionNode = FALSE
19 | EndGlobalSection
20 | GlobalSection(ExtensibilityGlobals) = postSolution
21 | SolutionGuid = {B59BBB42-AE37-40EB-89B4-AC4C64DB6702}
22 | EndGlobalSection
23 | EndGlobal
24 |
--------------------------------------------------------------------------------
/config.ini:
--------------------------------------------------------------------------------
1 | [Name]
2 | pattern=router_name|[wl]an_hostname|wan_domain
3 |
4 | [WAN]
5 | pattern=wan_(?!qos)
6 |
7 | [WAN #2]
8 | pattern=wan2_
9 |
10 | [WAN #3]
11 | pattern=wan3_
12 |
13 | [WAN #4]
14 | pattern=wan4_
15 |
16 | [IPv6]
17 | pattern=ipv6_
18 |
19 | [LAN]
20 | pattern=lan|jumbo
21 |
22 | [Wireless]
23 | pattern=wl_
24 |
25 | [Wireless (2.4 GHz)]
26 | pattern=wl0_|0:
27 |
28 | [Wireless (5 GHz)]
29 | pattern=wl1_|1:
30 |
31 | [Wireless (5 GHz #2)]
32 | pattern=wl2_|2:
33 |
34 | [Admin Access]
35 | pattern=http_|https_|crt_|web_|telnetd_
36 |
37 | [Port Forward]
38 | pattern=\w*forward$
39 |
40 | [UPnP]
41 | pattern=upnp
42 |
43 | [DHCP]
44 | pattern=dhcp
45 |
46 | [Time]
47 | pattern=tm_|ntp_
48 |
49 | [Logging]
50 | pattern=log_
51 |
52 | [DNS]
53 | pattern=dns
54 |
55 | [DDNS]
56 | pattern=ddns
57 |
58 | [TomatoAnon]
59 | pattern=tomatoanon_
60 |
61 | [Scripts]
62 | pattern=script
63 |
64 | [QOS]
65 | pattern=qos_|wan_qos_|new_qos
66 |
67 | [FTP]
68 | pattern=ftp_
69 |
70 | [Nginx]
71 | pattern=nginx_
72 |
73 | [BitTorrent]
74 | pattern=bt_
75 |
76 | [SSH Keys]
77 | pattern=sshd_\w+key
78 |
79 | [OpenVPN]
80 | pattern=$
81 |
82 | [OpenVPN Client]
83 | pattern=vpn_client1_
84 |
85 | [OpenVPN Client #2]
86 | pattern=vpn_client2_
87 |
88 | [OpenVPN Client #3]
89 | pattern=vpn_client3_
90 |
91 | [OpenVPN Server]
92 | pattern=vpn_server1_
93 |
94 | [OpenVPN Server #2]
95 | pattern=vpn_server2_
96 |
97 | [PPTP VPN Client]
98 | pattern=pptp_client_
99 |
100 | [PPTP VPN Server]
101 | pattern=pptp_server_
102 |
103 | [Tinc Daemon]
104 | pattern=tinc_
105 |
--------------------------------------------------------------------------------
/tomato-nvram.pyproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | Debug
4 | 2.0
5 | 9a97d858-f246-437e-bca8-5337449d4062
6 | .
7 | tomato-nvram.py
8 |
9 |
10 | .
11 | .
12 | tomato-nvram
13 | tomato-nvram
14 |
15 |
16 | true
17 | false
18 |
19 |
20 | true
21 | false
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/example-output.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Name
4 | nvram set lan_hostname=example
5 | nvram set router_name=Example
6 | nvram set wan_domain=example.com
7 | nvram set wan_hostname=example
8 |
9 | # LAN
10 | nvram set lan_ipaddr=192.168.123.1
11 |
12 | # Wireless
13 | for wl in wl0 wl1 wl2
14 | do
15 | nvram set ${wl}_akm=psk2
16 | nvram set ${wl}_antdiv=3
17 | nvram set ${wl}_country=US
18 | nvram set ${wl}_country_code=US
19 | nvram set ${wl}_security_mode=wpa2_personal
20 | nvram set ${wl}_ssid='Example'
21 | nvram set ${wl}_wpa_psk='redacted'
22 | done
23 |
24 | # Wireless (2.4 GHz)
25 | nvram set wl0_bw_cap=1
26 | nvram set wl0_channel=1
27 | nvram set wl0_chanspec=1
28 | nvram set wl0_nbw=20
29 | nvram set wl0_nbw_cap=0
30 | nvram set wl0_nctrlsb=lower
31 |
32 | # Wireless (5 GHz)
33 | nvram set wl1_channel=132
34 | nvram set wl1_chanspec=132/80
35 |
36 | # Wireless (5 GHz #2)
37 | nvram set wl2_channel=52
38 | nvram set wl2_chanspec=52/80
39 | nvram set wl2_nctrlsb=lower
40 |
41 | # Admin Access
42 | nvram set crt_ver=1
43 | nvram set http_lanport=8080
44 | nvram set http_passwd='redacted'
45 | nvram set web_css=red
46 | nvram set web_mx=status,tools
47 |
48 | # Port Forward
49 | nvram set portforward="\
50 | 1<1<<32400<<192.168.123.234\
51 | 0<1<<3389<<192.168.123.123"
52 |
53 | # UPnP
54 | nvram set upnp_clean=0
55 | nvram set upnp_enable=1
56 | nvram set upnp_lan=1
57 | nvram set upnp_lan1=0
58 | nvram set upnp_lan2=0
59 | nvram set upnp_lan3=0
60 |
61 | # DHCP
62 | nvram set dhcp_lease=23
63 | nvram set dhcp_num=64
64 | nvram set dhcp_start=128
65 | nvram set dhcpd_endip=192.168.123.191
66 | nvram set dhcpd_startip=192.168.123.128
67 | nvram set dhcpd_static="\
68 | 18:B4:30:00:00:03<192.168.123.100\
69 | 18:B4:30:00:00:04<192.168.123.101\
70 | 18:B4:30:00:00:05<192.168.123.102\
71 | 18:B4:30:00:00:06<192.168.123.103\
72 | 18:B4:30:00:00:0C<192.168.123.104\
73 | 18:B4:30:00:00:05<192.168.123.105"
74 |
75 | # Time
76 | nvram set ntp_server='0.us.pool.ntp.org 1.us.pool.ntp.org 2.us.pool.ntp.org'
77 | nvram set tm_sel=CST6CDT,M3.2.0/2,M11.1.0/2
78 | nvram set tm_tz=CST6CDT,M3.2.0/2,M11.1.0/2
79 |
80 | # Logging
81 | nvram set log_mark=0
82 |
83 | # TomatoAnon
84 | nvram set tomatoanon_answer=1
85 | nvram set tomatoanon_enable=1
86 | nvram set tomatoanon_id=0123456789
87 |
88 | # Save
89 | nvram commit
90 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # tomato-nvram
2 |
3 | Find the tomato settings changed. Pretty-print the output.
4 |
5 | Takes the **current nvram** dump, `nvram.txt`:
6 |
7 | ```
8 | ...
9 | wl2_rateset=default
10 | wl1_txpwr=0
11 | wl1_nmcsidx=-1
12 | tor_iface=br0
13 | mysql_net_buffer_length=2
14 | webmon_bkp=0
15 | wl_macaddr=
16 | wl1_bsd_if_select_policy=eth2 eth3
17 | lan_route=
18 | wl1_rx_amsdu_in_ampdu=off
19 | wl0_mrate=0
20 | wl1_channel=132
21 | mysql_binary=internal
22 | nginx_priority=10
23 | wan3_modem_band=7FFFFFFFFFFFFFFF
24 | wan3_proto=dhcp
25 | qos_inuse=511
26 | wan3_get_dns=
27 | ...
28 | ```
29 |
30 | Compares it against an **nvram** dump of the **defaults**, `defaults.txt`:
31 | ```
32 | ...
33 | lan_route=
34 | wl1_bsd_if_select_policy=eth2 eth3
35 | wl_macaddr=
36 | webmon_bkp=0
37 | mysql_net_buffer_length=2
38 | tor_iface=br0
39 | wl1_nmcsidx=-1
40 | wl1_txpwr=0
41 | wl2_rateset=default
42 | wan3_proto=dhcp
43 | wan3_modem_band=7FFFFFFFFFFFFFFF
44 | nginx_priority=10
45 | mysql_binary=internal
46 | wl1_channel=100
47 | wl0_mrate=0
48 | wl1_rx_amsdu_in_ampdu=off
49 | wan3_get_dns=
50 | ...
51 | ```
52 |
53 | **Generates** a readable shell script from the **difference**, `set-nvram.sh`:
54 | ```
55 | ...
56 |
57 | # LAN
58 | nvram set lan_ipaddr=192.168.123.1
59 |
60 | # Wireless (2.4 GHz)
61 | nvram set wl0_bw_cap=1
62 | nvram set wl0_channel=1
63 | nvram set wl0_chanspec=1
64 | nvram set wl0_nbw=20
65 | nvram set wl0_nbw_cap=0
66 | nvram set wl0_nctrlsb=lower
67 |
68 | # Wireless (5 GHz)
69 | nvram set wl1_channel=132
70 | nvram set wl1_chanspec=132/80
71 | nvram set wl1_radio=0
72 | ...
73 | ```
74 |
75 | - - -
76 |
77 | ## Use
78 |
79 | Requires: [Python 3.3+](https://www.python.org/downloads/)
80 |
81 | **Save** the current settings as **`nvram.txt`**, from _Administration→Debugging→Download NVRAM Dump_ in the Tomato web UI, in the same directory as `tomato-nvram.py`.
82 |
83 | **Reset** the router's NVRAM. Try to ensure that *all* the default settings have been set. This is how I do it:
84 | * Erase all data in NVRAM. Wait for the router to boot.
85 | * Reboot (because on my RT-AC66U, the 5 GHz radio doesn't show up otherwise).
86 | * Click Save without changing anything on at least these sections:
87 | * _Basic→Network_
88 | * _Advanced→Wireless_
89 | * _Administration→Admin Access_
90 |
91 | **Save** the defaults as **`defaults.txt`**, from _Administration→Debugging→Download NVRAM Dump_ in the Tomato web UI, in the same directory as `tomato-nvram.py`.
92 |
93 | **Run** `tomato-nvram.py`:
94 | ```
95 | $ python3 tomato-nvram.py
96 | 102 settings written to set-nvram.sh
97 | ```
98 |
99 | **View/Edit** output file `set-nvram.sh` to choose which settings to reapply.
100 |
101 | **Reapply** settings over SSH:
102 | ```
103 | > "C:\Program Files\PuTTY\plink.exe" -ssh root@192.168.1.1 -pw admin -m set-nvram.sh
104 | ```
105 | Or
106 | ```
107 | $ ssh root@192.168.1.1 'sh -s' < set-nvram.sh
108 | ```
109 |
110 | - - -
111 |
112 | ## Options
113 |
114 | $ python3 tomato-nvram.py --help
115 | usage: tomato-nvram.py [-h] [-i INPUT] [-b BASE] [-o OUTPUT] [-c CONFIG] [-e ENCODING] [--erase] [--reboot] [--linux]
116 |
117 | Generate NVRAM setting shell script.
118 |
119 | options:
120 | -h, --help show this help message and exit
121 | -i INPUT, --input INPUT
122 | input filename (default: nvram.txt)
123 | -b BASE, --base BASE base filename (default: defaults.txt)
124 | -o OUTPUT, --output OUTPUT
125 | output filename (default: set-nvram.sh)
126 | -c CONFIG, --config CONFIG
127 | config filename (default: config.ini)
128 | -e ENCODING, --encoding ENCODING
129 | file encoding (default: latin-1)
130 | --erase erase nvram first (default: False)
131 | --reboot reboot after (default: False)
132 | --linux output linux line endings (default: False)
133 |
--------------------------------------------------------------------------------
/tomato-nvram.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | '''Generate NVRAM setting shell script.
3 | '''
4 | import re
5 |
6 | # Names to ignore
7 | ignore_names = re.compile(r'''
8 | http_id # HTTP ID
9 | |os_\w+ # OS Values
10 | |ddnsx\d_cache # DDNS Cache
11 | ''', re.VERBOSE)
12 |
13 | def keep_item(item):
14 | name, value = item
15 | return not ignore_names.match(name)
16 |
17 | # Splits nvram.txt on names
18 | nvram_txt_split = re.compile(r'''
19 | (?:\n|^) # Newline (or start of string)
20 | (?P[\w.:/]+) # Name
21 | = # Equals
22 | (?!=|\s*\n[^\w.:/]) # Values can't start wtih an equals or a newline
23 | ''', re.VERBOSE)
24 |
25 | # nvram.txt epilogue
26 | nvram_txt_epilogue = re.compile(r'\n(---\n[\w\s,.]+)?$')
27 |
28 | def parse_nvram_txt(nvram_txt):
29 | '''
30 | Parse nvram.txt of the form:
31 |
32 | name1=value1
33 | name2=value2
34 | name3=multi
35 | line
36 | value3
37 |
38 | Return an iterable of name-value tuples.
39 | '''
40 | nvram_txt = nvram_txt_epilogue.sub('', nvram_txt)
41 | _, *namevalues = nvram_txt_split.split(nvram_txt)
42 | return filter(keep_item, zip(*[iter(namevalues)] * 2))
43 |
44 | def diff_files(input_name, base_name, encoding=None):
45 | '''
46 | Return a mapping of items in input_name but not base_name.
47 | '''
48 | with open(input_name, encoding=encoding) as infile:
49 | input = parse_nvram_txt(infile.read())
50 |
51 | if base_name:
52 | with open(base_name, encoding=encoding) as infile:
53 | base = parse_nvram_txt(infile.read())
54 |
55 | return dict(set(input).difference(base))
56 |
57 | else:
58 | return dict(input)
59 |
60 | def write_script(items, outfile, config, erase=False, reboot=False):
61 | '''
62 | Write items to outfile in the form:
63 |
64 | nvram set name1=value1
65 | nvram set name2=value2
66 | nvram set name3='multi
67 | line
68 | value3'
69 | '''
70 | # Bypass special items.
71 | crt_file = HttpsCrtFile.extract(items)
72 |
73 | # Group items based on pattern matched.
74 | groups = Groups(items.items(), config)
75 |
76 | # Collapse small groups.
77 | groups.collapse()
78 |
79 | # Dedup repeated settings.
80 | groups.dedup()
81 |
82 | # Preamble
83 | outfile.write('#!/bin/sh\n\n')
84 |
85 | # Erase
86 | if erase:
87 | outfile.write('# Erase\nnvram erase\n\n')
88 |
89 | # Write groups.
90 | outfile.write(groups.formatted())
91 |
92 | # Certificate
93 | if crt_file:
94 | outfile.write(crt_file.formatted())
95 |
96 | # Commit
97 | outfile.write('\n# Save\nnvram commit\n')
98 |
99 | # Reboot
100 | if reboot:
101 | outfile.write('\n# Reboot\nreboot\n')
102 |
103 | from collections import defaultdict
104 | class Groups(defaultdict):
105 | '''
106 | Container for groups/sections.
107 | '''
108 | def __init__(self, items, config):
109 | super().__init__()
110 | self.config = config
111 | for item in items:
112 | item = Item(*item)
113 | self[config.groupname(item)].append(item)
114 |
115 | def __missing__(self, key):
116 | return self.setdefault(key, Group(key, self.config.rank[key]))
117 |
118 | def collapse(self, minsize=3, dst='Other'):
119 | '''
120 | Collapse groups smaller than minsize into a group named dst.
121 | '''
122 | def collapsible(group):
123 | return self.config.collapsible(group) and len(group) < minsize
124 | for key in {key for key, group in self.items() if collapsible(group) and key != dst}:
125 | if dst:
126 | self[dst].extend(self[key])
127 | del self[key]
128 | return self
129 |
130 | def dedup(self, minsize=3):
131 | '''
132 | Factor out common settings.
133 | '''
134 | Deduper(self, self.config).dedup(minsize)
135 |
136 | def formatted(self):
137 | groups = sorted(self.values(), key=lambda group: group.sort_key)
138 | return '\n'.join(group.formatted() for group in groups)
139 |
140 | def scan(self, pattern):
141 | '''
142 | Yield (item, group, match) for each item whose name matches pattern.
143 | '''
144 | for group in self.values():
145 | for item in group:
146 | match = pattern.match(item.name)
147 | if match:
148 | yield item, group, match
149 |
150 | class Group(list):
151 | '''
152 | Format a named group of items.
153 | '''
154 | def __init__(self, name, rank, *args, prefix=None, suffix=None, **kwargs):
155 | super().__init__(*args, **kwargs)
156 | self.name = name
157 | self.rank = rank
158 | self.prefix = prefix
159 | self.suffix = suffix
160 |
161 | @property
162 | def large(self):
163 | return any(item.large for item in self)
164 |
165 | @property
166 | def sort_key(self):
167 | return self.large, self.rank, self.name
168 |
169 | def formatted(self):
170 | width = max(item.width for item in self)
171 | items = sorted(self)
172 | single = (item.formatted(width) for item in items if not item.newlines)
173 | multi = (item.formatted(width) for item in items if item.newlines)
174 | prefix = self.prefix + '\n' if self.prefix else ''
175 | suffix = self.suffix + '\n' if self.suffix else ''
176 | return '# {}\n{}{}{}{}'.format(self.name, prefix, ''.join(single), '\n'.join(multi), suffix)
177 |
178 | @classmethod
179 | def loop(cls, name, rank, prefixes, keys):
180 | prefix = commonprefix(prefixes)
181 | items = (Item('${{{}}}{}'.format(prefix, suffix), value) for suffix, value in keys)
182 | prefixes = ' '.join(sorted(prefixes))
183 | return cls(name, rank, items,
184 | prefix='for {} in {}\ndo'.format(prefix, prefixes),
185 | suffix='done')
186 |
187 | import shlex
188 | class Item:
189 | '''
190 | Format a single item.
191 | '''
192 | def __init__(self, name, value):
193 | self.name = name
194 | self.value = value
195 | self.__key = name, value
196 |
197 | parts = self.name_break.split(name)
198 | self.prefix = parts[0] if len(parts) > 1 else re.match('[a-z]*', name).group()
199 | self.suffix = parts[-1]
200 |
201 | self.command = 'nvram set {}={}'.format(name, self.quoted(value))
202 | self.newlines = self.command.count('\n')
203 | self.sort_key = self.newlines, self.prefix.isdigit(), name.lower().replace('_', ' ')
204 | self.width = len(self.command) if not self.newlines else 0
205 | self.large = self.newlines > 24 or self.width > 128
206 |
207 | def __eq__(self, other):
208 | return self.__key == other.__key
209 |
210 | def __hash__(self):
211 | return hash(self.__key)
212 |
213 | def __lt__(self, other):
214 | return self.sort_key < other.sort_key
215 |
216 | def __repr__(self):
217 | return '{}={}'.format(self.name, self.value)
218 |
219 | def formatted(self, width=0):
220 | comment = None
221 | if comment:
222 | if self.newlines:
223 | return '\n# {}\n{}\n'.format(comment, self.command)
224 | else:
225 | return '{:<{}} # {}\n'.format(self.command, width, comment)
226 | else:
227 | return '{}\n'.format(self.command)
228 |
229 | def groupname(self):
230 | if len(self.prefix) <= 4:
231 | return self.prefix.upper()
232 | else:
233 | return self.prefix.capitalize()
234 |
235 | @classmethod
236 | def quoted(cls, value):
237 | # Use double quotes with an initial newline if value contains newlines.
238 | if '\n' in value and not cls.special_chars.search(value):
239 | return '"\\\n{}"'.format(value)
240 |
241 | # Format tomato lists in double quotes, one item per line.
242 | list, found = cls.list_break.subn('\\\n', value)
243 | if found and not cls.special_chars.search(value):
244 | return '"\\\n{}"'.format(list)
245 |
246 | # Use double quotes if value contains a single quote.
247 | if "'" in value:
248 | return '"{}"'.format(cls.special_chars.sub(r'\\\g<0>', value))
249 |
250 | # Otherwise let shlex.quote handle it with single quotes.
251 | return shlex.quote(value) if value else value
252 |
253 | special_chars = re.compile(r'["\\`]|\$(?=\S)') # Require escaping in double quotes
254 | list_break = re.compile(r'(?<=>)(?!$)') # Where to break tomato lists
255 | name_break = re.compile(r'_|:') # Where to break names
256 |
257 | from functools import partial
258 | import itertools
259 | class Deduper:
260 | '''
261 | Factor out duplicate settings into loops.
262 | '''
263 | def __init__(self, groups, config):
264 | self.groups = groups
265 | self.config = config
266 |
267 | self.prefix_to_keys = defaultdict(set)
268 | self.group_names = dict()
269 | self.cleanup = dict()
270 | for item, group, match in groups.scan(self.prefix_pattern):
271 | prefix = match.group()
272 | key = item.name[match.end():], item.value
273 | self.prefix_to_keys[prefix].add(key)
274 | self.group_names[prefix, key] = group.name
275 | self.cleanup[prefix, key] = partial(self.remove_item, item, group, groups)
276 |
277 | def commonkeys(self, prefixes):
278 | return set.intersection(*(self.prefix_to_keys[prefix] for prefix in prefixes))
279 |
280 | def dedup(self, minsize=3):
281 | for prefixes, keys in self.to_factor(minsize):
282 | name = commonprefix(self.group_names[prefix, key] for prefix in prefixes for key in keys)
283 | group = Group.loop(name, self.config.rank[name], prefixes, keys)
284 | self.groups[id(group)] = group
285 | self.remove(prefixes, keys)
286 |
287 | def lines_saved(self, prefixes, keys=None):
288 | if len(prefixes) > 1:
289 | keys = keys or self.commonkeys(prefixes)
290 | saved = (len(prefixes) - 1) * len(keys) - 5
291 | if saved > 0:
292 | return saved
293 | return 0
294 |
295 | def prefix_groups(self, minsize=2):
296 | grouped = itertools.groupby(sorted(self.prefix_to_keys), key=lambda item: item[0:minsize])
297 | powerset = itertools.chain.from_iterable(self.powerset(prefixes, minsize=2) for _, prefixes in grouped)
298 | return filter(self.lines_saved, powerset)
299 |
300 | def remove(self, prefixes, keys):
301 | for prefix in prefixes:
302 | self.prefix_to_keys[prefix].difference_update(keys)
303 | for key in keys:
304 | self.cleanup[prefix, key]()
305 |
306 | def to_factor(self, minsize=3):
307 | for prefixes in sorted(self.prefix_groups(), key=self.lines_saved, reverse=True):
308 | keys = self.commonkeys(prefixes)
309 | if len(keys) >= minsize and self.lines_saved(prefixes, keys) > 0:
310 | yield prefixes, keys
311 |
312 | @staticmethod
313 | def powerset(iterable, minsize=0):
314 | "powerset([1,2,3]) --> () (1,) (2,) (3,) (1,2) (1,3) (2,3) (1,2,3)"
315 | s = list(iterable)
316 | return itertools.chain.from_iterable(itertools.combinations(s, r) for r in range(minsize, len(s)+1))
317 |
318 | @staticmethod
319 | def remove_item(item, group, groups):
320 | group.remove(item)
321 | if not group:
322 | del groups[group.name]
323 |
324 | prefix_pattern = re.compile(r'([a-z]+_[a-z]+\d+)|[a-z]+\d*(?=_)')
325 |
326 | import os.path
327 | import string
328 | def commonprefix(names):
329 | return os.path.commonprefix(list(names)).strip(string.punctuation + string.whitespace)
330 |
331 | import base64
332 | import io
333 | import tarfile
334 | class HttpsCrtFile:
335 | '''
336 | Certificate and private key for HTTPS access.
337 | '''
338 | def __init__(self, https_crt_file):
339 | self.tarfile = tarfile.open(fileobj=io.BytesIO(base64.b64decode(https_crt_file)))
340 |
341 | @classmethod
342 | def extract(cls, items):
343 | crt_file = items.pop('https_crt_file', None)
344 | return crt_file and cls(crt_file)
345 |
346 | def getpem(self, name):
347 | return self.tarfile.extractfile('etc/{}.pem'.format(name)).read().decode().strip()
348 |
349 | def formatted(self):
350 | return self.template.format(**{name: self.getpem(name) for name in ('cert', 'key')})
351 |
352 | template = '''
353 | # Web GUI Certificate
354 | echo '{cert}' > /etc/cert.pem
355 |
356 | # Web GUI Private Key
357 | echo '{key}' > /etc/key.pem
358 |
359 | # Tar Certificate & Key
360 | nvram set https_crt_file="$(cd / && tar -czf - etc/*.pem | openssl enc -A -base64)"
361 | '''
362 |
363 | import configparser
364 | class Config:
365 | '''
366 | Group configuration from config.ini.
367 | '''
368 | def __init__(self, filename, encoding=None):
369 | parser = configparser.ConfigParser()
370 | parser.read(filename, encoding=encoding)
371 | self.names, patterns = zip(*((name, section['pattern']) for name, section in parser.items() if 'pattern' in section))
372 | self.lookup = re.compile('|'.join('({})'.format(pattern) for pattern in patterns))
373 | self.rank = defaultdict(lambda: len(self.names), ((name, i) for i, name in enumerate(self.names)))
374 | self.rank['Other'] = len(self.rank) + 1
375 |
376 | def groupname(self, item):
377 | match = self.lookup.match(item.name)
378 | return self.names[match.lastindex - 1] if match else item.groupname()
379 |
380 | def collapsible(self, group):
381 | return group.rank == len(self.names)
382 |
383 | def getrank(self, itemname):
384 | match = self.lookup.match(itemname)
385 | return self.rank[self.names[match.lastindex - 1]] if match else len(self.names)
386 |
387 | import argparse
388 | parser = argparse.ArgumentParser(description=__doc__,
389 | formatter_class=argparse.ArgumentDefaultsHelpFormatter)
390 | parser.add_argument('-i', '--input', default='nvram.txt', help='input filename')
391 | parser.add_argument('-b', '--base', default='defaults.txt', help='base filename')
392 | parser.add_argument('-o', '--output', default='set-nvram.sh', help='output filename')
393 | parser.add_argument('-c', '--config', default='config.ini', help='config filename')
394 | parser.add_argument('-e', '--encoding', default='latin-1', help='file encoding')
395 | parser.add_argument( '--erase', action='store_true', help='erase nvram first')
396 | parser.add_argument( '--reboot', action='store_true', help='reboot after')
397 | parser.add_argument( '--linux', action='store_true', help='output linux line endings')
398 |
399 | def main(args):
400 | # Parse arguments.
401 | args = parser.parse_args(args)
402 |
403 | try:
404 | # Diff files.
405 | diff = diff_files(args.input, args.base, encoding=args.encoding)
406 |
407 | except FileNotFoundError as error:
408 | print(error)
409 | parser.print_help()
410 | return
411 |
412 | if diff:
413 | # Load conifg.
414 | config = Config(args.config, encoding=args.encoding)
415 |
416 | # Write output script.
417 | with open(args.output, 'w', newline='\n' if args.linux else None, encoding=args.encoding) as outfile:
418 | write_script(diff, outfile, config, args.erase, args.reboot)
419 |
420 | print('{:,} settings written to {}'.format(len(diff), args.output))
421 |
422 | else:
423 | print('No differences found.')
424 |
425 | if __name__ == '__main__':
426 | import sys
427 | main(sys.argv[1:])
--------------------------------------------------------------------------------