diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..da439db --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-python@v7.1.0 + with: + python-version: "3.13" + - name: Install pytest + run: pip install pytest + - name: Run unit tests + run: pytest tests/ diff --git a/group_vars/all/secrets.yml b/group_vars/all/secrets.yml index 5a188b4..25e4af6 100644 --- a/group_vars/all/secrets.yml +++ b/group_vars/all/secrets.yml @@ -1,242 +1,274 @@ $ANSIBLE_VAULT;1.1;AES256 -30646430326562323935316464303038653532663537373730626231376333643361363931343430 -3466316139626637353261323366323666303962663065320a626663653431326336306261623433 -37613064336162336239313863636465613330636131636162646435343233363834323134626337 -3832643039326165310a366565643262376361303562333830386530633566373362633964656566 -31306430376238326266303838313433396136666263323163346434326631666264653064333962 -66326530626639326335313263373730333534383962633930333736616437396130623830303362 -36653964373837613564636662343564333132636465663333376139346135353132383061353333 -32383237303537633038633662313137626231643162386436646535626536653966616434636465 -35636532626531393439353863653834623930383035303236663133333231653838303135396634 -36323732393932376132636566353936343730383130393633336365323939313738323231656232 -64333531386365313132666165656430343462613232333635393735633435336566363062396235 -37353330626635643666326562303463306531336562383039353035383631336137626330316535 -33333865646230353239366333616266363133366331376130346562326639623065346632613266 -39326566646234363938653664313637363161313833636435303866386138663037303532623139 -64613537326632306265346261646165623834613238336231616235333264313431383334386565 -63326137343939666332356539643831666332653236633239373662663333353838353862373130 -32623731653930303565336663643939633365346336663134616636313737306661653764663135 -39336532393932343032616533616530323133393638393163613666336630383736663234383831 -31373661323363306635323131373830396161346131393038666565643063373437316664623965 -33303363363739616263326661663834356365636430666431643262666164323163333463626335 -32306562633237376666636461653237663738306531646366383033646138303034623266313764 -31346165623639386165386666626233663935306238613938326535383037326331393564346162 -38633835363561663735383266386338303730336166323930303639343437333533313032636162 -30626430396630306263363837346334666133653262346662336337313534316565373131633562 -37636131633036616433656266623733636139306535636664336665323130366366326437306566 -64326462646265626636626261616261653533373531373761386533653536353133373464386335 -32396233333163363763356433363637353137633336623866373166653665666230663933373130 -65623730316633366534376330343736396436306536623934346331613961623438336533653535 -66633935653435306437393361613239343135656132356235613439613635396539626461666664 -31366138616534353530343834633830663464323563636165343264356430633334613438623432 -30383765383432363231636135303262613033343433643261373065666139383234616132633163 -63333335396461633338626166346539656432663164666566313965616134333231656136633165 -33333963623961373532666466396662313937383634636139383761356565313264333337363630 -38356135383562383861333839376135346534643563666535656363393264663433353966626138 -33323939343865616433366133343634643333343062363336663466613635626338373563656239 -30633162326131633636666162353431643332386363363765666230363833306266356139636235 -35623162663565363834623533306637653538366562616330346365653466366530356436343164 -66323032306436663134633336376462656235363933363836306637383463383066613935333066 -34363735306234366133333262633439626532326137663035656336626239663662326234616339 -34633032636336323466363231623365353933303437616434373565623862363362333361633030 -65646330613634363663636566613630383230306536383939646263323962336664663862336438 -63333566636161306136306136336162313663343066386334326630343865666365653134353934 -33343036623361356661316663326162636663363462346134656637656337636637346430386362 -37383632383335303064663438626561653661346235353834613639383366656233616338653338 -65623630653462613039333230393237653966323936643865633464386133323132336130323365 -36626631396233383263373866393563373238373161306439653036393631653336316265623936 -62323331356663343063663664663933356264396465643734393162383962663165326366336339 -62646334336331343465613938316666346433653039363637396638353931383931653038303232 -39393465303834643730666337396633383064613431376234366264323332376164353639653861 -35643166653636376631373736656664343330653034613765353063313363363833643537653166 -31333136306163643062376339613664393838343664353366336364613536313131393364666237 -30373138303131646432663162313237386131316563303133306333643361356138613638306436 -39336433366266366262346230333039303539616662373033386337653862326235323634353764 -64363661343933336666333537653230396232393436653662323961326334656636306330613166 -65653637396634343065396665356164316661366234326562323263396133356665353631613365 -62623932393936376562356165343432316134383033663863383763386535313566383732313863 -38393935643562633166383362366231386636343439633466396638653163636231653330653734 -35313033336337313461363761373263633566666237623235636132303032623163656339303735 -35666463663965376564383138643139616164623034346531363062316634613266393133623638 -62366235393735336231323531306466313437353239656562633833623362343261626663366335 -63363562363931303531653531356339393163393939346337323932626561643735386235306639 -65646164633564333934346666346537646232333730386164343438313933613865396538646534 -35343539616639333861616261396333333737623539326537313937393266383335663565333633 -34626132356366613266366639616139613035633738323735636461316532306566313937396332 -33353063646664303865383238303235613165643762366237653530613030356164386135303662 -34323864653030306636633862626531376134303938333264643831646330313166653932356536 -32333839663531656335656633333765343361323035323230333566383366626466346166363735 -38363565303632323134663561396332663138643931356230343633333833343565343862613063 -39373936663333633532636165353162386361643933393537393539393061393930383331656563 -64396363616536333332333963653834356264386630646261386637616430373332376133663235 -31616334316466666239313639373666346431356637393837313365656165363531363264343032 -65393565613466313834386663613230383532646631366364386139636331353131303338333033 -62393962626166333236326430393631323339343433346163643339303037333962393264313030 -30623130653966363062376633366261646264393535303163353838656334333139363737393261 -66336462383536353036666139616430633361313331303533613666646336383939656663666262 -62303839613630373861653838376137623564666363646362666438336534623962373430613865 -35393338646364363863306334636531623763326137306163626337306336313339633837653033 -63396532373464333261366133333534343766343663653937333830396334396666643236383036 -35326663653066353937353766373065626432363430326534333032386635383962303262666361 -37353237643763363031653239336239616231623036646434383361396330663432326565633938 -64343337363562613435616161646165623261643265353230666338363234326561396538313530 -36663962613534323635326564333336396662633330373239356262636335613364396264633865 -39393537633463336163613066316262303964306164343833386633393139376165346539613939 -62643964326438666634393736663362376265663862663035336565346130653235656133366636 -65336230646633316633376162616365616663393531633161353931653636663631383435363236 -39356333353064316631386161383931303235636133663634633136366462626437396265643632 -32393635383566643563383861363265666362336661623333393831313663303665663239343533 -35656534326466303865353938386461393232346333336666396264343835373435663963303531 -62636638303832623565396236613234616266333633386663636130636332303561333838323639 -64666435636333396537653764643431346231663164646231373237386466663863323134313731 -39623335666438383531626338376666633230656532303233633461623066656138393439323830 -33663563343033323365323632653239363135633266333465626234613461393536313836333031 -31303565333966313363646235613639366163636438386435333337636538386462363664616638 -66343032633533336163383330326463353163386638316364343063643130656234303762613434 -32613663333865656563303830643664393533323031666461376561666261323865326631643062 -30623832396432393565653330313366613864336432666539633130306437353661363061663065 -38376533383264383265306335363361656161356362336461316465653563373535616632363032 -32386661323635653563366266346536623431373664326465626162373431383436666263386136 -61663331666164386262396131373066373564646262356566303466623665663536386134643830 -33326533353737633635333630653031373738656566366539323433373864613033653661646339 -31343735343636363938646138343538303734383939313630666636363337316230373830616437 -32386365613865303334346430623965353531613935303934636330363132643935356336363763 -62343230633134386561343934323630316165346132366666333664393738383038626439303637 -66343336346361393135356465613439343035363239373536306566636632626561653263393236 -64343237376238383164326266626562386336626461336364656633363135336665383462363038 -37353037346166376136386533653265323662653834316439373561613336663033376266366238 -64666466313361616530313133613839643537313363656437353238363836326463623332303234 -65613661613532396230383765306466666464643666303739313834633566646536656337356631 -33333265343066623763383731383463373837643330616463303830323332646237383931366234 -32366534623863383634653838316438336664343333306564386431353161373166656263633365 -32303133353334333530356162643663353136373833663539323830383835336565343735613963 -64623634663966643263343338643639326438623563626638383062626366666335343162666236 -38306261383461346532396531643930313933373836306132613739363964636563643466373031 -63343337633334623032306534366231316137376566643765303630333835666633353334313037 -37623666343461646136623433623438646231343063396639643963653961376263353337653733 -39393461653333616535343738646266376164623136666531393463333937343536666362353963 -36386563343862303663376661376331336163346166306237366139303533386134633563323837 -63653833663539393661643630346461626533373939653038633861373164663566666265653563 -65653565396462633231393830336333633333366366656261316338313166386536333264316564 -66613234646236356332636336393533363466396633383030373636616165393837323039363663 -37336638303361393261383136636365393631393538643636353933386435303035373734316561 -32646466336563333130356262633336343266303131323638633262663263653139313465313166 -36353335303332666235316434393534663230653462386166313831646563623238646239313636 -36343665386630376561343032643938643266663065313366393731386133316539666161643635 -66303839663861633362643831323362396132623939623038613230636463613966666663353835 -36373830653838656465636665386437663234386239303139333039646431666463366333336362 -32346533393164303165316134326565666239623130383637336530306136616461323839663632 -35623539626134383230636261656534356164663563383834316539326165356138613531353264 -33313237383764356331313339353938643265636663613833623265653433336634323439623534 -62363539663430653333656662343432336337633835663233363763616464613430653161303064 -36323364313032363337323566373361613037386634393261636138636261613532623830393035 -31633965306235623863363463383533353938653430396332663064656138646436376330376136 -65643161343330363835313435653664643131376362333363616639663966303830663235396361 -61653334373762636134383062623838613333633135663534363531663266376536363439313739 -65336235643266623437306462613536636535646331373063393139343433333263653531316538 -66303533306335383739346531373462656336396338343962343039623332386364306332373165 -62323836666462613765656632326339393333353034326339396162333130333437356233353130 -36666436306531303630306561366163663938306633626162623933643839383233663839316161 -65663064333061653439383631386139323738306564343563343463323131386531373333306331 -33613532346530306463336435323263646161616364386635333030343666613838316663326131 -33363864346535363765636261333034376464363031303336366331336239623566313962666535 -31323933343732323265643830303562633034613061323966363664333537393239646535316639 -36666437613965333330393266333035633633656365376239376234623636346465663766633039 -62393237306565643035336137636232376333653462366130613062333131383031643034323236 -63633463633235393863663133383632393431616437363161653332323836633932613739623136 -31666433363838656565363232366431656561333037663232336633326265393034313165663965 -66633535303765386266373531363032616236356434336638383139336265383061636265383731 -62656131363633323336323230356162343662353732313263343131346539396362666339623666 -62376530366430313833653739353530323536613639343436383032623463626565653230306236 -30303130313663623832653365393433343163306365633961326433313366326266373434653562 -66653138303064343464663634316630376435386231363766633965633331623332343330646139 -34646631323532646566313061346337316535313263386563363238653139373538626531663938 -33303638663362356233636632636636393837396563383834386531653264363038396635303838 -39333261303531626665373134333764323165396334633162376265636331303839326334663530 -37303630633833633161336238646637346535376333313164343663383765353434373938663965 -35303835316239343861376136323035623966633138383864353936316162376335303630393136 -30646261656639323637323465356135663138623535623538326566333636666335623334303634 -36373034636365373439633939643731346563626163663134383761646532613635393537356632 -66623131313364393138393862303764383939326661386430663130383434336631366236303265 -31386461313861383536623437633238363034643934343537646335613635373334383235383931 -65623536376261616661333262316532613832323865656630363535616132353937343539346136 -36393735646132303530393938636462303065303331636237616134373465323530626539323864 -63323433393136616566623833666134646238343431636361613136393735363931343136666231 -33646265363661623062336434663038613131363135333964323033626631353164313337633264 -38326534303862333030356631646262343161363734646133393034323832353463613265373634 -37373439326666333038306135376665373934316438363033333866623466623362663430643834 -36626335333439616534396634643764626364376539376537366531366434326161373737646130 -63303063383966643932376138623933393138336662373530623764646330616136666163613739 -31663364396133373737323564633565646538366437323733666362303937343163376636633932 -37316665633230373564623066356339363438626339343166666639666134666530636662383131 -62653232613562313137666538343336656163636664363664346433663838303361386362613463 -30363664613861313537306330313563336330313131373034373761316233313837323232636264 -36333933313530326664373138383862616637333366396235313830366262336338376635653734 -33303963333661353530633432656337366131666236383465373538393334363433653734306633 -32623030613634616337323164616364656434633334373465663833393564343535306437643130 -62646132323233626663623437633934633865393066336437313534383030363861633433663236 -65386133646537306537356566313033656336396664373039306661393733306566386434303030 -62633736363036393435643263383236373039373733323134663631303839333664643765333639 -64396430316661323433646465373461373966613065363936616431613831383461346135326463 -65646333656433616464643030393936323239623732666461316238383565343262333332666563 -64663562653766356665363635663263393565363933336436643135663232383739633333636465 -30623236643563376334363833633337313437653662663663646664353539366239646639316534 -38326466313935636337643335336535616237393935623732303261313361646234616536336665 -66336563363161303266363432643233333862393564653535383631613835656166353364373938 -34383361333835633165653064326633646164646634623633386335653363376536336465326131 -39356366363062363761653136316561333461613930336461623262616235616332326365663362 -61326130623562393334343161613338333732393934376163643631323235373661356537633062 -33623734313166346230646561653264666366646239346430663333666136373137376331643831 -39353538313933356431363064386133366637643366363131653434393735633838396163346633 -37343965316133663035633436376133666539343933656339333464613164383330613035653830 -32366630616636633865663632663461303032326666383338666133386335336536636162656638 -35323739396232356237366266313032643364636531376636323039633761636536376536316462 -35613462623162643961343434383963653434396566623161643432656238666435303536343566 -61353265326461346339306261353333336466333464663338656163373266623236626563666232 -33323833353033653165383033623461366461613665303364353230646663653564323963626363 -38323534353137656264363132663063323936313136363930336333383461626538653336353832 -65383861653563356138336634616465356161663062323330636263633137363631303636346561 -38363965376366306562326461653937656363386632643433666166616331623437626239633962 -31353965663835373335666134643766643334623837373538626536633464363631366334343333 -64666433623339313836643430316365623264353766383332386261336339383733653938313539 -62343536353962343432323836313336656432633636326231666539316364663432626535663637 -33616665356435653863393130366637663339653864376165373933656665386565313635303463 -39303834363265353461633761653863303338643335366638633739656265353962353631333639 -36633339396436383537656236303539363061653266653964356662383663623630313938633364 -36353031383962373036303535616337373362373962393862646662393434663030383935646566 -38653736643466616130626564666562316361343564396561373537306439366466306231663463 -65306435323464396432363361323930633936316239353765376633613131356532653966356436 -35633862386466353437376264343834353966353238616332343536616634323834633236343931 -30383239353765333264616366633266646536336663623835353662363930393837333533373737 -38663030333961313763393437313661316466633037333565386362376564623331383037326438 -65613662306230663131336633303031353536376238323832363161633631663832626662326666 -36313939373236306137653736316339336262353036303537376462376439353932616163653533 -32353030663061626263663530636333333261636339666562613532396233613839633632393539 -62636130666530353734343233663765323463303636333561343064313166313534333433633761 -34626537336333383434303632663235373637613635653632383566663131346364623538376230 -63646433333538393530376636323830356635663333643166613861303134656365393039356534 -63623332373862303266373033353132396331636136386530356466663466633662316530643263 -65383763353137346463373839633038393666393239643231306563656430613335643938643539 -66633564643431356233333533363731333864346465396432663662623362633738313662323761 -61656161626461353339343664613739316637326233636364613464356430316138323462633035 -37616166643535636139383831313066646265383465376531363530343639666166653966393031 -33643236616365353335346231646339363030353565333364653835343431663339343539343638 -64663735633435323364396137363135373237323738353337663139653035656666633265623766 -36346235646536353731333435353733663231303730346534653363626262306238646239336437 -37653432333137316639663161313839336530653864363066326435313061343836623066653130 -31383737313238396162326235333935323365333462613039323136646636313235663366333734 -30323131336639313338663461623135363537646566306532343134333065366261653538333261 -36626639353065343331616136386563646330303239383537656363363930336562386237626233 -35633335613738316164613134333733313337383836636132396332653563353136383035653261 -37333135323337656333323464646466663766303833396362633462666337316539383065613361 -30316666373338383239623262623033393130306264626365653265353865336564303830363466 -65633133646632393935636162643238333232396434663137366661303834376134326337333764 -35666632353664323065316638373239353465363134343036643233643362646162353139303630 -32653339333064336635656361323863323264636536363930643664333639663335343931666332 -66363236613039623664633864396363633337346435653937356563633433346562613434383030 -61373033623536613034343261666430623434343032653232393537303264393037333064666361 -38616264633139336463666332613866393832376361356531623335633635356461323733393638 -31616132396636326233623665343661373838613737636234643836333737383564636339653931 -64646533356538363635613037336664386261623061316264636339653636353337616331343061 -66393938386261623664306333323661663431396664626330353738623934376466646663373437 -64633932376138333634383630363162343430303762343863313566636330643866 +64646461333837633439316666613032366537326633613334326335643562623334336161663039 +3161356436333566303437653063353465383436376338650a633765393134626363313136333766 +38366633636538303161653730343866636362366163336234386136613664333833326661363537 +6266613435626530660a626530633833393865373632303738313739633564366463343666643534 +64366136623437636631353763363130316162353232366461333866326663383038353130383062 +30346666626339376333636632346661666264373930336233343033326565663138313330343035 +34666663336362613234613136636461623331323362386133393864323138646339373638353231 +36366665613238383537363133393761333863663936313535343762633430393838646562346330 +37646236333562333236333731386465393064343062633934376237346630366631653530643532 +61356231303437646430646164353832316466616630316533383737323935336338623938613234 +65616561333465633861653433303938393762636636323437373532653064643066353461613937 +63393037613935623963663264626234353562653061316462383364343361646266326634613532 +63356231656234386133356162366337396535663633393931343964656361343562343334353838 +38623130393539303330313931626663363466636462383932323531356166303963303038653235 +62396134363236306537656466653736653030386363623336643031366536326239326134643736 +38613730333239613537383331666631633237666262383063313839643862336330326231323766 +35323230653033653538356132613763626237373432343566363639393138646332343235643766 +63623066336665333532323034363831656533393639633438666237336530663333396463643433 +34363666373461346335613563306561353834396566643632366533343031373536396561313962 +65316331383764323166363739613231366135653135333765663262666132396662306266386439 +30336161343639323833323264636235316431333064633935316264636465616236326434616262 +64323866363735616332633463313438303239316531653135306334393131653939316537643031 +31653331323231663561663665356632613333616230396134316237326562376466363938383332 +64363632646238303734343161643965346464356636616336323539313530326264653130336366 +30613039353166333166633830653965623361303933366330336638613237633662326636366266 +36623035626662316338353337626162326362646637396533653463613935316536356538373661 +38666537646531636436626465663435613636393132363538316239316437623366633366376538 +33636263653661353565333535663934303664633237653863376663306435636537323634303936 +33613832393365616362643463323866636461623031346262363063623762333864316437396663 +35343337643831326233343334306535633637343236373637393030356362353738363738643164 +30643738336631333931646437313261353666306139643862646536666563643035656439653639 +38323161666361353434393931386162646463626662633031663834653631633863653638376165 +64303036393831386230373864643638373766333831333466373335666162323762363961643032 +65376266373465323638326139323330366132356431343430643065346334373836383132653162 +62653430653637613434326562303561633039323666306535376261346135353435643363393462 +61623034313863373064656231613435363564353464306565633936353161393035656266366539 +63326163643534396339353665363738373736323439396236353436613436316530346564666237 +33373034643230306332643363626332306132633665633565343266396531393932383035346430 +36616438346262306134663966333666363639343630653036393731333832313663346638663335 +38646535666536353161323561306333633964336634346630666234613332363966306163393433 +36643462323066663337356637623433656437306261356162363064666331663666656336643133 +34616536356439336636353538653366646261353764353937393366316236343535323439383932 +36373730373966393735326364316237393862313630663830646634363064376131303233386263 +64356366313830346665303632643262303736646136316435383963363861623035303764636238 +38346131393962376165626638353466663961366336363163343430636635623730376262386365 +31313761646663663536336464336536373437336637366139653263303961623035643563633261 +66666637396163376636326431363931643935633938363736326263643162303866313036383234 +65666262343631386339663964616237643739306632356366623564303038303462333366613666 +35666662373635643330656262346538393864356464616231653265316239666364356266643931 +38616330343435656334333439356136666535663464353839626435313032356431653237353330 +36346130343136396265356339653863343035306266363439303230663163333365323862643030 +31656630616564616265616634303534376533353864336563306132623432386138363762306231 +36323865363961356231643962643332646261623432373565383762323735643130346135303063 +36373132643136376266303362343166323562313531393639636438333562323861363436373138 +36343138613665366465613866333663336531353430393730653538666462363739646135633035 +31343766383835626465376639613033653862336139636435303330366266363838613233623637 +61323934363962363138643434383666646565363363306165626330323163343032643230323062 +39383935333436663838373462313666373962393035386138326134376266623765613061346431 +30353438643261363661353763366161326138623965336237623634396366306430323231666638 +30666563623130303438623933313337343632303764356463613931323938333463663463356662 +65613931626264316135623833346262376334346330616566333862373635623833393065336564 +61333730343536303438363564626533663437383266663761333937616337613339393835323262 +61383638383033333866333039313732363039396133373035653763313664393364393031623532 +38363565326432356534383138303235383436383132363237376361373539333462356135623062 +64636338363864343237356432613962616366663832356434323665303737303338376533333561 +64363664383364636165316264326338386263616261643832396466616565313166633532653138 +33623761386539643038653862343637633831316230663334633634633964313466656339653766 +37376135353465346139613864363434613362646339663637303232653131626331633837353533 +64633135373832326466366233353566633636343766346637356365626465616639613634363061 +65366364333434323337376335346437343830386536326438643933316537643863653363623861 +33656536303335366132343537623530373731643863376538316537663334333465343966336462 +34633135313430343436393133343966386232353332653135333434303663656336656139646366 +34353033643165386565616332373963656335353731656665613038613839326136353266346338 +36616231363131323435393834303264643265636262633531393930303262663531326365663565 +33373832326136363265613437336464376137393637613434626236613366656638306265616562 +34386365623130316339653865633437353266623265626333393235636131343864303463663866 +64333638376135353936396566376634383233323135636138366464373461363637386166633833 +37353933396538326239303032306166616366316135326539626364393436363061393663663463 +33633234623831383463326266366365366534313463383137613564633965303530396661353662 +65646261313536323932366161623334393261653564363562316639626632633362666232383566 +62666236373731363239623962653561303732626130633439643534363635636135396535313132 +37616164633030373134633030643336626666636136313366306631363134383764643064393562 +36633766633333366237313836313464336132666334653737623065396561376462373439366665 +32386362376531666465363561346635633236386663396132396233386538326363323739353965 +31323337393434613637393536333262663563316230363365646265323539326666346337623437 +30333130636330663637643230386439346638316663383864663861313630356562353736393366 +39356135313336316135353832343737353839323762366432663232656666663937393130373838 +62626566343330306461313765343234653264393537363933386563386130666462373334383931 +34623935373733313535366137633231613336383132663463336139373030363138613664363234 +38366161636630346533376638653361393764303939303566613761393933303834636464396262 +30643062333235313561306364633866613236396533343431383262633433653639666561303832 +36373834623635633433646435633430643961376162636433333135616330656339336435356661 +36383536363762313366376437633130663039393132633537613464613239326633363564326230 +35383530333662353837343265383930333563383362363861666266653361643636353431643665 +61303938616361323663626466663139383561616163663165393266613537336465393235613863 +33323266343130633636396133393937366135303932663334623139396363386235646238383030 +63306464373231623238366663346335616237336637633366383039393964303331326164353330 +30626632343033393533373535656332353462376562366438623762663966333031626463343830 +39326533396130313964316365316264343731383234656466336634393666376433343964643064 +38396138643162623563336662383030383334366139373733656233323835313833356162363031 +30666339323166666563626637323334626639303437653033306263386532616238326238373764 +39636534613638616431316133313035306233376534346562656561643430336631663736323464 +62363164663665313363303637633964323233393466386463623461303136363331393765613565 +61313733663236323661346438643532386338323464326232653065663266656535386136303733 +32653635633962656533636538363764623763636663323362366430656531626236626432653533 +61623736333630633639313034346634383061613734643164383230396161316433333962656138 +63393862653462396631613265363665656165393633646165656137333464353634376562343337 +35326663376162383333303364653765363164663433616431373462633338373835633538346631 +30316639616566383939623339613566616565653262626435656636343333363762613131313232 +63663466323536643163383666663438323761343533636536373463323265613166396163653562 +33323131656132346564303136653563323064303263303366643766366339636432373135393836 +32643335386630313937316565396561316237366465363235316537356131613765373030653662 +37663461653464316231313361373930363933623337363932376262656662663730653936353166 +65333131373633353734613666623830363664336236643737366130383766373033366465643834 +31653133373731323730316163633964376366303563633265653466613266373233366134663764 +39313835333831303638613464353637303034666461626166316563353963666131323838636230 +31386666626161616264356664663165633835383134666663393835303466383733306530386663 +31626566393430316331316166633264323833363862633962376361666439366138393566626133 +66383762363231346164303162666264353861306464613064633665623837366565386432353366 +39366366633965613439323336616239343138333031623966303136613831356164306335356136 +62383536376362653439626537386462383265646531343663356134303030373131333464373732 +39333233663732373435643732613464333336363835643339613833323533393063346264396236 +33393764333966666336316434616335363530373130323130653862636364633539366239313365 +37643133383636363763323133336330643738386131626331363763356266633932306164646263 +65353530323830346539326633333739393437373534363536623762393665653664336436613539 +39666264323761633637623166306335313465663530653037623937613738373230653830623135 +38663333656432666130396266376535366562613132656361376531353638633536313532626536 +61393031633463663836306162376334363035623633343334633135366265366161616637663663 +30633731653233656364373435373965363537323739616532313266336133343733353461633937 +32356365383131653137333336303764633561353939633836393061383337326634393637626566 +62646432386534326565363462356662636134616263393266396637333337613631343534663634 +61383165623437623234376530653436663833373535633266353432323330653531616361633931 +32383065353265373261343865333262653339373365623361643762373136326264656536663237 +38663034663837373532646433323934373735626265356439376161653137666264393330396566 +36653663326333623938616636623662646563383862313037626636373231343432653532613635 +65353431356265623538643932343564303438616265373131396239633933306566643832626363 +36343239363533313439663862643863663833336363653430366132326462323862653831656534 +61613338373164326166636665326162623631656435666239613931623333613732303431633138 +61346639383466656630646262383761653663643033623132313231393134643132336331636232 +61653163373131613732323133396631306130313464313839313465323564646238646238663163 +34393739386233663864623733383030353166376233313037373161393462643063633334373133 +62336339643737313432653636326537313862663636633264666638613262303534343766633363 +38363030386561626164373637323335353030336366346232633337613730363538613637623334 +34643634376436373438313665323934386536326435636262323232313334373832373336623639 +34356566336466393932353334366661366466336563616532316365303730666530653964646434 +64656232393432396563393938356464316365636266396566353262653237656462623365303936 +39303665653265383662323366383934373031343932373939666431326565663437653061616338 +61313833343762386465656331653638316462393036363235636134646335336139613934626634 +30323564656666303436353431376664326531306261646637373335333533623933373663376537 +36653766343838653131363463663439343330616537323961333366333438363939623434653339 +63666331333364303732376232343666646336356630323635343738306236663238626337343237 +38353439393466343863383965393963396131306466393630303438363665326532343737396133 +37653939396238363463323230356433323139633063643037633439616633306533303761363765 +35386637633962366566343534323838636136663936636233353239346535663237393739313532 +37336234643232646538663832366563653631306533306563356466366661633330333165346437 +65393461373664323538313732336134393638643138376365313064303737303065353434373363 +66653862356135303133343331326561343165303635386336626234356634646464663331663639 +61393063363333646561613533393239343466396439656365643034343665366262663235656235 +36343462613231653331323534353165353263383138346137393934663730303961636631346535 +38653963316638323462643064633664363033303565376438616463653735306231643762323433 +63393432386434383036376536386134663036326366376432393637346661656462356531393964 +37646532303939643930656235633634313136333662396664613239303732653065383063306631 +66386335373733656339656562333530323032653639316134303035613039633331323631386130 +36613035323131613434356438306462366535353639636634323535336462303435636264633239 +63633865323033663763653731383330306364623136323039633139373139396430636161303235 +37313038373935613263323065323433646336356335656161303136653064653239666563303561 +66373365636561396538303164666663303265343233666635623063323462343132633664373561 +30663732366231393238646636326664306239356665333364353434373433303032613938373433 +34636637663831353039316339313032643864656634343731383963346135623662363232373233 +33346666336336306663373732663231343465633063643165373230303137326230393363643434 +63336230356663383536636536363530643762613039666337373964633064646533333864313461 +62626366386635316638346165636265616664303266343330383263333134386165623462636434 +31303838626464313932396438373565346538336239383064383131333865343062646138613366 +35396462323134336230363761313530633132323531636231316535356561663635356436343234 +31323562306435376465316337346234666539376636643435613939363037373866333138633538 +33313363306237343231343139386138343231646635323463316663346338616561623132346631 +34323631323132383930333831313034316133633433613234356435343065316366633663633361 +38613461663637343732323732363065383332646231323739623966386530326134653764366162 +35613165313563666433356235356639386538373966313865386535363031306435623530316265 +32643437396539383234666338623561303663326335323061343562346139343939643039336331 +33383837313739313964653464396638613335353362393531303035323366303337663864323535 +33333863373537323263363735363439386262613736653863313832333261313238653563353537 +34636262643833656563666131356465366433653736633263383538313064646566326130363630 +61323032306435313233623030383362336364633564306332343732326537383934323933663638 +37393563613738613662663432373135663061393363646533383230346234303836353362613964 +37313263643865393533396139626462613535663364633532663838666166613736663766643266 +38386535333964343266306164366431303535366234386232353339343365613732353663656162 +37666637383565333433363433626336393063336565633663326136643638323037366130366333 +38336538383339313564316264343735383532313963663034643161323931353335653938666330 +63396533363330623462303864313462633966336539343761636666313538393431643135613066 +37343465653162323633356333326537393635336230313337666364616138323136323364323465 +39383432646639313031616233643930373963356661633834326331393837643131613631396137 +33613135653338613638653865613464303038383034323363326532343863613866666230386164 +30323032383134643164643533663739333863343262666334643862663030613564346236323235 +35323961613333343633393034383837396361623036313531316431656363356638326262366261 +35333462363166353139373839646665613463396536306531393238346134643534366538326166 +63613661363638663265613430363238393962343062316561663035303666623636326633666630 +33313139656539643031633430363664656238623964623561363838376132333136323539353532 +66653233373532656462373663366635353033393566613636323635653438353234396161613337 +35326534373964306332323637316134333537376138346166646465376135643466396266383534 +66663763366636353231383861316665306461376166383436636162353633646565633065383763 +32306230616439363731353763626130383636626162393463643132373161613161343166383261 +33316238663863626636383239336465633561636163323732616264623862353531613030346631 +32313766346235393535336431303964303066666566343264633432343834366666303362376232 +39636634656639626532633366366632346231373533343562373437626133393331613234303436 +64633935393930653538323762613833636436633336646437626637643564333562356464616536 +31326461643637323030373333363061333634323132336165663335343532613334663138396363 +32643139306338353765653064666265313137363334666330336338633134313239316338633939 +62376239316561303261663732633065623634353232373461613732373132343663323864343732 +36376566333337326664303531363164613734323766653733663831393738353534643362386130 +61633130643433366566666133373834633139656434383038623532376634393535643631326433 +63613131356463353734393033623864336535313164313630373362316665363963656162333461 +38343361643132323738363237626333303033663063633761306161323237333365613238306138 +35663235356361346334343838333066653833653534393865343361323139633364613063336161 +34303364326563383161316264663832646639393931666564613637376636353065613263616161 +38643430623332316334653635376362643062333530656438613361313464386530663631613932 +66396465636430373166306535643037646366623733386236336663326330303536313830656664 +38633731633665663037353039326663393133323438623765653439653236633835636138623730 +32353062643339633032373634363133636266333061643136666536306437383936303733663738 +35663538313466303364633734653864656533386436316339643530393664613638323437383734 +33333039643033366436323633646562363639383939383164376663373064363035646263313235 +63373863303064373036663834316230646530303465343161616230663233353638653530643436 +66333236326338363331316630323166393131623165666236633636643536653761633861623039 +66303762616664393934356631386639633931626335303930356162383832666235366161366230 +38373464656135616639636165313237353161643234643338353330333334383838653736376661 +65353139343232663265316162393832383231393436616462396665396635373661393636366131 +65313231376164653834623431663135396162343832343131663738303337656464333332356262 +61323937663433356631633630353031633563373165363136353436613039383963666434303661 +62353961303363363534316466623334663530626231306266376265333861353033663764616361 +64306131313738613438303636323635303463323766613665356564383466626162323636363733 +39356162356363326363383539333865613736353232376539366333646434393962646163633437 +61623535306661363335633432376330316230383565333939356634323437613233346130303262 +66663733653466343530623961336234626434663932373464313339306130366336353964613238 +64393564333863386134646362303565316130333032393137383535626230323932613231376162 +37383037646565623839333530666365323934383333653238353664333538303064353133656464 +32663931363366316363626564613134633736656531333135373864313931393864646230376330 +30386265633533333664303162346639303934396263646230346466373737666437383066396435 +65613639373064613161346534366137313663346364393232623939633364663261326464666163 +32366563353063626530393363313661363030653437333336393938376366316666616634636261 +36313363373030366539386462323430313331323038643532666163636231643331323062393861 +32303733646132633361316538343938656134333066333030313830663566383930373733623461 +30613030366330343630646261393365333135313233623866646132306339653035313863663238 +30333730633865393461333138386664633838366532316637626138326432353236323030336235 +63656331383836323266663564383432316639323837326531303532643030653738323139376361 +36376563353262313531613961353330623731376539386366363662376266383463353339383333 +62353039353437303436333937386630336539396262366462636663363633356665376666393962 +63396238613035333937323762613965376135633630633435303836613166653432376563336338 +39633161366665386339663932346561636439613832646265393139353634386434323066633034 +38646163303033366537343238303637386362323238643635326332383662303138626631373339 +62646139653364636534656636396137336133366262643066636364623439373763636538323232 +64353362623530626433313433333164663063306532393465396537366265653161613536376364 +37623739303732643635643236386631353534353839643865376638346464623233323761663130 +65326330323561386333633166323164336634393136643539666336393532636635343239646566 +31346530383433346366303331363032363066383933383866316534633230313034383863336234 +36333333363936373762663335343832616237386562616533626332333665643437303739666663 +61336535346130633464646464396265323431623836343635343533396361343064313063326631 +62396437323961323363613631636537386538383162666632363438663262333739653637633237 +34346462303137313064393738333737346132323238313238323165363262306531383930653938 +30303438373739343561326638383563396264336337343032323833303233393439326639393635 +35643830616439656364323333376463383065623466633661383961663662333064343737633733 +30346465323664656433386662336633396237373566393166343533386461326437663966396130 +37333635613962383931666435313933386136336564633038393464656534663136643636666634 +65373535636333333534386266336131633162396432356565333865653263383166623032363235 +34633562313232653766363437353133313937393164346566376361346636303233653264656637 +37383433313164383863396337363961653637323763643538306436306433323938636637613461 +62316430636465363135363331313632306537323530623163353034313161366366313832653734 +32353764346564386530343561333161326430623431333830316131303166313563633630316135 +32393833383736336665643131636539626362303931643464363563613431366635336530373930 +66626334643735313966376364643065346235666530643532383038393136333334373639316630 +64666265663338306565613431323433626431343233393439613631323734393635393565393633 +33353438363936626239356334333238393561613035616465333834623738343265666663396434 +36613232623165646265356161616261633338623532313738623462356263353231303964396432 +6235 diff --git a/group_vars/parker_concentrators/main.yml b/group_vars/parker_concentrators/main.yml new file mode 100644 index 0000000..68fd208 --- /dev/null +++ b/group_vars/parker_concentrators/main.yml @@ -0,0 +1,80 @@ +--- +# Parker uplink: nodes register over HTTP and receive a routed /63; their +# clients reach IPv4 through the NAT64 + DNS64 pair that playbooks/parker.yml +# installs. + +# Our own segments use 2a03:2260:3006:0100::/56, :0200::/56 and :0300::/56, +# so the upper half of the /48 is free for parker slots. +# +# One /56 for now: 128 slots, ids 1..127, which no FFRL filter can object to - +# they already route our other /56s. Short of the surrounding /49 (16384 +# slots) on purpose, and growing later is purely additive: the id -> /64 +# mapping is 2*id, the same in both, so no node is renumbered when the +# announced prefix gets shorter. That /49 is 2a03:2260:3006:8000::/49. +parker_pool6: "2a03:2260:3006:8000::/56" + +# Pool the reconciler derives the node prefixes from; the broker allocates out +# of the same one, so both sides have to read it from here. +parker_prefix6: "{{ parker_pool6 }}" + +# Publish each node's last handshake into the registry, so the broker-side GC +# can also see nodes that only ever reach this one concentrator. +parker_gc_publish: true + +# Hand each node's respondd answer to the monitoring yanic. The monitor cannot +# poll a parker node itself: no batman interface to multicast into, and it +# records this host as the node's address, so it never asks the node either. +# The concentrator puts the request into the tunnel and forwards the answer +# unchanged to the monitor's central yanic listener (its own respondd +# connection does exactly the same for the bat nodes). +parker_respondd_push: true + +# DNS64 must answer only clients whose IPv4 transits our NAT64: synthesised +# AAAA for native-IPv4 clients breaks Happy Eyeballs, and an open resolver on +# a supernode uplink is worse. +dns64_networks: + - "{{ parker_pool6 }}" + +# Same /96 for both halves of the pair, so a client's synthesised AAAA is an +# address Jool actually translates. The play asserts the two agree; this line +# keeps them from drifting apart in the first place. +dns64_prefix: "{{ jool_pool6 }}" + +# The public IPv4 this host's NAT64 masks translated flows with. Every +# concentrator sets it in its host_vars; the empty default is what makes a +# forgotten one fail the jool role's assert instead of the whole play with an +# undefined variable. +parker_pool4: "" + +# NAT64 masks translated flows with the host's public IPv4 (see host_vars). +jool_pool4_prefix: "{{ parker_pool4 }}" + +# Where the delegated /64s are routed instead of main: bird's kernel table on a +# supernode (host_vars: bird.kernel ip_table 42 with `learn: true` and the +# accept_parker_pool_v6 import filter). bird announces them in OSPF, and the +# destination-based ip rule in host_vars sends this host's own lookups for the +# pool into the same table - without both, the prefixes would be announced but +# the NAT64's return packets would leave through the backbone default. +parker_route_table: "42" + +# Ports NAT64 may mask translated flows to on the pool4 address. One BIB entry +# per client source port and protocol, held for udp 5 min and tcp 2 h, so the +# width is the flow capacity of the whole concentrator: 11000 ports carry +# ~36 new UDP sockets/s or ~1.5 new TCP connections/s in steady state, or +# 11000 simultaneous TCP connections. Kept below the host's ephemeral range +# (32768-60999) and clear of its listening ports; the role asserts both. A +# dedicated second pool4 address lifts the ceiling to the full port space once +# real traffic outgrows this. +jool_port_range: "20000-30999" + +# Only parker clients may use the NAT64. Their addresses come out of the same +# pool the broker hands prefixes from, so the guard and the DNS64 ACL are the +# same set - anything outside it would be turned into an open relay. +jool_source6_allow: + - "{{ parker_pool6 }}" + +# ... and they have to come through the tunnel. Without this the guard trusts +# the source prefix alone, so a packet with a spoofed pool address (sent from +# anywhere that can route to 64:ff9b::/96) would be translated too. On wg-nodes +# wireguard has already checked the peer's allowed-ips. +jool_source6_interface: "wg-nodes" diff --git a/host_vars/bb-a.yml b/host_vars/bb-a.yml index 3e1d304..6d65d5a 100644 --- a/host_vars/bb-a.yml +++ b/host_vars/bb-a.yml @@ -103,6 +103,7 @@ bird: authentication: "none" staticroutes: - name: uplink_hostroute + is_ipv6: no rules: - "route 185.66.193.46/32 via 100.64.17.157" filter: diff --git a/host_vars/sn03.yml b/host_vars/sn03.yml index 0f8ce7c..696daaf 100644 --- a/host_vars/sn03.yml +++ b/host_vars/sn03.yml @@ -55,6 +55,27 @@ networkd_interfaces: table: 42 - from: "10.0.0.0/11" table: 42 + # The parker pool is routed in bird's table 42, and the packets Jool + # translates back carry 64:ff9b:: as their source, so the `from` rules + # above never match them. Without this destination-based rule the kernel + # looks the pool up in main, finds no route there, and sends the return + # path to the backbone default instead of the tunnel. + - to: "{{ parker_pool6 }}" + table: 42 + # clear of the auto-assigned block (3276x) and above the kernel's + # `from all lookup main` at 32766, which would otherwise win + priority: 32750 + # ... and the forward direction needs the same push: traffic sourced + # from a pool address is the node talking to the outside world. The pool + # is announced to FFRL, not to the site uplink, so only the backbone link + # has a path back. Left to main the packet leaves via eth0 and the answer + # either never returns or is dropped as spoofed at the site uplink's + # ingress filter. + - from: "{{ parker_pool6 }}" + table: 42 + # ahead of the `to` rule so a node-to-node flow uses one table either + # way, and still above `from all lookup main` + priority: 32749 - name: ens19 # IPv4 only, no router advertisement on this link either no_radv_accept: True @@ -73,6 +94,15 @@ bird: - "filter filter_kernel_v4" - is_ipv6: yes ip_table: 42 + # ff.parker_concentrator writes the delegated /64s into table 42 with + # `ip route replace`. bird ignores routes it did not install itself + # unless it is told to learn them: without this the prefixes sit in 42, + # reach neither master6 nor OSPF, and nothing forwards to them. The + # import filter keeps table 42 from feeding arbitrary routes into our + # announcements - only the parker pool is taken over. + learn: true + imports: + - "filter accept_parker_pool_v6" # bird_table: auxtable exports: - "filter filter_kernel_v6" @@ -119,6 +149,17 @@ bird: dead: 20 check: link authentication: "none" + staticroutes: + # Originate the parker pool as one aggregate, the way the bat /56 is + # originated from lo: accept_public_prefix_v6 carries it to the + # backbones over OSPF and they announce it to FFRL, which routes no + # longer prefix for us. Kept here rather than as a static on the + # backbones, so a backbone that loses its adjacency to sn03 withdraws + # the pool instead of blackholing it. In table 42 the node /64s shadow + # it; a free slot answers unreachable instead of looping to the default. + - name: parker_pool + rules: + - "route {{ parker_pool6 }} unreachable" filter: - name: reject_internal_prefixes_v4 default_accept: yes @@ -136,6 +177,13 @@ bird: default_accept: no rules: - "if net ~ [2a03:2260:3006::/48+] then accept;" + - name: accept_parker_pool_v6 + # Import filter of the kernel protocol: the parker reconciler adds one + # /64 per node to table 42 without telling bird. `learn` picks them up, + # this filter decides which ones are ours. + default_accept: no + rules: + - "if net ~ [{{ parker_pool6 }}+] then accept;" - name: filter_kernel_v4 default_accept: yes rules: @@ -156,3 +204,60 @@ radv_announce_default: true #local_vxlan_supernode: 2a00:fe0:43::201 #remote_vxlan_supernode: 2a00:fe0:43::202 yanic_central: "true" + +# parker uplink (playbooks/parker.yml): the address NAT64 masks translated +# flows with - eth0's public IPv4, whose return path is already routed here +parker_pool4: "5.145.135.153/32" + +# This concentrator's end of the parker tunnels. The address sits in the id-0 +# /63 of the pool, the one slot the allocator never hands out, and the node +# configures it as the peer of its own /128 - so it needs no route of its own. +parker_address6: "2a03:2260:3006:8000::1/128" +parker_privatekey: "{{ parker_sn03_privatekey }}" + +# Parker config service. The broker itself comes from playbooks/broker.yml; +# `monitor` keeps its instance for images that still have +# wg-broker.freifunk-aachen.de baked in. usign_secret is the vaulted key +# content the role writes to wgbroker_parker_secret_key. +# +# Everything a node is told is in this block; the registry it builds starts +# local to this host. +wgbroker_parker: + enabled: true + usign_secret: "{{ parker_usign_secret }}" + prefix6: "{{ parker_pool6 }}" + # Community-wide client IPv4: no per-node assignment, so every node hands + # its clients an address out of this /22 and the first 16 are reserved for + # the routers themselves (clientaddr.lua). 10.80.96.0/22 is free in our + # network - the mesh carries no other client IPv4. + range4: "10.80.96.0/22" + address4: "10.80.96.1" + mtu: 1406 + wg_keepalive: 25 + retry: 120 + # Omit xlat to fall back to routed IPv4 in the tunnel fleet-wide; the + # concentrator interface carries IPv6 only, so this stays on. + xlat: true + rate_limit: + burst: 10 + window: 3600 + daily: 200 + # Both concentrators are always sent, so a node fails over on a stale + # handshake instead of waiting for a reflash. address4 is only read in the + # routed mode; under xlat the node ignores it. + concentrators: + - id: 1 + pubkey: "7IRdL29opT9mPWqLL/BR2uOprNYa0Evh7zDUsiM6kzs=" + endpoint: "03.wg-node.freifunk-aachen.de:10000" + address6: "2a03:2260:3006:8000::1" + address4: "10.80.96.1" + # Log-only for the first cycle, so we see what it would remove. + gc: + enabled: true + dry_run: true + never_handshaked_days: 7 + silent_days: 90 + +# site.conf sends both broker endpoints (add_key, /config) here, so the role +# brings its own nginx vhost and certificate (playbooks/broker.yml). +wgbroker_vhost: 03.wg-node.freifunk-aachen.de diff --git a/inventory b/inventory index 1ab0c0f..7de86ad 100644 --- a/inventory +++ b/inventory @@ -39,6 +39,18 @@ supernodes: sn02: sn03: +# Hosts that terminate parker uplinks next to their supernode duties. One at +# first. +parker_concentrators: + hosts: + sn03: + +# The wireguard broker (add_key and /config). It still runs on `monitor` via +# playbooks/monitoring.yml; this group is the host it is moving to. +wgbrokers: + hosts: + sn03: + monitoring: hosts: monitor: diff --git a/playbooks/broker.yml b/playbooks/broker.yml new file mode 100644 index 0000000..fd8b4ce --- /dev/null +++ b/playbooks/broker.yml @@ -0,0 +1,17 @@ +--- + +- name: Configure the wireguard broker + hosts: "wgbrokers" + pre_tasks: + - name: "Import pre_tasks: reboot-required.yml" + ansible.builtin.import_tasks: "include/reboot-required.yml" + tags: [always] + - name: "Import pre_task etckeeper" + ansible.builtin.import_tasks: "include/etckeeper_pre.yml" + tags: [always] + roles: + - { role: ff.wgbroker, tags: wgbroker, become: true } + post_tasks: + - name: "Import post_task etckeeper" + ansible.builtin.import_tasks: "include/etckeeper_post.yml" + tags: [always] diff --git a/playbooks/files/parker_lib.py b/playbooks/files/parker_lib.py new file mode 100644 index 0000000..62a204d --- /dev/null +++ b/playbooks/files/parker_lib.py @@ -0,0 +1,575 @@ +"""Shared helpers for the parker config service. + +Used by the ff.wgbroker broker (the /config endpoint and its garbage +collector) and by the ff.parker_concentrator reconciler. Kept in +playbooks/files/ so all three install the same copy. + +The module is stdlib-only and free of Ansible templating so it can be +unit-tested as-is (tests/test_parker_lib.py). Every operation that runs a +subprocess takes an injectable ``run`` callable with the signature of +subprocess.run; the broker runs as the ``auto`` user directly, the +reconciler and the GC run as root and pass demote_runner() so that git +operations keep the ``auto`` identity. + +Registry format, one file per parker node below ``REPO/parker/``:: + + + id: 412 + conc: 1,2 + +The first line is a plain key like in the batman-side files, so +autoupdate_wg.py keeps ignoring these records: they live in a subdirectory +it never scans. The id maps to the node's delegated prefix pair +(range6 + xlat_range6) via id_to_prefixes(); it is allocated once and +never re-derived from the key. + +The registry is a git repository of its own, not the batman peer list: the +two have different lifetimes and only one of them is rewritten by every +node poll. It may live without a remote (sync=False below), in which case +the commits are the local audit log and nothing leaves the host. +""" + +import fcntl +import json +import os +import re +import subprocess +import tempfile +import threading +import time +import zlib +from ipaddress import IPv6Address, ip_network + +PUBKEY_RE = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") +NONCE_RE = re.compile(r"^[0-9a-f]{32}$") +NODE_ID_RE = re.compile(r"^[0-9a-f]{12}$") + +RECORDS_SUBDIR = "parker" +STATUS_SUBDIR = "parker-status" + +# A delegated unit is one /64; every id owns range6 and the xlat sibling, +# so it consumes two of them. Public because the concentrator's reconciler +# needs it to tell its own routes from bird's in the shared table. +DELEGATION_BITS = 64 + + +class ParkerError(Exception): + """Base class for everything that can go wrong below.""" + + +class InvalidRequest(ParkerError): + """Malformed request (bad pubkey or nonce).""" + + +class PoolExhausted(ParkerError): + """No free id in the parker prefix pool.""" + + +class RecordConflict(ParkerError): + """A record is malformed or claims an id/pubkey that is in use.""" + + +class RateLimited(ParkerError): + """Too many new nodes at once, try again later.""" + + +class GitError(ParkerError): + """A git operation failed.""" + + +class PushPending(GitError): + """The record is committed locally but not pushed: do not answer.""" + + +class SignError(ParkerError): + """usign failed or produced something unexpected.""" + + +# --------------------------------------------------------------------------- +# validation + + +def validate_pubkey(pubkey): + if not PUBKEY_RE.match(pubkey): + raise InvalidRequest(f"not a wireguard public key: {pubkey!r}") + return pubkey + + +def validate_nonce(nonce): + if not NONCE_RE.match(nonce): + raise InvalidRequest(f"not a 32 hex digit nonce: {nonce!r}") + return nonce + + +# --------------------------------------------------------------------------- +# records + + +def sanitize_pubkey(pubkey): + """Filename-safe form of a base64 key (+ -> -, / -> _, no padding).""" + return pubkey.translate(str.maketrans("+/", "-_")).rstrip("=") + + +def record_relpath(pubkey): + return os.path.join(RECORDS_SUBDIR, sanitize_pubkey(pubkey)) + + +def format_record(pubkey, node_id, conc_ids): + return f"{pubkey}\nid: {node_id}\nconc: {','.join(str(c) for c in conc_ids)}\n" + + +def parse_record(text, path=RECORDS_SUBDIR): + """Parse one registry record. Extra fields are ignored on purpose.""" + lines = [line.strip() for line in text.splitlines() if line.strip()] + if not lines: + raise RecordConflict(f"{path}: empty record") + + pubkey = lines[0] + if not PUBKEY_RE.match(pubkey): + raise RecordConflict(f"{path}: {pubkey!r} is not a wireguard public key") + + node_id = None + conc = [] + for line in lines[1:]: + key, _, value = line.partition(":") + key, value = key.strip().lower(), value.strip() + if key == "id": + try: + node_id = int(value) + except ValueError: + raise RecordConflict(f"{path}: invalid id {value!r}") from None + elif key == "conc": + try: + conc = [int(part) for part in re.split(r"[,\s]+", value) if part] + except ValueError: + raise RecordConflict(f"{path}: invalid conc {value!r}") from None + + if node_id is None: + raise RecordConflict(f"{path}: missing id") + return {"pubkey": pubkey, "id": node_id, "conc": conc, "path": path} + + +def load_records(repo_dir): + """All parker records, keyed by pubkey. Raises on duplicate pubkey or id.""" + records = {} + seen_ids = {} + directory = os.path.join(repo_dir, RECORDS_SUBDIR) + if not os.path.isdir(directory): + return records + for entry in sorted(os.scandir(directory), key=lambda e: e.name): + if not entry.is_file() or entry.name.startswith("."): + continue + relpath = os.path.join(RECORDS_SUBDIR, entry.name) + with open(entry.path, encoding="utf-8") as handle: + record = parse_record(handle.read(), relpath) + if record["pubkey"] in records: + raise RecordConflict( + f"{relpath}: pubkey also in {records[record['pubkey']]['path']}" + ) + if record["id"] in seen_ids: + raise RecordConflict( + f"{relpath}: id {record['id']} also in {seen_ids[record['id']]}" + ) + seen_ids[record["id"]] = relpath + records[record["pubkey"]] = record + return records + + +# --------------------------------------------------------------------------- +# id <-> prefix mapping + + +def _pool_network(prefix6): + net = ip_network(prefix6) + if net.prefixlen > DELEGATION_BITS: + raise ValueError(f"{prefix6}: pool is smaller than one /{DELEGATION_BITS}") + return net + + +def id_capacity(prefix6): + """Number of ids the pool holds; ids 1..capacity-1 are allocatable, 0 is infra.""" + net = _pool_network(prefix6) + return net.num_addresses // (2 ** (DELEGATION_BITS + 1)) + + +def id_to_prefixes(prefix6, node_id): + """Return (range6, xlat_range6) as /64 strings for one id. + + The mapping is a bijection over the pool: range6 is the 2*id-th /64 of + the pool, xlat_range6 its sibling. Renumbering the pool is therefore a + config change, not a migration of records. + """ + net = _pool_network(prefix6) + capacity = net.num_addresses // (2 ** DELEGATION_BITS) + if node_id < 1 or 2 * node_id + 1 >= capacity: + raise PoolExhausted(f"id {node_id} does not fit into {prefix6}") + step = 2**DELEGATION_BITS + base = int(net.network_address) + 2 * node_id * step + return f"{IPv6Address(base)}/64", f"{IPv6Address(base + step)}/64" + + +def first_free_id(used_ids, prefix6): + """Lowest free id in the pool, or PoolExhausted. + + First fit, so ids freed by the GC are reused. With auto-approval a + monotonic cursor would turn unauthenticated spam into permanent prefix + exhaustion. + """ + used = set(used_ids) + capacity = id_capacity(prefix6) + for candidate in range(1, capacity): + if candidate not in used: + return candidate + raise PoolExhausted(f"no free id below {capacity} in {prefix6}") + + +# --------------------------------------------------------------------------- +# config response body + + +def build_config(pool_cfg, pubkey, node_id, nonce, now=None): + """The /config answer for one node, as a dict. + + pool_cfg holds the community-wide constants; the concentrator list is + always sent in full so the node keeps a second tunnel and can fail over + without being reflashed. The node caps mtu against its own WAN MTU + (nodeconfig.lua), so the value here is the community maximum, not a + per-link computation. + """ + range6, xlat_range6 = id_to_prefixes(pool_cfg["prefix6"], node_id) + address6 = node_address(range6) + + conf = { + "nonce": nonce, + "time": int(time.time() if now is None else now), + "id": pubkey, + "mtu": pool_cfg["mtu"], + "wg_keepalive": pool_cfg["wg_keepalive"], + "retry": pool_cfg["retry"], + "range6": range6, + "address6": address6, + "range4": pool_cfg["range4"], + "address4": pool_cfg["address4"], + "concentrators": [ + { + key: conc[key] + for key in ("id", "pubkey", "endpoint", "address6", "address4") + } + for conc in pool_cfg["concentrators"] + ], + } + # 464XLAT: without this field the node falls back to routed IPv4 in the + # tunnel. Leaving it out is the documented per-node kill switch. + if pool_cfg.get("xlat", True): + conf["xlat_range6"] = xlat_range6 + return conf + + +# --------------------------------------------------------------------------- +# respondd + + +# The module list yanic asks for on a bat interface; a parker node answers the +# same request over its tunnel. +RESPOND_QUERY = b"GET nodeinfo statistics neighbours" +RESPOND_PORT = 1001 + + +def node_address(range6): + """The node's own address inside its delegated /64 - always ::1.""" + return str(ip_network(range6)[1]) + + +def respondd_node_id(response): + """The 12 hex digit node id from a decoded answer, or None. + + yanic accepts an answer only when every module that is present carries the + same id, so a single module is enough to name the node. + """ + for module in ("nodeinfo", "neighbours", "statistics"): + section = response.get(module) + if isinstance(section, dict): + node_id = section.get("node_id") + if isinstance(node_id, str) and NODE_ID_RE.match(node_id): + return node_id + return None + + +def parse_respondd(payload): + """Decode one respondd answer: raw deflate, JSON object, valid node id. + + The node answers in exactly the format yanic's own collector produces, so + the concentrator forwards the payload unchanged. Decoding here only keeps + junk out of the monitor's log and tells us which node stayed silent. + """ + try: + response = json.loads(zlib.decompress(payload, -zlib.MAX_WBITS)) + except (zlib.error, ValueError) as exc: + raise ParkerError(f"not a deflated JSON answer: {exc}") from None + if not isinstance(response, dict): + raise ParkerError("answer is not a JSON object") + if respondd_node_id(response) is None: + raise ParkerError("answer carries no 12 digit node id") + return response + + +# --------------------------------------------------------------------------- +# subprocess plumbing + + +def demote_runner(user="auto"): + """A subprocess.run lookalike that drops privileges before exec. + + Needed by the root-run cron scripts; the broker already runs as user. + """ + import pwd + + pw = pwd.getpwnam(user) + + def demote(): + os.setgid(pw.pw_gid) + os.setuid(pw.pw_uid) + + def run(cmd, **kwargs): + return subprocess.run(cmd, preexec_fn=demote, **kwargs) + + return run + + +def git(run, repo, *args, check=True): + proc = run(["git", "-C", repo, *[str(a) for a in args]], capture_output=True) + if check and proc.returncode != 0: + raise GitError( + "git " + + " ".join(str(a) for a in args) + + f": exit {proc.returncode}: " + + proc.stderr.decode(errors="replace").strip() + ) + return proc + + +class AllocationLock: + """Advisory lock so concurrent /config requests allocate one id each.""" + + def __init__(self, path): + self.path = path + self._handle = None + + def __enter__(self): + self._handle = open(self.path, "a+") + fcntl.flock(self._handle, fcntl.LOCK_EX) + return self + + def __exit__(self, *_exc): + fcntl.flock(self._handle, fcntl.LOCK_UN) + self._handle.close() + self._handle = None + + +# --------------------------------------------------------------------------- +# allocation + + +def get_or_allocate( + repo_dir, + pool_cfg, + pubkey, + *, + run=subprocess.run, + lock_path=None, + push_flag=None, + before_create=None, + sync=True, +): + """Return the record for pubkey, creating and pushing one if it is new. + + Ordering matters: once the answer carries a prefix, a lost push means + the next node gets the same /63. So the record is committed and pushed + *before* the caller is allowed to answer; anything else raises and the + node retries on its next poll. + + A push failure leaves a record behind that the next gateway will never + learn about from the remote. It is kept (the local concentrator already + serves it) but flagged; while the flag exists no further allocation + happens and every call retries the push first. + + sync=False is for a registry that only exists on this host: no pull, no + push, no push flag. The commit stays local, which keeps the ordering + guarantee trivially - there is nothing that can be lost on the way out. + + before_create(pubkey) is called once per new node under the lock; the + broker passes its rate limiter here. + """ + if lock_path is None: + lock_path = os.path.join(tempfile.gettempdir(), "ff-parker-allocate.lock") + + with AllocationLock(lock_path): + if sync: + if push_flag and os.path.exists(push_flag): + # A previous allocation is committed but unpushed. Answering + # more nodes before that lands doubles out the concentrators. + git(run, repo_dir, "push") + os.remove(push_flag) + + git(run, repo_dir, "pull", "--ff-only") + records = load_records(repo_dir) + + record = records.get(pubkey) + if record is not None: + return record + + if before_create is not None: + before_create(pubkey) + + node_id = first_free_id( + (rec["id"] for rec in records.values()), pool_cfg["prefix6"] + ) + conc = [conc["id"] for conc in pool_cfg["concentrators"]] + relpath = record_relpath(pubkey) + fullpath = os.path.join(repo_dir, relpath) + os.makedirs(os.path.dirname(fullpath), exist_ok=True) + + tmp = fullpath + ".tmp" + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(format_record(pubkey, node_id, conc)) + os.rename(tmp, fullpath) + + git(run, repo_dir, "add", "--", relpath) + git(run, repo_dir, "commit", "-m", f"parker: auto add id {node_id}") + if sync: + try: + git(run, repo_dir, "push") + except GitError as exc: + if push_flag: + with open(push_flag, "w", encoding="utf-8") as handle: + handle.write(f"{pubkey} id {node_id}\n") + raise PushPending( + f"record for {pubkey} committed but not pushed" + ) from exc + + return {"pubkey": pubkey, "id": node_id, "conc": conc, "path": relpath} + + +# --------------------------------------------------------------------------- +# rate limiting + + +class RateLimiter: + """Sliding window per source plus a cap on new nodes per day. + + The /config endpoint is unauthenticated and, unlike the batman side, + every accepted new node consumes a routed prefix. The limits make spam + annoying instead of damaging; the GC gives the burnt ids back. + """ + + def __init__(self, burst=10, window=3600, daily=200): + self.burst = burst + self.window = window + self.daily = daily + self._per_source = {} + self._today = [] + self._lock = threading.Lock() + + def allow_new(self, key, now=None): + now = time.time() if now is None else now + with self._lock: + self._today = [t for t in self._today if t > now - 86400] + if len(self._today) >= self.daily: + return False + seen = [t for t in self._per_source.get(key, []) if t > now - self.window] + if len(seen) >= self.burst: + self._per_source[key] = seen + return False + seen.append(now) + self._per_source[key] = seen + self._today.append(now) + return True + + +# --------------------------------------------------------------------------- +# signing + + +def sign_message(secret_path, message, *, binary="usign", run=subprocess.run): + """Detached usign signature over message (bytes) as a single line. + + nodeconfig.sh splits the response at the last line and verifies + everything before it, so exactly one signature line must come back. + ``binary`` names the signing tool; Debian's signify-openbsd writes the + same format and accepts the same flags. + """ + with tempfile.TemporaryDirectory() as tmpdir: + msgfile = os.path.join(tmpdir, "message") + sigfile = os.path.join(tmpdir, "message.sig") + with open(msgfile, "wb") as handle: + handle.write(message) + # The tool writes the detached signature to .sig and prints + # nothing, so name the file explicitly and read it back. + proc = run( + [binary, "-S", "-m", msgfile, "-s", secret_path, "-x", sigfile], + capture_output=True, + ) + if proc.returncode != 0: + raise SignError( + f"{binary}: exit " + f"{proc.returncode}: " + + proc.stderr.decode(errors="replace").strip() + ) + try: + with open(sigfile, encoding="utf-8") as handle: + siglines = handle.read().splitlines() + except OSError as exc: + raise SignError(f"{binary} wrote no signature file: {exc}") from exc + sigs = [ + line + for line in siglines + if line.strip() and not line.startswith("untrusted comment") + ] + if len(sigs) != 1: + raise SignError(f"{binary} produced {len(sigs)} signature lines") + return sigs[0] + + +# --------------------------------------------------------------------------- +# gateway status files (GC input) + + +def status_relpath(hostname): + return os.path.join(STATUS_SUBDIR, f"{hostname}.tsv") + + +def format_status(samples): + """One day-bucket per line: \\t. + + Buckets instead of timestamps so an active fleet only changes the file + once a day and publishing it does not spam the repo. + """ + return "".join(f"{pk}\t{bucket}\n" for pk, bucket in sorted(samples.items())) + + +def load_status(repo_dir): + """Merge the per-gateway status files, newest day-bucket per pubkey.""" + merged = {} + directory = os.path.join(repo_dir, STATUS_SUBDIR) + if not os.path.isdir(directory): + return merged + for entry in sorted(os.scandir(directory), key=lambda e: e.name): + if not entry.is_file() or not entry.name.endswith(".tsv"): + continue + with open(entry.path, encoding="utf-8") as handle: + for line in handle: + pubkey, _, bucket = line.strip().partition("\t") + if pubkey and bucket.isdigit(): + merged[pubkey] = max(merged.get(pubkey, 0), int(bucket)) + return merged + + +def record_age_days(run, repo_dir, relpath, now=None): + """Days since the record was first committed, or None if unknown.""" + proc = git( + run, repo_dir, "log", "--diff-filter=A", "--format=%ct", "--", relpath, check=False + ) + stamps = proc.stdout.decode().split() + if proc.returncode != 0 or not stamps: + return None + return ((time.time() if now is None else now) - int(stamps[0])) / 86400 diff --git a/playbooks/parker.yml b/playbooks/parker.yml new file mode 100644 index 0000000..5b3ec59 --- /dev/null +++ b/playbooks/parker.yml @@ -0,0 +1,78 @@ +--- + +- name: Configure the parker uplink + hosts: "parker_concentrators" + pre_tasks: + - name: Read the IPv6 forwarding setting + # ff.ipforward in playbooks/supernode.yml sets this; the parker play + # does not install it, so a concentrator that never ran that play would + # silently drop every packet it is supposed to translate. + ansible.builtin.slurp: + src: /proc/sys/net/ipv6/conf/all/forwarding + register: ipv6_forwarding + changed_when: false + + - name: Assert that IPv6 forwarding is enabled + ansible.builtin.assert: + that: + - ipv6_forwarding.content | b64decode | trim == "1" + fail_msg: >- + net.ipv6.conf.all.forwarding is + {{ ipv6_forwarding.content | b64decode | trim }} - NAT64 and DNS64 + only serve other hosts' traffic, and Jool hooks PRE_ROUTING, so + forwarded packets must arrive here. Run playbooks/supernode.yml + (role ff.ipforward) on this host first. + quiet: true + + - name: Assert that DNS64 and NAT64 use the same prefix + # Two independent defaults for one /96: AAAA records synthesised under a + # prefix that Jool does not translate (and vice versa) fail silently, + # with a resolvable name whose address goes nowhere. + ansible.builtin.assert: + that: + - dns64_prefix == jool_pool6 + fail_msg: >- + dns64_prefix ({{ dns64_prefix }}) and jool_pool6 + ({{ jool_pool6 }}) differ - DNS64 would synthesise addresses that + nothing translates. + quiet: true + + - name: Assert that bird reads the parker route table + # ff.parker_concentrator writes the delegated /64s into + # parker_route_table instead of main. Any other table only works if + # bird's kernel protocol is attached to it and learns routes it did not + # install itself - otherwise the prefixes exist, are announced by nobody, + # and nothing forwards to them either. + ansible.builtin.assert: + that: + - parker_route_table | default('main') == 'main' or + (bird | default({})).get('kernel', []) | selectattr('ip_table', 'defined') + | map(attribute='ip_table') | map('string') + | select('equalto', parker_route_table | string) | list | length > 0 + - parker_route_table | default('main') == 'main' or + (bird | default({})).get('kernel', []) | selectattr('learn', 'defined') + | selectattr('learn') | list | length > 0 + fail_msg: >- + parker_route_table is "{{ parker_route_table | default('main') }}", + but no bird kernel protocol uses that table, or it does not learn + foreign routes. Add `learn: true` and an import filter for the pool + to that kernel protocol (see host_vars/sn03.yml). + quiet: true + + - name: "Import pre_tasks: reboot-required.yml" + ansible.builtin.import_tasks: "include/reboot-required.yml" + tags: [always] + - name: "Import pre_task etckeeper" + ansible.builtin.import_tasks: "include/etckeeper_pre.yml" + tags: [always] + roles: + # The interface first, so the NAT64 guard can name it; then the pair that + # translates: DNS64 answers AAAA from A for clients in the parker pool, + # NAT64 translates the resulting 64:ff9b::/96 traffic to IPv4. + - { role: ff.parker_concentrator, tags: parker, become: true } + - { role: ff.dns64, tags: dns64, become: true } + - { role: ff.jool, tags: jool, become: true } + post_tasks: + - name: "Import post_task etckeeper" + ansible.builtin.import_tasks: "include/etckeeper_post.yml" + tags: [always] diff --git a/playbooks/roles/ff.backbone/templates/bird2.conf.j2 b/playbooks/roles/ff.backbone/templates/bird2.conf.j2 index 137bc6a..a83ace8 100644 --- a/playbooks/roles/ff.backbone/templates/bird2.conf.j2 +++ b/playbooks/roles/ff.backbone/templates/bird2.conf.j2 @@ -44,12 +44,15 @@ protocol kernel { {% if kernel.bird_table is defined %} table {{ kernel.bird_table }}; {%endif %} +{% if kernel.learn is defined and kernel.learn %} + learn; +{% endif %} } {% endfor %} {% for route in bird.staticroutes | default([]) %} protocol static {{ route.name }} { - import all; + {% if not route.is_ipv6 is defined or route.is_ipv6 %}ipv6 {% else %}ipv4 {% endif %}{ import all; }; {% for rule in route.rules %} {{ rule }}; {% endfor %} diff --git a/playbooks/roles/ff.dns64/defaults/main.yml b/playbooks/roles/ff.dns64/defaults/main.yml new file mode 100644 index 0000000..9858eb9 --- /dev/null +++ b/playbooks/roles/ff.dns64/defaults/main.yml @@ -0,0 +1,27 @@ +--- +# DNS64: recursive resolver that synthesises AAAA from A under +# jool_pool6-style prefix. Only for clients whose IPv4 transits our NAT64 - +# handing synthesised answers to native-IPv4 clients breaks Happy Eyeballs. +# Therefore dns64_networks must be exactly the networks those clients come +# from (for parker LANs: the delegated pool). + +dns64_prefix: "64:ff9b::/96" + +# Networks allowed to query and to receive synthesised answers. Required, +# e.g. ["2a03:2260:3006:8000::/56"]. +dns64_networks: [] + +dns64_forwarders: + - "2620:fe::fe" + - "9.9.9.9" + +# Listen addresses. Clients reach this via RDNSS, i.e. over IPv6; keeping v4 +# loopback-only avoids a stray v53 on the host's backbone interfaces. +# The IPv6 wildcard is spelled "any", not "::": BIND reads a literal address +# and skips it silently when it is not assigned here, which left the instance +# without a single IPv6 socket on sn03 and would also hide any address added +# after named started. +dns64_listen_v6: + - "any" +dns64_listen_v4: + - "127.0.0.1" diff --git a/playbooks/roles/ff.dns64/tasks/main.yml b/playbooks/roles/ff.dns64/tasks/main.yml new file mode 100644 index 0000000..9a04128 --- /dev/null +++ b/playbooks/roles/ff.dns64/tasks/main.yml @@ -0,0 +1,62 @@ +--- + +- name: Assert dns64 variables + ansible.builtin.assert: + that: + - dns64_networks | length > 0 + - "'::/0' not in dns64_networks" + - "'0.0.0.0/0' not in dns64_networks" + fail_msg: >- + dns64_networks is required and must not contain "::/0" or + "0.0.0.0/0": DNS64 may only answer clients whose IPv4 transits our + NAT64. An open resolver on a supernode uplink hands synthesised AAAA to + anyone and breaks Happy Eyeballs for native-IPv4 clients. + quiet: true + +- name: Install bind9 + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - bind9 + # dig is how the instance gets checked from a client's point of view; + # the Debian split keeps it out of the server package + - bind9-dnsutils + +- name: Configure the DNS64 instance + ansible.builtin.template: + src: named.conf.options.j2 + dest: /etc/bind/named.conf.options + mode: "0644" + # the fragment is a complete config on its own (acl + options), so + # checkconf can parse it before it replaces the working file + validate: /usr/bin/named-checkconf %s + +- name: Keep named.conf.local empty + # Debian's named.conf includes this file, and a pure DNS64 instance defines + # no zones of its own. The package ships only a comment in it - there is no + # default-zones file any more - so writing it keeps whatever a previous use + # left there from being served. + ansible.builtin.copy: + content: "// managed by ansible - no zones on a pure DNS64 instance\n" + dest: /etc/bind/named.conf.local + mode: "0644" + +- name: Enable and start bind9 + ansible.builtin.systemd: + name: named.service + state: started + enabled: true + daemon_reload: true + +- name: Reapply the configuration to the running instance + # apt starts named with Debian's own options before the template lands, and + # a handler only fires when a file changes. A play that aborts before the + # end therefore leaves a daemon that never read our config, and the next + # run has nothing to notify: on sn03 that meant no dns64 and no ACL at all. + # Reloading on every run converges the daemon onto the file on disk, and + # `rndc reload` re-reads it without dropping the listening sockets. + ansible.builtin.systemd: + name: named.service + state: reloaded + changed_when: false diff --git a/playbooks/roles/ff.dns64/templates/named.conf.options.j2 b/playbooks/roles/ff.dns64/templates/named.conf.options.j2 new file mode 100644 index 0000000..dc9fbd6 --- /dev/null +++ b/playbooks/roles/ff.dns64/templates/named.conf.options.j2 @@ -0,0 +1,38 @@ +// {{ ansible_managed }} +// DNS64 for parker LANs only. Single-purpose instance: Debian's +// named.conf includes this whole file, so the options block lives here and +// everything zone-related stays empty. + +acl dns64_clients { +{% for net in dns64_networks %} + {{ net }}; +{% endfor %} +}; + +options { + directory "/var/cache/bind"; + + recursion yes; + allow-recursion { dns64_clients; }; + allow-query { dns64_clients; }; + + // synthesised AAAA records cannot carry RRSIGs, so this instance + // must not validate for its clients + dnssec-validation no; + + // BIND >= 9.19 grammar: block statement. With the well-known /96 the + // default suffix embeds IPv4 addresses per RFC 6052, as the nodes expect. + dns64 {{ dns64_prefix }} { + }; + + forwarders { +{% for fwd in dns64_forwarders %} + {{ fwd }}; +{% endfor %} + }; + + listen-on { {{ dns64_listen_v4 | join('; ') }}; }; + listen-on-v6 { {{ dns64_listen_v6 | join('; ') }}; }; + + forward only; +}; diff --git a/playbooks/roles/ff.jool/defaults/main.yml b/playbooks/roles/ff.jool/defaults/main.yml new file mode 100644 index 0000000..508ae1d --- /dev/null +++ b/playbooks/roles/ff.jool/defaults/main.yml @@ -0,0 +1,41 @@ +--- +# Stateful NAT64 (SIIT+PLAT) via the jool kernel module. One instance, +# installed from the Debian packages. The role applies /etc/jool/jool.conf +# with `jool file handle` on every run: that replaces the config of the +# running instance and keeps its state. A `systemctl restart` is not +# equivalent - the packaged unit stops the instance with `instance remove`, +# and every dynamic BIB entry and session dies with it. + +jool_instance: nat64 + +# The well-known SYN64 prefix. The parker nodes hardcode announcing +# 64:ff9b::/96 to their clients, so this must match them. +jool_pool6: "64:ff9b::/96" + +# Address(es) translated traffic leaves from. Required; for a first test the +# host's own public IPv4 /32 works when its return path is already routed. +jool_pool4_prefix: "" + +# Ports NAT64 may use on jool_pool4_prefix, "-". Deliberately no +# default: which range is safe depends on the host's ephemeral range +# (/proc/sys/net/ipv4/ip_local_port_range) and on what listens where, and the +# role asserts both. The width is the whole concentrator's flow capacity - +# one BIB entry per client source port and protocol, held for udp 5 min and +# tcp 2 h - so it is a per-host decision, not a constant. +jool_port_range: "" + +# Source prefixes allowed to use the NAT64. Jool hooks PRE_ROUTING only and +# translates whatever is addressed to jool_pool6, so without this list every +# host that can route to us may reach IPv4 through our address. Empty means no +# guard is installed at all; the guard is opt-in and is not torn down when the +# list is emptied later on (that needs `systemctl disable --now jool-guard` +# plus `ip6tables -t raw -D PREROUTING -d -j JOOL_GUARD`). +jool_source6_allow: [] + +# Interface the traffic has to arrive on to reach the guard's chain. A source +# prefix alone proves nothing - anyone can write it into a packet header. On a +# concentrator this is the wireguard interface: its peers' allowed-ips already +# decided which source may appear there, so the interface check makes the +# source trustworthy. Empty leaves the source prefix as the only check, which +# is all that is left for clients arriving on a bridge or a lab veth. +jool_source6_interface: "" diff --git a/playbooks/roles/ff.jool/tasks/main.yml b/playbooks/roles/ff.jool/tasks/main.yml new file mode 100644 index 0000000..4062c2b --- /dev/null +++ b/playbooks/roles/ff.jool/tasks/main.yml @@ -0,0 +1,167 @@ +--- + +- name: Assert that the required jool variables are set + ansible.builtin.assert: + that: + - jool_pool4_prefix | length > 0 + - jool_port_range is match('^[0-9]{1,5}-[0-9]{1,5}$') + fail_msg: >- + jool_pool4_prefix is required (e.g. the host's public IPv4 /32) and + jool_port_range must be "-", got + "{{ jool_port_range }}". The range has no default on purpose: what is + safe depends on this host's ephemeral range and on which ports already + listen here. + quiet: true + +- name: Assert that jool_port_range describes a real range + vars: + jool_port_first: "{{ jool_port_range.split('-')[0] | int }}" + jool_port_last: "{{ jool_port_range.split('-')[1] | int }}" + ansible.builtin.assert: + that: + - jool_port_first > 0 + - jool_port_last < 65536 + - jool_port_first < jool_port_last + fail_msg: >- + jool_port_range "{{ jool_port_range }}" must satisfy + 1 <= first < last <= 65535, got {{ jool_port_first }}-{{ jool_port_last }}. + quiet: true + +- name: Read the host's ephemeral port range + # The host opens its own connections (apt, monitoring, ...) with a source + # port from here, on the same pool4 address that the NAT64 masks to. An + # overlap lets a host socket and a translated flow claim the same port, and + # one of them receives the other's replies. + ansible.builtin.slurp: + src: /proc/sys/net/ipv4/ip_local_port_range + register: jool_ephemeral_ports + +- name: Assert that jool_port_range stays clear of the ephemeral range + vars: + jool_ephemeral: "{{ (jool_ephemeral_ports.content | b64decode).split() | map('int') | list }}" + jool_port_first: "{{ jool_port_range.split('-')[0] | int }}" + jool_port_last: "{{ jool_port_range.split('-')[1] | int }}" + ansible.builtin.assert: + that: + - jool_port_last < jool_ephemeral[0] or jool_port_first > jool_ephemeral[1] + fail_msg: >- + jool_port_range {{ jool_port_range }} overlaps this host's ephemeral range + {{ jool_ephemeral[0] }}-{{ jool_ephemeral[1] }} + (/proc/sys/net/ipv4/ip_local_port_range): the host and the NAT64 would + draw from the same ports on the same address. Put the range outside it, + as the concentrator group vars do. + quiet: true + +- name: Find local sockets that listen inside jool_port_range + # Jool handles packets addressed to the pool4 address in PRE_ROUTING, before + # local delivery: a service bound there would see translated traffic it never + # asked for, and drop it for having no session. A wildcard bind owns the port + # on the pool4 address as well, so it counts too. Read-only, and also wanted + # under --check, hence check_mode: false. + ansible.builtin.shell: + executable: /bin/bash + cmd: | + set -o pipefail + { ss -H -lnt; ss -H -lnu; } | awk -v range="{{ jool_port_range }}" -v pool4="{{ jool_pool4_prefix | regex_replace('/.*$', '') }}" ' + BEGIN { split(range, r, "-"); lo = r[1] + 0; hi = r[2] + 0 } + { + n = split($4, a, ":"); port = a[n] + 0 + addr = substr($4, 1, length($4) - length(a[n]) - 1) + if (port >= lo && port <= hi && + (addr == "0.0.0.0" || addr == "*" || addr == "[::]" || addr == "::" || addr == pool4)) + print + }' + register: jool_listener_hits + changed_when: false + check_mode: false + +- name: Assert that no local socket listens inside jool_port_range + ansible.builtin.assert: + that: + - jool_listener_hits.stdout | trim | length == 0 + fail_msg: >- + jool_port_range {{ jool_port_range }} contains ports this host already + listens on: + {{ jool_listener_hits.stdout | trim }} + Jool catches packets to the pool4 address before local delivery, so those + services would stop seeing their own traffic. Move the range, or move the + service. + quiet: true + +- name: Install jool and the headers its dkms module builds against + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - jool-dkms + - jool-tools + - linux-headers-amd64 + +- name: Install iptables for the NAT64 source guard + when: jool_source6_allow | length > 0 + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: iptables + +- name: Install the NAT64 source guard script + when: jool_source6_allow | length > 0 + ansible.builtin.template: + src: jool-guard.sh.j2 + dest: /usr/local/sbin/jool-guard.sh + mode: "0755" + +- name: Install the NAT64 source guard unit + when: jool_source6_allow | length > 0 + ansible.builtin.template: + src: jool-guard.service.j2 + dest: /etc/systemd/system/jool-guard.service + mode: "0644" + +- name: Apply the source guard before NAT64 starts translating + when: jool_source6_allow | length > 0 + # No handler needed: the guard is a oneshot without RemainAfterExit, so it is + # dead after every run and systemd executes it again here, picking up a + # rewritten unit file and any change to the allow list. Starting a dead + # oneshot is a change every single run, which would hide real ones. + ansible.builtin.systemd: + name: jool-guard.service + state: started + enabled: true + daemon_reload: true + changed_when: false + +- name: Ensure the jool config directory + # jool-tools ships no /etc/jool; the packaged unit skips itself while + # /etc/jool/jool.conf is missing, so it stays dead without this + ansible.builtin.file: + path: /etc/jool + state: directory + mode: "0755" + +- name: Write jool config + # no notify: the config is applied by the task below, in this same run. A + # handler would only fire at the end of the play, and one still queued when + # a later task fails is dropped while the file on disk is already new - the + # next run then finds the file unchanged, notifies nothing, and stays green + # with an instance that never saw the change. + ansible.builtin.template: + src: jool.conf.j2 + dest: /etc/jool/jool.conf + mode: "0644" + +- name: Enable and start the NAT64 instance + ansible.builtin.systemd: + name: jool.service + state: started + enabled: true + daemon_reload: true + +- name: Apply the config to the running NAT64 instance + # `jool file handle` swaps the config of the running instance and keeps its + # state. A `systemctl restart` is not an equivalent: the packaged unit stops + # the instance with `jool ... instance remove`, which throws away every + # dynamic BIB entry and session. Always apply, so a play that died after + # writing the file still converges on the next run. + ansible.builtin.command: jool file handle /etc/jool/jool.conf + changed_when: false diff --git a/playbooks/roles/ff.jool/templates/jool-guard.service.j2 b/playbooks/roles/ff.jool/templates/jool-guard.service.j2 new file mode 100644 index 0000000..62de5c8 --- /dev/null +++ b/playbooks/roles/ff.jool/templates/jool-guard.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=Restrict which IPv6 sources Jool translates (NAT64 source guard) +Documentation=https://nicmx.github.io/Jool +After=network-pre.target +Before=jool.service + +[Service] +Type=oneshot +# The logic lives in /usr/local/sbin/jool-guard.sh: it needs rule parsing and +# loops, which do not survive systemd's own ExecStart quoting and $-expansion. +# See the script for what the chain looks like and why a failed run leaves it +# stricter instead of open. +ExecStart=/usr/local/sbin/jool-guard.sh + +[Install] +WantedBy=multi-user.target diff --git a/playbooks/roles/ff.jool/templates/jool-guard.sh.j2 b/playbooks/roles/ff.jool/templates/jool-guard.sh.j2 new file mode 100644 index 0000000..a088f2f --- /dev/null +++ b/playbooks/roles/ff.jool/templates/jool-guard.sh.j2 @@ -0,0 +1,112 @@ +#!/bin/sh +# NAT64 source guard for Jool on {{ inventory_hostname }}. +# +# Jool registers a PRE_ROUTING hook only, so it translates every forwarded +# packet addressed to the NAT64 prefix no matter where that packet came from. +# Left alone, the host is an open NAT64 relay: anyone who can route a packet +# to the pool6 prefix gets their IPv4 traffic masked with our address. +# +# The chain is jumped to only for packets addressed to the NAT64 prefix, so it +# cannot affect the host's other IPv6 forwarding. Raw PREROUTING runs at +# priority -300, ahead of Jool's -75, so nothing is translated before the +# source has been checked. +# +# A source prefix is not an identity: anyone can put it in a packet header. +# With jool_source6_interface set, the jump additionally matches the interface +# the traffic has to arrive on - on a concentrator the wireguard interface, +# where the peer's allowed-ips already decided which source may appear at all. +# Both checks are kept: the interface proves the packet came through the +# tunnel, the prefix that the peer may use the NAT64. +# +# The chain is built the fail-closed way round: the terminal DROP is created +# before the chain is reachable at all and stays the last rule, so the user +# chain is never empty - an empty chain returns every packet instead of +# dropping it. Permissions are only inserted above the DROP, and stale ones are +# deleted by name, never by flushing the chain. A run that dies halfway +# therefore leaves the guard stricter than required, not open, and a raw table +# flushed by a reboot is rebuilt on the next boot. +# +# Generated by the ff.jool role - edit the template, not this file. + +set -eu + +chain=JOOL_GUARD +pool6='{{ jool_pool6 }}' +allowed='{{ jool_source6_allow | join(" ") }}' +iface='{{ jool_source6_interface }}' + +ipt() { + ip6tables -w -t raw "$@" +} + +# 1. The chain, with its terminal DROP, before anything can jump into it. +if ! ipt -N "$chain" 2>/dev/null; then + : +fi +if ! ipt -C "$chain" -j DROP 2>/dev/null; then + ipt -A "$chain" -j DROP +fi + +# 2. The jump from PREROUTING, only for traffic addressed to the NAT64 prefix. +if [ -n "$iface" ]; then + jump="-d $pool6 -i $iface" +else + jump="-d $pool6" +fi +if ! ipt -C PREROUTING $jump -j "$chain" 2>/dev/null; then + ipt -I PREROUTING 1 $jump -j "$chain" +fi + +# 3. One RETURN above the DROP per allowed source prefix. +for src in $allowed; do + if ! ipt -C "$chain" -s "$src" -j RETURN 2>/dev/null; then + ipt -I "$chain" 1 -s "$src" -j RETURN + fi +done + +# 4. Delete RETURN rules for prefixes that are no longer allowed. +ipt -S "$chain" | while read -r rule; do + case "$rule" in + "-A $chain -s "*" -j RETURN") + src=${rule#"-A $chain -s "} + src=${src%" -j RETURN"} + case " $allowed " in + *" $src "*) ;; + *) ipt -D "$chain" -s "$src" -j RETURN ;; + esac + ;; + esac +done + +# 5. Delete jumps for a pool6 or interface that is no longer configured. +ipt -S PREROUTING | while read -r rule; do + case "$rule" in + "-A PREROUTING "*) ;; + *) continue ;; + esac + case "$rule" in + *"-j $chain") ;; + *) continue ;; + esac + dst='' + got='' + prev='' + for tok in $rule; do + case "$prev" in + -d) dst=$tok ;; + -i) got=$tok ;; + esac + prev=$tok + done + # A jump without a destination was not written by this script; deleting it + # would need its exact argument list, and leaving it only makes the guard + # stricter. Our own rules always carry -d. + [ -n "$dst" ] || continue + if [ "$dst" != "$pool6" ] || [ "$got" != "$iface" ]; then + del="-d $dst" + if [ -n "$got" ]; then + del="$del -i $got" + fi + ipt -D PREROUTING $del -j "$chain" + fi +done diff --git a/playbooks/roles/ff.jool/templates/jool.conf.j2 b/playbooks/roles/ff.jool/templates/jool.conf.j2 new file mode 100644 index 0000000..1acf26b --- /dev/null +++ b/playbooks/roles/ff.jool/templates/jool.conf.j2 @@ -0,0 +1,28 @@ +{ + "comment": { + "description": "Stateful NAT64 for parker node clients, managed by Ansible.", + "note": "bib entries are static mappings only; dynamic clients get mapped from pool4 automatically." + }, + + "instance": "{{ jool_instance }}", + "framework": "netfilter", + + "global": { + "pool6": "{{ jool_pool6 }}", + "logging-debug": false + }, + + "pool4": [ +{% for proto in ['TCP', 'UDP'] %} + { + "protocol": "{{ proto }}", + "prefix": "{{ jool_pool4_prefix }}", + "port range": "{{ jool_port_range }}" + }, +{% endfor %} + { + "protocol": "ICMP", + "prefix": "{{ jool_pool4_prefix }}" + } + ] +} diff --git a/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 b/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 index 5f9b206..82fed48 100644 --- a/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 +++ b/playbooks/roles/ff.networkd/templates/1x-iface.network.j2 @@ -72,7 +72,15 @@ MultiPathRoute={{ mpr }} {% for policy_rule in interface.routing_policies | default([]) %} [RoutingPolicyRule] +{% if policy_rule.from is defined %} From={{ policy_rule.from }} +{% endif %} +{% if policy_rule.to is defined %} +To={{ policy_rule.to }} +{% endif %} +{% if policy_rule.priority is defined %} +Priority={{ policy_rule.priority }} +{% endif %} Table={{ policy_rule.table }} {% endfor %} diff --git a/playbooks/roles/ff.parker_concentrator/defaults/main.yml b/playbooks/roles/ff.parker_concentrator/defaults/main.yml new file mode 100644 index 0000000..daf5a4c --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/defaults/main.yml @@ -0,0 +1,38 @@ +--- +# Parked (routed wireguard uplink) concentrator side. The broker assigns +# prefixes; this role applies them to one wireguard interface. +parker_iface: wg-nodes + +# Registry the broker writes (pubkey -> id -> concentrators), read here every +# minute. Unset remote = the registry is local to this host, nothing to pull. +parker_repo: /var/lib/parker/registry +parker_repo_remote: "" + +parker_listen_port: 10000 + +# Pool the broker allocates from; both must use the same value so the +# reconciler derives the same prefixes the nodes were told. +parker_prefix6: "" + +# This concentrator's address on the tunnel, the concentrators[].address6 +# the broker sends (a /128 is enough - only point-to-point peers use it). +parker_address6: "" +parker_privatekey: "" + +# Table the delegated /64s are routed in. supernode.yml exports table 42 +# into BGP; main works for a plain router. +parker_route_table: "main" + +# Publish handshake liveness into the registry so the broker-side garbage +# collector can tell dead nodes from alive ones. +parker_gc_publish: false + +# Hand each node's respondd answer to the monitoring yanic. The monitor cannot +# poll a parker node (no batman, and it records this host as the node's +# address), so the concentrator asks over the tunnel and forwards the answer +# untouched to the monitor's central yanic listener. +parker_respondd_push: false +parker_respondd_monitor: "monitor.{{ dns_searchdomains }}" +parker_respondd_port: 11001 +# Seconds a pass waits for answers before forwarding what arrived. +parker_respondd_timeout: 5 diff --git a/playbooks/roles/ff.parker_concentrator/handlers/main.yml b/playbooks/roles/ff.parker_concentrator/handlers/main.yml new file mode 100644 index 0000000..451d454 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/handlers/main.yml @@ -0,0 +1,6 @@ +--- + +- name: Restart networkd + ansible.builtin.systemd: + name: systemd-networkd + state: restarted diff --git a/playbooks/roles/ff.parker_concentrator/tasks/main.yml b/playbooks/roles/ff.parker_concentrator/tasks/main.yml new file mode 100644 index 0000000..3e4b25f --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/tasks/main.yml @@ -0,0 +1,120 @@ +--- +# Concentrator side of the parker uplink: one wireguard interface whose +# peers and prefix routes follow the parker/ records in the parker registry +# (a repository of its own, not the batman peer list). The broker +# (ff.wgbroker) hands those records out over /config. + +- name: Assert parker concentrator variables + ansible.builtin.assert: + that: + - parker_prefix6 | length > 0 + - parker_address6 | length > 0 + - parker_privatekey | length > 0 + fail_msg: "parker_prefix6, parker_address6 and parker_privatekey are required" + quiet: true + +- name: Install python3, pyroute2 and wireguard-tools + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - python3 + - python3-pyroute2 + - wireguard-tools + +- name: Ensure wireguard config directory + ansible.builtin.file: + path: /etc/wireguard + state: directory + mode: u=rwx,g=rx,o=rx + owner: root + group: systemd-network + +- name: Create parker privatekey file (DIFF REDACTED) + ansible.builtin.copy: + content: "{{ parker_privatekey }}" + dest: "/etc/wireguard/privatekey-{{ parker_iface }}" + mode: "0640" + owner: root + group: systemd-network + no_log: true + diff: "{{ show_secret_diffs }}" + +- name: Generate parker netdev (DIFF REDACTED) + ansible.builtin.template: + src: parker.netdev.j2 + dest: "/etc/systemd/network/31-{{ parker_iface }}.netdev" + mode: "0644" + owner: root + group: systemd-network + diff: "{{ show_secret_diffs }}" + notify: Restart networkd + +- name: Generate parker network + ansible.builtin.template: + src: parker.network.j2 + dest: "/etc/systemd/network/31-{{ parker_iface }}.network" + mode: "0644" + notify: Restart networkd + +- name: Ensure the parker library directory + ansible.builtin.file: + path: /usr/local/lib/parker + state: directory + mode: "0755" + owner: root + group: root + +- name: Install parker helper library + ansible.builtin.copy: + src: "{{ playbook_dir }}/files/parker_lib.py" + dest: /usr/local/lib/parker/parker_lib.py + mode: "0644" + owner: root + group: root + +- name: Install parker peer reconciler + ansible.builtin.template: + src: autoupdate_parker.py.j2 + dest: /usr/local/sbin/autoupdate_parker.py + mode: "0755" + owner: root + group: root + +- name: Install cron + ansible.builtin.apt: + name: cron + +- name: Install crontab for the parker peer reconciler + ansible.builtin.cron: + name: "autoupdate parker peers from git" + user: root + minute: "*/1" + # Cron would mail whatever the script prints; the journal is where we look + # for what a pass did. + job: /usr/local/sbin/autoupdate_parker.py 2>&1 | logger -t autoupdate_parker + +- name: Install parker respondd collector + ansible.builtin.template: + src: respondd_parker.py.j2 + dest: /usr/local/sbin/respondd_parker.py + mode: "0755" + owner: root + group: root + +- name: Install crontab for the parker respondd collector + ansible.builtin.cron: + name: "push parker node data to the monitor" + user: root + minute: "*/1" + job: /usr/local/sbin/respondd_parker.py 2>&1 | logger -t parker_respondd + when: parker_respondd_push | bool + +- name: Remove the crontab when pushing is off + # A concentrator that was switched back off keeps no stale job that would + # keep feeding a monitor it no longer belongs to. + ansible.builtin.cron: + name: "push parker node data to the monitor" + user: root + state: absent + when: not (parker_respondd_push | bool) diff --git a/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 b/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 new file mode 100644 index 0000000..45353e4 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/autoupdate_parker.py.j2 @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Apply parker node records from the registry to the concentrator interface. + +Runs as root from cron; git operations demote to the ``auto`` user, wireguard +and route changes need root. Mirrors what autoupdate_wg.py does for the +batman-side wg- interfaces, with two differences: + +* AllowedIPs are the delegated prefixes from the registry instead of a + key-derived link-local address, so they are asserted together with the + route on every pass. +* With parker_gc_publish the latest handshake per node is committed to + parker-status/ so the broker-side GC can also see nodes that only ever + reach this one gateway. + +A record conflict stops all changes: better stale peers than routing two +nodes' client prefixes to the same /63. +""" + +import os +import re +import subprocess +import sys + +sys.path.insert(0, "/usr/local/lib/parker") + +import parker_lib # noqa: E402 +from pyroute2 import WireGuard # noqa: E402 + +IFACE = "{{ parker_iface }}" +REPO = {{ parker_repo | tojson }} +SYNC = {{ 'True' if (parker_repo_remote | default('')) else 'False' }} +PREFIX6 = "{{ parker_prefix6 }}" +ROUTE_TABLE = {{ parker_route_table | tojson }} +GC_PUBLISH = {{ parker_gc_publish | default(false) | bool | ternary('True', 'False') }} +HOSTNAME = "{{ inventory_hostname }}" + +git = parker_lib.demote_runner("auto") + +DAY = 86400 + + +def peer_keys(): + out = subprocess.check_output(["wg", "show", IFACE]).decode() + return re.findall(r"^peer:\s+(\S+)$", out, re.MULTILINE) + + +def last_handshakes(): + out = subprocess.check_output( + ["wg", "show", IFACE, "latest-handshakes"] + ).decode() + found = {} + for line in out.splitlines(): + pubkey, _, stamp = line.partition("\t") + stamp = stamp.strip() + if pubkey and stamp.isdigit(): + found[pubkey] = int(stamp) + return found + + +def prefixes_of(record): + """The two delegated /64s of a record, or [] if its id is out of pool.""" + try: + return list(parker_lib.id_to_prefixes(PREFIX6, record["id"])) + except parker_lib.PoolExhausted: + print(f"parker: id {record['id']} outside {PREFIX6}, skipping") + return [] + + +def current_routes(): + """Routes in the table that belong to a node: the delegated /64s. + + bird writes its own routes into the same table (the interface's /128 among + them) and puts back what is deleted, so anything that is not a /64 is not + ours to touch. + """ + out = subprocess.check_output( + ["ip", "-6", "route", "show", "table", str(ROUTE_TABLE), "dev", IFACE] + ).decode() + routes = set() + for line in out.splitlines(): + parts = line.split() + if not parts or "/" not in parts[0]: + continue + if int(parts[0].split("/")[1]) == parker_lib.DELEGATION_BITS: + routes.add(parts[0]) + return routes + + +def sync_peers(records): + """Add/remove wireguard peers; return the set of expected prefixes.""" + iface_keys = set(peer_keys()) + repo_keys = set(records) + expected = set() + + with WireGuard() as wg: + for key in sorted(iface_keys - repo_keys): + wg.set(IFACE, peer={"public_key": key, "remove": True}) + print(f"parker: removed peer {key} from {IFACE}") + for key in sorted(repo_keys - iface_keys): + record = records[key] + prefixes = prefixes_of(record) + wg.set(IFACE, peer={"public_key": key, "allowed_ips": prefixes}) + # Without this the route set below is empty on the pass that adds + # a peer, and every existing route would be deleted for a minute. + expected.update(prefixes) + print(f"parker: added peer {key} id {record['id']} {prefixes}") + for key in sorted(iface_keys & repo_keys): + # Re-assert: the id (and with it the prefixes) of a record is + # only ever rewritten by the GC and a re-registration, but a + # hand-run `wg` command must not survive a minute. + prefixes = prefixes_of(records[key]) + wg.set(IFACE, peer={"public_key": key, "allowed_ips": prefixes}) + expected.update(prefixes) + return expected + + +def sync_routes(expected): + """Routes are what makes a prefix reachable; keep them in lockstep.""" + present = current_routes() + for prefix in sorted(expected - present): + subprocess.run( + ["ip", "-6", "route", "replace", prefix, "dev", IFACE, + "table", str(ROUTE_TABLE), "proto", "static"], + check=True, + ) + print(f"parker: route {prefix} -> {IFACE} table {ROUTE_TABLE}") + for prefix in sorted(present - expected): + subprocess.run( + ["ip", "-6", "route", "del", prefix, "dev", IFACE, + "table", str(ROUTE_TABLE)], + check=False, + ) + print(f"parker: removed route {prefix} from table {ROUTE_TABLE}") + + +def publish_status(): + """Commit this gateway's handshake day-buckets, push if we are ahead.""" + samples = { + pubkey: stamp // DAY + for pubkey, stamp in last_handshakes().items() + if stamp > 0 + } + relpath = parker_lib.status_relpath(HOSTNAME) + fullpath = f"{REPO}/{relpath}" + + if not samples: + # Nothing this gateway can report: a concentrator without peers keeps + # no file, and one whose peers all left does not leave stale buckets + # behind. The gc reads "no bucket" as "never handshaked here". + tracked = parker_lib.git( + git, REPO, "ls-files", "--error-unmatch", "--", relpath, check=False + ) + if tracked.returncode != 0: + return + if os.path.exists(fullpath): + os.unlink(fullpath) + parker_lib.git(git, REPO, "rm", "--quiet", "--", relpath) + parker_lib.git( + git, REPO, "commit", "-m", f"parker status: {HOSTNAME}: no peers" + ) + return + + new_content = parker_lib.format_status(samples) + + changed = True + try: + with open(fullpath, encoding="utf-8") as handle: + changed = handle.read() != new_content + except FileNotFoundError: + pass + + if changed: + # This script runs as root, the repository belongs to auto: without + # the chown the next git commit as auto would meet a root directory + # and a root file in a tree it is supposed to own. + import grp + import pwd + + status_dir = f"{REPO}/{parker_lib.STATUS_SUBDIR}" + os.makedirs(status_dir, exist_ok=True) + with open(fullpath, "w", encoding="utf-8") as handle: + handle.write(new_content) + uid = pwd.getpwnam("auto").pw_uid + gid = grp.getgrnam("auto").gr_gid + os.chown(status_dir, uid, gid) + os.chown(fullpath, uid, gid) + parker_lib.git(git, REPO, "add", "--", relpath) + parker_lib.git(git, REPO, "commit", "-m", f"parker status: {HOSTNAME}") + + ahead = subprocess.run( + ["git", "-C", REPO, "rev-list", "--max-count=1", "@{u}..HEAD"], + capture_output=True, + ) + if SYNC and ahead.stdout.strip(): + try: + parker_lib.git(git, REPO, "push") + except parker_lib.GitError as exc: + print(f"parker: status push failed, retrying next pass: {exc}") + + +def main(): + # A failed pull must not stop us: the local records are stale but safe, + # and aborting here would drop live peers instead. A local-only registry + # has nothing to pull in the first place. + if SYNC: + proc = subprocess.run( + ["git", "-C", REPO, "pull", "--rebase", "--autostash"], + capture_output=True, + ) + if proc.returncode != 0: + print("parker: pull failed: " + + proc.stderr.decode(errors="replace").strip()) + + try: + records = parker_lib.load_records(REPO) + except parker_lib.RecordConflict as exc: + print(f"parker: refusing to touch {IFACE}: {exc}") + return 1 + + expected = sync_peers(records) + sync_routes(expected) + # Nothing to publish into before the broker has created the registry. + if GC_PUBLISH and os.path.isdir(os.path.join(REPO, ".git")): + publish_status() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 b/playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 new file mode 100644 index 0000000..7924873 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/parker.netdev.j2 @@ -0,0 +1,8 @@ +# {{ ansible_managed }} +[NetDev] +Name={{ parker_iface }} +Kind=wireguard + +[WireGuard] +PrivateKeyFile=/etc/wireguard/privatekey-{{ parker_iface }} +ListenPort={{ parker_listen_port }} diff --git a/playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 b/playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 new file mode 100644 index 0000000..75e9729 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/parker.network.j2 @@ -0,0 +1,9 @@ +# {{ ansible_managed }} +[Match] +Name={{ parker_iface }} + +[Network] +Address={{ parker_address6.split('/')[0] }}/128 +# the concentrator is the other end of point-to-point tunnels, nothing +# announces routes here +IPv6AcceptRA=false diff --git a/playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 b/playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 new file mode 100644 index 0000000..c68b6e5 --- /dev/null +++ b/playbooks/roles/ff.parker_concentrator/templates/respondd_parker.py.j2 @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Forward parker node data to the monitor's yanic. + +A parker node is not part of a batman domain: its address sits in the parker +pool behind wg-nodes, so neither this host's yanic nor the monitor's can reach +it with a multicast request - and the monitor records this host as the node's +address, so it never polls the node itself either. + +What does work is one unicast request over the tunnel: the node runs the same +respondd as a Gluon node and answers on port 1001 with nodeinfo, statistics and +neighbours. The answer is already raw-deflated JSON, the format yanic's own +respondd database connection sends, so it is handed to the monitor unchanged; +the monitor parses it like any local answer. + +Runs from cron every minute. A lost request costs one minute of freshness, not +a marked-offline node: the monitor's offline_after is five minutes. +""" + +import socket +import sys +import time + +sys.path.insert(0, "/usr/local/lib/parker") + +import parker_lib # noqa: E402 + +REPO = {{ parker_repo | tojson }} +PREFIX6 = "{{ parker_prefix6 }}" +MONITOR_HOST = "{{ parker_respondd_monitor }}" +MONITOR_PORT = {{ parker_respondd_port | default(11001) | int }} +TIMEOUT = {{ parker_respondd_timeout | default(5) | int }} + + +def targets(records): + """address -> record for every record that fits into the pool.""" + found = {} + for record in records.values(): + try: + range6, _xlat = parker_lib.id_to_prefixes(PREFIX6, record["id"]) + except parker_lib.PoolExhausted: + print(f"parker-respondd: id {record['id']} outside {PREFIX6}, skipping") + continue + found[parker_lib.node_address(range6)] = record + return found + + +def ask(addresses): + """Send the module list to every node, collect answers until TIMEOUT.""" + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + sock.settimeout(0.2) + answers = {} + try: + for address in addresses: + sock.sendto( + parker_lib.RESPOND_QUERY, (address, parker_lib.RESPOND_PORT) + ) + deadline = time.monotonic() + TIMEOUT + while len(answers) < len(addresses) and time.monotonic() < deadline: + try: + payload, source = sock.recvfrom(65535) + except socket.timeout: + continue + address = source[0] + try: + parker_lib.parse_respondd(payload) + except parker_lib.ParkerError as exc: + print(f"parker-respondd: {address}: {exc}") + continue + if address not in addresses: + print(f"parker-respondd: {address}: answer nobody asked for") + continue + answers[address] = payload + finally: + sock.close() + return answers + + +def forward(payloads): + """Hand every answer to the monitor unchanged.""" + info = socket.getaddrinfo( + MONITOR_HOST, MONITOR_PORT, socket.AF_INET6, socket.SOCK_DGRAM + ) + family, socktype, proto, _canon, sockaddr = info[0] + sock = socket.socket(family, socktype, proto) + try: + for payload in payloads: + sock.sendto(payload, sockaddr) + finally: + sock.close() + + +def main(): + try: + records = parker_lib.load_records(REPO) + except parker_lib.RecordConflict as exc: + # Same stance as the reconciler: report a broken registry instead of + # guessing around it, and send nothing this pass. + print(f"parker-respondd: refusing to read {REPO}: {exc}") + return 1 + + wanted = targets(records) + if not wanted: + return 0 + + try: + answers = ask(list(wanted)) + except OSError as exc: + print(f"parker-respondd: cannot query the nodes: {exc}") + return 1 + + for address in (address for address in wanted if address not in answers): + print(f"parker-respondd: no answer from {address} (id {wanted[address]['id']})") + + if answers: + try: + forward(list(answers.values())) + except OSError as exc: + # A monitor outage is not a reason to mail every minute; the + # monitor ages the nodes out on its own. + print( + f"parker-respondd: cannot reach {MONITOR_HOST}:{MONITOR_PORT}: {exc}" + ) + return 1 + + print( + f"parker-respondd: {len(wanted)} nodes, {len(answers)} answered, " + f"{len(answers)} forwarded to {MONITOR_HOST}:{MONITOR_PORT}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/playbooks/roles/ff.wgbroker/defaults/main.yml b/playbooks/roles/ff.wgbroker/defaults/main.yml new file mode 100644 index 0000000..f309542 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/defaults/main.yml @@ -0,0 +1,60 @@ +--- +# Parker config service (GET /config). Leave wgbroker_parker undefined (or +# without enabled: true) to keep the broker at add_key only. Example: +# +# wgbroker_parker: +# enabled: true +# prefix6: "2a03:2260:3006:8000::/56" # pool; id -> 2*id-th /64 (+ sibling for xlat) +# range4: "10.80.96.0/22" # community constant, only its length sizes the LAN DHCP +# address4: "10.80.96.1" +# mtu: 1406 +# wg_keepalive: 25 +# retry: 120 +# xlat: true # omit in a config change to drop 464XLAT per fleet +# usign_secret: | # key content, `usign -G -p parker.pub -s parker.sec` +# untrusted comment: ... +# rWV2... +# lock_path: /home/auto/.parker-allocation.lock +# push_flag: /home/auto/.parker-push-pending +# rate_limit: +# burst: 10 # new nodes per source per window +# window: 3600 +# daily: 200 # new nodes per day overall +# concentrators: +# - id: 1 +# pubkey: "" +# endpoint: "01.wg-node.freifunk-aachen.de:10000" +# address6: "2a03:2260:3006:8000::" +# address4: "10.80.96.2" +# - id: 2 +# ... +# gc: +# enabled: false # cron on the broker host +# dry_run: true # log what would be deleted +# never_handshaked_days: 7 +# silent_days: 90 +wgbroker_parker: {} + +# Registry for the parker records (pubkey -> id -> concentrators) and the +# gateway status files. Its own repository, not the batman peer list: parker +# keys must not live in a tree that every node registration rewrites, and +# the concentrator reads it on a minute schedule of its own. +# Empty remote = local only: the broker commits and never pulls or pushes, +# so the commits on this host are the audit log. +wgbroker_parker_repo: /var/lib/parker/registry +wgbroker_parker_repo_remote: "" + +# Where the role writes wgbroker_parker.usign_secret (usign/signify key +# content) and the tool the broker signs /config with. Debian ships OpenBSD +# signify as signify-openbsd and has no usign package; the signature format +# and the key files are the same, so a node's usign verifies either. Unlike +# usign, signify insists that a secret key file be named *.sec. +wgbroker_parker_secret_key: /home/auto/usign/parker.sec +wgbroker_parker_signer: signify-openbsd + +# Public name the broker answers on. When set, the role installs its own +# nginx vhost with a Let's Encrypt certificate and keeps it renewed; empty +# leaves the front to the host's own nginx (so `monitor`, which runs +# ff.nginx/ff.certbot, does not grow a second vhost). +wgbroker_vhost: "" +wgbroker_acme_webroot: /var/www/html diff --git a/playbooks/roles/ff.wgbroker/handlers/main.yml b/playbooks/roles/ff.wgbroker/handlers/main.yml new file mode 100644 index 0000000..0f31453 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded diff --git a/playbooks/roles/ff.wgbroker/tasks/main.yml b/playbooks/roles/ff.wgbroker/tasks/main.yml index f2c1344..c4bcd35 100644 --- a/playbooks/roles/ff.wgbroker/tasks/main.yml +++ b/playbooks/roles/ff.wgbroker/tasks/main.yml @@ -8,7 +8,6 @@ vars: packages: - python3 - - python3-pip - git - name: Create auto user @@ -53,12 +52,12 @@ group: auto register: updatebroker -- name: Install gunicorn and Flask python package - become: true - become_user: auto - ansible.builtin.pip: +- name: Install flask and gunicorn + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 name: - - flask + - python3-flask - gunicorn - name: Set Git Config Mail @@ -89,6 +88,17 @@ scope: local value: "auto" +# The workers import this at start, so it has to be in place before the +# service is (re)started below. +- name: Install parker helper library + ansible.builtin.copy: + src: "{{ playbook_dir }}/files/parker_lib.py" + dest: /home/auto/parker_lib.py + mode: "0644" + owner: auto + group: auto + register: updateparkerlib + - name: Install broker service ansible.builtin.template: src: wgbroker.service.j2 @@ -102,3 +112,178 @@ daemon_reload: true enabled: true when: servicefile is changed or updatebroker is changed + or updateparkerlib is changed + +# parker config service (GET /config), see defaults/main.yml + +- name: Install the parker config signer + ansible.builtin.apt: + name: "{{ wgbroker_parker_signer }}" + when: wgbroker_parker.get('enabled', false) | bool + +- name: Ensure the signing key directory + ansible.builtin.file: + path: "{{ wgbroker_parker_secret_key | dirname }}" + state: directory + owner: auto + group: auto + mode: "0750" + when: wgbroker_parker.get('enabled', false) | bool + +- name: Install parker signing key + ansible.builtin.copy: + content: "{{ wgbroker_parker.usign_secret }}" + dest: "{{ wgbroker_parker_secret_key }}" + mode: "0600" + owner: auto + group: auto + directory_mode: "0750" + no_log: true + when: wgbroker_parker.get('enabled', false) | bool + +# The registry is a repository of its own, so the parker records never mix +# with the batman peer keys in peers-wg. Local only unless a remote is set: +# the broker then commits here and the concentrators read the same tree. +- name: Create the parker registry directory + ansible.builtin.file: + path: "{{ wgbroker_parker_repo }}" + state: directory + owner: auto + group: auto + mode: "0750" + when: wgbroker_parker.get('enabled', false) | bool + +# git init has no module (the git module clones, it does not create), and a +# local registry needs no clone. +- name: Initialise the parker registry repository + become: true + become_user: auto + ansible.builtin.command: # noqa: command-instead-of-module + cmd: git init --initial-branch=main . + chdir: "{{ wgbroker_parker_repo }}" + creates: "{{ wgbroker_parker_repo }}/.git" + when: wgbroker_parker.get('enabled', false) | bool + +- name: Set the parker registry commit identity + become: true + become_user: auto + community.general.git_config: + name: "{{ item.name }}" + value: "{{ item.value }}" + repo: "{{ wgbroker_parker_repo }}" + scope: local + loop: + - { name: user.name, value: "auto" } + - { name: user.email, value: "auto@{{ dns_searchdomains }}" } + when: wgbroker_parker.get('enabled', false) | bool + +- name: Install parker garbage collector + ansible.builtin.template: + src: parker_gc.py.j2 + dest: /home/auto/parker_gc.py + mode: "0744" + owner: auto + group: auto + when: wgbroker_parker.get('enabled', false) | bool + and wgbroker_parker.get('gc', {}).get('enabled', false) | bool + +- name: Install crontab for the parker garbage collector + ansible.builtin.cron: + name: "parker gc" + user: auto + minute: "30" + hour: "4" + job: /usr/bin/python3 /home/auto/parker_gc.py + when: wgbroker_parker.get('enabled', false) | bool + and wgbroker_parker.get('gc', {}).get('enabled', false) | bool + +# Public front, only when the role owns the vhost (wgbroker_vhost). Tagged so +# the certificate and the vhost can be brought up before the broker itself. + +- name: Install nginx and certbot + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + name: + - nginx + - certbot + - cron + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Create the ACME webroot + ansible.builtin.file: + path: "{{ wgbroker_acme_webroot }}" + state: directory + mode: "0755" + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Check for an existing broker certificate + ansible.builtin.stat: + path: /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem + register: wgbroker_cert + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +# Only until there is a certificate: the https vhost below carries the same +# port 80 block, and two templates on one file means both report changed on +# every run and nginx reloads for nothing. +- name: Install the broker vhost (http, for the certificate) + ansible.builtin.template: + src: wgbroker-vhost-http.conf.j2 + dest: /etc/nginx/sites-available/wgbroker.conf + mode: "0644" + notify: Reload nginx + when: + - wgbroker_vhost | length > 0 + - not wgbroker_cert.stat.exists + tags: [broker_vhost] + +- name: Enable the broker vhost + ansible.builtin.file: + src: /etc/nginx/sites-available/wgbroker.conf + dest: /etc/nginx/sites-enabled/wgbroker.conf + state: link + notify: Reload nginx + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Obtain the broker certificate + ansible.builtin.command: + cmd: >- + certbot certonly --webroot -w {{ wgbroker_acme_webroot }} + -d {{ wgbroker_vhost }} --non-interactive --agree-tos + --email {{ info_mail }} + creates: /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +- name: Check for the broker certificate + ansible.builtin.stat: + path: /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem + register: wgbroker_cert + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] + +# Only with a certificate on disk: an ssl server block that points at a +# missing file keeps nginx from starting at all. +- name: Install the broker vhost (https) + ansible.builtin.template: + src: wgbroker-vhost.conf.j2 + dest: /etc/nginx/sites-available/wgbroker.conf + mode: "0644" + notify: Reload nginx + when: + - wgbroker_vhost | length > 0 + - wgbroker_cert.stat.exists + tags: [broker_vhost] + +- name: Set up certificate renewal + ansible.builtin.cron: + name: certbot-renewal + job: "/bin/bash -lc '/usr/bin/certbot -q renew'" + minute: "0" + hour: "14" + when: wgbroker_vhost | length > 0 + tags: [broker_vhost] diff --git a/playbooks/roles/ff.wgbroker/templates/broker.py b/playbooks/roles/ff.wgbroker/templates/broker.py index 6643379..843ef3e 100644 --- a/playbooks/roles/ff.wgbroker/templates/broker.py +++ b/playbooks/roles/ff.wgbroker/templates/broker.py @@ -1,4 +1,5 @@ import glob +import json import os import pwd import re @@ -7,16 +8,57 @@ from pathlib import Path -from flask import Flask, jsonify, request +import parker_lib +from flask import Flask, Response, jsonify, request +from werkzeug.middleware.proxy_fix import ProxyFix REPO = "/etc/wireguard/peers-wg" +# The parker records live in a registry of their own, not in the batman peer +# list above. Empty remote = local only: no pull, no push, the commits on +# this host are the audit log. +PARKER_REPO = {{ wgbroker_parker_repo | tojson }} +PARKER_REPO_REMOTE = {{ wgbroker_parker_repo_remote | default("") | tojson }} + +# Parker config service: community constants and concentrator list from +# group/host vars, null disables the /config endpoint. See +# playbooks/files/parker_lib.py. +# (Double tojson: the config arrives as one JSON string literal, since +# tojson's true/false/null are not python literals.) +# usign_secret is key content, not runtime config: drop it so the key never +# lands in this readable file; the broker signs with PARKER_SIGN_KEY below. +{% set _parker = (wgbroker_parker | default({}, true)) | dict2items + | rejectattr("key", "equalto", "usign_secret") | items2dict %} +PARKER = json.loads({{ _parker | tojson | tojson }}) + +# The role writes the signing key to this path (owner auto, mode 0600). +PARKER_SIGN_KEY = {{ wgbroker_parker_secret_key | tojson }} +PARKER_SIGNER = {{ wgbroker_parker_signer | tojson }} + app = Flask(__name__) +# nginx is the only front (gunicorn listens on localhost): without the +# header from the vhost every node looks like 127.0.0.1, and the per-source +# rate limit turns into one global bucket. +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1) +# Flask's logger inherits WARNING from the root logger, so the one line per +# /config request (who, which version, which mtu) would never reach the +# journal. +app.logger.setLevel("INFO") # TODO(ruairi): Refactor load_config to return Dataclass. WG_PUBKEY_PATTERN = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") +_parker_cfg = PARKER if PARKER and PARKER.get("enabled") else None +_parker_limiter = None +if _parker_cfg: + _limits = _parker_cfg.get("rate_limit", {}) + _parker_limiter = parker_lib.RateLimiter( + burst=_limits.get("burst", 10), + window=_limits.get("window", 3600), + daily=_limits.get("daily", 200), + ) + # https://gist.github.com/berlotto/6295018 _slugify_strip_re = re.compile(r'[^\w\s-]') _slugify_hyphenate_re = re.compile(r'[-\s]+') @@ -48,9 +90,16 @@ def execute_autouser(cmd): env = os.environ.copy() env.update({'HOME': homedir, 'LOGNAME': autouser, 'USER': autouser}) - s = subprocess.Popen([cmd], shell=True, env=env, - preexec_fn=demote(user_uid, user_gid), stdout=subprocess.PIPE) - s.wait() + s = subprocess.run([cmd], shell=True, env=env, + preexec_fn=demote(user_uid, user_gid), + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if s.returncode != 0: + # Before the reply carried allocated prefixes a lost commit or push + # was invisible and harmless; the node would just retry. Surface it + # in the response instead of answering "OK" regardless. + raise RuntimeError(f"{cmd}: exit {s.returncode}: " + + s.stderr.decode(errors='replace').strip()) + return s def push_repo(): execute_autouser(f"git -C {REPO} push") @@ -114,6 +163,80 @@ def add_key(): return jsonify({"Message": "OK"}), 200 +def _parker_error(message, status): + # nodeconfig.sh retries on any failed fetch; a plain status code is all + # the node reads, the body is for journalctl. + app.logger.warning(f"parker /config {status}: {message}") + return Response(f"{message}\n", status=status, content_type="text/plain") + + +@app.route("/config", methods=["GET"]) +def parker_config(): + """Introduce a parker node and hand it its delegated prefixes. + + Query parameters as sent by nodeconfig.sh: pubkey, nonce, v6mtu, + version and optionally tc_ingress/tc_egress. The response is the JSON + config followed by a newline and one detached usign signature line; + the node verifies it against site.parker.config_pubkey. + """ + if _parker_cfg is None: + return _parker_error("parker config service disabled", 404) + + try: + pubkey = parker_lib.validate_pubkey(request.args.get("pubkey", "").strip()) + nonce = parker_lib.validate_nonce(request.args.get("nonce", "")) + except parker_lib.InvalidRequest as exc: + return _parker_error(str(exc), 400) + + app.logger.info( + "parker /config from %s: version=%s v6mtu=%s tc=%s/%s", + request.remote_addr, + request.args.get("version"), + request.args.get("v6mtu"), + request.args.get("tc_ingress"), + request.args.get("tc_egress"), + ) + + def rate_limit(new_pubkey): + source = request.remote_addr or "unknown" + if not _parker_limiter.allow_new(source): + raise parker_lib.RateLimited( + f"allocation limit reached for {source}" + ) + + try: + record = parker_lib.get_or_allocate( + PARKER_REPO, + _parker_cfg, + pubkey, + lock_path=_parker_cfg.get("lock_path", "/home/auto/.parker-allocation.lock"), + push_flag=_parker_cfg.get("push_flag", "/home/auto/.parker-push-pending"), + before_create=rate_limit, + sync=bool(PARKER_REPO_REMOTE), + ) + conf = parker_lib.build_config(_parker_cfg, pubkey, record["id"], nonce) + body = json.dumps(conf, separators=(",", ":")) + "\n" + signature = parker_lib.sign_message( + PARKER_SIGN_KEY, body.encode("utf-8"), binary=PARKER_SIGNER + ) + except parker_lib.RateLimited as exc: + return _parker_error(str(exc), 429) + except parker_lib.PushPending: + # Committed here but not on the git server: the second gateway + # would not program this prefix. Retry the push on the next poll. + return _parker_error("registry not in sync, retrying", 503) + except (parker_lib.PoolExhausted, parker_lib.RecordConflict) as exc: + return _parker_error(f"parker registry: {exc}", 503) + except parker_lib.GitError as exc: + return _parker_error(f"registry unavailable: {exc}", 503) + except parker_lib.SignError as exc: + return _parker_error(f"signing failed: {exc}", 500) + + return Response( + body + signature + "\n", status=200, content_type="text/plain; charset=utf-8" + ) + + #@app.route("/api/del_key", methods=["POST"]) def del_key(): data = request.get_json(force=True) diff --git a/playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 b/playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 new file mode 100644 index 0000000..59ecfc1 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/templates/parker_gc.py.j2 @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Delete parker records whose node is gone, freeing the id for reuse. + +Runs as user ``auto`` on the broker host. With auto-approval the /config +endpoint mints routed prefixes to whoever asks, so the GC is the valve that +keeps unauthenticated spam from permanently eating the pool: a record that +never handshaked or went silent for long enough is removed and its id +becomes allocatable again. + +Liveness comes from the per-gateway status files the reconcilers publish +below parker-status/ in the registry. A record is only deleted when no +gateway has seen a recent handshake: a node that only ever reaches one +concentrator stays alive on the strength of that one file. + +Until parker_gc.dry_run is turned off this only logs. +""" + +import json +import subprocess +import sys +import time + +import parker_lib + +REPO = {{ wgbroker_parker_repo | tojson }} +SYNC = {{ 'True' if (wgbroker_parker_repo_remote | default('')) else 'False' }} + +# double tojson: json.loads of a string literal, because tojson's +# true/false/null are not python literals +CFG = json.loads({{ wgbroker_parker | tojson | tojson }}) +GC = CFG.get("gc", {}) + +NEVER_HANDSHAKED_DAYS = GC.get("never_handshaked_days", 7) +SILENT_DAYS = GC.get("silent_days", 90) +DRY_RUN = GC.get("dry_run", True) + +run = subprocess.run +DAY = 86400 + + +def find_dead(records, status, now): + today_bucket = int(now) // DAY + dead = [] + for pubkey, record in records.items(): + bucket = status.get(pubkey) + if bucket is None: + age = parker_lib.record_age_days(run, REPO, record["path"], now=now) + if age is not None and age > NEVER_HANDSHAKED_DAYS: + dead.append((record, f"never handshaked, {age:.0f} d old")) + elif today_bucket - bucket > SILENT_DAYS: + dead.append((record, f"silent since day {bucket}, {today_bucket - bucket} d")) + return dead + + +def main(): + if SYNC: + parker_lib.git(run, REPO, "pull", "--ff-only") + + records = parker_lib.load_records(REPO) + status = parker_lib.load_status(REPO) + dead = find_dead(records, status, time.time()) + + if not dead: + print(f"parker gc: {len(records)} records, nothing to do") + return 0 + + for record, reason in dead: + print(f"parker gc: {'would delete' if DRY_RUN else 'deleting'} " + f"{record['path']} (id {record['id']}): {reason}") + + if DRY_RUN: + return 0 + + for record, _reason in dead: + parker_lib.git(run, REPO, "rm", "--quiet", "--", record["path"]) + parker_lib.git( + run, + REPO, + "commit", + "-m", + f"parker gc: remove {len(dead)} dead record(s)", + ) + if SYNC: + parker_lib.git(run, REPO, "push") + print(f"parker gc: done, {len(dead)} record(s) removed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 new file mode 100644 index 0000000..9512ee3 --- /dev/null +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost-http.conf.j2 @@ -0,0 +1,31 @@ +# Managed by the ff.wgbroker role. Plain HTTP only, so that certbot can +# answer the ACME challenge before any certificate exists. +server { + listen 80; + listen [::]:80; + server_name {{ wgbroker_vhost }}; + + location /.well-known/acme-challenge/ { + root {{ wgbroker_acme_webroot }}; + } + + # The node-facing endpoints stay on plain HTTP: Gluon's busybox is built + # without TLS, so nodeconfig.sh cannot follow a redirect. The answer is + # signed and the request carries nothing but the node's public key; the + # nonce keeps a captured answer from being replayed. + location /config { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location /api/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location / { + return 301 https://$host$request_uri; + } +} diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 new file mode 100644 index 0000000..590335d --- /dev/null +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker-vhost.conf.j2 @@ -0,0 +1,48 @@ +# Managed by the ff.wgbroker role. +server { + listen 80; + listen [::]:80; + server_name {{ wgbroker_vhost }}; + + location /.well-known/acme-challenge/ { + root {{ wgbroker_acme_webroot }}; + } + + # The node-facing endpoints stay on plain HTTP: Gluon's busybox is built + # without TLS, so nodeconfig.sh cannot follow a redirect. The answer is + # signed and the request carries nothing but the node's public key; the + # nonce keeps a captured answer from being replayed. + location /config { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location /api/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ wgbroker_vhost }}; + + ssl_certificate /etc/letsencrypt/live/{{ wgbroker_vhost }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ wgbroker_vhost }}/privkey.pem; + ssl_trusted_certificate /etc/letsencrypt/live/{{ wgbroker_vhost }}/chain.pem; + ssl_stapling on; + ssl_stapling_verify on; + + location / { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:8000; + } +} diff --git a/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 b/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 index de444cb..d1539f0 100644 --- a/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 +++ b/playbooks/roles/ff.wgbroker/templates/wgbroker.service.j2 @@ -7,7 +7,10 @@ After=network.target User=auto WorkingDirectory=/home/auto Environment="PYTHONUNBUFFERED=TRUE" -ExecStart=/home/auto/.local/bin/gunicorn -b [::]:8000 broker:app +# Loopback only: nginx is the front, and a directly reachable worker would +# let anyone hand in their own X-Forwarded-For and step around the per-source +# rate limit. +ExecStart=/usr/bin/gunicorn -b 127.0.0.1:8000 broker:app Restart=always [Install] diff --git a/tests/test_parker_lib.py b/tests/test_parker_lib.py new file mode 100644 index 0000000..116da03 --- /dev/null +++ b/tests/test_parker_lib.py @@ -0,0 +1,521 @@ +"""Tests for playbooks/files/parker_lib.py. + +Run with: python3 -m pytest tests/ +""" + +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "playbooks" / "files")) + +import parker_lib # noqa: E402 + +# wireguard keys: 42 base64 chars, one check char, one '=' +PUBKEY = "A" * 43 + "=" +PUBKEY2 = "Ag" + "A" * 40 + "E=" +PUBKEY3 = "A" * 42 + "M=" +NONCE = "0123456789abcdef0123456789abcdef" +POOL = "2a03:2260:3006:8000::/56" +# exactly two /64 pairs: only id 1 exists, id 2 is already past the end +SMALL_POOL = "2a03:2260:3006:c000::/62" + + +def pool_cfg(prefix=POOL): + return { + "prefix6": prefix, + "range4": "10.80.96.0/22", + "address4": "10.80.96.1", + "mtu": 1406, + "wg_keepalive": 25, + "retry": 120, + "concentrators": [ + { + "id": 1, + "pubkey": "B" * 42 + "E=", + "endpoint": "01.example:10000", + "address6": "2a03:2260:3006:8000::", + "address4": "10.80.96.2", + "uninteresting": "dropped", + } + ], + } + + +@pytest.fixture +def repo(tmp_path): + """A clone with a bare origin, like /etc/wireguard/peers-wg.""" + origin = tmp_path / "origin.git" + work = tmp_path / "peers-wg" + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True + ) + subprocess.run(["git", "clone", "-q", str(origin), str(work)], check=True) + for key, value in ( + ("user.name", "auto"), + ("user.email", "auto@example"), + ("commit.gpgsign", "false"), + ): + subprocess.run(["git", "-C", str(work), "config", key, value], check=True) + (work / "README").write_text("peers\n") + subprocess.run(["git", "-C", str(work), "add", "README"], check=True) + subprocess.run(["git", "-C", str(work), "commit", "-qm", "init"], check=True) + subprocess.run( + ["git", "-C", str(work), "push", "-q", "-u", "origin", "main"], check=True + ) + return work + + +@pytest.fixture +def local_repo(tmp_path): + """A registry without any remote, like /var/lib/parker/registry.""" + work = tmp_path / "registry" + subprocess.run(["git", "init", "-q", "-b", "main", str(work)], check=True) + for key, value in ( + ("user.name", "auto"), + ("user.email", "auto@example"), + ("commit.gpgsign", "false"), + ): + subprocess.run(["git", "-C", str(work), "config", key, value], check=True) + return work + + +def remote_log(work, branch="main"): + return subprocess.run( + ["git", "-C", str(work), "log", "--format=%s", f"origin/{branch}"], + capture_output=True, + check=True, + ).stdout.decode().splitlines() + + +def run_but_fail_on(fail_on): + """A subprocess.run lookalike that fails for git subcommands in fail_on.""" + + def run(cmd, **kwargs): + if any(arg in fail_on for arg in cmd): + return subprocess.CompletedProcess(cmd, 1, b"", b"simulated failure") + return subprocess.run(cmd, **kwargs) + + return run + + +# --- validation ------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bad", ["", "short", "a" * 43, PUBKEY[:-2] + "a=", PUBKEY + "x"] +) +def test_validate_pubkey_rejects(bad): + with pytest.raises(parker_lib.InvalidRequest): + parker_lib.validate_pubkey(bad) + + +def test_validate_pubkey_and_nonce_accept(): + assert parker_lib.validate_pubkey(PUBKEY) == PUBKEY + assert parker_lib.validate_nonce(NONCE) == NONCE + with pytest.raises(parker_lib.InvalidRequest): + parker_lib.validate_nonce("xyz") + + +# --- records ---------------------------------------------------------------- + + +def test_record_roundtrip_ignores_extra_fields(): + text = parker_lib.format_record(PUBKEY, 412, [1, 2]) + "reviewed: yes\n" + rec = parker_lib.parse_record(text) + assert rec == {"pubkey": PUBKEY, "id": 412, "conc": [1, 2], "path": "parker"} + + +@pytest.mark.parametrize( + "text", + [ + "\n", + "notapubkey\nid: 1\n", + PUBKEY + "\n", + PUBKEY + "\nid: twelve\n", + PUBKEY + "\nid: 3\nconc: a,b\n", + ], +) +def test_parse_record_rejects(text): + with pytest.raises(parker_lib.RecordConflict): + parker_lib.parse_record(text) + + +def test_load_records_detects_duplicates(repo): + (repo / "parker").mkdir() + (repo / "parker" / "k1").write_text(parker_lib.format_record(PUBKEY, 5, [1])) + assert parker_lib.load_records(str(repo))[PUBKEY]["id"] == 5 + + (repo / "parker" / "k2").write_text(parker_lib.format_record(PUBKEY, 6, [1])) + with pytest.raises(parker_lib.RecordConflict, match="pubkey"): + parker_lib.load_records(str(repo)) + + (repo / "parker" / "k2").write_text(parker_lib.format_record(PUBKEY2, 5, [1])) + with pytest.raises(parker_lib.RecordConflict, match="id"): + parker_lib.load_records(str(repo)) + + +def test_load_records_missing_dir(tmp_path): + assert parker_lib.load_records(str(tmp_path)) == {} + + +def test_record_filename_is_filesystem_safe(): + weird = "ab+cd/efghijklmnopqrstuvwxyz0123456789012E=" + name = parker_lib.record_relpath(weird) + assert "/" not in Path(name).name and "+" not in name + assert name.startswith("parker/") + + +# --- id <-> prefix ---------------------------------------------------------- + + +def test_pool_sizing(): + assert parker_lib.id_capacity("2a03:2260:3006::/48") == 32768 + assert parker_lib.id_capacity(POOL) == 128 + # growing to the surrounding /49 later is a config change, not a + # migration: 2*id maps onto the same /64 in both + assert parker_lib.id_capacity("2a03:2260:3006:8000::/49") == 16384 + assert parker_lib.id_capacity(SMALL_POOL) == 2 + + +def test_id_to_prefixes_bijection(): + # an id owns two consecutive /64s of the pool; id 0 is reserved for infra, + # so id 1 starts at the *second* pair of /64s of the pool + assert parker_lib.id_to_prefixes(POOL, 1) == ( + "2a03:2260:3006:8002::/64", + "2a03:2260:3006:8003::/64", + ) + assert parker_lib.id_to_prefixes(POOL, 12) == ( + "2a03:2260:3006:8018::/64", + "2a03:2260:3006:8019::/64", + ) + with pytest.raises(parker_lib.PoolExhausted): + parker_lib.id_to_prefixes(POOL, 0) + with pytest.raises(parker_lib.PoolExhausted): + parker_lib.id_to_prefixes(POOL, parker_lib.id_capacity(POOL)) + + +def test_first_free_id_reuses_and_exhausts(): + assert parker_lib.first_free_id([], POOL) == 1 + assert parker_lib.first_free_id({1, 2, 4}, POOL) == 3 + with pytest.raises(parker_lib.PoolExhausted): + parker_lib.first_free_id({1}, SMALL_POOL) + + +# --- config body ------------------------------------------------------------- + + +def test_build_config_fields(): + conf = parker_lib.build_config(pool_cfg(), PUBKEY, 12, NONCE, now=1762000000) + assert conf["id"] == PUBKEY + assert conf["nonce"] == NONCE + assert conf["time"] == 1762000000 + assert conf["range6"] == "2a03:2260:3006:8018::/64" + assert conf["address6"] == "2a03:2260:3006:8018::1" + assert conf["xlat_range6"] == "2a03:2260:3006:8019::/64" + assert conf["range4"] == "10.80.96.0/22" + assert conf["mtu"] == 1406 + assert conf["concentrators"] == [ + { + "id": 1, + "pubkey": "B" * 42 + "E=", + "endpoint": "01.example:10000", + "address6": "2a03:2260:3006:8000::", + "address4": "10.80.96.2", + } + ] + + +def test_build_config_xlat_kill_switch(): + cfg = pool_cfg() + cfg["xlat"] = False + assert "xlat_range6" not in parker_lib.build_config(cfg, PUBKEY, 1, NONCE) + + +# --- respondd ---------------------------------------------------------------- + + +def deflate(payload): + """Raw deflate, the way respondd and yanic's respondd connection pack.""" + import zlib + + comp = zlib.compressobj(9, zlib.DEFLATED, -15) + return comp.compress(payload) + comp.flush() + + +def nodeinfo(node_id="ea5981962ae4"): + return {"nodeinfo": {"node_id": node_id, "hostname": "ffac-parker"}} + + +def test_node_address_is_the_first_host(): + assert parker_lib.node_address("2a03:2260:3006:8018::/64") == \ + "2a03:2260:3006:8018::1" + # id 0 is the concentrator's own /63, ids start at 1 + assert parker_lib.node_address("2a03:2260:3006:8000::/64") == \ + "2a03:2260:3006:8000::1" + + +def test_respondd_query_asks_for_the_three_modules(): + assert parker_lib.RESPOND_QUERY.split()[0] == b"GET" + assert set(parker_lib.RESPOND_QUERY.split()[1:]) == { + b"nodeinfo", b"statistics", b"neighbours" + } + assert parker_lib.RESPOND_PORT == 1001 + + +def test_parse_respondd_accepts_a_node_answer(): + import json + + answer = nodeinfo() + answer["statistics"] = {"node_id": "ea5981962ae4", "clients": {"total": 3}} + response = parker_lib.parse_respondd(deflate(json.dumps(answer).encode())) + assert response["statistics"]["clients"]["total"] == 3 + assert parker_lib.respondd_node_id(response) == "ea5981962ae4" + + +def test_parse_respondd_rejects_plain_json(): + # an answer that arrives uncompressed is foreign to both yanic and us + with pytest.raises(parker_lib.ParkerError, match="deflated"): + parker_lib.parse_respondd(b'{"nodeinfo":{"node_id":"ea5981962ae4"}}') + + +def test_parse_respondd_rejects_a_missing_or_broken_node_id(): + import json + + for answer in ({"statistics": {"clients": 1}}, + nodeinfo(node_id="ea5981962ae"), + nodeinfo(node_id="ZZZZZZZZZZZZ")): + with pytest.raises(parker_lib.ParkerError, match="node id"): + parker_lib.parse_respondd(deflate(json.dumps(answer).encode())) + + +def test_parse_respondd_rejects_a_non_object(): + with pytest.raises(parker_lib.ParkerError, match="JSON object"): + parker_lib.parse_respondd(deflate(b"[1, 2, 3]")) + + +def test_respondd_node_id_survives_a_missing_module(): + # yanic drops the modules whose id disagrees, so one present module names + # the node + answer = {"neighbours": {"node_id": "ea5981962ae4"}} + assert parker_lib.respondd_node_id(answer) == "ea5981962ae4" + assert parker_lib.respondd_node_id({}) is None + + +# --- rate limiting ----------------------------------------------------------- + + +def test_rate_limiter_window_per_source(): + limiter = parker_lib.RateLimiter(burst=2, window=60, daily=4) + t = 1000.0 + assert limiter.allow_new("1.2.3.4", now=t) + assert limiter.allow_new("1.2.3.4", now=t) + assert not limiter.allow_new("1.2.3.4", now=t) + assert limiter.allow_new("5.6.7.8", now=t) # other source unaffected + assert not limiter.allow_new("1.2.3.4", now=t + 59) + assert limiter.allow_new("1.2.3.4", now=t + 61) # window passed + + +def test_rate_limiter_daily_cap(): + limiter = parker_lib.RateLimiter(burst=100, window=60, daily=2) + t = 1000.0 + assert limiter.allow_new("a", now=t) + assert limiter.allow_new("b", now=t) + assert not limiter.allow_new("c", now=t) + + +# --- allocation -------------------------------------------------------------- + + +def allocate(repo, pubkey, cfg=None, **kwargs): + kwargs.setdefault("lock_path", str(repo.parent / "alloc.lock")) + kwargs.setdefault("push_flag", str(repo.parent / "push.flag")) + return parker_lib.get_or_allocate(str(repo), cfg or pool_cfg(), pubkey, **kwargs) + + +def test_allocate_creates_and_pushes(repo): + rec = allocate(repo, PUBKEY) + assert rec["id"] == 1 and rec["conc"] == [1] + content = (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY)).read_text() + assert content == parker_lib.format_record(PUBKEY, 1, [1]) + assert "parker: auto add id 1" in remote_log(repo) + + +def test_allocate_without_remote_keeps_the_commit_local(local_repo): + # no pull, no push, no push flag: the registry never leaves the host + rec = allocate(local_repo, PUBKEY, sync=False) + assert rec["id"] == 1 + assert not (local_repo.parent / "push.flag").exists() + assert allocate(local_repo, PUBKEY, sync=False)["id"] == 1 # idempotent + log = subprocess.run( + ["git", "-C", str(local_repo), "log", "--format=%s"], + capture_output=True, + check=True, + ).stdout.decode().splitlines() + assert log == ["parker: auto add id 1"] + assert subprocess.run( + ["git", "-C", str(local_repo), "remote"], + capture_output=True, + check=True, + ).stdout == b"" + + +def test_allocate_is_idempotent(repo): + first = allocate(repo, PUBKEY) + before = remote_log(repo) + second = allocate(repo, PUBKEY) + assert second["id"] == first["id"] + assert remote_log(repo) == before # no re-registration commit + assert allocate(repo, PUBKEY2)["id"] == 2 + + +def test_allocate_reuses_freed_id(repo): + allocate(repo, PUBKEY) + allocate(repo, PUBKEY2) + subprocess.run( + ["git", "-C", str(repo), "rm", "-q", "--", parker_lib.record_relpath(PUBKEY)], + check=True, + ) + subprocess.run(["git", "-C", str(repo), "commit", "-qm", "gc"], check=True) + subprocess.run(["git", "-C", str(repo), "push", "-q"], check=True) + assert allocate(repo, PUBKEY3)["id"] == 1 + + +def test_allocate_exhaustion_creates_nothing(repo): + allocate(repo, PUBKEY, cfg=pool_cfg(SMALL_POOL)) + with pytest.raises(parker_lib.PoolExhausted): + allocate(repo, PUBKEY2, cfg=pool_cfg(SMALL_POOL)) + assert not (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY2)).exists() + assert len(remote_log(repo)) == 2 # init + first allocation + + +def test_allocate_before_create_can_veto(repo): + def veto(_pubkey): + raise parker_lib.RateLimited("nope") + + with pytest.raises(parker_lib.RateLimited): + allocate(repo, PUBKEY, before_create=veto) + assert not (repo / "parker").exists() + assert len(remote_log(repo)) == 1 # nothing was committed + + allocate(repo, PUBKEY, before_create=lambda p: None) # allowed path creates + + +def test_allocate_push_failure_defers_answer_and_recovers(repo): + flag = str(repo.parent / "push.flag") + with pytest.raises(parker_lib.PushPending): + allocate(repo, PUBKEY, run=run_but_fail_on({"push"})) + assert Path(flag).exists() + # committed locally so the id is not handed to a second node... + assert (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY)).exists() + # ...but the other gateway cannot see it yet + assert "parker: auto add id 1" not in remote_log(repo) + + rec = allocate(repo, PUBKEY) # next poll flushes the push, answers the same id + assert rec["id"] == 1 + assert not Path(flag).exists() + assert "parker: auto add id 1" in remote_log(repo) + + +def test_allocate_blocks_new_ids_while_push_pending(repo): + with pytest.raises(parker_lib.PushPending): + allocate(repo, PUBKEY, run=run_but_fail_on({"push"})) + # while the flag exists and the remote is still down, no node is served + with pytest.raises(parker_lib.GitError): + allocate(repo, PUBKEY, run=run_but_fail_on({"push"})) + with pytest.raises(parker_lib.GitError): + allocate(repo, PUBKEY2, run=run_but_fail_on({"push"})) + assert not (repo / "parker" / parker_lib.sanitize_pubkey(PUBKEY2)).exists() + + +def test_allocate_serialises_concurrent_same_key(repo): + """Two workers racing on the same fresh key: one commit, both see the id.""" + results = [] + barrier = threading.Barrier(2) + + def call(): + barrier.wait() + results.append(allocate(repo, PUBKEY)) + + threads = [threading.Thread(target=call) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + assert {r["id"] for r in results} == {1} + assert len([line for line in remote_log(repo) if line.startswith("parker:")]) == 1 + + +# --- signing ----------------------------------------------------------------- + + +def test_sign_message_takes_the_sig_line(): + seen = {} + + def run(cmd, **kwargs): + seen["cmd"] = cmd + sigfile = Path(cmd[cmd.index("-x") + 1]) + sigfile.write_text("untrusted comment: signed by key 00\nRVRnT3Sig\n") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + assert parker_lib.sign_message("/nope/secret", b"{}\n", run=run) == "RVRnT3Sig" + assert seen["cmd"][0] == "usign" + assert seen["cmd"][seen["cmd"].index("-x") + 1].endswith(".sig") + + +def test_sign_message_names_the_tool_in_errors(): + def failing(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, b"", b"no key") + + with pytest.raises(parker_lib.SignError, match="signify-openbsd"): + parker_lib.sign_message("/nope", b"x", binary="signify-openbsd", run=failing) + + +def test_sign_message_errors(): + def failing(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, b"", b"no key") + + with pytest.raises(parker_lib.SignError): + parker_lib.sign_message("/nope", b"x", run=failing) + + def no_file(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + with pytest.raises(parker_lib.SignError): + parker_lib.sign_message("/nope", b"x", run=no_file) + + def no_signature_line(cmd, **kwargs): + Path(cmd[cmd.index("-x") + 1]).write_text("untrusted comment: signed by key 00\n") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + with pytest.raises(parker_lib.SignError): + parker_lib.sign_message("/nope", b"x", run=no_signature_line) + + +# --- status / GC input -------------------------------------------------------- + + +def test_status_roundtrip_and_merge(tmp_path): + (tmp_path / parker_lib.STATUS_SUBDIR).mkdir() + (tmp_path / parker_lib.STATUS_SUBDIR / "sn01.tsv").write_text( + parker_lib.format_status({PUBKEY: 20000, PUBKEY2: 19999}) + ) + (tmp_path / parker_lib.STATUS_SUBDIR / "sn02.tsv").write_text( + parker_lib.format_status({PUBKEY: 20001}) + ) + merged = parker_lib.load_status(str(tmp_path)) + assert merged == {PUBKEY: 20001, PUBKEY2: 19999} + + +def test_record_age_days(repo): + allocate(repo, PUBKEY) + relpath = parker_lib.record_relpath(PUBKEY) + age = parker_lib.record_age_days( + subprocess.run, str(repo), relpath, now=time.time() + ) + assert 0 <= age < 1 + assert parker_lib.record_age_days(subprocess.run, str(repo), "nope") is None