├── modbus.py ├── plcscan.py └── s7.py /modbus.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: modbus.py 3 | Desc: partial implementation of modbus protocol 4 | Version: 0.1 5 | 6 | Copyright (c) 2012 Dmitry Efanov (Positive Research) 7 | """ 8 | 9 | __author__ = 'defanov' 10 | 11 | from struct import pack,unpack 12 | import socket 13 | 14 | from optparse import OptionGroup 15 | 16 | import string 17 | 18 | __FILTER = "".join([' '] + [' ' if chr(x) not in string.printable or chr(x) in string.whitespace else chr(x) for x in range(1,256)]) 19 | def StripUnprintable(msg): 20 | return msg.translate(__FILTER) 21 | 22 | class ModbusProtocolError(Exception): 23 | def __init__(self, message, packet=''): 24 | self.message = message 25 | self.packet = packet 26 | def __str__(self): 27 | return "[Error][ModbusProtocol] %s" % self.message 28 | 29 | class ModbusError(Exception): 30 | _errors = { 31 | 0: 'No reply', 32 | # Modbus errors 33 | 1: 'ILLEGAL FUNCTION', 34 | 2: 'ILLEGAL DATA ADDRESS', 35 | 3: 'ILLEGAL DATA VALUE', 36 | 4: 'SLAVE DEVICE FAILURE', 37 | 5: 'ACKNOWLEDGE', 38 | 6: 'SLAVE DEVICE BUSY', 39 | 8: 'MEMORY PARITY ERROR', 40 | 0x0A: 'GATEWAY PATH UNAVAILABLE', 41 | 0x0B: 'GATEWAY TARGET DEVICE FAILED TO RESPOND' 42 | } 43 | def __init__(self, code): 44 | self.code = code 45 | self.message = ModbusError._errors[code] if ModbusError._errors.has_key(code) else 'Unknown Error' 46 | def __str__(self): 47 | return "[Error][Modbus][%d] %s" % (self.code, self.message) 48 | 49 | 50 | class ModbusPacket: 51 | def __init__(self, transactionId=0, unitId=0, functionId=0, data=''): 52 | self.transactionId = transactionId 53 | self.unitId = unitId 54 | self.functionId = functionId 55 | self.data = data 56 | 57 | def pack(self): 58 | return pack('!HHHBB', 59 | self.transactionId, # transaction id 60 | 0, # protocol identifier (reserved 0) 61 | len(self.data)+2, # remaining length 62 | self.unitId, # unit id 63 | self.functionId # function id 64 | ) + self.data # data 65 | 66 | def unpack(self,packet): 67 | if len(packet)<8: 68 | raise ModbusProtocolError('Response too short', packet) 69 | 70 | self.transactionId, self.protocolId, length, self.unitId, self.functionId = unpack('!HHHBB',packet[:8]) 71 | if len(packet) < 6+length: 72 | raise ModbusProtocolError('Response too short', packet) 73 | 74 | self.data = packet[8:] 75 | 76 | return self 77 | 78 | class Modbus: 79 | def __init__(self, ip, port=502, uid=0, timeout=8): 80 | self.ip = ip 81 | self.port = port 82 | self.uid = uid 83 | self.timeout = timeout 84 | 85 | def Request(self, functionId, data=''): 86 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 87 | sock.settimeout(self.timeout) 88 | 89 | sock.connect((self.ip,self.port)) 90 | 91 | sock.send(ModbusPacket(0, self.uid, functionId, data).pack()) 92 | 93 | reply = sock.recv(1024) 94 | 95 | if not reply: 96 | raise ModbusError(0) 97 | 98 | response = ModbusPacket().unpack(reply) 99 | 100 | if response.unitId != self.uid: 101 | raise ModbusProtocolError('Unexpected unit ID or incorrect packet', reply) 102 | 103 | if response.functionId != functionId: 104 | raise ModbusError(ord(response.data[0])) 105 | 106 | return response.data 107 | 108 | def DeviceInfo(self): 109 | res = self.Request(0x2b, '\x0e\x01\00') 110 | 111 | if res and len(res)>5: 112 | objectsCount = ord(res[5]) 113 | data = res[6:] 114 | info = '' 115 | for i in range(0, objectsCount): 116 | info += data[2:2+ord(data[1])] 117 | info += ' ' 118 | data = data[2+ord(data[1]):] 119 | return info 120 | else: 121 | raise ModbusProtocolError('Packet format (reply for device info) wrong', res) 122 | 123 | def ScanUnit(ip, port, uid, timeout, function=None, data=''): 124 | con = Modbus(ip, port, uid, timeout) 125 | 126 | unitInfo = [] 127 | if function: 128 | try: 129 | response = con.Request(function, data) 130 | unitInfo.append("Response: %s\t(%s)" % (StripUnprintable(response), response.encode('hex'))) 131 | except ModbusError as e: 132 | if e.code: 133 | unitInfo.append("Response error: %s" % e.message) 134 | else: 135 | return unitInfo 136 | 137 | try: 138 | deviceInfo = con.DeviceInfo() 139 | unitInfo.append("Device: %s" % deviceInfo) 140 | except ModbusError as e: 141 | if e.code: 142 | unitInfo.append("Device info error: %s" % e.message) 143 | else: 144 | return unitInfo 145 | 146 | return unitInfo 147 | 148 | def Scan(ip, port, options): 149 | res = False 150 | try: 151 | data = options.modbus_data.decode('string-escape') if options.modbus_data else '' 152 | 153 | if options.brute_uid: 154 | uids = [0,255] + range(1,255) 155 | elif options.modbus_uid: 156 | uids = [int(uid.strip()) for uid in options.modbus_uid.split(',')] 157 | else: 158 | uids = [0,255] 159 | 160 | for uid in uids: 161 | unitInfo = ScanUnit(ip, port, uid, options.modbus_timeout, options.modbus_function, data) 162 | 163 | if unitInfo: 164 | if not res: 165 | print "%s:%d Modbus/TCP" % (ip, port) 166 | res = True 167 | print " Unit ID: %d" % uid 168 | for line in unitInfo: 169 | print " %s" % line 170 | 171 | return res 172 | 173 | except ModbusProtocolError as e: 174 | print "%s:%d Modbus protocol error: %s (packet: %s)" % (ip, port, e.message, e.packet.encode('hex')) 175 | return res 176 | except socket.error as e: 177 | print "%s:%d %s" % (ip, port, e) 178 | return res 179 | 180 | def AddOptions(parser): 181 | group = OptionGroup(parser, "Modbus scanner") 182 | group.add_option("--brute-uid", action="store_true", help="Brute units ID", default=False) 183 | group.add_option("--modbus-uid", help="Use uids from list", type="string", metavar="UID") 184 | group.add_option("--modbus-function", help="Use modbus function NOM for discover units", type="int", metavar="NOM") 185 | group.add_option("--modbus-data", help="Use data for for modbus function", default="", metavar="DATA") 186 | group.add_option("--modbus-timeout", help="Timeout for modbus protocol (seconds)", default=8, type="float", metavar="TIMEOUT") 187 | parser.add_option_group(group) 188 | -------------------------------------------------------------------------------- /plcscan.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: plcscan.py 3 | Desc: PLC scanner 4 | Version: 0.1 5 | 6 | Copyright (c) 2012 Dmitry Efanov (Positive Research) 7 | """ 8 | 9 | __author__ = 'defanov' 10 | import modbus 11 | import s7 12 | 13 | import sys 14 | from optparse import OptionParser 15 | import socket 16 | import struct 17 | 18 | def status(msg): 19 | sys.stderr.write(msg[:-1][:39].ljust(39,' ')+msg[-1:]) 20 | 21 | def get_ip_list(mask): 22 | try: 23 | net_addr,mask = mask.split('/') 24 | mask = int(mask) 25 | start, = struct.unpack('!L', socket.inet_aton(net_addr)) 26 | start &= 0xFFFFFFFF << (32-mask) 27 | end = start | ( 0xFFFFFFFF >> mask ) 28 | return [socket.inet_ntoa(struct.pack('!L', addr)) for addr in range(start+1, end)] 29 | except (struct.error,socket.error): 30 | return [] 31 | 32 | def scan(argv): 33 | parser = OptionParser( 34 | usage = "usage: %prog [options] [ip range]...", 35 | description = """Scan IP range for PLC devices. Support MODBUS and S7COMM protocols 36 | """ 37 | ) 38 | parser.add_option("--hosts-list", dest="hosts_file", help="Scan hosts from FILE", metavar="FILE") 39 | parser.add_option("--ports", dest="ports", help="Scan ports from PORTS", metavar="PORTS", default="102,502") 40 | parser.add_option("--timeout", dest="connect_timeout", help="Connection timeout (seconds)", metavar="TIMEOUT", type="float", default=1) 41 | 42 | modbus.AddOptions(parser) 43 | s7.AddOptions(parser) 44 | 45 | (options, args) = parser.parse_args(argv) 46 | 47 | scan_hosts = [] 48 | if options.hosts_file: 49 | try: 50 | scan_hosts = [file.strip() for file in open(options.hosts_file, 'r')] 51 | except IOError: 52 | print "Can't open file %s" % options.hosts_file 53 | 54 | for ip in args: 55 | scan_hosts.extend(get_ip_list(ip) if '/' in ip else 56 | [ip]) 57 | 58 | scan_ports = [int(port) for port in options.ports.split(',')] 59 | 60 | if not scan_hosts: 61 | print "No targets to scan\n\n" 62 | parser.print_help() 63 | exit() 64 | 65 | status("Scan start...\n") 66 | for host in scan_hosts: 67 | splitted = host.split(':') 68 | host = splitted[0] 69 | if len(splitted)==2: 70 | ports = [int(splitted[1])] 71 | else: 72 | ports = scan_ports 73 | for port in ports: 74 | status("%s:%d...\r" % (host, port)) 75 | try: 76 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 77 | sock.settimeout(options.connect_timeout) 78 | sock.connect((host,port)) 79 | sock.close() 80 | except socket.error: 81 | continue 82 | 83 | if port == 102: 84 | res = s7.Scan(host, port, options) 85 | elif port == 502: 86 | res = modbus.Scan(host, port, options) 87 | else: 88 | res = modbus.Scan(host, port, options) or s7.Scan(host, port, options) 89 | 90 | if not res: 91 | print "%s:%d unknown protocol" % (host, port) 92 | 93 | 94 | status("Scan complete\n") 95 | 96 | if __name__=="__main__": 97 | try: 98 | scan(sys.argv[1:]) 99 | except KeyboardInterrupt: 100 | status("Scan terminated\n") 101 | 102 | -------------------------------------------------------------------------------- /s7.py: -------------------------------------------------------------------------------- 1 | """ 2 | File: s7.py 3 | Desc: Partial implementation of s7comm protocol 4 | Version: 0.1 5 | 6 | Copyright (c) 2012 Dmitry Efanov (Positive Research) 7 | """ 8 | 9 | __author__ = 'defanov' 10 | 11 | from struct import * 12 | from random import randint 13 | from optparse import OptionGroup 14 | 15 | import struct 16 | import socket 17 | import string 18 | 19 | __FILTER = "".join([' '] + [' ' if chr(x) not in string.printable or chr(x) in string.whitespace else chr(x) for x in range(1,256)]) 20 | def StripUnprintable(msg): 21 | return msg.translate(__FILTER) 22 | 23 | class TPKTPacket: 24 | """ TPKT packet. RFC 1006 25 | """ 26 | def __init__(self, data=''): 27 | self.data = str(data) 28 | def pack(self): 29 | return pack('!BBH', 30 | 3, # version 31 | 0, # reserved 32 | len(self.data)+4 # packet size 33 | ) + str(self.data) 34 | def unpack(self,packet): 35 | try: 36 | header = unpack('!BBH', packet[:4]) 37 | except struct.error as e: 38 | raise S7ProtocolError("Unknown TPKT format") 39 | 40 | self.data = packet[4:4+header[2]] 41 | return self 42 | 43 | class COTPConnectionPacket: 44 | """ COTP Connection Request or Connection Confirm packet (ISO on TCP). RFC 1006 45 | """ 46 | def __init__(self, dst_ref=0, src_ref=0, dst_tsap=0, src_tsap=0, tpdu_size=0): 47 | self.dst_ref = dst_ref 48 | self.src_ref = src_ref 49 | self.dst_tsap = dst_tsap 50 | self.src_tsap = src_tsap 51 | self.tpdu_size = tpdu_size 52 | 53 | def pack(self): 54 | """ make Connection Request Packet 55 | """ 56 | return pack('!BBHHBBBHBBHBBB', 57 | 17, # size 58 | 0xe0, # pdu type: CR 59 | self.dst_ref, 60 | self.src_ref, 61 | 0, # flag 62 | 0xc1, 2, self.src_tsap, 63 | 0xc2, 2, self.dst_tsap, 64 | 0xc0, 1, self.tpdu_size ) 65 | def __str__(self): 66 | return self.pack() 67 | 68 | def unpack(self, packet): 69 | """ parse Connection Confirm Packet (header only) 70 | """ 71 | try: 72 | size, pdu_type, self.dst_ref, self.src_ref, flags = unpack('!BBHHB', packet[:7]) 73 | except struct.error as e: 74 | raise S7ProtocolError("Wrong CC packet format") 75 | if len(packet) != size + 1: 76 | raise S7ProtocolError("Wrong CC packet size") 77 | if pdu_type != 0xd0: 78 | raise S7ProtocolError("Not a CC packet") 79 | 80 | return self 81 | 82 | class COTPDataPacket: 83 | """ COTP Data packet (ISO on TCP). RFC 1006 84 | """ 85 | def __init__(self, data=''): 86 | self.data = data 87 | def pack(self): 88 | return pack('!BBB', 89 | 2, # header len 90 | 0xf0, # data packet 91 | 0x80) + str(self.data) 92 | def unpack(self, packet): 93 | self.data = packet[ord(packet[0])+1:] 94 | return self 95 | def __str__(self): 96 | return self.pack() 97 | 98 | class S7Packet: 99 | """ S7 packet 100 | """ 101 | def __init__(self, type=1, req_id=0, parameters='', data=''): 102 | self.type = type 103 | self.req_id = req_id 104 | self.parameters = parameters 105 | self.data = data 106 | self.error = 0 107 | 108 | def pack(self): 109 | if self.type not in [1,7]: 110 | raise S7ProtocolError("Unknown pdu type") 111 | return ( pack('!BBHHHH', 112 | 0x32, # protocol s7 magic 113 | self.type, # pdu-type 114 | 0, # reserved 115 | self.req_id, # request id 116 | len(self.parameters), # parameters length 117 | len(self.data)) + # data length 118 | self.parameters + 119 | self.data ) 120 | 121 | def unpack(self, packet): 122 | try: 123 | if ord(packet[1]) in [3,2]: # pdu-type = response 124 | header_size = 12 125 | magic0x32, self.type, reserved, self.req_id, parameters_length, data_length, self.error = unpack('!BBHHHHH', packet[:header_size]) 126 | if self.error: 127 | raise S7Error(self.error) 128 | elif ord(packet[1]) in [1,7]: 129 | header_size = 10 130 | magic0x32, self.type, reserved, self.req_id, parameters_length, data_length = unpack('!BBHHHH', packet[:header_size]) 131 | else: 132 | raise S7ProtocolError("Unknown pdu type (%d)" % ord(packet[1])) 133 | except struct.error as e: 134 | raise S7ProtocolError("Wrong S7 packet format") 135 | 136 | self.parameters = packet[header_size:header_size+parameters_length] 137 | self.data = packet[header_size+parameters_length:header_size+parameters_length+data_length] 138 | return self 139 | 140 | def __str__(self): 141 | return self.pack() 142 | 143 | 144 | class S7ProtocolError(Exception): 145 | def __init__(self, message, packet=''): 146 | self.message = message 147 | self.packet = packet 148 | def __str__(self): 149 | return "[ERROR][S7Protocol] %s" % self.message 150 | 151 | class S7Error( Exception ): 152 | _errors = { 153 | # s7 data errors 154 | 0x05: 'Address Error', 155 | 0x0a: 'Item not available', 156 | # s7 header errors 157 | 0x8104: 'Context not supported', 158 | 0x8500: 'Wrong PDU size' 159 | } 160 | def __init__(self, code): 161 | self.code = code 162 | def __str__(self): 163 | if S7Error._errors.has_key(self.code): 164 | message = S7Error._errors[self.code] 165 | else: 166 | message = 'Unknown error' 167 | return "[ERROR][S7][0x%x] %s" % (self.code, message) 168 | 169 | 170 | def Split(ar,size): 171 | """ split sequence into blocks of given size 172 | """ 173 | return [ar[i:i+size] for i in range(0, len(ar), size)] 174 | 175 | class s7: 176 | def __init__(self, ip, port, src_tsap=0x200, dst_tsap=0x201, timeout=8): 177 | self.ip = ip 178 | self.port = port 179 | self.req_id = 0 180 | self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 181 | 182 | self.dst_ref = 0 183 | self.src_ref = 0x04 184 | self.dst_tsap = dst_tsap 185 | self.src_tsap = src_tsap 186 | self.timeout = timeout 187 | 188 | def Connect(self): 189 | """ Establish ISO on TCP connection and negotiate PDU 190 | """ 191 | #sleep(1) 192 | self.src_ref = randint(1, 20) 193 | self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 194 | self.s.settimeout(self.timeout) 195 | self.s.connect((self.ip, self.port)) 196 | self.s.send(TPKTPacket(COTPConnectionPacket(self.dst_ref, self.src_ref, self.dst_tsap, self.src_tsap, 0x0a)).pack()) 197 | reply = self.s.recv(1024) 198 | response = COTPConnectionPacket().unpack(TPKTPacket().unpack(reply).data) 199 | 200 | self.NegotiatePDU() 201 | 202 | def Request(self, type, parameters='', data=''): 203 | """ Send s7 request and receive response 204 | """ 205 | packet = TPKTPacket(COTPDataPacket(S7Packet(type, self.req_id, parameters, data))).pack() 206 | self.s.send(packet) 207 | reply = self.s.recv(1024) 208 | response = S7Packet().unpack(COTPDataPacket().unpack(TPKTPacket().unpack(reply).data).data) 209 | if self.req_id != response.req_id: 210 | raise S7ProtocolError('Sequence ID not correct') 211 | return response 212 | 213 | 214 | def NegotiatePDU(self, pdu=480): 215 | """ Send negotiate pdu request and receive response. Reply no matter 216 | """ 217 | response = self.Request(0x01, pack('!BBHHH', 218 | 0xf0, # function NegotiatePDU 219 | 0x00, # unknown 220 | 0x01, # max number of parallel jobs 221 | 0x01, # max number of parallel jobs 222 | pdu)) # pdu length 223 | 224 | func, unknown, pj1, pj2, pdu = unpack('!BBHHH', response.parameters) 225 | return pdu 226 | 227 | def Function(self, type, group, function, data=''): 228 | parameters = pack('!LBBBB', 229 | 0x00011200 + # parameter head (magic) 230 | 0x04, # parameter length 231 | 0x11, # unknown 232 | type*0x10+group, # type, function group 233 | function, # function 234 | 0x00 ) # sequence 235 | 236 | data = pack('!BBH', 0xFF, 0x09, len(data)) + data 237 | response = self.Request(0x07, parameters, data) 238 | 239 | code, transport_size, data_len = unpack('!BBH', response.data[:4]) 240 | if code != 0xFF: 241 | raise S7Error(code) 242 | return response.data[4:] 243 | 244 | def ReadSZL(self, szl_id): 245 | szl_data = self.Function( 246 | 0x04, # request 247 | 0x04, # szl-functions 248 | 0x01, # read szl 249 | pack('!HH', 250 | szl_id, # szl id 251 | 1)) # szl index 252 | 253 | szl_id, szl_index, element_size, element_count = unpack('!HHHH', szl_data[:8]) 254 | 255 | return Split(szl_data[8:], element_size) 256 | 257 | def BruteTsap(ip, port, src_tsaps=(0x100, 0x200), dst_tsaps=(0x102, 0x200, 0x201) ): 258 | for src_tsap in src_tsaps: 259 | for dst_tsap in dst_tsaps: 260 | try: 261 | con = s7(ip, port) 262 | con.src_tsap = src_tsap 263 | con.dst_tsap = dst_tsap 264 | con.Connect() 265 | return src_tsap, dst_tsap 266 | 267 | except S7ProtocolError as e: 268 | pass 269 | 270 | return None 271 | 272 | def GetIdentity(ip, port, src_tsap, dst_tsap): 273 | res = [] 274 | 275 | szl_dict = { 276 | 0x11: 277 | { 'title': 'Module Identification', 278 | 'indexes': { 279 | 1:'Module', 280 | 6:'Basic Hardware', 281 | 7:'Basic Firmware' 282 | }, 283 | 'packer': { 284 | (1, 6): lambda(packet): "{0:s} v.{2:d}.{3:d}".format(*unpack('!20sHBBH', packet)), 285 | (7,): lambda(packet): "{0:s} v.{3:d}.{4:d}.{5:d}".format(*unpack('!20sHBBBB', packet)) 286 | } 287 | }, 288 | 0x1c: 289 | { 'title': 'Component Identification', 290 | 'indexes': { 291 | 1: 'Name of the PLC', 292 | 2: 'Name of the module', 293 | 3: 'Plant identification', 294 | 4: 'Copyright', 295 | 5: 'Serial number of module', 296 | 6: 'Reserved for operating system', 297 | 7: 'Module type name', 298 | 8: 'Serial number of memory card', 299 | 9: 'Manufacturer and profile of a CPU module', 300 | 10:'OEM ID of a module', 301 | 11:'Location designation of a module' 302 | }, 303 | 'packer': { 304 | (1, 2, 5): lambda(packet): "%s" % packet[:24], 305 | (3, 7, 8): lambda(packet): "%s" % packet[:32], 306 | (4,): lambda(packet): "%s" % packet[:26] 307 | } 308 | } 309 | } 310 | 311 | con = s7(ip, port, src_tsap, dst_tsap) 312 | con.Connect() 313 | 314 | for szl_id in szl_dict.keys(): 315 | try: 316 | entities = con.ReadSZL(szl_id) 317 | except S7Error: 318 | continue 319 | 320 | indexes = szl_dict[szl_id]['indexes'] 321 | packers = szl_dict[szl_id]['packer'] 322 | for item in entities: 323 | if len(item)>2: 324 | n, = unpack('!H', item[:2]) 325 | item = item[2:] 326 | title = indexes[n] if indexes.has_key(n) else "Unknown (%d)" % n 327 | 328 | try: 329 | packers_keys = [ i for i in packers.keys() if n in i ] 330 | formated_item = packers[packers_keys[0]](item).strip('\x00') 331 | except (struct.error, IndexError) : 332 | formated_item = StripUnprintable(item).strip('\x00') 333 | 334 | res.append("%s: %s\t(%s)" % (title.ljust(25), formated_item.ljust(30), item.encode('hex'))) 335 | 336 | return res 337 | 338 | def Scan(ip, port, options): 339 | src_tsaps = [ int(n.strip(), 0) for n in options.src_tsap.split(',') ] if options.src_tsap else [0x100, 0x200] 340 | dst_tsaps = [ int(n.strip(), 0) for n in options.dst_tsap.split(',') ] if options.dst_tsap else [0x102, 0x200, 0x201] 341 | 342 | res = () 343 | try: 344 | res = BruteTsap(ip, port, src_tsaps, dst_tsaps) 345 | except socket.error as e: 346 | print "%s:%d %s" % (ip, port, e) 347 | 348 | if not res: 349 | return False 350 | 351 | print "%s:%d S7comm (src_tsap=0x%x, dst_tsap=0x%x)" % (ip, port, res[0], res[1]) 352 | 353 | # sometimes unexpected exceptions occures, so try to get identity several time 354 | identities = [] 355 | for attempt in [0, 1]: 356 | try: 357 | identities = GetIdentity(ip, port, res[0], res[1]) 358 | break 359 | except (S7ProtocolError, socket.error) as e: 360 | print " %s" % e 361 | 362 | for line in identities: 363 | print " %s" % line 364 | 365 | return True 366 | 367 | def AddOptions(parser): 368 | group = OptionGroup(parser, "S7 scanner options") 369 | group.add_option("--src-tsap", help="Try this src-tsap (list) (default: 0x100,0x200)", type="string", metavar="LIST") 370 | group.add_option("--dst-tsap", help="Try this dst-tsap (list) (default: 0x102,0x200,0x201)", type="string", metavar="LIST") 371 | parser.add_option_group(group) --------------------------------------------------------------------------------