├── 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:]) --------------------------------------------------------------------------------