#!/usr/bin/env python3 """Explicit offline IPv4 policy-routing and VLAN delivery models; no sockets.""" import argparse import copy import ipaddress import json def route(prefix, via=None, src=None, kind='unicast', metric=0, dev='eth0'): return dict(prefix=prefix, next_hop=via, preferred_source=src, kind=kind, metric=metric, dev=dev) def demo_tables(): connected=[route('192.0.2.0/24',src='192.0.2.10'), route('198.51.100.0/24',src='198.51.100.10')] return { 255:[route('192.0.2.10/32',kind='local',dev='lo'), route('198.51.100.10/32',kind='local',dev='lo')], 254:connected+[route('0.0.0.0/0','192.0.2.1','192.0.2.10')], 200:copy.deepcopy(connected)+[route('0.0.0.0/0','198.51.100.1','198.51.100.10')], 253:[]} def demo_rules(policy=True, endpoint=False): rules=[dict(priority=0,table=255),dict(priority=32766,table=254), dict(priority=32767,table=253)] if policy:rules.append(dict(priority=100,source='198.51.100.10/32',table=200)) if endpoint:rules.append(dict(priority=50,destination='203.0.113.81/32',table=254)) return rules def policy_lookup(destination, source, rules, tables): """Match fixed source/destination only; then LPM and metric inside each table. None means source not yet selected: it cannot match a specific from rule. Does not model socket retries, marks, NAT, IPv6, ECMP or kernel route state. """ dst=ipaddress.IPv4Address(destination) src=None if source is None else ipaddress.IPv4Address(source) trace=[] for rule in sorted(rules,key=lambda x:x['priority']): source_net=ipaddress.IPv4Network(rule.get('source','0.0.0.0/0')) dest_net=ipaddress.IPv4Network(rule.get('destination','0.0.0.0/0')) matches=(dst in dest_net and (source_net.prefixlen==0 or (src is not None and src in source_net))) step=dict(priority=rule['priority'],table=rule['table'],matched=matches) trace.append(step) if not matches:continue candidates=[row for row in tables.get(rule['table'],[]) if dst in ipaddress.IPv4Network(row['prefix'])] if not candidates: step['result']='no_route_continue';continue row=min(candidates,key=lambda x:(-ipaddress.IPv4Network(x['prefix']).prefixlen,x['metric'])) step.update(prefix=row['prefix'],result=row['kind']) if row['kind']=='throw': step['result']='throw_continue';continue if row['kind'] not in ('unicast','local','unreachable','blackhole','prohibit'): raise ValueError('unsupported route type') rejected=row['kind'] in ('unreachable','blackhole','prohibit') return dict(destination=str(dst),requested_source=source, selected_source=str(src) if src is not None else row['preferred_source'], priority=rule['priority'],table=rule['table'],prefix=row['prefix'], next_hop=row['next_hop'],dev=row['dev'], status=row['kind'] if rejected else ('local' if row['kind']=='local' else 'forward'), trace=trace) return dict(destination=str(dst),requested_source=source,status='no_route',trace=trace) def L15(): tables=demo_tables();bad=copy.deepcopy(tables) bad[200]=[row for row in bad[200] if row['prefix']=='0.0.0.0/0'] throwing=copy.deepcopy(tables);throwing[200]=[row for row in throwing[200] if row['prefix']!='198.51.100.0/24']+[route('198.51.100.0/24',kind='throw')] rejecting=copy.deepcopy(tables);rejecting[200]=[row for row in rejecting[200] if row['prefix']!='198.51.100.0/24']+[route('198.51.100.0/24',kind='unreachable')] s='198.51.100.10';remote='203.0.113.80';lan='198.51.100.55' cases={ 'no_policy':policy_lookup(remote,s,demo_rules(False),tables), 'source_unspecified':policy_lookup(remote,None,demo_rules(),tables), 'source_policy':policy_lookup(remote,s,demo_rules(),tables), 'lan_correct':policy_lookup(lan,s,demo_rules(),tables), 'lan_missing_route':policy_lookup(lan,s,demo_rules(),bad), 'endpoint_exception':policy_lookup('203.0.113.81',s,demo_rules(endpoint=True),tables), 'local_delivery':policy_lookup('192.0.2.10',s,demo_rules(),tables), 'throw_fallthrough':policy_lookup(lan,s,demo_rules(),throwing), 'explicit_reject':policy_lookup(lan,s,demo_rules(),rejecting)} return dict(kind='model',cases=cases, assumption='Fixed or unspecified source, source/destination rules, LPM/metric only; not a Linux execution.') def ipv4_multicast_mac(group): ip=ipaddress.IPv4Address(group) if not ip.is_multicast:raise ValueError('not an IPv4 multicast group') value=int(ip)&0x7fffff return '01:00:5e:'+':'.join(f'{b:02x}' for b in value.to_bytes(3,'big')) def mac_address(text): parts=text.split(':') if len(parts)!=6 or any(len(p)!=2 for p in parts):raise ValueError('invalid MAC') value=bytes(int(p,16) for p in parts) return ':'.join(f'{b:02x}' for b in value),value class DeliveryModel: """Single access VLAN per port. Explicit policies, not IGMP or full bridging. MDB keys are IP groups. Leaves mean confirmed removal; expiry is artificial. Router ports always receive multicast; IPv4 224.0.0/24 data floods. """ def __init__(self,ports=None,router_ports=None,unknown='flood',snooping=True): if unknown not in ('flood','drop'):raise ValueError('unknown policy must be flood or drop') self.ports=dict(ports if ports is not None else {1:10,2:10,3:10,4:10,5:20}) self.router_ports=router_ports if router_ports is not None else {10:{4}} for vlan,ports_set in self.router_ports.items(): if any(self.ports.get(p)!=vlan for p in ports_set):raise ValueError('router port VLAN mismatch') self.unknown=unknown;self.snooping=snooping;self.fdb={};self.mdb={} def join(self,vlan,group,port,now=0,lifetime=60): ipv4_multicast_mac(group) if self.ports.get(port)!=vlan:raise ValueError('member VLAN mismatch') if lifetime<=0:raise ValueError('membership lifetime must be positive') group=str(ipaddress.IPv4Address(group)) self.mdb.setdefault((vlan,group),{})[port]=now+lifetime def leave(self,vlan,group,port): """Confirmed removal of this whole member port, not a raw IGMP Leave.""" key=(vlan,str(ipaddress.IPv4Address(group))) members=self.mdb.get(key,{}) members.pop(port,None) if not members:self.mdb.pop(key,None) def expire(self,now): for key,members in list(self.mdb.items()): remaining={p:end for p,end in members.items() if end>now} if remaining:self.mdb[key]=remaining else:del self.mdb[key] def snapshot(self): return {f'{vlan}/{group}':sorted(members) for (vlan,group),members in self.mdb.items()} def forward(self,ingress,source_mac,dest_mac,group=None,now=0): vlan=self.ports[ingress] source,source_bytes=mac_address(source_mac) destination,dest_bytes=mac_address(dest_mac) if source_bytes[0]&1:raise ValueError('source MAC must be individual') self.expire(now);self.fdb[(vlan,source)]=ingress eligible={p for p,v in self.ports.items() if v==vlan and p!=ingress} if destination=='ff:ff:ff:ff:ff:ff': reason='broadcast';out=eligible elif not (dest_bytes[0]&1): known=self.fdb.get((vlan,destination)) reason='known_unicast' if known is not None else 'unknown_unicast' out=({known}&eligible) if known is not None else eligible else: if group is None:raise ValueError('model requires an IPv4 group for multicast data') ip=ipaddress.IPv4Address(group);group=str(ip) if ipv4_multicast_mac(group)!=destination:raise ValueError('group and MAC mismatch') routers=set(self.router_ports.get(vlan,set())) if not self.snooping: reason='snooping_off';out=eligible elif ip in ipaddress.IPv4Network('224.0.0.0/24'): reason='ipv4_link_local_flood';out=eligible elif (vlan,group) in self.mdb: reason='known_multicast';out=(set(self.mdb[(vlan,group)])|routers)&eligible else: reason='unknown_multicast_'+self.unknown out=eligible if self.unknown=='flood' else routers&eligible return dict(vlan=vlan,destination_mac=destination,group=group,ports=sorted(out),reason=reason) def L16(): a='02:00:00:00:00:01';b='02:00:00:00:00:02';x='02:00:00:00:00:99' g='239.192.0.42';mac=ipv4_multicast_mac(g);sw=DeliveryModel() cases={'unknown_unicast':sw.forward(1,a,b)} sw.forward(2,b,a) cases['known_unicast']=sw.forward(1,a,b) cases['broadcast']=sw.forward(1,a,'ff:ff:ff:ff:ff:ff') sw.join(10,g,2,lifetime=30);sw.join(10,g,3,lifetime=60) before=sw.snapshot();cases['multicast_known']=sw.forward(1,a,mac,g) sw.leave(10,g,3);cases['multicast_after_leave']=sw.forward(1,a,mac,g) cases['multicast_after_expiry']=sw.forward(1,a,mac,g,now=31) after=sw.snapshot() cases['multicast_unknown_drop']=DeliveryModel(unknown='drop').forward(1,a,mac,g) mdns='224.0.0.251' cases['mdns_link_local']=DeliveryModel(unknown='drop').forward(1,a,ipv4_multicast_mac(mdns),mdns) cases['multicast_snooping_off']=DeliveryModel(snooping=False).forward(1,a,mac,g) return dict(kind='model',cases=cases,members_before=before,members_after_expiry=after, mac_aliases={ip:ipv4_multicast_mac(ip) for ip in [g,'224.64.0.42']}, assumption='Single VLAN/access port; no real frames, IGMP/MLD, timers, source filters, IPv6 or MAC-only alias forwarding.') def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('lab',choices=['L15','L16']) args=parser.parse_args() print(json.dumps(globals()[args.lab](),ensure_ascii=False,indent=2)) if __name__=='__main__':main()