-- CeetOS signed release. Generated; do not edit.
-- CEETOS_RELEASE_MANIFEST:e2NoYW5uZWw9InJlbGVhc2UiLHByb2ZpbGU9ImF1dGgtc2VydmVyIix2ZXJzaW9uPSIwLjE3LjEiLHNpemU9Mjc5MjMyLGRpZ2VzdD0iMGE4OGNjNDlkM2Q3Mzc2ZGY0ZDkzZDM3ZmI1NGJkNTNmMGEyNDc3ZmU5Y2IwYTE5Mjc4MzI2MzM3MzQ2NWYyZSIsa2V5X2lkPSJhdXRoLXNlcnZlci1yZWxlYXNlLTAuMTcuMCIsbmV4dF9rZXlfaWQ9ImF1dGgtc2VydmVyLXJlbGVhc2UtMC4xNy4xIixuZXh0X3B1YmxpYz0iZ3Q4M1dydnhhTzNYeGJlV0JkckxTenp3cDUyQUk4dnc2SG5uQWtVZFZhdCtrZW0vWkRBZEFmQk9wK2pjYWhGTFJOUURwUVRkeXR6U2FRYnpWYktaN1dYY0dqallTb2FSUG1mc3IzU2w4UnltZlM1RmJHOHlaMzZJVDFIRHI5YmY0UWhFYmpFOFZJNXhxVE9WSzFSVlViWlJaRW5MSk5tV0ZKd1pRL0RHLzJ3YlVyZWNQYTBQOTdoZm9URklEL2JMQkR0ZG0wOWxCR0EvL0VyRGh6UzRKdFFTLzBaellNV3Q0ZlVYWlUzUHdYQW1nWHdnSzIxRjlBME84djJsZ0h1enMzMUpqM0V1YTcwcnF5MjFUTTBkdTdlb3A0OHNwcXRPVmNSZi9HQlVEUzRIejMyOW8xdkRqQnVDcldPeWVyZWxKUkRNeDVSUGxRaUxZRWZWVTVxU3JQVWJRNmFYNHFUdTlESUg3OVkyOEVUcjd2b3JONXZjN053OFVnckp4Z3paTDJZUEM0Q2JPWjVHY2NKVFczUG00cGRSMmxLL2tyZ0FnWmoxekRSM0o4ZUhTMFNBR3pjbzNSUExlcHFMeXdaVHdBY1RjcG5RUHRCQ05HeVNTRDRsc0lTR1BMdk83Sm5jbVBuOVduV0lPSHlDZ2d2dEdqQlhHQUU4RC9mcmIvRkRRTmhIZitEaFova3hobkVWMkgwT3FNMkwxV0V3TTBEMHdhM2x2MUV4aDNXQ1pRRTJjRG1ibVN2WElGZW1wcXhxWnpUMWxkZkkrSlJZUGt0NEl5Nk1ZZjdFMTRtMVRodzN2V2ZTVlF0cm5lOWppclpNS09yTHpBbi9BYWRobWR0Vjl2OUg1L0hoaWoxMXBsSUY0NDdOcDl2MjczTDBnWThLUVhxYkJOOHRhQ0taRDJ6dGh0MzdXclZKbGI2UG5jbnFsYTB3VnZiaGhXVFZzVnhVNTdGKzhSbGdCczNrTUpSd0xNUE5sNm5mWFdKWnU5L3B4VWIzQ3Blb3NsWTlHZ2tPMVNEQ0ZWaDRFY3pqMWFBNU5XY01VVUtJQkVuNFZvMS9ib1JjNzNIU1ZHTWloSlpiTlliSDQySzNjdHN5bC9xVmViUk9sWlRrall0VU43RndMbjIvZlRma0lFVjd5WVhyOWxDUURJKzNqK2w3T0tyVTh5dlhwbDNKaHo4M2RpTVhrMXNMWFJTZkR0NkRBbWVyV0hnMlQrd01WREJ0Qk1vczQ4SEF2YUUxYUlGT0R3M1F0OTE3QlNFR255c0J5NTdtYTlLVVBFeFEvTlMvSWczTnhRSlJaY3RJTG5tR0xhcTQ5RTlOc0J4TkFDVVJ1R2tRL3lJNEhjQTNEejJPcHVySWJPbWFOOXNvMlpjY1g1aUd3ekNHNGwyS291ZStYM3BFWlZ3OSs0MjNVMlJiVXdGd2tFRDhyMkMyU2tWUDRIdDVFSnhaMU5IcUR5aStJL3Vtamx2dlNGWkQvUlRmZ2NxWjhnQUtSd2w2dWhGLzVPTGNMeGNhcERYMlVlbEp5UVh3eXBUWDRrZ055b1pzU05hczJiQ21KeWhUdDdYZUpWY1ZVK2ZzNHBOeWc4c2Faanl3bk9UellsZnVueU4zZWtianpDdzFNc2M4U3pMd2drTW5LNGNOZWc4UUpkOThFYVB2VitMa3hyR1ByVHJBVkVPbjFleCtvcmp3Q1gydTZXOHFoelJlR1VHa0NDR1VleFM3cWF6YXZERVdrZXM5bTlvMGFXWjI0djVrMzd3WVRpaFpEa3VYa2JDampGQnhwRjV4cG5kOFhwTGh3R2grVk5waGdGeWdvVHlBYUpVUk4rUDBJVG1TL0ExWkZheXQ0OHZqNEJsYU9qcjU5R1d2QUk1NTF2bTdpcHNpSXJBTkcwSnVFaEx3S25kZnVObTBjaStmUDNrS0x6RURyUVZlelU5RGRYMFdiNVNpL3RYZFQ4aHNiMXFPM0RKQm8vNzdmQ3JaandzU3gwKzlPQVpoV1ZWekxqdUdiSGhCdGN3ZjNpSjhONHM0ZjJXQmNsaDVycU1JNWw5RkZXRE80TjI0THVwOTZKdkNMK2JGMGJ6ZUdyVUw4NmVpOEUvbU1ZMWhtM0ZqMHpaekFGb3lWU2xLSkl6Mlc4b25IMi9jQUU1WlB6OHc1c2Uvd0t2MWNvNlpLenl0QXpzZXN6MzVrTG5NVE9tK3BRR2dvR3RrdFhyV3FrcmtzMzNCTXRaMk9yN09HcGc5cDdveWduWHFDZlRyWGc5aGZBUVZJWW5rdWc1ZnRZS0NZdzVCYThIN21GR05qV1pqdGJWUGlHU1ZsK1YzR1VBTG5lQjh5NDJuQ0h0dUREbFZEMXpLQTJQWnJpVUFVdFVubFloaVJVNGt1WjBKL1ZsZW9FV0w2NS9Ydlh2QkhETThXbE9DNzV6YTRiSU05ZW5xbWk4Ykc0WVZwMGVkemZVa2lGcWJ2a2lBL0NlRnVTRGcwNDFUMEVaZ0lTNTFTMnBNQUpibEZ0OFI0cVFNbW85RDRGYkxjc2RkKzlFUldnKzRTM2VIYmlaMy81WWxoZW9kTXdaSzF3SUY0S2tzc0wyNFliTlZNNWM2Rklzd1l4WmZKZVlyekJObXN0T2taV3ZQWWg5OTAwTUhVYUl6anBpTEdDWjBIRkxGQTA2RXc4MzlFbDVpblgvUkFNZDZqYktqclNxYVdpaDVIRm5PQXc4YnpreUVicmM3LzBkaEtSZ2hKajBEMzQvQ2VVd0VHcDcvT1BxYkRGZlVUajVPQTBtZ2R0L2hDR3I1V3NjeXFHTUhkWm1VT1Q2elJNOUZ4R2RwMHJsZ3VCT2MvSE1kN29IZGkycG9aOU1WaStWdVMrM1BCWFFBNzZyN3VLZDNVQXJyRWdIWktzRk14dHlJdTVxUWtqa0Y1WGFPd1lWZ2djbGNOMGk1YVBJcmJ6QXJhSXhSK0pTQU5jSE1GUWR3OTk1UEhKSTBOU1pWRHB6WGNabUFlYjNqbC9vWC84dlZ6aEJ0c2ZmSUE0ckx0bU1vK1V0c3BnQVpkOGduK0V1L1I2SnZFSDJyRWdLUmUxVGUwYkxBZ1FsSFpMQUE4MU9PWE5Hb205WEhkNXB0ZC9EVVJKaHJMMEx3UnhFV3hhRU8zb0JTQ0tXZEJBbzVMNitzWXJlYisrbnRrbGZoczJzZ0ZFMWwvT2VHRTdNSCs4R2tlbkhuN2dFR0dKUFdHUVI2VlVvSUpWUlpxOUxiS1JPU1dpN1Q0bW0yN0loWGd3dXkvcjFzYkFUaFE3R2hQWVFldStENUlxL3JsQ08vZ1d3NE1sVDNDbDN5VmRpVTRWOVJ2R1VQbWxRWmFVNmZpOXNoQUFNbkNFekp4Y25DSkVrWThEbVNkcHBmVVN6QlBoZG9ZNVMzL2l5Ui94WFd2aXBpcnZxTjB6eFVaelZ4STZzRWtoQmtRUndMRFNtTTdoTkd1aEN4UDlON0J2bjJKYXpFT0FyTVVwSG1ma2RIc2xqTnQ5KzVGcW9rZWRKM0xCaXVpV2JmajRqZllISUJIbmwvbk45K00wZFZaWG5lempoNi94VFRtTFBzYnlud1B6NXNTb0ZFa20wUWtYUTBjaEwvZnJ1ZHdnd1ZDRE9QM1JEandPMnNQa3VwK3JPUWNOamd6ZjVkbHRUdGdkdDFIdVZqaTRGb3lyY044Sk0vNGFxVFdZS0hjaWQveGo0L0dWbCs4dW5tQ1hmeVNiQWp3UkFuZkRlSHBvUHpwbUpGTDhLUVY3bkpDdnlaUU5EWDVieWhjUlVqQTd4K2dvVUd5SDBKU2NIaHNiZnh5K3g5Z1R4OFdOUnhCeVppMzZiMVFjTjh2WjJNTHp3amx4dGtFeDZDTjUyakFJeUREWUpOZGMvUHBiNEZvQWF3cHlPNmhYODRCWVR3TzBKWXV2WTJlTEpzWUNiY3ViNVBYVzR3LzFoRXRTYjNkZ0xLL0o3OTRIalJnampSblFoWU11a1BNNmJuQ2Z6SHNRWnF2Q0R2NlVSeW5tYUNjQ29Lbk1oM1NSemZNdU13UHV1UTluS2gyaTBFOVozWm4rbGxJMGRyc0MvZW9ydWJ3R0JQcktmYzQ3R3NvVnBFWEJNUzJCa28wc2lpbENmMFUyVDdHQXRNK3h4dFRHY0Fnd3pHSmc0a3R6WWhpZEVQZUJ4MjJWWnZtOFdtanVCSzN1dU5sbENaaXo4Rmxsc3czTnlCMkR3Wi9wTzhIbHhPZXNoaFdUb0tNb0ZycktLM0cwQWxKZlRQV0RKSzhneXZrOVM1UGpkdndoK2hJdFd6eDN6SFVaZyt4a1RrbkdHZDhLdkM5SHFxY2VWOTQ4ajd0anVIS3ZremkwU2puSjllVmhjNFR0cFRoTnZvck5oNSs3SGE2Q3NLdXdqUFc4YndmUSthRXV1eUVqY0RYMjRQdXhDUkFQamYxY0hQMXV4NTRLQjI5SXpEN2tZMUh4QUxFbkpZcGd1RE9RTGZxNWZ6UEE5UTJidWpQandJL2hjcHM1R213VlEzZnN6YlprMjhZOThvdllOdTZKOTJPSUEwUll2M3g1UlRQWUl2amdqM294a1owN1NyenU5VW1GNnFJLzRpYysrL214ZGFubTRSNlRDc2VrcVNMSlJ6T1o1VG9qQlgreWVPTlNmMWxmNkdldjQyQ2wxWjZBZldZbDMrS1laMG9jdjEyYjdxTkJybGQwRUNScStKM0ZOcVFOZThTcmtTQnBxYURPSGxHQUdjTndrVzJvNTR6SFV0UnkreW80dC9Od0NPNUdpaUl5dFZaaUIzSGRVMHBRY0pnYVpFeThJN2lreGcrSkRyckVFQkYwcmtNQkxWcmJ3QlJVOVpHc3ZRbFJDYmx5WWhsem5hTXhHcmFHN1pkekVuTVJEVUk3R1J1TU5KbWtvUFdwd1B6MXp6UEJBY0tYMFdRLzdqR2luZHAxemhlWjZzaWljS21hSFc4Yk8zSTRmSXN4R29SeWtqQ2dVVzZnSEJrSVFqRjNRMzFGRHhNdGFFOFVCakZFYWJCSUFhM0VrMXpvVU5qNWUrb3lZdDc2ZEVqK3ZWdjg4YkloOXdVM1JrMkNVTmRKRUsxKy8vdERjT3ZFaElaZFM0OVZRZWtKSitEclFqbmdhcHQxdEhZdEM2aUFyRlFSNW9rMmtxS0VRTnZOMEJUOHY0eEhBL2FndU1aNUtZQlMyNHBJa0dzZ0NXSlBmeEtFR0N0ZlZFMHN2SzUrSkhQNXhOMWZINktDMEIrMTRVR29CcnpoM2VNVFIvbFR6bFNFVDl6WUFRU1p0VXA5Slk2aDNpZnl0MVlhTG04ek5XNlRVS1I0NWRoeExacWRsSkVwbEVaWUtqOE9zVHRDVFBuaFExV2FmZFhZUTN5TkFtSkFuVmxuM2IvZTNLL3JLOXZ0OXVDbkhhSEdpMW5zcE5KUVg3S1ExTCtHQ0txWUJGQ2NEWndlclQ3MVVDZVVRcEEwelFnU3pFTVNCWUpJWWRkQ0ZpbmlqQWJ4a0ZPaUxqUXMyOXk1M0FTa293KzFNV0NqdnF5d3BWRUtlalUzaHFzWGY1TUYvNU9uRmFuQTFoSVhSalVMUTNhMGM4TS8zYUJhb1BPUURwais3c3VqaS9WYUE3eEgwOTRPRXR2dWlESkZKNTEra1NpeFhpZ1FjU0xPYjBSOW05QXhhb2pYLzB2RkE3RWlheE5zRW5GblU1Z1hnUWM0cERVUXR4WFY5TUV4NGgyZFZ4NFllb2o0RDVlWDhDNC84c2JxRFRseFRBd09kWHoxT2laWnRmZStYQWdxQ2hLcTd4aURSbVA2cFlrMHJQQVY4TjVjaFRORGdCUUdjcFZHREtpQmVrREpuZVN3YmxVek1zeWd0dHh6UlBmckVZNUhQTklZdzNTcVNaSis5cCt1WFdnZlVPRW9XYTZ2Nkw4d05hbXhVSkx6cWQzaGcwYVo5ZmpQOENIMURDQ2xsdEptc3p5NnlXY2lod2l1dkdoR0ovdG54SmovUUlNREVmcjB4Q3lQWkxPcTV0OU1FaDRaVTJoL05NbEtOdTZPV3hBMUNlWDRCdHhNV01ucUN2Ulh3Zm1lWG51UkNzSHNrQjd5V1pDblRJb3V4c1VmTzVtL3JoalpDVVhubXdIdlhRSVd5SlJENnVnNnVjelAvc1dFRWNCSXRHbFBUMEFDZG5NWVc4d0ozTXFYeDk4NGdKNlRLNnRNMmYyQTQ5SjVvT2V0ejVVQ3k1emJHVUNYeENIdTBVbmdsM0Z1dkdQaGdFVUloanphTnpaaGdySHd6dnFrOTNGWTV1NXdsMEpTdVRJNjdkT2hzb0s0aWt3Q2ZLZDYvUk10d09GSk5vV0pDMDM4UU5CcFU1cmxGL3pRUVJ5dHgxQkw4UE92bG5hNGNiU3RrSytCMmtYc2x2Q1RRWUpaa2pHeWFVbk5Zb3NFeWNVM28zUjlrNEczMHBXdzBubGVJU0M2aGlkWkVBR0tKamg0QTU1N2xYTm1DU1g2MmxlZkRnVllNYWwybWNwUUdJcFZObzFLTEdmOXhhRXc5L2VJbTh4VXY3QXN1OUFuSGoybUw5cjhxVmlVQW1KNjVwR2JnR2t3Zmo0Z0JyU3FBUG5yQUJla0JWZzVzazV2c1RZdmx6Z1dCSzFhYUZ3UWxKRWZrRUVrOHM5S1Zya1c5b2d4WDIvSjYxSEkvanh6UHp6QnJOa3E5dWRmUjVEVmdIbXFKRHhpdVIzQUlsVFdaalhWVHgrd0p5YkhQSVhLSTE3LzFYNWEzKzRSanBBWE9rZkxyUGdZeDd2aTRUemZYblZuOHFlTTZIby9KdXpqTDRuWFFsRzhER1FPajlMRXZoZU5oSGFLVXNGV00wSnFjZUJTSnpWN25ldUJ1VzUrQTFER3NacFhNZWtiaFdoaU9XRWtLMjl6NHBUZVZ6Rm1nQWN4WmYvcHRMdHZtYnJ2WnovcTlHU3N4cEJMdk1EaGJpNmp4enoxUG11c3BPSjZqSW15RlZiNm9VaklTUC9SbDN3WGZEak9PUWRUOFlleEErMjZjVkc4Mlg4ZUM2WkEwTHpwblduTEpvM0F0NUZEcHlhSzk3QmdPbVphNUtENThkbDExdG43bnB6SE14TW9CKzhWWno4QXJRcmJSb3l0akxxYlBYWlVrUW9qZndpWG1tdnBkTUNNd3hFY2ZSUDd6eVNxdGthditSUCtJZkxwMW9hU0lsdXd5YTFKSDhVdjBhWHpNbkxwODZ6Wk9YZ3NWOFpLU3lVeFdUMTBEN2c3ZG96azZnRU9mZVZ4S0FzdlFBcENzRVpzWEFCYXR6TWNOOWR5SEhTdG50eTlNM0J5S1hSa3krcnVkT3l3ZE1PUThKb0g4cDZDaEdRbmtSZW9qUXVCcEFubXcrVm4rTTVLUzByMmNNVk9xM3ZiaVJlWlp6Q1BQbm14LzVwM0NLQXpST1IwNzk5NXBNZTl5cFEvcmZUcUkzSkpMdmx1TFFTRWJZYlJ1ZkcvS3UxamJqZ1NXejk1MXhBQldxa0VKaHBYbzQ1a2I0MVp1VDVaZlorOU5mVWFOcGFHTlZVRnB0aVE2cHBxSlk5WEN2c1FpM2kzVFFTd2dObkJvSjVUVDJEekVJaGVWc3JsMmNsS2NUY1M4VDR0andiM3NMSzFNeTY1VTZTdnFjNmZ1NnV3R3IxZEkvU1FLTXM1MnJEbDZlSytTRi9jT3N2M3hBVm9XcXllaHBmZjRZZ2lkeUlhaWxVa0JPRXV4QWJIaFV3ZGtLU2JESTBYcUxpSmxBZmlqbnNzdE1nQWZRVEh0eXRLNmI0SFcvMUJtcFhxNnBWRG05UE1JUlEreHZCUEt5ODYzVDRuZ0UwaVBGc2diUWQzSnA4Nm9KZ0hxVTlmOXhlM0xjRmlBNkI0eCtzdHRNbldaL2t4KzZPdURyNDU3YkNqODZ6YVFNY2NkTzk5aXh0RUtNNFdjeVY5Z3F2WG4rZ213SktGaXR2OUxjdks1QjFHTHkxTzliT1B5dkdoME1oSUFVUStCdHNuRFExaVNHTnBxaVdCemNnQVlrdzl0WTBNYVhNdFByQUQ5dWs4SEhpQXlFWHNteXM1TGN3VFJZVXFRMXdFb2ZlMm1kaUROSW1UYTlxZFdWTGZTNHRwTWdKY0V6aFFXczA4L0VOM2ZTUEJzdmVlVzlUMFhQbFdROVdsS2dKblF5U2xEclZwL0tYYnhVczh1TFJoczIxL1d0MCtCT09jdVQ2YXVMaW1KcEduNjJoSkgyWFEvRGpFUjgzMzVKNHFQUElxeEFmV25GQStydlFyZVpYblcyZTFnWnZHdVZFK3BXcmZiNmtsc3RWTWZ1Ym1FZ2dGTHMrdDkxc053MXhLeFQwTmxwQ0xtUUEzZGNZMjN3SHc2TVFobkRqRlVrUDFwTis2ZmxqYkhFQ28wL0pRWVZWWWF4VFpydElYaG1LdnE0U1VDaFB0M1AvUmtGU2I2N0VhY3dPU29PV3pjRTRraDB6UlAxemJpYVJiUmxzcnZLUkd0MFJlNVlnR3MrS2lzcHREU3hjS0R2VDVhRmVTSy85NitFR1FiUXJxM05qUXI1SmNWSVp3RXgvYlpiUnlWMllsMzhwbFVsSkFhMk5PV0ZDWHJwWHZFQm9sOElEcU5ZSnYzSVp1eWZzUVVhUnFoTDRoWnl0UGdnQVZQcFBTS2FNVzBCcFZaT291UDAyS2xGK3RCa2VodGYwTlpzWDBQek5ady9SNW5Od3ptRFVqdW8xOTcwdktOL2diTGZWNVhzVmQzMTdFSE1NcW51dVdUYVFSQkNUajhDSDllZW9LdC8ycmNkZXZsc3Btc2dUUS96VFFtSnlGS1RyQmlXbXlEQ2NVeU9qd3lHeFBzQ1BUVHlTVDgxM05zN0FvTTErNTlGa3NCeGxCRndsU1dBQmZyaHVxOTBVTFdRejlPL1o4dVJLb3BXMnVaMEZGeTZuWWo2YUpUVk8yMkh1QWQ0aWtTenRjU0NLZGlRTERBOTlFQWdTRXRkdkpzbWNEbDZVTzRrZERWNWc5Q01HMmpWckoxam5JK1JFVWRlVTJUYnMzK25xVDJTQlBkQUN1Um5IdVpTcGZ4aEQzUGluOUNPeGdqc2VGMEMzRGtZWmpOT2FMWk54WjRsODlOeXFjVlZheks1Y1Jua1dyYzFndnpOTDhKNWUwR082NTEvK2JVSnJHWFUyOVdtckZJMEN2aXQwd01IaGVDbmdlWDhMV1ZqSysydlVxVjJVU1NXUGJlVnlSTlJHQ1MxQ3RKanU3NUpEZ3hpM1lhNXk0R2ZnVUg4YVhHR1VXbWFDdThETzVoNWRNbmxqYW1vcEVwSER5RUlxN1JvQytjL0RSUW1WVm5SSjI2Yzc2NGxjZWtUWUkzdEl3Qi84VENqbXpBdTVPcjlaWGV5OERGRTU5YnRUTTg3a2VScE84L3BwUVVrUlVHekdWVUsxTU1INlo0ak1uM0d2VzVwS004Y3RTcmxQUTRZYUFxeURtQy9yTHl2d2t6T01tUVAzaytnc0xNZklPeWtxTC9YN21mUXpVMVNLUEs1UUZtWUJBa2M2TmM3azZHWTkwYWF6WnBtMXBQYzBvcWJUY0JKZ3lGOG4rVHd0dVBVaDZMUGo1em9HcHVnNVYzaHhPZFpucmVCcXFoWmFXYlNxNnN6VUR6VkRybkZBRlh3UEJoaGdUMFN4QWlmS2NiOGNLbE5JeVVGRFI0bFFVOHYxWHlPQWl4U21lUW5VOVo5WWlwYXNNNlJRRit3S2p3aExRNWZxS3dHNnRabmJqK1ZrWDRMNWhYUHNrM2grMzgxMlBYRG9ZN3U2bU1JcTBQNEY2Y0pjUCs3a2lrc3hxVG04WEhrbkJKSkJRQ29DYmx3aXdHOHhhWDcrTHZnYWkvMGpoSlI3MkUvWXNuUmdveEdhdjFYbmtaWHB0eUJWa3VLVndVSWZpR2NaTGJhVXlPcTh2Q3czRFVLbGNZNkZIaytKQlM4WWticGxjN0NaQldhU1FrQzlkTmZBY05GcUdWZ2h1dUgrK1FhQXJoNHBWYU5WMUxRMWlyS3p2WnRkZ3ZvS3N5WGtuTk5qSjZ5TXZnU1dYUXU4QXlaVC9GTzdRc1ZOVU9IUkJQcXZOdFVBeDdnSHc0dFlCR0k3cGFzdGFTWUp2RHd0NzFIZys4eE5IeWNML1BzcjVmVS9EUkxybXpHTVBQUCtTRTRYL0NvalovWDZLbGwwaTVkY3N1L21XK0JFNVZtcXkrOFJtUG5nV0ZrR1FKSm5yTmg1WnlTMHNaaHlkQ05wOEF0SXJXSUNNcnlpRFZObDhFRi9vUXhtZUlRWkJHcS9EcXJUWk5ja2hoM0ZzckQwYkhKQVpZd0pDRHJmbkQ2UHNDQWpUVDk4MW1GVFNSYnZzT1NLTnFyZmhZM0M3UGhMcnppSkFkcERDT3Y0ZTFxVW03aFRaa3lQM1FtNDZ4Qis2VmdZbFUra0w3YS9CNng0blZSdHV0T204OGI1SE53endRUFVnbmZZN2VxcFY5a2Z2MnF0VmUzSXp5cU9JbXluNWNSNExLRFVGMW1TcGdqcHBYQUgvWTNRL3Z4Sm1ydS9kRkVBcExlSDUvRjdnVnZSZk8rNlQrd3U4NWdhT2RKVk5WME96T2cvNWVuQkMvWGVudVQ4djFoUGY1c0hvQkJYbVFqSUNEbDJJSHIrOURNNVY0R2N2a0RrV2lVNGlJc01PV0xrbTJwcHlXdlF0VmllVXRzVmMxNWRkanpJTS8xMzQzRUNwbU9CR203SDZabnJ4cG5JZlNsdFl1Z055QUoxT1RLNWZhanN1dDN2Zmx1UXNBZGF1OHRIbVJtbStJVjdBbnJibEczWDFHQzYrNEdOcElNTjJDQ015S0N4cUV2VThNb3N3cFdlNFA5R1pWTVRXV3A3U3FBRnB3dzhsd0RmUzYzb0tXVFR1Mko2eHNzaDhVaUQyalRtRDhyNWpBZW1aR0ErajgyVGlybFJlUzhUOWZ4eE5wb1d2bE50WEtITXZsNHRGOXd1Q1RDN0VlL2pwdkw1SmV0QjZBSXVwQSs5WUE5M3lJV25Tak1aZytIL0tKNzZtUjBYWE5yWU9CMW56aXJ4UnJUYm50blVjVzMwTG9SaFBhOU9hOU5sMEVsY09PU1dINWpZTHpIY3pxRW1JVHVqeXlROGRJY3diUUJ6WGdZUUJqZ0RkNkVxYkJOWHE1dTBFY1pPOEM5ZkZRTnZhN0Yxb0dWaHF6VFo5bnZGOVFwWFNDaXpSS1hRd1dKT3JHRUVxRGh3U0JLa2ZyZEtZVVhLOXlvWkxhWFJWQzJucW9IalY1Mktoa0pldEZ2TFA4NGgrcGdwR1gvd2lPM2pTWGRBWlVVWnFXOTF2cWNtM2t5NStYRXNzOVJCSzVkU2VGWEpZeU9qSXJTdm84UGhXTXpYQmgzWFlZdXROZUI3U1cvclNhbmMwSmlQVFFYRGJEVUtGaG9sYk9vL00zSy9jRG5GdnhsRkkzOE16M0dWOXFoNVRwdlFOTlJTdDZsQUk0Z2NaMkRpUjFlTGI5VWpQcjRrbDZyelJQYm1EM3hLWm42SzlBcFN3bmI3K3gycjVZTEI1cldsTzZDWk5wZ1NwdXNDNmhMZ3h2L1c1bW52U0t3ZmJhY2cxL1JqNTN2VkFSM3JPaVB2QUJSR0xQMFNHbFdSK3E0MDFwekYxbWlibm5kNEdpQjFYQ0lPV0NtMmNWc284Y1VJSis4eVpzNWVPNlhHZ21JeStMaWVtcm1UakF4WmpYelJHeHA5NjdINGE3Sk9INHFLQitXOGtYbFQwd2dCTXYzV2YzcDBHWk5maEcvcGVuRzNIMmNWL0JMeG9Gd1NCaWVjKzlvbU1BbGYrZVp2b3Y3SXAxeXErRFpUV0lsYnA1ZmJvMUZGZXJFNkZVSzZaTXpiREplV1ZrRmMxWXFjaXo3MUlmaXpKSTl5Q1pxSE5nZkh5VkJFU3d4SVhZNk5jb0dPdjZOa3l1by8zbEpVTHcwNGszc0VDRzkwWFJ1S2dwK0FvTTV5VEg4dW5DS0VyRDRFUHk4dGljZmkvb3hxLytiK01Cci81UlpESFFQMmNSR0xkeVF6REovaGtnTW9WeTdoaWNhYXlVdGltZ3hnODJwdExBY1hTZURBQUdqWDhkSFVsRlQxWlVuMHczV1VGejZ0U3VNZGJsYU5YWTlaSXJPamtOSzFIejkvWGorc3RwUmhkaHltQ2txQ1dqT2xJT3FWRkdnZkpLNXpQVDJvUjVRb2hXL2JkNXpCblkxeWU3VEdTekszYjdUL1M2Mmh0UitQTU5FcCtaU3MybkYwTUk1ME1Ha0FHR0tHSWo1bDdiakhVWDBINkQ0d01HTlBBZDBwZmZqcGo2NzRJRlI2M01aelo2anV1akMraXBXbkx2MkM4a3pYeVNjNGhNd05KZXQ1bENWSW80U2NxL09kOTl3WnN1Q3krRGdvbWNQeWNCZGFSU3cyeXFKOTRwWllXZm1HT0RjbHQwc3NIZHZIcjlHZVp4Q3hBTUd0OENzMS9HTkhBT1ZsejcwZlZ6M1pEQXJkQWROeXJqRnFnRkpUUEkwWGhwRFpjMTI1TmkxN3VvaEwySXJIS1lNL0hLQkJjeFhjQmZUanRmNTllZkhNSFdXeWFOMk5jSnRrekFBaVlwbElPcFEzUm1UU1hjT1JBbERRS05ncGsvb002YWtxaC9PaCs3TkVGOTEzNmJBVk14bC9ibGRRc3YyMWxPS3JhTU1Na1JiUVdIWDZ5bU5oK1dyOWV5K2l1WTdqNlJocHh1cGtQRTNsdCtZdVpCR1N0WVNsMzlWMExMam51Y0o0a0MvdE41STNrL2RrMFdnOGlwSHJNaDJiaTMyNGJOeElPeldMT2VTRHhFSGNVM041OUxoZWVnd05Vait6b2NNV2UxT2NGQXFUM2tHNjJXWGlIVjVrdEZpSGN1U0lsOHQ3N255UHJoRzlDTVY3ekM3MTRMSjVCdUhwb3lNSnpjUWJKMzBRQ1d4LzBiOEMrM056V0hqZ3FBTWE0Wko4ODBzcTJ3OGw2TEpYb3N6dzdNNzBtMVJpd1B4ZVdkV1VHbnFIaHlqbWVGekJmMUw4VHhnZEVnMlBRb0VhdVgzQ3FHWXBKWTUzUlViRm16VUhkbUc1VisyRWZlNVRJaEVoMkRDdGJnZGdCVG5KbHJwY2FvY2JkMStMTTBpNWRLSFoxa044Wkxkc1FZcU5IWXdxdXlSc3ZiVVYrVlFmamduNkdWMmRMcUpxT1VxS0xXMHlCeGVzQnc0OHN5VzQvUUZWdGcyekd0bDNBTk5Ma0dyQVF6WWlUbnFKeHU0TDNIMDFub1l5aWhhUlZCZWxveEpxdFF0em5vMDlTYlJ1emlaT1k4U1RIeHkzLzFraUtCMFdmTGp1cklUc1gxakRzRVNWOHlPUWV0QWkxRWlHcVpWMmk0c096WnNCZGh3RCtoV0dCK1RMenI2NlV4QVVOcW5OY3JSZXpZQjlKcEN0bzFNRHY2NUs1Q2p5QmZvclkvajE4WmoxU1QyKzFTcmZ0VmFEaCtXeUNJYjFCUW1ScEw1MjY3WjZRNHk4Q1VBaWo0RzBFNmpYRG1nQ2ovMWZFSStjT0NxdlNyOVBvK1d1QklQbnZwbk52QWZtYXJKaTNaeC9tL3ZMdXZJbXNVQ0FFR0tXZUFaaSs3R05QRlVFcG4zbDdkbjdXTnhCZXYzZ2lTQUZJR1VueGtZYXJCbUlwTnZqaHVGVUVYUHRvNVlucitkSmdVVUloY3RCenNrT25iWnpKeUphRU1zb1F5SU5kRVZ0Yzh5Q1YrcU1aSGdZcU9HalVQUjhpcHRjUUxqV3NLNllTSTlzRnlwYmtFZHF5Q1E0T3F2R3h3ZmVCR1FsU0ZCcTJvb0RIWDhhanRIenBwZXFZNVBFSWxHZDRvM1lzdFRDWmtrUGIxT3VqT2I0RVRoVllKQ3dKS2xycWNGamFiS21xMTRvRndvam1hZXZIazdGSk9IeEFQaDJvcUYrNTEwU1UvT3E4cUlDa2xLT2dPZ1V1Sm91R1NhcGNnRGEveWNxcy9RYkFnbWpxaEx5Zy9IVVNKeGRZZFhHRDRJQnVrSWF6a2dlQ3BHL3EzYURvZGxkSHI0QVp5RlMxdnpId0FmUGtLMlJkbU5hN1BDbjBmTkJCWHlRODYyU2pkRkRYTFZrUHAzSkc5WUdyYjI4Z2I3bnVnOVpyOUZzbmdwRDliakk4UGJEdHBQcnc2RnlLclVTSkkrMjlBY2dtN25uRUhvWk95WXZHWDlYRkhIOS9xTDFZdVlIRzdwZEtJblV3R1RXQzErRW91Nm9EalZZL2o5S2RORzdtbGxPTE9aYk40a2c3eExwZkVMeCtXa3hIS0dvUGpOU0NEVnFScHgybnBGbHNGZjl5NmFwYU9SLytLWGthRnBmaTdBeGh5U3hwRVhMblZUeHRTbzFucVlhWllmUm9YLzYvR2xCZHRKem9WOUMrbmtlaUY0NHlEUFhRdHlwelpnQytlQUQxTXNlbk9CUXYvWGc3R05WY0JCV3BDV2tRbjdFOUt1ZWU4aDNDUEo2UjRMOHczN3NiRTNFWlNaS1FDWXE3K1lYL1N0YTY4bGI5NHlvMmNZWU5zTk1JQ2VROEladysvbENzbjlmZkZ1d1JvOFhvOUY5VUxnVmJ3QkxGOTNyOTgxd0Y2ajRNMGdLcFFqeFRFdGtVYUpSOThlZm1ZTFVEQ0I0ZEU3RlR6OVpQQURMVmpKb0RkbVBzenRScVNUUEFhZTZrS1lNamRERDZoUGJ4UjB5MVV5THBTQTcyK2JzbWJ1d2ZtV0dqbFJGVXZSZnRUUXcvbTBXVmJNd1ptem5kODVVUVFCRlA3dHc4QU41bUFQOWdFMVpENGR3U0FGSWM0bVNKR0pWMTBkd0VnT1BIWHg0c1FUd1BpRkhCU1Fmdkt0TFRsUkhIRldaQjJNcFFObVhOR0pQQ1pxK2YvZ1ZxeVFxRzFpdjlrZmtkOTc2Z3RlWlp6VVVGQXJMZVBnMkZvb3R5akRESktvY0laS0RESUVMeENYeEVxOW9oL2xjclpwKzF2MnUyWk1haWF1cTZDVnNCNkdWaWxrVWFncWlrWWM2Uys1N1pmaDltWis2UzROdllFZlRBTnZjU2lFY1V4WDNRVVBiNkdYaUdFNWNla3lyTDkxYmZ4RVVkSmdrZXMrcWZpczhoWXVZUy9PRUJkTnlad2VjYWVmbm4zb3cwNjA3RC9XbVMySExWODZYZmppTmZRNTd1SnNDOXpsN1E4dVNEekpZTkR0M3pTcGxieXZoTHdaMGFIeGFFOXpvc1VnQmh2TC9RM09NUGtvNFRxZHdjZFFtSHo2Q1Z1bjV1OUlQYlVZc3VCa0ZpWlNBcDhjcGZoenlNaS9IMVZqblM0cnE5bkVxdEg0TXo0WjJ0WTBCc25UbURkd3ZaYmZhYytGM3BHQUppR1N6N1ZFUjc5dzU3UURnckNLMmVtSFFVNUwzQ2dSTTV6Y2pGMXMrbWNkdkZIQ0M3VS9yVjJLbk1NMjNKb3dubFovbjhibkduNjVac2Y3ZXN0aHhmZlpYcVhJWWNxQ0ZZclJQRklBNGQwRHZ5VDVGTnprNzdxVUx4QkErbEhmQzhyV1VvZU5FN2YxSWxnT1lvb0xVbEhtdVQzb01FZGdXOEFwZDFFanY2aUhndHduUUIrYk1qZm1aSlc0OW5kYnN0NlB1cmhzdDkrdjl4SnBjcUdZQy8xaE5VSmJaRTNjblJZd3dPeVdabnkyUXBReVJhNS9mRGtmNVd5eXdzNkRlVXJxMmxlYTNlOXNRYzdhaTB5WEZJTDEyTDdsUG11YUpyQ0U1eWx4WDhrdmNncEpLOStWQWc4SklJVHQ1Z3BXTzFlcVUyV2FSUWRlVmJjK1MyVm5ZZUpKaGJ0UUt5eDdaMWdUSWlKb25VRUNYbWpuUGp4WnNYK0UzZ0psY3FEV2xiMDNjWStMMVB1OHYra1U0cWpQRitlWmMrdlFVTDd3b3dZQm9wd1RSN2dFcGova2VQWGxIbVhJZXY5RjRHdFVMK3VJYWFEZGk2THF0aHZvZHFMd3RTTzRidGdWdCtLNXZoVU81eFY1MUI5VXFjNTF0YkJoOE5Ob1hrS1pyc3Q5eWo0WW1QdGpUSmJBWEtvYXB4T2p0VTgzMWp5cGwrUVU5aW03SFF4QzdhQlI0OWIzWnJiaVBYcVFlbHlNOHNLSGk0VFlQeHg1dFZxTjVjd3FtbkpNNjhwYitMZVdSZDR1ZDNobVkvRzFrTkkxbmRqM0Z5THFLSXVKTTVZZnVvaTJjMEtKZHRXN0dsbmx1eVZLNk5Hbm1CK0NqTDdIMHBGSFo2NFJrRmFDWktGL2pHSjNnRWIrd3hRTS9hRXREbHJxRGZ5TXpQVjVtS3JzQkp5NUk0T0U0TG5UdEZHTDNCbkNpcHhrejVWcnhvTnJ2RHlzNXUyM2dCL3RlRm12bklvcG1XNklZQ2x5bDhOeTMyeUM2cFdZSjdYNFE4ekFqandPeGRoMTA5K2dEbVV6dTg3NmJCZUk2Y1hONzUzbGRYOU93YnAwUm1MTEx5VkhvSUpjTVpTRkkwQkduUXJ6RzMwL003elpFVUc3ZzdJVlJ2MmRPTlRRcXZjVk9pdUlZZ09qWEtyYkpYYmlOaERPMFFqQmptVmdHeWVBUzUxMTg2S0YvYSt3TlBCSkdtLzZRUU5IWVc5VmpuYytQNlZXeUZzLzdpM0Vzblo0OGtPeUFSYUtaZTVNV1U4Q1BER2RCQzlreXZwNmhISndXTExVMkNINkI2TnJsWlJ4U0RkWUoxYldyUE92eVpTRnVneG52elRKZTBnYUZzZFBkZTEwTTFsd3BWOXFNamx5UzdwTXAxNnZWcWlIVFI0UVltY1hPTWQrbHpsUUd0R1lTT080cXpRWnA3U0ZJcDRwSmo3SjdFQk8zallqSnQ3ejJ0Q2dobnlsS0dBMVJ2YnkvTk1CTXZEcm8yQnYzYWpYNkF6L09GaU9hSmVYNFBVRnRBSVk2N1R6Q00wUG1NazR0Z3QwYzZaRUJPSFJaVHBuKzhna3BnT2IyZzVwbThzRVdra0ZKT2FlMnhtS0ROcDBZNGUxWVgvTWNsMjBEUTRQM2R4SzR3SjIwY0Y0Z08vdi84Mi9YK1N4VFhHSUlzcVMrR2svbFZraVp3Mlp4THpIbkxhVUo4WlVIMGsrTVB3cXptQWprR0ZLaG55T0JjQ3JKMTZNOGk0dUt2eXlOQnJJS0RwRFdhN2tUd1JCOWVUcVhVMHA1OHJwd2VkK3UwK3pCQ2xuTWt1TGhkSmo5UFVIeWFVYXpYQll0RnVSTjg2cWJEdkNoRTM4dFRnWWgwUHBqM3p5Q1g3bE84ajNReFhZVDlzdkZ3NE51SWREMUNIWWFGNUZjZlZtL2ZsQyt6YjNwQ0FHd0NITEFlbmtFbVlzcUxmQ2NVRGFvWHdGaGlHV1JqMlZhdzRjNEJBajNQVyt1Yk1EZk1jSEgxNC9Ua2ZMQmhKakpGWVF4U3dYMWZSTXVQMC9GUHlubWlCQnoyaHptZE5PNFFCYVRRbENQMFU4ZG5LVGFBSGFEd0VOYzI1UWQ2VkJMVmhrSkRVRzJMYmFtcE91VUlobVZpVzJVWndvNzducVlCM3lHMVViVzNIYTIyQktMeFZmTXA4ZVdZSUdnbUdZK3NoMFlRbi9ia1lReGtpeGZLMDErVjd2WlZOUHlVRlNacENESzZPMlRjQzVxZi85K2oxOW5BeDJLWkhCYW1jT1U2NTZqZGthRi9IWXNEWDljUmt2SWIyTjExQWUya1hrbkZVTkJ0b3hBUEpINUdkYUlpS2Y4SHQ3UjkrSFNsM1BKWGZZRmZ3RiszTFpwNldodGgxWHRoRmlMUDh4a3RMaU9PVlRXSEV0bVQ0eDhDa2txRFpXOWFyYWdDdkRDL0U5QTQ4TFlqWWFuUUk1QUZMMU9QNG9GS0kwQjRDbW9GWENjNDVrY1E3MHJKK2QwUlNrS3lSSG5uanpsei9kVXJTY1BOSEVSMmVsZ2xNYkJzc0luVWZhd3dHZmVQdFJ6OG5aNWN6UURsb3lkMTNvNU1Rd1FNUU9VVXZDZk5DWEg0U0FOS0tacHFOWFFUM1FUaDkyWlZSWHBPZnBvcTNhWWx5VTEwWVRQSXhwamVpeEc5UGc0N05NMmRsVFdHTklBVkp0b2RtbElIWkI3NDFJU0U4OFZldG5CSXduYW5CU1o3Qm1NRTRuakgvTC9xeU9JaGJVeWRhSDloWUtIQWQ4OEpNa20zajBuOTNqQmE2b2NzbDM0Ym9wVkROUEhoejRMQnBEWXU1TjMwZTkxNTAxYmFVTzdlWFh5cDBveld3T1hHN1ZXVFcyU0ZPaVdpYVV6aXpUVGxwS2w1ZUxSRGgvVDlLUHNqZ01adGlJQWdqWkNZc0VZaG4xdEFIMFBLTG5YSm9DUnlnaXFoeGJwR1Bnd2R1QWxZOEVGN3dOaC8vZjJWU052SlFqWHlrUFNwK0ZRdXo1Q1QvSDErNXN5NENxMG5pM21COVFZcVgxcmp6bGpLcXlhSXlnQ2lEbmFlU3AzT2t5OERNaC9OdzVJa1ZCNUxOeXZHYlVQcG9RNlJUczlRNDJXVnVFL250YTR4VjROQ0dKdWE2WXFvWVNuYm04a1NUdGJ3aGNNR3RxYm1iT1hxV3hHTEc2TEFsSW95dlo0bmIzelY5UDZDSHFSZzJZNS9HY3pvRUM4MGN1WlJTZk1xWXpKN1hSVzBrN3RIcThoWkFpK09Ub2l1OFROaHlWRE1kVWJpOU9KaTkzK1BMMGQxVStObk5JNXNKQ1YwQ0NVN1FXVE44c055akR5cCtuSldkdlN3RFh2YXJyY0diMklpS1N5cnBkN250ZGdwVWZiWW5QYW82L05xdGkwa0Fia1BVeHJIY1VTbWNHZDUrb3FLRmxQTjhsb2hZZ1l1K2E4UVRTaWZGL0pUK2kvTk9Pbzc2N2hKUFQ2d2NJN1FDUEFXdnRuQlRSaW9iaDRCL0JOVDVxMnUvMC9xdXFOSzE1aUwyRHp6bzVvYW1HVGk5SGs3QzlDOXVkVHZjVmhkZTZtekR0ZmtuTDhNcEtGL2N6bzRYenVWKzhDTmJ0aVk2blZocHUxQUhxMit5Rm1obHRUSjV6d1B0ak5EcW53VktQUlEvQlJVajRHWUxlV1E4c2lscGFjb2c4ZWNpNGFkMnMzRUhQdWVQSUhSdFZ1c3NJc2pKUFVKbno5ejdGU0xHbndnYXcxVGZ5VzY5WldtMEFUbWpsR3hBTExudm96STR6R21OTXdiSkNQL0Y4RFR6encrYUMvSklVWm11SWRCY2tHZ0FiTDJ6T3pxa1dyNXVuMFpIL1NQMGZlSkV4WWNkbGU1K0VVQ3ErUEJ1TTMwQTUxTlVHUDJMZzdtc3l2WXJuSGxubVppa25xRW5VLzIvZ0pCQ3lwUlViUnFISS8wTkRBTjRBSVI3azUwYVA4MXlwaDd5dXkzUXJJRFVIZ3BKR1pjeUxYS281cDNyemxoSmlST1lNTk5pcFc0a1Ztek0zb21GSjJYclBtMnFKVXJIZWFFb1JjbWc1TmhGeDVFaXlObXp6T0Ztdy9WdE12aEdJM1A4TE1vWEVkdUhYdUJXYSs0ODU3SDhoRUNRS29VOG84S2JOcDJDTGRmQmRsQnk4NVlicUQxbDlmd2lVUHJkb29aL3Q5MUFxMXladGxsY3ZHNlJydWVoTGU0bkZQc3ROTU1vY2tBckMySTBmL0ZCbDZZb0QzRGFWRW5BU2xocDRKTUFFNFg4TzUvVm1nNVJvLy8zL1RPNWNoTDF4SFJua2pzWVNCN2JyQmt0eGkyYTVZc2tDNFNIeEc0TFBQbzBIMG5SZm9GcE54VlQzdnlOQ0FibFVWekZtcXNSeEladmZZTzlXcWpmdW80YlJNZjdWT3hjU2cyb2dCKzVmbVFjUUZBWVdrWWo2TnpYZ1N0NWNGb2JZZlZTeG9QM2o3dzBabFVjUlF0T1lvYzE1ODJwQ2JGVDl1dmhaQis0bWt1amtZdzNpTFVXTVpzMFAzVTJEZm9uYXFVVnlGdDcxeEZIRldKUlBUajlOdjNQN0c1bXVHRjhsL0tBMlN5RDZheW5uMnRlcmRxME5Xa3ZQVnlUaEpSZzRlc0NCYkhHY05BdldFNkhVNS9FR0U3Y3UrUEU4bTkwMkJHbmt5NCtLL1JFdnJZd3RnckYvNE5vTGw2MUZ0N25yMlIrblN1VEkwcitkb1VCaEI2Y1JsVHR4ZXpVSVhMNzhFS0p2azM0T0x2cmx6VFJCVXhZN0c0KzJvS1IxRU1QUThocTA1dWwxTkhsaFJCOWVhbzR6TjlGdWhPSUJtVFZycGozamtUV1pBNHd1ZTllcGZnaDdLWVR0d3NNcjRBRHdRQW5kVERCS3BINTRDTjREalgxTW82TWVUOTh4M25VMlZ2UVpqenROUkVjQjBORUQ5bEpCSHBWeEFRQXdvdS93SW1LejR0dEdJL01QblE3cS9IVXlpM0psMkNsYlBsYWoyTkJoVXJRbExhYmVURFR3ZmJrTko4bkdnOG1vQzBsMDUzU1RXYmhxNldPYVZBdWc3YUU5MzJldFpWc3ZNQnNseUxGaWJHWitHdktPdmVESUEvNVphd09QWlEvUFd5K2Y5cVNINkpmMVdLdFEzT2JGUThEWStTNVhXTDZFQXFCYkVyMjN5Y0I4QmorZW5BcnlJbnhKSEMvcXd6eHc0eXNSdW5SR1RJVHBDTkNUb0FybndpV0w2dUZSbFNReSs0SFBqd1FkQ3FPZC9sV2hmZ2Zpc2d0QlF1dW5YN2ZFZUlXNmhzSk1URHpuV3BnL3FtU2pwdDJYSkltM1c1VWh4cHJwR01FRVJXNG9BTGQxUHJOYnRGVnFkSDBLRW1mbUxsT1pLMFFzaWpiQUR6MWZGTEZpNDlVYksvTjZSQjJudTE0SXN5TkJtS3FTY2d5RWxYTDVkaG9ZeGlWbW91dFVVN2R4M0xQNUNuUkUxQnNpMFFyQldGMU1wRi9MYng0Tk4vSENpMmFmejJYUmZVaXVnaW9EWm9hOWs0aHhCSnRRSTdHNVJLejVNOGpGMlU4SkwrcVZEemd5Zk15eElDTUxoY05FdXRhUzdNdzFlMXJFZFVsNFlOdTgxVlVZN2NEbktIUjBrV1Boc0UrUEtmR2F6MXhZU3hUU2tadlEvMCtNaHdRbmszVTFPemR3SFhWZVg2NmZLZWZlSGo0ek53QVhubXZ0RHo1RHVQZTlCclRQS2FHL0pra09HT3FyZkUvblI4TGFWZFJDTW9NeEM2MDhmZ3MyYVVsbisyaFFkNHpTdm1HNElEVzFQMHhTQ3FJN0VyRlBDR09qOFFucnR6UTljRkUvaUZVT1BKeFRIcDJHRWFhc3Nkd1V3OEdTOTYvY1B2Tlp4dmVoQ0Q1MUpxTVZ6emxPWlY1aXY3eGppYUEyck1hNUtPWkNlbTF4bW1mRFhmaThkYkpTRlBYdUdqV3R1TE1pUkp5Q3dzdThSYldhN2tQRUt2QmdEVVFwNE1qOTc2OWJWME9kYVRZMTJ3UEJnRjAvdkR5M0hTUWFENjZ6V3NEYTVkNWxkbEk1dUdaVW8ydS9BRjlrN1ZqdzV4eWNzcWZLTWduZkh0bkQzVW5EK2dpNWZLb25nQ09uSmdwTk93bXZQMC9pZmxVVXg0MEUxZXdFL2h4LzcrbHBkMHdaQW9INzVJS1NFS3E3WTRITnk1bVR6ZmN4UmozZlJFU3h0OEQrNWlBRUdXOUY2aGpPcHFxdXhOQTYvUC9qVFFSeTE1Q2pSVmlzMDlBamk0cDRCaWZYNkhOWHlzV2M0eUlyOHdVcUU4OVBtNDA0Q0tyakg2bUYrU0lyU1JKVFBCekoySGVJaFViU2V4bW5YKzh4b0tyeVVQc1BCNnM1YzJvckxudGpwUlVnRTI1ODh6d1BPdmhXeFl2NE1JanlCT1VNTG1zR2NNODlzL2FaT3RCdnVvMnBBSGhIWXJPNFI4ZDBNdkdWVHorMUt6bGxyNWNLMlFYK0NoZ1A0dWZaU1hKRFlxUzVremp0bWc5aUlTWDU5clN6Zy9VcllaUUg2NmZPVFgzcHVMQ1YrcDZqdmJzYm1iK1l3cUI3L3dPMWxER2RFMzd6RzhGL01SUlhaRC81T1daeXhNOUFwTUQxc2QvcFd1bE4vL1ZKV252UjF3c29kdjA1TWVoenpXK2ZtVlZtSGxtYWRMU3FlYTFjeG9ySEtNQTUwTUREU09BczArdU1TZEJKV0IvZy9JMW5xdnVWSlVXT3pBRDVzRU9vV2hQSHZJMGRDaU80dGNnL0J1S3FVdXJhc1RTQ1duekhaSjk4Z0J6c3E2VGpKZmx0WnppTm1zL0ZIcHlIVlAvWGlHMmtZZWR6MnVKVzJxR1E2UXRGd2Q2THFOL0dDTUkrRjJHZm1qa0NtTkVCalQxL3hCeTZZMXBYSE1VOHVNeFRTNzZPMW5tY2Z1bTQ3S0FZWUo0WE9iU3NlTTE3ME5XYVkvb2Z0bG5VT0VDelpSMk03KzJCM1pVUHU2akt5eVpUMlQvbEEwRlcyc3FkUWwrSUdvMGxhcE0xbDRaeU5LZHhNUldvNmREVkJjaG9ia3orUFdrQzJRZTc2UmVvTXBDNHpKSEtTR0tiRndKVzVWK1NFb0lHaHJaclg4RkZNYlVrb0FMMk53U0ZmbUd3WDl3clpTRGptTVdtTUF1Mml2N1ZsOTJnOWppcTIwb3o0RFV3cTdmY2svUk1CcjBMd1FsRGc2bXZxZHM3ZWRTS0hDK2dTVGEvQm83RVl4RFlvMkxhWWJ0dGRiMWlRUyt4VTl3K2llM3lPWGg2Tnp6cWRVZW84LzlER1ZFK0ZTY1hFVy9xd0wrVnhVVTU2K2ZqL1FJOGxOelZXbWFqSTI3aDhMU3k5QnVaSW41WnZqbUlNdmNRalp4R2ZEbk9iM3VQWFNNV2d5S2RCZEJMbjNXVmY2dVFWVnlIbk53SUo0bGllL0w0QzFBd3hLME1CQlBIVmVDa2QxRDJ4dGVvbVl6bExkVlN3K2JqZW1VSFpCRU5JeDdzcW1pdXB5NmlhM3VLbFBUTzRIWURvb2pDUGJkYXM4MmlWUys4YVVxbHFDRVNrRHp1L1gxNFQ0djlvWXV0TzZZL1gxMlpsSXhHbTZPUXJFT2U0ZEtRM1o4V3hsa0JUN2RieHVIaEpxcjg4Q2FkM1Y4aXFacGpDMjhZUzJEY0t0K3dIZHFxZHhIRjc1MHVNYVlIT2JnQWFLdVRWWTBudWxKTXJPRGxSQ05LK3hVRlBXVHRvMEZ6NWFCN003eG1aTjNjUHAwZnYzUHNpNUFZVzRRanR1MXpEVHRnS3Q4VnFNa3JiRzNDUWFjK21YS0VQVE9FOUhEZFpmcXpwK3ovQ3BZd1NLODF5ZGtEL25FSm5MSWJHbkwrRUhvdU14Vi9GNzBDdzZEVFFudWREYzJweGMvUG84c1VzT0lzcXFpdXNKTHlndGpMUGlkK3NaTGx3N2dpN1J6eklxOEM1aGJUTUdYMXUrZTZYdlJ5K1NKcWVEUXJXb29vSzh6Qk9nbDQraEJxMk9zYXhDMGYyS0JGc0JLeUhKUEp3NktVUk5WaEZyYzBoT0huTGtHaUw0NWtGVlhZL2h1KzNldVdycTlqN3c1QXJXbmhLUk1Jc1NsU0dySDJ6T3cyUTBoZGpsZTVUSGszSEplaDd1OW1wSDJPZ2VrNzNHTFJ1Qlp5T0tQbGd2NnRhZndya3lNTkVmbTRta0g3ZnVRRjlHM0c4VVFWWjZwTklyYVBZWnpzUlRndVNlajA1eEQrd2JjdzBFSUNpVDRkKzhpRGsyWFFkVUU4K0pGTWNhUVhtVWFxcUdCYXU1NncxQ0d6dlZFUEVuUU4yNXFXVm9qREMxakhWZjVhRUdSZmREd2hjWDJZWFBGLzVJWHFMTUc3T29VbWU4WHFjTzZ4RFJKM3J6ODhza3pqVjlTODhCNVFWS29IZndJdXQxbEw0a21rWU5QemhuUnFJMVdZQ0ZxOGdaZVpXdmxuNk5hMHAxcmRZQkhCa0JoMk84a1NDZmlKV2RuNzlKekRMTnQvMS9rRldMNVFtT0dLekd2bnB0MkdGUHdHWWZzcEgrNk1tcW56S3JQTjJ0STk3MW50MlNwRnhyYmgyVHFCeXJMdVJMc0lib2ViREVtU2ZDOEhxWGdlQjM2SjM5d3JoVktwczhDSm1MUG41cGhKUjA4NlNaYkNYalFPMEwwYzFTTkllalhJUnBlTjJEdGd1emJUU0FoQ09aOTZ5SWE0bGVibjFJSVR1SGRydVFnRFYwUitLazVsbnRPYjBKMVpWT0JkU1EzYytnRS9QOWZKaW1NZTB1VmowK2JwdlBWMmRNRGozRENlWnV5RlFwNTFHc1FnWHU4aGVKbWdwRURzY0dIblI0V3p0SFZQYmw1M1VKMjV3UUpnWHRLLzJGYXk0d0cwVDdRbUc0NUZla2ZtVlVoY1NBTDVWcUlwMFhxYm0wLzUzUFpuM3VQRUNKak5COGFycGR3dEpzVEdSbWlMUGdZaHdGRGV6M00wYU5oYS9OZ3c5MkdPd0hQc1VJTFBuQWpXbUpubldhaEM5ZVV2SndINHZndHdqVmlUYitIKzBBZGVMajRCQ051Z2Ixb2hNTGUxNm5YMCtscElVV29PQWRGZE9TWS9zL241TDlSV3hKeitaTTYxa2lFbzhKL0VwN21LRy9mS0Y4bEVuc3ZEUFZlVVBDSjN4K3BVYzdGVC95U05tRk5DOGF5WE1kVTZnWmZMaWxrWXZLNjNmOHE1UURmeGpmUjFFcllac2hGaFhkVnMrTGZ4YW16UXQ1Q3lqSzBFaGpLUUxWbmZCQnRiSlJuak1ibkdiTUJZMXhMYXZMekdVNi9QZ1AvNWVvdXUvWndUWFo1WVpaUDdqZHZ6azI5RWcyZGpCZUF5N1pCM2diemR1NG9RZEY0YlhDR3lpRjZ5YlJIU3J3NlQvQUtLMXlkcHZQMHh5T0ZFWnNxRHJGbnZ1emJDTG4rWXd6UDI0QVAwME81L2h5TktBT2d1WkxVa2owS2x3d3NYR25DM0Zoc2h1ckttcjFYRTlYWmU5L09qMGpqY0o1NTE0QXN0TnVoeElaT01CSU1xeHRINUI4Y1JZNUZGaEhqUDJ4RFBwemFPcG1uc0N2K1Nhb25teTVOc2dpTkNDQ1FhMTBaMkFBTFZjRVJ2RXhLbWhtdXAvSjI3MmZ2amNXaWI2UHlDSWhXQm9yRzRORm80ZXY2ejQ2OVY5dFdIN1dSZTR6YTk4bmZJbnVXNjkxZnJ4bEpTQ3BFOXlFZlNMSWFEQVdVbDl1c04zcXlSbmFMRXlqM1h6NGN4WURBTUJWblVxNkd6d0N5d2FVeittSFY1SjhtNVFxUS9peEVoZGFWRk5WNGxiaXZ6QmRQRUpmdXpQZllzajdTYVBHZUpFZ3hkUDRiOGNzZDdjZUM0OEFBWkV1OFlFSjRUemh0NkdpcW4rL1ZtSVFkNEROT0t0SWx4WFd3SzVJbTdDa2pzQ2FXSFFzSUFmNkV6R1krL01CTzVULzdkVW1xQ1ZQY2ZjajlneWhRZjJ4Q2tZSy9RS2JRbFBwc0E5WlNoREx6SVZ5MjVJMFhnSVNmck5TQmJRSmY5d20vUGFjNklxWitMMzk4a3YvcGRsUFhBTGhQVlg2bm9BRUtuMzl1UzZHUTNZNGpOU2EybG8zWVhOWno4ZmF5elpCUjNhazAxVTU3Yi9WU3RjWlVoMERUc0pTV2FQVFA2cy81U3BmRmF1U3QzVERRZTJHcVkwN3FySGhLQnUvSGh1OGZJWjVuc0Z3aU9aaFNSVWhPcDFsVVNwVUdESVJZWU9LaUg1S0xVcUIyWEJwSjZUd0Z3VWNyMjdZb2N1QTRueDBYaVR4MXhzOWRKQ1ZDQUpYYk1HYk8yRGdBV2kycVljMWhqSGJrZ1kyTmZVQ21ObG04MFc1aGQvVnFXZTM2NEp4VVBreFJsV3BySXFZVUw3ZTBaVFppSGF1dXIvZC82YWlXV0E5cDlyTUtOUHhBY0V0alMvZWFOajZaTlB2L0M2QlhJWWZ2YXJOMUtRc2tHY0FoYlhzTU1nWllQbXZiL3BKZVJ5bDRGRlFQc3ZISCtIeUNYUGhvTHZLbnE2bTRpYjBXWGlkd29FMm5laE1veW1KUnZ4dHNITUV3QmJuQnBMcVRId2dzOUtXZVdyNUhxb1dqeXpXMUx6cXNhOFQyRlhRa2xoZGt1Zzc2S0FqOExMK1hzc1FsNEluaEZ3MlM3K3plRE84VkJIL1Z1cHRtMlhLamFlM1Y0SlJOZCs3VVpHa2UzM1huZ0Q3d3JrUDNpbmZTdUJ6Yk1tTUFkNDdybmduRGh1RlBLVVlmeUt4aElYc1V3Q3VyQWtuR2NCd3VGYTZXVnBkZDRKbFRtcDF3Q3FJQlh5NFZnMFNvYm5DQ0VFNitwejVxZDkvVnQwQzd6WVA0WVBwQ2p6WmZ4TkZhczVTNGVxR21ROWphbEZhNnlNMWk0MEpmL0F0WG9CVG9zdDd5Um9XV1ZZZ1AyNHhtWXR3WmpjM2xoZFBtRVdBLytWbEtybC9rT1VPNHhQUngyMlNOckd4emMvQjFBUzljVkxWZmUzNm9aUjUvTnNCYkhNcjJ1L0lZUGp5bnlncG4reFBOVWo1NDdSTjdCTVpyWHpVOFJobnpRNzNWZXFCZzNrRTlDSkV2ZHhhdVQwVSs4aFNrVVppRDVUMTg4bHFxM3FMaVJKc21mZ3NveU05M2YwWnZwcjJVaTFsSnpnMGdLZDJqYzllQjR4Qmx3eWloSkFlamFxdzZnZmhORy9WTU9sblBBTytWSUd5dVJDNUxZdDBWNGRISU9JY3RscVlLNEdJbUlERTgraldma1lZaFUyMFFLZFBwbnlPN0Q5SFhuT0FKN0xoS0xJZFFROVl6ZWpFY1Z6RWY4UitXOHNzcXU3Skp2ZXMyeGNoTWlaemxjQ0o1c1IwdWxSRnN0V0dNakJrNzZvWldZbWdXa25KVkdUQy9RZU1pT21JU2JpZVFZaG9GZWNnVDBKVXI5R3Jic0lTREdXalJGTWVldHdzb2xjdFpHa2pJaUVNQVh1K2tmOCsxdFFUWFBFSFBzOFRDL3R4VjVCS0tXazdhaG1qVzBHN1RHQitKbXNkSE5TcVBOZUNzZ21NaVhTQ2pab1IwWUhYMWJqVk8wU2NGWGU3elM4c2RCU2wzL0hERlNZV0h3Y3hoYnlKZEcyenJTYm9YSDVHOUh6UmJoWnVoNlhiU01lWTZPQmlkTzNBelB6QW8vOU5mK3YyTnFMUFpteFdrYnVvRTlQc1hTdi9jcWVzZlgrU1pDL243bmJDNjRHNmc3K21IaEdOcHB1Q2R1WXR5dHM5UitONUZLMS91ME5NcUp5QW92WCtRK0dVdGh6eklQMm56Q1RIRFpQYWZrZEs4dEJtZlZWNlcyN1hTbTdBV04zR1YvSXJSV0NWbkc0QzZkOUVqMHA2R25Fcmtycit4emtiWFZxL21WWHJGQ0JwZElqYkQ3NlJ4SFFLV2crdjQ4VFJ1UTFZYkcxd05KV0w2WFQ5ZFU0SXpXTEt0MTE4aFVUK1IwUUNpa0ZkbEhObjlVcHlTajNlMkZWVlpiVURlc2UrcmFLQ1RZc1JjTGN1ZE80M2J1T2JwSlZic25YQ281N0lNOEdlYVhPaDdCZytrVlA2V1JCWHZ1cWZScDV6bC92M1V6VWJrTEtpdnFiR2ZXRFVBOUZyaVEvNTc5UzZkQXFlNFVoTzBEN21YdlFkTkpKVXEyN1NSUGRnNnBBRGRUNjJrWjc5Tnh5RnlUeGFYeS8zMDhoVllsVzJXbjFScXU2bEdRTlh4UmNxaVFOREhxRHVpRENNR0F6YWJvWlhKUUE5VVZKYit1aXdBQ295aFF5R0FHZWR3RkZCU3pBYmgyQ0V6L2RjS2R4bEt3MGovVUhyVE00ZmRuT0N6OUtmY3M1SUc5NjZSUWlLRk56eGJQaTlLYmlCRk5YR0JLeDNHMHQ5aGVOTXc0RnBmcHBLVnFhamVES0E5UEVIWE9qZEs3WTc1emx2TkJPSS9OZnFpci96UXBBTzJERmZyS1ZsR1ZmKzBoVkcrMENWQjVCV3E3MHM5OVdmc2xMdm1Jbk56THBJTmNrT3ZwNFJoRXNjbjZuNGcrc0Z1VFdPbENWam5lSVFiQUZpTlcwcUxkTjJWVFhHQnNiNm10dTFKY21kT1JDdmI2cjBhWlF3a09IdVkwYmRuQlJpcXUwOElYY1U4WndiTjY2Y2JEZDBZdUFmSE1TQ1YyNXdGd0l0MW8rUThaa3lpaHFkL1ZaeERzNUJOaUJJaVNlaVBkMTRPcTVQZ0hJL1hrMEU1OTd0bmhLUkxHODY1cU84NmlPMGVHY3pIc3BIbXZpQlJqTk1qc25jcTdBTHBuWktxY1VkMkVFMGVCQ1dSSnJna0Y1UkZkdWFlZ1prbitDUjJnWHhseUVsNVNlQktnY3J0VzVMeGJNdWM3SFhnUEFUUGg3bUJSMVlucTNpNVJibHVnZXdzVG8yYi9nTFkxR2lrWDNxeTBjTlZndG0vNjVHVEVUdTJCdWRPNHJvSWtXemFlYVhJYjNUcG5lTm5kMmJ2R205a0ZzOWR3T2xPc0dLSkg0cFJpWkRKSGpHKy9lWkNKQ2hNbHN3L09hOVRpOGVKUGhDNW5wY2ZwTG1aNHpCSW9JOURXWmJpcUw5U3U0SG1lTDlSWHlRWjFKeEQ5YmtXdXlWOXdIMENYM01selZ0ODBBMWRQUHNocTRjYStmRjZkMXdHYUtZOW1RYkNpRnExVVhPV0Vla3hDMUJmRmFtemV4S3ZxY1J1OWQ3dGNkWjJVTFppbTk3dnp6WVpLREE0bzRreEJzSHhyN0dqelBNZGVYSnZpbW8zaUlwaHQ5emd0V05HZ0RHV1lPQkRkRmJ2MThaeVhQdGZOVWFlOUNQcVJJSmdQSFNoVTZra0Y5L3ErejJMdlBtTDZBS3VZRk1FRU9BekhSVkpvTlNodE1TcWZqbXI0U1NILzlrajRCQkxnRjhROEZhUmZNNjJlRnZRK2FnPT0ifQ==
-- CEETOS_RELEASE_SIGNATURE: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
-- CEETOS_RELEASE_PAYLOAD_BEGIN
-- CeetOS installer. Generated; do not edit.
local files = {
  ["startup.lua"] = "local pendingPath,releasePath=\"/ceetos-updates/pending.lua\",\"/ceetos-updates/release.lua\"local pending if fs.exists(pendingPath)then local loader=loadfile(pendingPath)if loader then local ok,value=pcall(loader)if ok and type(value)==\"table\"then pending=value end end end local function checksum(text)local hash=2166136261 for index=1,#text do hash=(hash*31+text:byte(index))%4294967296 end return string.format(\"%08x\",hash)end local function recoveryAgent()local root,descriptor=\"/ceetos-dev/recovery\",\"/ceetos-dev/recovery/slots.lua\"local source=\"/ceetos/recovery/agent.lua\"if not fs.exists(source)then return nil end if not fs.exists(root)then fs.makeDir(root)end local slots={}if fs.exists(descriptor)then local handle=fs.open(descriptor,\"r\")if handle then slots=textutils.unserialise(handle.readAll())or{};handle.close()end end slots.active=slots.active==\"a\"and\"a\"or\"b\"local inactive=slots.active==\"a\"and\"b\"or\"a\"local input=fs.open(source,\"r\")local text=input and input.readAll()or nil if input then input.close()end if not text then return nil end local candidate=root..\"/agent-\"..inactive..\".lua\"if(slots[inactive]or\"\")~=checksum(text)and load(text,candidate,\"t\")then local temporary=candidate..\".tmp\"local output=fs.open(temporary,\"w\")if output then output.write(text);output.close();if fs.exists(candidate)then fs.delete(candidate)end;fs.move(temporary,candidate);slots[inactive]=checksum(text)end end local active=root..\"/agent-\"..slots.active..\".lua\"local fallback=root..\"/agent-\"..inactive..\".lua\"local activeSlot,fallbackSlot=slots.active,inactive local function validSlot(path,expected)if not fs.exists(path)then return false end local handle=fs.open(path,\"r\");local body=handle and handle.readAll()or nil if handle then handle.close()end return body~=nil and(expected==nil or expected==checksum(body))and load(body,path,\"t\")~=nil end if not validSlot(active,slots[slots.active])then if validSlot(fallback,slots[inactive])then active,fallback,slots.active=fallback,active,inactive activeSlot,fallbackSlot=inactive,activeSlot else return nil end end local out=fs.open(descriptor..\".tmp\",\"w\")if out then out.write(textutils.serialise(slots));out.close();if fs.exists(descriptor)then fs.delete(descriptor)end;fs.move(descriptor..\".tmp\",descriptor)end return{primary=active,fallback=validSlot(fallback,slots[fallbackSlot])and fallback or nil}end local recovery=recoveryAgent()if pending and pending.state==\"apply\"and fs.exists(releasePath)then shell.run(releasePath,\"--ceetos-apply\")elseif recovery then local ok=shell.run(recovery.primary)if ok==false and recovery.fallback then shell.run(recovery.fallback)end elseif fs.exists(\"/ceetos/startup.lua\")then shell.run(\"/ceetos/startup.lua\")else shell.run(\"shell\")end",
  ["ceetos/startup.lua"] = "local root=\"/ceetos\"local profile={id=\"desktop\"}if fs.exists(root..\"/profile.lua\")then local loader=loadfile(root..\"/profile.lua\")if loader then local ok,value=pcall(loader);if ok and type(value)==\"table\"then profile=value end end end if profile.id~=\"desktop\"and fs.exists(root..\"/server.lua\")then shell.run(root..\"/server.lua\")elseif fs.exists(root..\"/ceet.lua\")then shell.run(root..\"/ceet.lua\")else printError(\"CeetOS is incomplete; starting CraftOS shell.\")shell.run(\"shell\")end",
  ["ceetos/profile.lua"] = "return {schema=1,id=\"auth-server\"}",
  ["ceetos/lib/store.lua"] = "local M={}local cache={}local function internalPath(path)return type(path)~=\"string\"or path:sub(-4)==\".new\"or path:sub(-4)==\".bak\"end local function call(fn,...)local result={pcall(fn,...)}if not result[1]then return false,tostring(result[2])end if result[2]==false then return false,tostring(result[3]or\"operation failed\")end return true,result[2],result[3]end local function exists(path)local ok,value=call(fs.exists,path)return ok and value==true end local function remove(path)if not exists(path)then return true end local ok,err=call(fs.delete,path)return ok,err end local function ensureDirectory(path)local dir=fs.getDir(path)if dir==\"\"or exists(dir)then return true end local ok,err=call(fs.makeDir,dir)return ok,err end local function readRaw(path)local ok,handleOrErr=call(fs.open,path,\"r\")if not ok or not handleOrErr then return false,nil,tostring(handleOrErr or\"could not open file\")end local handle=handleOrErr local readOk,content=call(handle.readAll)local closeOk,closeErr=call(handle.close)if not readOk then return false,nil,content end if not closeOk then return false,nil,closeErr end return true,content end local function writeRaw(path,content)local ok,handleOrErr=call(fs.open,path,\"w\")if not ok or not handleOrErr then return false,tostring(handleOrErr or\"could not open file\")end local handle=handleOrErr local writeOk,writeErr=call(handle.write,content)local closeOk,closeErr=call(handle.close)if not writeOk then return false,writeErr end if not closeOk then return false,closeErr end return true end local function move(source,destination)local ok,err=call(fs.move,source,destination)return ok,err end local function decode(raw)local ok,value=pcall(textutils.unserialise,raw)if not ok or value==nil then return false,nil,\"invalid serialised value\"end return true,value end local function readValue(path)local ok,raw,err=readRaw(path)if not ok then return false,nil,err end return decode(raw)end local function recoverValue(path)local activeOk,activeValue=false,nil if exists(path)then activeOk,activeValue=readValue(path)end if activeOk then remove(path..\".new\")remove(path..\".bak\")return true,activeValue end local backupOk,backupValue=false,nil if exists(path..\".bak\")then backupOk,backupValue=readValue(path..\".bak\")end if backupOk then if exists(path)then remove(path)end if not exists(path)then move(path..\".bak\",path)end remove(path..\".new\")return true,backupValue end remove(path..\".new\")return false,nil end local function validLines(raw)for line in(raw..\"\\n\"):gmatch(\"(.-)\\n\")do if line~=\"\"then local ok,value=pcall(textutils.unserialise,line)if not ok or value==nil then return false,\"invalid appended value\"end end end return true end local function recoverAppend(path)if exists(path)then local activeOk,raw=readRaw(path)if activeOk then local valid=validLines(raw)if valid then remove(path..\".new\")remove(path..\".bak\")return true,raw end end end if exists(path..\".bak\")then local backupOk,raw=readRaw(path..\".bak\")if backupOk and validLines(raw)then if exists(path)then remove(path)end if not exists(path)then move(path..\".bak\",path)end remove(path..\".new\")return true,raw end end remove(path..\".new\")return false,\"no valid appended value\"end local function commitRaw(path,raw,validator)local dirOk,dirErr=ensureDirectory(path)if not dirOk then return false,dirErr end local temporary,backup=path..\".new\",path..\".bak\"local removeOk,removeErr=remove(temporary)if not removeOk then return false,removeErr end local writeOk,writeErr=writeRaw(temporary,raw)if not writeOk then remove(temporary);return false,writeErr end local checkOk,checkedRaw,checkErr=readRaw(temporary)if not checkOk then remove(temporary);return false,checkErr end local valid,validErr=validator(checkedRaw)if not valid then remove(temporary);return false,validErr or\"temporary validation failed\"end local backupRemoveOk,backupRemoveErr=remove(backup)if not backupRemoveOk then remove(temporary);return false,backupRemoveErr end if exists(path)then local backupOk,backupErr=move(path,backup)if not backupOk then remove(temporary);return false,backupErr end local activateOk,activateErr=move(temporary,path)if not activateOk then if not exists(path)then move(backup,path)end remove(temporary)return false,activateErr end else local activateOk,activateErr=move(temporary,path)if not activateOk then remove(temporary);return false,activateErr end end return true end function M.readCached(path,fallback)if internalPath(path)then return fallback end local entry=cache[path]if entry~=nil then return entry.value end return M.readFresh(path,fallback)end function M.readFresh(path,fallback)if internalPath(path)then return fallback end local ok,value=recoverValue(path)if not ok then value=fallback end cache[path]={value=value}return value end function M.read(path,fallback)return M.readCached(path,fallback)end function M.writeAtomic(path,value)if internalPath(path)then return false,\"cannot write store temporary or backup path\"end if value==nil then return false,\"cannot store nil\"end local serialisedOk,raw=pcall(textutils.serialise,value,{compact=true})if not serialisedOk or type(raw)~=\"string\"then return false,tostring(raw or\"could not serialise value\")end local valid=decode(raw)if not valid then return false,\"could not validate serialised value\"end recoverValue(path)local ok,err=commitRaw(path,raw,decode)if ok then cache[path]={value=value}end return ok,err end function M.write(path,value)return M.writeAtomic(path,value)end function M.append(path,value,limits)if internalPath(path)then return false,\"cannot append to store temporary or backup path\"end if value==nil then return false,\"cannot append nil\"end local serialisedOk,line=pcall(textutils.serialise,value,{compact=true})if not serialisedOk or type(line)~=\"string\"then return false,tostring(line or\"could not serialise value\")end local valid=validLines(line..\"\\n\")if not valid then return false,\"could not validate appended value\"end local recovered,current=recoverAppend(path)if not recovered then if exists(path)or exists(path..\".bak\")then return false,current end current=\"\"end local lines={}for existing in(current..\"\\n\"):gmatch(\"(.-)\\n\")do if existing~=\"\"then lines[#lines+1]=existing end end lines[#lines+1]=line local maxEntries=limits and tonumber(limits.maxEntries)local maxBytes=limits and tonumber(limits.maxBytes)if maxEntries then maxEntries=math.max(1,math.floor(maxEntries))end if maxBytes then maxBytes=math.max(1,math.floor(maxBytes))end while maxEntries and#lines>maxEntries do table.remove(lines,1)end local raw=table.concat(lines,\"\\n\")if#raw>0 then raw=raw..\"\\n\"end while maxBytes and#raw>maxBytes and#lines>1 do table.remove(lines,1)raw=table.concat(lines,\"\\n\")..\"\\n\"end if maxBytes and#raw>maxBytes then return false,\"appended value exceeds byte limit\"end local ok,err=commitRaw(path,raw,validLines)if ok then cache[path]=nil end return ok,err end function M.readAppend(path)if internalPath(path)then return\"\"end local ok,raw=recoverAppend(path)return ok and raw or\"\"end function M.invalidate(path)cache[path]=nil end return M",
  ["ceetos/lib/profile.lua"] = "local store=require(\"ceetos.lib.store\")local M={}M.PATH=\"/ceetos/profile.lua\"M.IDS={desktop=true,[\"recipe-server\"]=true,[\"router-server\"]=true,[\"auth-server\"]=true}local cache local function normalise(value)if type(value)~=\"table\"then value={}end local id=tostring(value.id or value.profile or\"desktop\")if not M.IDS[id]then id=\"desktop\"end return{schema=1,id=id,installedAt=tonumber(value.installedAt)or 0}end function M.current()if not cache then cache=normalise(store.read(M.PATH,{id=\"desktop\"}))end return cache end function M.id()return M.current().id end function M.is(id)return M.id()==id end function M.isServer()return M.id()~=\"desktop\"end function M.set(id)assert(M.IDS[id],\"invalid CeetOS profile\")cache={schema=1,id=id,installedAt=os.epoch(\"utc\")}return store.write(M.PATH,cache)end function M.capability(name)local id=M.id()local tableFor={desktop={desktop=true,craftingClient=true,orders=true},[\"recipe-server\"]={server=true,recipes=true,recipeImport=true,recipePlan=true},[\"router-server\"]={server=true,routing=true},[\"auth-server\"]={server=true,authAuthority=true},}return tableFor[id][name]==true end return M",
  ["ceetos/lib/auth.lua"] = "local store=require(\"ceetos.lib.store\")local password=require(\"ceetos.lib.password\")local audit=require(\"ceetos.lib.audit\")local M={current=nil}local USERS=\"/ceetos/data/users.lua\"local SESSION=\"/ceetos/data/session.lua\"local TOMBSTONES=\"/ceetos/data/user-tombstones.lua\"local THROTTLE=\"/ceetos/data/login-throttle.lua\"local RECOVERY=\"/ceetos/data/recovery.lua\"local AUTHORITY=\"/ceetos/data/auth-authority.lua\"local CENTRAL_SESSION=\"/ceetos/data/auth-session.lua\"local DIRECTORY=\"/ceetos/data/auth-directory.lua\"local rank={viewer=1,operator=2,admin=3}M.SESSION_TTL_MS=8*60*60*1000 M.LOGIN_THROTTLE={failures=5,windowMs=5*60*1000,lockMs=60*1000,maxEntries=64}local configuredWorkFactor=password.DEFAULT_WORK_FACTOR local sessionPathDelete local function now()return os.epoch(\"utc\")end local function accountName(name)return type(name)==\"string\"and name:match(\"^[%w_%-]+$\")and#name<=32 end local function users()store.invalidate(USERS);return store.read(USERS,{})end local function save(value)return store.write(USERS,value)end local function tombstones()store.invalidate(TOMBSTONES);return store.read(TOMBSTONES,{})end local function saveTombstones(value)return store.write(TOMBSTONES,value)end local function authority()store.invalidate(AUTHORITY)local value=store.read(AUTHORITY,nil)return type(value)==\"table\"and value.active==true and type(value.id)==\"string\"and value or nil end function M.authority()return authority()end function M.centralActive()return authority()~=nil end local function centralDirectory()store.invalidate(DIRECTORY)local value=store.read(DIRECTORY,{})return type(value)==\"table\"and type(value.users)==\"table\"and value or{users={}}end function M.acceptAuthority(record)assert(type(record)==\"table\"and type(record.id)==\"string\"and#record.id<=64,\"invalid auth authority\")local public={schema=1,active=true,id=record.id,term=math.max(0,math.floor(tonumber(record.term)or 0)),revision=math.max(0,math.floor(tonumber(record.revision)or 0)),seen=now()}assert(store.write(AUTHORITY,public),\"could not save auth authority\")if type(record.directory)==\"table\"then assert(store.write(DIRECTORY,{schema=1,revision=public.revision,users=record.directory}),\"could not save account directory\")end local loaded,profile=pcall(require,\"ceetos.lib.profile\")if not loaded or not profile.is(\"auth-server\")then if fs.exists(USERS)then fs.delete(USERS)end if fs.exists(TOMBSTONES)then fs.delete(TOMBSTONES)end sessionPathDelete()end return public end function M.updateDirectory(directory,revision)if type(directory)~=\"table\"then return false,\"invalid account directory\"end return store.write(DIRECTORY,{schema=1,revision=math.max(0,math.floor(tonumber(revision)or 0)),users=directory})end function M.saveCentralSession(value)if type(value)~=\"table\"or not accountName(value.name)or type(value.role)~=\"string\"then return false,\"invalid central session\"end local session={sessionVersion=3,name=value.name,role=value.role,token=value.token,issued=value.issued,expires=value.expires,accountRevision=value.accountRevision,authority=value.authority}if type(session.token)~=\"string\"or#session.token<16 then return false,\"invalid central session token\"end store.write(CENTRAL_SESSION,session)M.current=nil return true end local function safeAudit(action,detail)pcall(audit.log,\"auth\",action,detail or{})end local function stamp(account)account.updated=now()account.origin=tostring(os.getComputerID())account.revision=math.max(0,math.floor(tonumber(account.revision)or 0))+1 return account end local function makeAccount(role,plainPassword,context)local record,err=password.record(plainPassword,password.newSalt(context),configuredWorkFactor)assert(record,err)record.role=role return stamp(record)end sessionPathDelete=function()if fs.exists(SESSION)then fs.delete(SESSION)end store.invalidate(SESSION)end local function saveSession()if M.current==nil then sessionPathDelete();return end local durable={sessionVersion=2,name=M.current.name,nonce=M.current.nonce,issued=M.current.issued,expires=M.current.expires,accountRevision=M.current.accountRevision,}store.write(SESSION,durable)end local function makeSession(name,account)local issued=now()M.current={name=name,role=account.role,nonce=password.newSalt(\"session:\"..name),issued=issued,expires=issued+M.SESSION_TTL_MS,accountRevision=tonumber(account.revision)or 0,}saveSession()return M.current end local function liveAccount(name)local all=users()return all[name],all end local function normaliseSession(saved,account)if type(saved)~=\"table\"or not account or account.disabled then return nil end if saved.sessionVersion==2 then local issued,expires,revision=tonumber(saved.issued),tonumber(saved.expires),tonumber(saved.accountRevision)if type(saved.nonce)~=\"string\"or#saved.nonce<16 or not issued or not expires or not revision then return nil end if expires<=now()or revision~=(tonumber(account.revision)or 0)then return nil end return{name=saved.name,role=account.role,nonce=saved.nonce,issued=issued,expires=expires,accountRevision=revision}end if saved.sessionVersion==nil and type(saved.role)==\"string\"then return\"legacy\"end return nil end function M.refreshSession()local central=authority()if central then store.invalidate(CENTRAL_SESSION)local saved=store.read(CENTRAL_SESSION,nil)local entry=type(saved)==\"table\"and centralDirectory().users[saved.name]or nil if type(saved)~=\"table\"or saved.sessionVersion~=3 or saved.authority~=central.id or type(saved.token)~=\"string\"or(tonumber(saved.expires)or 0)<=now()or type(entry)~=\"table\"or entry.disabled==true or saved.accountRevision~=entry.revision then M.current=nil if fs.exists(CENTRAL_SESSION)then fs.delete(CENTRAL_SESSION)end return nil end M.current={name=saved.name,role=entry.role,nonce=saved.token,issued=saved.issued,expires=saved.expires,accountRevision=saved.accountRevision,authority=central.id,central=true}return M.current end store.invalidate(SESSION)local saved=store.read(SESSION,nil)if type(saved)~=\"table\"or not accountName(saved.name)then M.current=nil;return nil end local account=liveAccount(saved.name)local session=normaliseSession(saved,account)if session==\"legacy\"then return makeSession(saved.name,account)end if not session then M.current=nil sessionPathDelete()return nil end M.current=session return M.current end local function throttleState()store.invalidate(THROTTLE)local data=store.read(THROTTLE,{})return type(data)==\"table\"and data or{}end local function saveThrottle(data)local entries={}for name,entry in pairs(data)do if accountName(name)and type(entry)==\"table\"and type(entry.last)==\"number\"then entries[#entries+1]={name=name,last=entry.last}end end table.sort(entries,function(a,b)return a.last>b.last end)local kept={}for index,entry in ipairs(entries)do if index<=M.LOGIN_THROTTLE.maxEntries then kept[entry.name]=data[entry.name]end end store.write(THROTTLE,kept)end local function locked(name)if not accountName(name)then return false end local entry=throttleState()[name]return entry and(tonumber(entry.lockedUntil)or 0)>now()or false end local function noteFailure(name,reason)if not accountName(name)then return end local data,entry,time=throttleState(),nil,now()entry=data[name]or{failures=0,first=time}if time-(tonumber(entry.first)or 0)>M.LOGIN_THROTTLE.windowMs then entry.failures,entry.first=0,time end entry.failures=math.max(0,math.floor(tonumber(entry.failures)or 0))+1 entry.last=time if entry.failures>=M.LOGIN_THROTTLE.failures then entry.lockedUntil=time+M.LOGIN_THROTTLE.lockMs end data[name]=entry saveThrottle(data)safeAudit(\"login.failure\",{account=name,reason=reason or\"invalid\",throttled=(entry.lockedUntil or 0)>time})end local function clearFailures(name)local data=throttleState()if data[name]~=nil then data[name]=nil;saveThrottle(data)end end function M.throttleStatus(name)local entry=accountName(name)and throttleState()[name]or nil if not entry then return{failures=0,lockedUntil=0}end return{failures=tonumber(entry.failures)or 0,lockedUntil=tonumber(entry.lockedUntil)or 0}end function M.setPasswordWorkFactor(value)local work,err=password.normaliseWorkFactor(value)assert(work,err)configuredWorkFactor=work return work end function M.passwordWorkFactor()return configuredWorkFactor end function M.bootstrap(initialPassword)if authority()then return false end local all=users()if next(all)then return false end local plainPassword=initialPassword if plainPassword==nil then term.write(\"Create CeetOS admin password: \");plainPassword=read(\"*\")end local account=makeAccount(\"admin\",plainPassword,\"admin\")all.admin=account assert(save(all),\"could not save administrator account\")makeSession(\"admin\",account)safeAudit(\"bootstrap\",{account=\"admin\"})return true end function M.login(name,plainPassword)if authority()then local client=require((\"ceetos.lib.auth_client\"))return client.login(name,plainPassword)end if not accountName(name)or type(plainPassword)~=\"string\"then noteFailure(name,\"invalid\")return false,\"invalid credentials\"end if locked(name)then safeAudit(\"login.failure\",{account=name,reason=\"throttled\",throttled=true})return false,\"too many failed attempts; try again later\"end local account,all=liveAccount(name)if not account or account.disabled then noteFailure(name,\"invalid\")return false,\"invalid credentials\"end local valid,state=password.verify(account,plainPassword)if not valid then noteFailure(name,\"invalid\")return false,\"invalid credentials\"end if state==\"legacy\"then local upgraded=makeAccount(account.role,plainPassword,\"upgrade:\"..name)upgraded.updated=account.updated or upgraded.updated upgraded.origin=account.origin or upgraded.origin upgraded.revision=math.max(tonumber(account.revision)or 0,upgraded.revision)stamp(upgraded)all[name]=upgraded assert(save(all),\"could not upgrade password record\")account=upgraded safeAudit(\"password.upgraded\",{account=name})end clearFailures(name)makeSession(name,account)safeAudit(\"login.success\",{account=name})return true end function M.logout()M.current=nil sessionPathDelete()if fs.exists(CENTRAL_SESSION)then fs.delete(CENTRAL_SESSION)end end function M.add(name,role,plainPassword)if authority()then assert(rank[role]and accountName(name),\"invalid user or role\")local record,err=makeAccount(role,plainPassword,\"central:\"..name)assert(record,err)return require((\"ceetos.lib.auth_client\")).mutate(\"add\",{name=name,role=role,record=record})end M.require(\"operator\")assert(rank[role],\"invalid role\")if role==\"admin\"then M.require(\"admin\")end assert(accountName(name),\"invalid user name\")local all=users()assert(not all[name],\"user exists\")all[name]=makeAccount(role,plainPassword,name)assert(save(all),\"could not save user\")safeAudit(\"user.add\",{account=name,role=role})end local function adminCount(all)local count=0 for _,account in pairs(all)do if account.role==\"admin\"and not account.disabled then count=count+1 end end return count end function M.setRole(name,role)if authority()then return require((\"ceetos.lib.auth_client\")).mutate(\"role\",{name=name,role=role})end M.require(\"operator\")assert(accountName(name)and rank[role],\"invalid user or role\")local all,account=users(),users()[name]assert(account,\"unknown user\")if role==\"admin\"or account.role==\"admin\"then M.require(\"admin\")end assert(not(M.current and M.current.name==name),\"cannot change the active account role\")assert(not(account.role==\"admin\"and role~=\"admin\"and adminCount(all)<=1),\"cannot remove the last administrator\")account.role=role stamp(account)all[name]=account assert(save(all),\"could not save account role\")safeAudit(\"user.role\",{account=name,role=role})end function M.setDisabled(name,disabled)if authority()then return require((\"ceetos.lib.auth_client\")).mutate(\"disable\",{name=name,disabled=disabled==true})end M.require(\"admin\")assert(accountName(name),\"invalid user name\")local all,account=users(),users()[name]assert(account,\"unknown user\")assert(not(M.current and M.current.name==name),\"cannot disable the active account\")if disabled then assert(not(account.role==\"admin\"and adminCount(all)<=1),\"cannot disable the last administrator\")end account.disabled=disabled==true stamp(account)all[name]=account assert(save(all),\"could not save account state\")safeAudit(\"user.disabled\",{account=name,disabled=account.disabled})end function M.remove(name)if authority()then return require((\"ceetos.lib.auth_client\")).mutate(\"remove\",{name=name})end M.require(\"operator\")assert(accountName(name),\"invalid user name\")local all,account=users(),users()[name]assert(account,\"unknown user\")if account.role==\"admin\"then M.require(\"admin\")end assert(not(M.current and M.current.name==name),\"cannot delete the active account\")assert(not(account.role==\"admin\"and adminCount(all)<=1),\"cannot delete the last administrator\")all[name]=nil local removed=tombstones()removed[name]=now()assert(saveTombstones(removed),\"could not save account tombstone\")assert(save(all),\"could not remove account\")safeAudit(\"user.remove\",{account=name})end local function recoveryActive()store.invalidate(RECOVERY)local recovery=store.read(RECOVERY,{})return type(recovery)==\"table\"and(tonumber(recovery.until_ts)or 0)>now()end local function setPassword(name,plainPassword)assert(accountName(name),\"invalid user name\")local all,account=users(),users()[name]assert(account,\"unknown user\")local replacement=makeAccount(account.role,plainPassword,\"reset:\"..name)replacement.updated=account.updated or replacement.updated replacement.origin=account.origin or replacement.origin replacement.revision=math.max(tonumber(account.revision)or 0,replacement.revision)replacement.disabled=account.disabled==true stamp(replacement)all[name]=replacement assert(save(all),\"could not save password\")if M.current and M.current.name==name then M.current=nil;sessionPathDelete()end safeAudit(\"password.reset\",{account=name})return true end function M.resetPassword(name,plainPassword)if authority()then local account=centralDirectory().users[name];assert(account,\"unknown user\")local record,err=makeAccount(account.role,plainPassword,\"central-reset:\"..name);assert(record,err)return require((\"ceetos.lib.auth_client\")).mutate(\"reset\",{name=name,record=record})end if not M.allowed(\"admin\")and not recoveryActive()then error(\"permission denied (requires admin or active recovery window)\",2)end return setPassword(name,plainPassword)end function M.adminResetPassword(name,plainPassword)if authority()then local account=centralDirectory().users[name];assert(account,\"unknown user\")local record,err=makeAccount(account.role,plainPassword,\"central-reset:\"..name);assert(record,err)return require((\"ceetos.lib.auth_client\")).mutate(\"reset\",{name=name,record=record})end M.require(\"operator\")local account=users()[name]assert(account,\"unknown user\")if account.role==\"admin\"then M.require(\"admin\")end return setPassword(name,plainPassword)end function M.allowed(role)if not rank[role]then return false end local current=M.refreshSession()return current~=nil and rank[current.role]>=rank[role]end function M.require(role)if not M.allowed(role)then error(\"permission denied (requires \"..tostring(role)..\")\",2)end end function M.list()local out={}local source=authority()and centralDirectory().users or users()for name,account in pairs(source)do out[#out+1]={name=name,role=account.role,disabled=account.disabled==true,revision=account.revision}end table.sort(out,function(a,b)return a.name<b.name end)return out end function M.snapshot()if authority()then return{users={},tombstones={},source=tostring(os.getComputerID()),schema=3,central=true}end return M.authoritySnapshot()end function M.authoritySnapshot()local copy={users={},tombstones={},source=tostring(os.getComputerID()),schema=2}for name,account in pairs(users())do copy.users[name]={role=account.role,salt=account.salt,verifier=account.verifier,passwordVersion=account.passwordVersion,scheme=account.scheme,workFactor=account.workFactor,revision=tonumber(account.revision)or 0,disabled=account.disabled==true,updated=account.updated or 0,origin=account.origin or copy.source,}end for name,removed in pairs(tombstones())do copy.tombstones[name]=removed end return copy end local function validIncomingPassword(account)if password.isModern(account)then local work=password.normaliseWorkFactor(account.workFactor)return work~=nil and type(account.verifier)==\"string\"and#account.verifier==64 and account.verifier:match(\"^[0-9a-f]+$\")~=nil end return type(account.salt)==\"string\"and#account.salt>0 and#account.salt<=256 and type(account.verifier)==\"string\"and account.verifier:match(\"^[0-9a-fA-F]+$\")~=nil and#account.verifier==8 end function M.merge(snapshot,authoritySeed)if authority()and authoritySeed~=true then return false,\"distributed account sync is disabled by central authority\"end if type(snapshot)~=\"table\"or type(snapshot.users)~=\"table\"then return false,\"invalid account sync\"end local all,removed,changed=users(),tombstones(),false for name,deletedAt in pairs(snapshot.tombstones or{})do if accountName(name)and type(deletedAt)==\"number\"and deletedAt>(removed[name]or 0)then local localAccount=all[name]if not localAccount or(localAccount.updated or 0)<=deletedAt then all[name],removed[name],changed=nil,deletedAt,true end end end for name,account in pairs(snapshot.users)do if accountName(name)and type(account)==\"table\"and rank[account.role]and validIncomingPassword(account)then local updated=tonumber(account.updated)or 0 local localAccount,incomingOrigin=all[name],tostring(account.origin or snapshot.source or\"\")local localOrigin=localAccount and tostring(localAccount.origin or os.getComputerID())or\"\"local wins=not localAccount or updated>(localAccount.updated or 0)or(updated==(localAccount.updated or 0)and incomingOrigin~=\"\"and incomingOrigin<localOrigin)if(removed[name]==nil or updated>removed[name])and wins then all[name]={role=account.role,salt=account.salt,verifier=account.verifier,passwordVersion=account.passwordVersion,scheme=account.scheme,workFactor=account.workFactor,revision=math.max(0,math.floor(tonumber(account.revision)or 0)),disabled=account.disabled==true,updated=updated,origin=incomingOrigin,}changed=true end end end if changed then assert(save(all),\"could not save account sync\")assert(saveTombstones(removed),\"could not save account tombstones\")M.refreshSession()end return changed end function M.authorityAccount(name)if not accountName(name)then return nil end return users()[name]end function M.authorityDirectory()local out={}for name,account in pairs(users())do out[name]={role=account.role,disabled=account.disabled==true,revision=tonumber(account.revision)or 0}end return out end function M.authorityMutate(action,fields)fields=type(fields)==\"table\"and fields or{}local name=fields.name assert(accountName(name),\"invalid user name\")local all,account=users(),users()[name]if action==\"add\"then assert(not account,\"user exists\")assert(rank[fields.role],\"invalid role\")local record=fields.record assert(type(record)==\"table\"and password.isModern(record),\"central account mutation requires a verifier record\")record.role,record.disabled=fields.role,false;all[name]=stamp(record);assert(save(all),\"could not save user\")elseif action==\"role\"then assert(account and rank[fields.role],\"unknown user or invalid role\")if account.role==\"admin\"and fields.role~=\"admin\"then local admins=0;for _,candidate in pairs(all)do if candidate.role==\"admin\"and not candidate.disabled then admins=admins+1 end end assert(admins>1,\"cannot demote the last active admin\")end account.role=fields.role;stamp(account);all[name]=account;assert(save(all),\"could not save role\")elseif action==\"disable\"then if account and account.role==\"admin\"and fields.disabled==true then local admins=0;for _,candidate in pairs(all)do if candidate.role==\"admin\"and not candidate.disabled then admins=admins+1 end end assert(admins>1,\"cannot disable the last active admin\")end assert(account,\"unknown user\");account.disabled=fields.disabled==true;stamp(account);all[name]=account;assert(save(all),\"could not save user state\")elseif action==\"remove\"then assert(account,\"unknown user\")if account.role==\"admin\"and not account.disabled then local admins=0;for _,candidate in pairs(all)do if candidate.role==\"admin\"and not candidate.disabled then admins=admins+1 end end assert(admins>1,\"cannot remove the last active admin\")end all[name]=nil;local removed=tombstones();removed[name]=now();assert(saveTombstones(removed),\"could not save tombstone\");assert(save(all),\"could not remove user\")elseif action==\"reset\"then assert(account,\"unknown user\")local replacement=fields.record assert(type(replacement)==\"table\"and password.isModern(replacement),\"central password reset requires a verifier record\")replacement.role,replacement.disabled=account.role,account.disabled==true;all[name]=stamp(replacement);assert(save(all),\"could not save password\")else error(\"unsupported account mutation\")end safeAudit(\"authority.\"..action,{account=name})return M.authorityDirectory()end M.refreshSession()return M",
  ["ceetos/lib/password.lua"] = "local M={}M.SCHEME=\"pbkdf2-hmac-sha256\"M.PASSWORD_VERSION=2 M.DEFAULT_WORK_FACTOR=256 M.MIN_WORK_FACTOR=64 M.MAX_WORK_FACTOR=2048 M.MIN_LENGTH=8 M.MAX_LENGTH=128 local MOD=4294967296 local band,bor,bxor,bnot=bit32.band,bit32.bor,bit32.bxor,bit32.bnot local rshift,lshift=bit32.rshift,bit32.lshift local unpack=table.unpack or unpack local K={0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,}local initial={0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19,}local function add(...)local value=0 for i=1,select(\"#\",...)do value=(value+(select(i,...)or 0))%MOD end return value end local function rrotate(value,amount)if bit32.rrotate then return bit32.rrotate(value,amount)end return bor(rshift(value,amount),lshift(value,32-amount))end local function word(bytes,index)return bytes:byte(index)*16777216+bytes:byte(index+1)*65536+bytes:byte(index+2)*256+bytes:byte(index+3)end local function packWord(value)return string.char(band(rshift(value,24),0xff),band(rshift(value,16),0xff),band(rshift(value,8),0xff),band(value,0xff))end local function sha256Raw(value)local bitLength=#value*8 value=value..string.char(0x80)value=value..string.rep(\"\\0\",(56-(#value%64))%64)value=value..packWord(math.floor(bitLength/MOD))..packWord(bitLength%MOD)local h={unpack(initial)}for offset=1,#value,64 do if#value>16384 and offset>1 and((offset-1)/64)%128==0 and type(sleep)==\"function\"then sleep(0)end local w={}for i=1,16 do w[i]=word(value,offset+(i-1)*4)end for i=17,64 do local x,y=w[i-15],w[i-2]local s0=bxor(rrotate(x,7),rrotate(x,18),rshift(x,3))local s1=bxor(rrotate(y,17),rrotate(y,19),rshift(y,10))w[i]=add(w[i-16],s0,w[i-7],s1)end local a,b,c,d,e,f,g,hh=unpack(h)for i=1,64 do local s1=bxor(rrotate(e,6),rrotate(e,11),rrotate(e,25))local choice=bxor(band(e,f),band(bnot(e),g))local t1=add(hh,s1,choice,K[i],w[i])local s0=bxor(rrotate(a,2),rrotate(a,13),rrotate(a,22))local majority=bxor(band(a,b),band(a,c),band(b,c))local t2=add(s0,majority)hh,g,f,e,d,c,b,a=g,f,e,add(d,t1),c,b,a,add(t1,t2)end h[1],h[2],h[3],h[4]=add(h[1],a),add(h[2],b),add(h[3],c),add(h[4],d)h[5],h[6],h[7],h[8]=add(h[5],e),add(h[6],f),add(h[7],g),add(h[8],hh)end local result={}for i=1,8 do result[i]=packWord(h[i])end return table.concat(result)end local function hmac(key,message)if#key>64 then key=sha256Raw(key)end key=key..string.rep(\"\\0\",64-#key)local inner,outer={},{}for i=1,64 do local byte=key:byte(i)inner[i],outer[i]=string.char(bxor(byte,0x36)),string.char(bxor(byte,0x5c))end return sha256Raw(table.concat(outer)..sha256Raw(table.concat(inner)..message))end local function xorBytes(left,right)local out={}for i=1,#left do out[i]=string.char(bxor(left:byte(i),right:byte(i)))end return table.concat(out)end local function cooperate(round)if round%32~=0 or not(os and os.queueEvent and os.pullEvent)then return end os.queueEvent(\"ceetos_password_yield\")os.pullEvent(\"ceetos_password_yield\")end local function toHex(value)return(value:gsub(\".\",function(byte)return string.format(\"%02x\",byte:byte())end))end local function constantEqual(left,right)if type(left)~=\"string\"or type(right)~=\"string\"then return false end local differing=bxor(#left,#right)local length=math.max(#left,#right)for i=1,length do differing=bor(differing,bxor(left:byte(i)or 0,right:byte(i)or 0))end return differing==0 end function M.hmacSha256(key,message)if type(key)~=\"string\"or type(message)~=\"string\"then return nil,\"HMAC key and message must be strings\"end return toHex(hmac(key,message))end function M.sha256(value)if type(value)~=\"string\"then return nil,\"SHA-256 input must be a string\"end return toHex(sha256Raw(value))end function M.constantTimeEqual(left,right)return constantEqual(left,right)end function M.legacyDigest(value)local h=2166136261 for i=1,#value do h=bxor(h,value:byte(i))h=(h*16777619)%MOD end return string.format(\"%08x\",h)end function M.normaliseWorkFactor(value)value=tonumber(value)if not value or value%1~=0 then return nil,\"invalid password work factor\"end if value<M.MIN_WORK_FACTOR or value>M.MAX_WORK_FACTOR then return nil,\"password work factor must be \"..M.MIN_WORK_FACTOR..\"-\"..M.MAX_WORK_FACTOR end return value end function M.policy(password)if type(password)~=\"string\"then return false,\"password is required\"end if#password<M.MIN_LENGTH or#password>M.MAX_LENGTH then return false,\"password must contain \"..M.MIN_LENGTH..\"-\"..M.MAX_LENGTH..\" characters\"end if password:find(\"[%z\\1-\\31\\127]\")then return false,\"password contains control characters\"end return true end function M.derive(password,salt,workFactor)local work,err=M.normaliseWorkFactor(workFactor)if not work then return nil,err end if type(password)~=\"string\"or type(salt)~=\"string\"or#salt<16 or#salt>256 then return nil,\"invalid password record\"end local block=string.char(0,0,0,1)local value=hmac(password,salt..block)local result=value for _=2,work do value=hmac(password,value)result=xorBytes(result,value)cooperate(_)end return toHex(result)end local saltCounter=0 function M.newSalt(context)saltCounter=saltCounter+1 local now=os.epoch and os.epoch(\"utc\")or 0 local computer=os.getComputerID and os.getComputerID()or 0 local label=os.getComputerLabel and os.getComputerLabel()or\"\"local random=math.random and math.random(0,2147483647)or 0 return toHex(sha256Raw(table.concat({tostring(now),tostring(computer),tostring(label),tostring(context or\"\"),tostring(saltCounter),tostring(random)},\":\"))):sub(1,32)end function M.record(password,salt,workFactor)local valid,err=M.policy(password)if not valid then return nil,err end local work,workErr=M.normaliseWorkFactor(workFactor or M.DEFAULT_WORK_FACTOR)if not work then return nil,workErr end salt=salt or M.newSalt()local verifier,deriveErr=M.derive(password,salt,work)if not verifier then return nil,deriveErr end return{passwordVersion=M.PASSWORD_VERSION,scheme=M.SCHEME,salt=salt,verifier=verifier,workFactor=work,}end function M.isModern(record)return type(record)==\"table\"and record.passwordVersion==M.PASSWORD_VERSION and record.scheme==M.SCHEME end function M.verify(record,password)if type(record)~=\"table\"or type(password)~=\"string\"or type(record.salt)~=\"string\"or type(record.verifier)~=\"string\"then return false,\"invalid password record\"end if M.isModern(record)then local verifier,err=M.derive(password,record.salt,record.workFactor)if not verifier then return false,err end return constantEqual(record.verifier,verifier),nil end return constantEqual(record.verifier,M.legacyDigest(record.salt..password)),\"legacy\"end return M",
  ["ceetos/lib/release_keys.lua"] = "return{algorithm=\"lamport-sha256-v1\",roots={[\"auth-server-release-0.16.9\"]={public=\"/bitAOi6uFRiTEQEVqKCYdP1CCdDAAGhuxqin7+Whx4LrH9hfybmz3yCsdFl0cE0lqtVqybs0qXgYma7xxl4lanvlGgLvGn90cgtXLF8rqknHE2VmXB4NT41dYqvi49KnjGStcy8pr7Mv4Hokoncg5EhPI8nVkl3ttPi3FNZLot4o9WntutLGCXYRws5+VfiMaBXz8DjQ48js0IBcZklc3HBrs3TbyFywa2arapXDEi0YBsMUAwiVoXkIbBGVi3Phj1yZXXb1Nx3KJD60svDVHgI4b7CFPUtJey2ZUW0o6EQQyTuFBjXxzMbYEcEld2bIrC7MlG2uxZ3dtpBV+d+5HMnWSc195ABeqJBQaDDVxF8YV7mfiGjdIkZyVIlP5C0Bj7ns1k/6Ssw46bGEj3u4xcTytbVgZ0IMA4vuQ4VnCjjTfIDq9CpQAboAQn38s47lUo7ovZ0z+Uxx/5S0MBjXOIx//6Qlav3JnqyAun3hhVN6X8LgSVUgdcl2gH/0E3GiLTnZnRUh0iDMHEh8Si8rI1+3H5eHJuFF2O/XVarHNBc+Fwd3XKx738Xkh59uLXZvHCBKt48xY3m3Qd8ieG7gWEC10rkrHeey3E8slTRHknUNibUKNuDxTXpNOBypKx8NJRAzcLgsYk/7mQS2L5w7A2sWTkd6cDi9+45vLMVWhAW/0X7gQgixXjaqX+1LLm0Fer2+m1X+Uwjyhj69/GHDqG4322cxrohxcY4Tczz6QjFm3U/rKxtg5PumaUofKXk3ch1+DKK8+GurH9xt7yqLloDUpMLR2n0k/94T4GuldXNuL18aoSIPj7KWuwHYS58FN6ftCMujWIopW2CX7avWjcyFVeX7YH5u+MRnjjvWzAtb/h3+ApF3BYMjtcltokUS+hEuzdv5lmawKjGZH42hq3uI/THapSUpDs1ZHldn390nfU8Jc/yhMVoM2oCUSaz2xkQNjk5JozMS19e7fOFGjdQ/eiv3zl4Ustst5UPSjevZiDdz6JY7Q158aKahYT2jOIm9HwSNe+eCSN8KnCrVjHHv51e0yiElLXla7rV871lIcdFxUGQkaVF7PVuYN2Ab8r5McBrDR9IDdSFnnL9e2efO/TZUfsH34OiG2XNi8n9VGqkhbPoGTcwDm9diruCaLq/zmXyMxPJTjHv++CxPWMH7xwMSK+Vbx2zWt8DZ9vMhuVpKs145ZX4dJ6JW+PbjYlmU8x+DMrzRphNfRaajdBfjip6D8KPuPN69nULzJHFZIL5Zf4ofPFkcKy48Rcz2toyYgsWitGFfEFodPsfQvcdUnrptuzmCC2BsKGFwE6YJdvgpErmQ2nh13RxbNQ86H4t7kgOZzFPSZbE7+TzCzNS8Kx+cqJPSLHKLMXTmrtkX7uYYZi2iY3cQPPfqKkAlkefRLQmmcCR2nYrRrbjqOv2bxoXusIS7EFketsea3Yd83Gi5f34QiyrcDoOPX2qB0bwegicy0AqFX0X3KV9W44aUNgIFLJVipNGrZCKCfhpYc3ZlU5VjE7YjlL1p9EI9baPqFPmt2+XJmu0EzdbNuG36b8Y3nVZRzi3k8ZxcYj6VSGUM9CXSgFtpCk4ftKBDKQ2MiYQYXzNNeayLng6FuP3JyuIVz3pTSrx1c9KZ57rvgQ0WreC76Nt8SV6YxxvL3SI0E8L2yELxQptbVua+ms+CHtcuH2ecRfmEuLA+pTCV4x8pDn6k+tlJ8K4tY9KAxzOc7Ajlabvd9ToP9yhpy+TrJx/80qurw6J4OldABaXTaD7fojGbzsaG1C+ocA/rRJ+yzfjkRH7v0u7APJZ7G6qFP5ZeW9tJIbtIycBsT14RPdtMObLkHXHHcWD57Xz54yYdKWIOirOReRAq8VgQvfp9/z6tK3MIFgp6t1qIBgTbsoS8p42GaTHUjOFcd0tm/t832G+6y2C21lMnkTlEZdVuR9OjxvhX5YtTf1o+oesAdaCw4L7/gz/81KTkabEqnlsLUPL7lZfpUJtpXuQ2HYtTiKm4S29HuVisw55ym4Gco7uOpNSB75cow89h3C/gBFFvC6oq0LLdzo06rGh6+ipxgN6tUd+FBwvmmjo8wOk2ahfOM9bSBcDbFJ6odTuZVJWWi+g1KOQTLcK3F1O3H0MrwlFq5l8XWVl5XofsGSfw+ApssfiZcnFkwHtneZL7ovue06sQOkkwQiw9q0XMk87iuU/rDKk6LcSC4rWxF+S4yA2IvrUyO34puoquV2ynZXZD35wpMZt0DN/3o6MvSxBicey977VIqe5Zcp+/4sBtsBg66jPAyL4wZLdc54WUFN4YextZw/5f7/7FIo66qU6ShjqkvFlEl1HWLmvsR2SOvXTikRLBWYn5Wp8Epit073yx/BJdj917+88RVia1Y7LTBJBOurwUeJI39am7th9ZM8jQ6gd4ip42RF9vTcXRtg+2eJ/ioUTWh0UxpONyb9Fg7uCYAgjHZfFTbO+bD97594m0+96QfASaoSiLdag6RbAJfe4FtEwiC8vLdJWGKC8SawnomD3si3X3K5Y+tB44rH2R8XLMFyLomOj4vaB2yGlQsMUuFmE7hE9KGgnS1pQvPl45MmI0rzfwOAJlFZ+OdtTmvR/EVB8UENl/4oFvC4WsF0Ww1sIDx+3w/ZA8oyK0MGRUcRpIxbUoxx/dLEoNt7ozefsZmS0hVYOi524XJGXCrxG2zDKfMejKcJXWjMCj2vg8lGVU4OKkmh0SdGWFA3LAPbFX6tcgf+a4FxMlitXe+OKqeLqYg5NB5WdsuXfLWi8a30qhtFfZT8ECBMPoNHqiTa6wm9jJcPO6yFJ1NtHUIjOvpdy5TmuKVr/Ggmx68+6/I6RCjYSQg3Ca8MM5nYzEEPudfrsfI/KzONcRuNR95xW1UTuAj+/Bb2TR3E+tTwBtuhjgtgSRAreQaDUtV7+ZtzQekjGAtUX4w7A+bNKVQcUGMdVT2kforEujKkKfs/LWtwZqpyzLdH+ErN3qJxa5lUdreBBcw6dPgH6TlFLPw0UgxpVpOqdWX7YYXBYT5DopJvKyuHbGWlFFmuyRnrX8k1QSVxU0zGJU5dozH4kLsr5u9oxOoPo7xqaDVxEkHQpC4UbkJk+H9lfKSuPUhnjDtjQG//yA3FVpO0acHU3W93QayW9K0VvJZrTuC1cR+V+F5tkGgSsN/ffI6K+2glSSk25QYIu5jNM/ij+RC2AF8MBv0iEeTsdrJCRpqvab7wey3vBmEObcif1hdoufdSjgW35qOeStFx4TmymICsIodEAFWxmpaP6YEFScGm4nTEso+VaMRtBF7eGdWjpGQqOiwouwFXO5tSTeTD8qDH43LhvBwhDbFlo1zlFTKFTyZDpKMn2lcX69upwrgO5ezQvo+okzzS0y4/z8PkYDAj717Tf1zy1jnXnr8QZgAcGVllrbnosxK9BzV1UTmSGskUckVPXWdsrCfXy3oiF0s6b3Oukm7Km1B/Jphp7614SvJ3t724eHLzPmO/UJ0mkYe5Lpdbo8jmyrQrDlhi4+0skffaSyORDHDiRu5+vnG9DzoLwhaRl1oxENe6FH7+/xE1n0QAwe9urpiZjQl4uCDL5oS0NtfZPOGQ9uFWgXwQzZ5kkt3NGTQZhvwTpYrqFgeuISfF7w8aCsoN1fvdYogoC+KOfM8zI3S21P1l9kDinHq4Rttv7GkbByF6AvKg0Gut1+KKVsbBQ/edc+oKHsK1UbVfQ594Od9l18q/NKab4JOcLiZn3Exbkgb6Nkn66+d0tIBsoJXTrNBmSH4gnMR2VVnvx6WrT1ho/mCY9uxiYopz+klFsIEbtmFKipKQ4jSzz3vm0jnR5yTy4sbrT7r7nimkVqv0a7braADX4nWftXh03bCePWAc1Tb5LKpFZm+PzEcqib4l9NNAtFg4I+Fu2TUWc791DguW9x5Xhd7fJjDHUCTVuOiqf1R6V0D42Ur5Gcsem4tKtMZL6b52EvDbARNWyVPV7hh1jj341Ls5XTw8OXrnOndlxWaP0uZxR1D5ZY0JGRT+ZmFBEyL9pS1AfxJOSUBGSsQf7/YtZ173MZiXNkr22KaiVNQN90bKIEtaEPHM53jZuMZhraaRpBWHxjORscud+R3AzDTODRXTy8RET2MMNm7LW9EiBzsqw4B8BuzpWeun+XZJxXBCVvmH9dMcSVCGwU6TOkuPVnLiLLQFAMthWtODRmUnu9+qHpn8Cx1r7vpNTSQI6ElX6Gd+xTSxFzi7sLLjEa8r7gUxUbgvaVX6/ouo7gNW0HPuAifzzpaAMrJlNvmRREHVMexOmHJPzNlof3PmrCHc7prhiHwEOUbBpnUYnU+B4X58Ja4CEOXEz4LgW2+l084LQgonteUb6SzLGwP89I4iZIc3eMRLb+WN0MyXRyrxenKwLUBuDvsH+xto5vOXPgt1VL/3OlEEJelysrDLqhCVXPUwoGMomFMPo6qtqEkwauZc73vYf9RW8iCH8+JStjodBiKsL1T78ditCW1fwwnt5ZqGMrRZEz5H7ic16HgpX2WPyYBw37aG58Y3TeKExVqpAyAFcmJoPuPwQc3tKCUDvj9A37uSk070g7FwsDH7GK1+ka/W1I00gA6FQPa962KZNedFZeSViAlOjFDsc/mtdtvWshUK0pOQsluu3OYkS88ci+IdkoGgHTUJVkVI3SgWvef8zdma5eQ23baN4r8fCEmR6zVvhzSYcsmXv5c+21kdMx/FuEiV9p8j2ieszYD/Ve3RYIHlC4Bab/t5hjcyO8qZ8wVTL3JyvpJ0STRXFjKJRlsY/w4RGcRlp8hLU6XtozNp7lQY33o185UeIFWc4O1PeAvhWo+NPnUGJiGOtH/QbuGxjhBzVKybA5P+3LFqnBuh1TxMuEo7vEQWeEd8R5Mahf7vXgWk2qYmYdt8JYO61Pwctp6DBgbwZwTywZZmpS1lk0qMedlMcw58S9FMW/bCYh8SXRikGZLUrn2EcjbqyhsmiTiyhyAUnP8BsXsItbO32aE4ZzpcyoIZWNG2TKERhULvHABbzTv5I90Y3TNoS+Jj+1rF9VRaRgvRrdiyd/RVSjAv+8P9cb9XEriqa9/NrxUN6pXzQCWR4lcTnwMLIbcFNVF0EYqUN4B56wx4NCtvLZF3w7Sxw5y0G6Qcf2KCLBHPZ2G6RRyJwhvjqXhS2C4qDkIiu6TDPIjGfB2+n5E31hfapRYGlooMzyi+nyWQKDExDWYstuFm82t6q2UU0mUfyEQL4eP7Bfn+31JjJuzxT0zhN4uVqxSepzHRqHi1kqBGs91gv0BgxrtDJIeIIkOLh+dgi+BjB2miX97sg4vwopuWM8Wqyt+JxnlXfRa+aocXg6mwHzN0VpvKJaoHH05ZJaHqBaUhXlIqsF5VZIyV3O73ALg/mc7o/krSut+5XFRUFQCKPxHWvklIJYfA8wCkTnBIhTVsjvuOFDUsq+rzDY6GELza9Zob3J524H6BsT+uAb/IuXOu9ajqCqgjqSBjT9lFdPW3pt2MO3VFmF6NS/ShgC9fmT/Kke2DWFhuhZDtVwXRFeXXcpQ2ra3dEBdRrF+WirUUL2BGaLjjVYKNTgy2PaGcjYYyZRVnhI3XlHEjk9tFPGs8W5fjJ3zhT/zGGSeqTiKk94kqAbDOau4QxsuKifrg2s4zv+aKLxUikx5Urg+H5CP1enLtbhMY198d++nlR4XKpxLGtzEIJkIZ4lfWYffTjxp9/KiDWft2fm2mIII71wQYU8pSC3OTgnvX+lLkJgxKHK6EnG4e+Hs6qXdR9/UMyZuRmN3mgoRreSKa/QeLvstHrGihOKMFEdUsIbIEsY0oXjIZC/Owwdrz7oR2Uydkr7rcDhirRsgq41Rs7ifFwb9lpOOQ2ZdE6S3HX3ySwq0x3J7Jmjh9GcoMyrzQEtDI4H0iTXdBydzt+o+hJaox91r+rWA2zSjwxeKXqCPRi8UCJPIhgOpz1hgrkqhe6ylMtqZzPSISAl9T1JuCN/63tzrjPd3ZSK1fEQadBIiigRfcNC7y97VREnMwa9zZrExzyZ4mAy9x01NfJjFvgaXhQo4P6wl15LGmYLRkMxHOTUVg9kVpKZg3GD4ezRZdRYPzNYJ0Zf6QnlVvfTdKPp/sdTp+eVNTvCRECCVXDsI0Q6i6JkwHejasK61MgU7/Qm0uASqWC2zRUAsrd6YB5bic98T2HjMXLu6e88xmvu8eSnUDNWrDoZEZ8LqWPGh9v1pdHgh702b1EMjfZ0IFwcDKmf031Qa84Bi5wBNH32wy7VqZtY8PCDE0QQrqTbYClfVTUWszHFh/H/5t19Sp/SJjPKAp/PPfWTxazbcRFAFLpgA558nvOSMZfkPKAfrE1q+/IeWe8aOnAqBwbrK8hoPT8NnB/Vbzj4AkZ3wsbIhitgwQ8rrvoWTGhq0cp1+KQ8w3pIqMWm6uJNfRCJ0nDXP0hq+jr/WXxruftHvwHaFtWaxeM7wEWY4U89GduuGjnV5x/eVFEWM59+ET1vifHJ1mGC6VRc4vXj0AOsC+hIC62AOI1l//MMunYqPT8tDr6Is0Acctt3LMt0tEcfBcoA7vADC/xlbwD4QrUnSSXRsX5i7bouVrHEOd/aBCdfTO43OiDsiCAKkkABNMyjEsnucbXs2V4Fo4SJSbqQyfYsR8ZddafpIfLf1pj9HXTrbqIMYomZHPIrvEm0KzWbGvMiPMZY3J58TgVcqc9SQ5Z5jGqtbk0nGBzg6KNl1Ect3hOBo0RSHxv8vb+cW0+IlLzP+PyJ19tGIeSn7u6K6yLw2EPVBaWcShpxKBHPHLYODWu1O8sV3oUCde/ZpjNl3NLqKs3wWgPI04QK99ESXWjWep+LOklHRMameR1+p3XOSVFQtW9iE23myoDV3JNgHaRIv1BqWx2W6iJKjSUh5e9F7pPP5tEh7WUm7ELADHbEHum+sO/ueFuRouJNxQ5KaZEDWW3h92AHj9uwKLD9yN3Kq/wLsqdpzsY2zjzwsFd9S2r60v2T2D9StfGtJ4yMi+qHArILxEaW4aOU8FvVZ3YsdwnwwngCitBEEQE+5w6kIce3IrXRdz6bmQwPUWnZNILuDI2RWXR6gKTpW5/vnJE4rdFUHg/ogcItTsdmBqvoNIASf91+1z1l5eBUZGF43WZNiRu8tU12rxasyQ0CxeuExAHwPf4oGDmKP6OEUrvSX+D+8OzOHzZErW6/2y1yWrQM4jIrMYU09rez2na8IJvIt9RqFU9C+F4006+grf22oOtPMIvP56CBhJMMRgQE8oRq06y1jrLnxwMC9VaqGRAbjOtcHXSMY4Sr5uiB4BRu/jajSwZ8ruHGMDUvwmP2FhakA2L1q6had+R2OuQnOYX5AqZKE6wbHfBAIAwdq7nAx6tnjtWzBWbu0Wm68cubFmp0wMFBouXJK8EA4PHTEXN3rjJzjclu0KgvS872AxdpD1uJkA+BVMgbOEdpQJZzsCRucfwR4aTl+xhF0Di/F3pQH1Fr7PqspaQLMDmrebLB3iwENTcsNCOBABrB5fw20EDfUMivTeRxL1SOrkxQHPAXa9udISc4G0FjHmH/LxaOeB5bFNimPm7BUcGHxiT/Xib4YPFhiZ+Hfnus8M9gyT0h3FmYgspzdJadRXFd11sumpk14ZZmUDuZBgRMf+9ra5wP+DYP1+WctJiOB6/RKZ6uxnC2wQ3Yp/Dyrmpkx1m1kiqWqgEg/J02fCPoBPlR9+yZZF1kfdSMsJkR8gsgqq39LdUM5DBF/pNXE8QEz0DdZRTwpVIjDAI2c5t1wHmbhKYAC3nyPYpKIfSp6vpP7hF2dbsk4nSvzX6asgsyvk8APJoKK/hnEpTZkx1bCUbGUwGIk1XcSAGF93QFx+sZk6PARbs0AMof46OQtzlQcZkjth0wigyy6vxtQGHaIUQZsgRytwz41OqYdxdTvXdnnRVF78feaRjXtI8PMqSEyO6SGlcm+UEeToEVL5+vXFaAtO2qxAQFU5MbMK2f3fNKmudUX3wXU9LYCsheIM66Wh3d3P97frRLVrnINFB4ZolcWVKn9szMx2ub7efFkFI/ZCosphHoOoS/h5HDEiU8mPFtrBCNpr5eaA5SNQAz5wv1HbfTVicnaXT+Uwq/yMecvWKxTyWY/wiD1Iphu6yy4HY9WqYPGnkarf8Qm7Vjdc7xoo4EQSosStDIWh19BCqFnbpiBw1i0SubMBc+c2b+aKgqFuul08zmCbeVyAjG/1Ph8rWtzOkt6hWixiM374qdGzmcjwTbUY9TunNFptTJrMSY1dl1PRUKUcW8f0Io3rY8c4gm1WgOSU+goGIKKoHcB8M+gKVJBCHSDqtmQ4gLk82qE/fOhvilgbygkA/VzggEvyKL0nIiaQM7yXdOnjkduCo36m/sVqoWw9xLRND9LFWv+TyfdpDQRM97bFfEsQQTFG3qdyjUqCcGhrYWu6SY+WAphsPFNIGc+UksvQBT/xy8M4gMrJBHUr99cODqPpfamraHXL5QUE7vqq7GgM8WaDc68gSHsBs8xFJf3HxEZ6tSenmDnMiQ4k1jeHqEzX/E9jdCZCzYNwQmKmvcPns6VvWi64+0Z9IPQeysm/rpN/F8RouHmXfcwmx/4MB8vg+25rfC9uprMf2h+myRho/1SbucCa1dzdwUv8c5FuCgqlASdZ1OKYHd/Qs4EocTPPjqOzVCyTxiDF5DqzQnRR24FZoB6z4DXmLd2T/Aet9fNNeC0Ao6oejshgwgh6PEpJ8t98xv1GnnPX9gx2XrF+VAb+TCJgx9fvCsrpEMnWT8pK8mNdyfyeWhtmJH+vSoPEGYZQf8GT1NiSXI3REbH/GWhe9pUYZ2bUH5K3FDnapuVQJ7MmnlWSUuRlZcYjUtB5AG5wnohnOgDV6BQcnkIWye4gf5TDI+4rwpDMACPL/wx2OcpRYUhwRAt+BPZrARGISN722Mls0Ff/Xevh07GPx3kjGuV3mzaCD3I5Nw0VguPOGcmNBVxt0cbtOssQxxu/WNRXm738XnUg6Q4WDV2oohLrZuKXbm3DhIZsfwSX/E8HgySs2FGLBiUKnILORxbUqk1VD7pgXg5guZPeVJB2jCMakt1+WBzwU+RfxrxxktqeDBLpeHb33We6MJyf9rSdWtaqa+6aWW1yTjyhtPxFWS9mA6bLgLpzhTCm6YJ2+QgOz+PTEOdaALcI788JRhUaACvRUlNTJz7Cxq+lolDKDdP3UYHrOIr51V6h0lW8TpgxT7vy00T1z5Z14ts7h0uqPJrBGiV4mA1kuj1n3behRYRcnxLzH4psQ+1EBXv7OU6h1iWYMr3Vc7eKu/NDAjeUkwlnOxl1A9bjxWS/Fhgy5EOO9MJu4LvsJXv5I81TK3aoBxBqSCEB/xEuY4CjuYMGrjkeZYPqlI+4YSfzRq8n2KRkQ2SbJ0/z2ocWxlODEkfR/JZXWFI0DavsaTDhThNW9MDtG/o1B4eX4YCaT7/6HGQrWDHggf8OKupjxkAxEOGqLdFfyGajCIwb4J/rUMxxoem4UbIPOCKA60FnGh9Pi/p19ZbTh0us0vRlF4IiCyg/rEl/FlJ3BycYyr5M64TaDz6k3WYbP299ttWVzp5cZwPBOSa9+IyeQv8vpe3O865DbhjmnkA0kyFHUqXbqTreG/3OBTKihpFcvsiH0r/HQ5jfX8fmzriNIdHcgeJtOJJGT3IBRPzn5X8TUiQNfEj3x5UhbF3iweEXJ3VEBNbF8HIG0gwRWyazvJCSm7ob4u4LgGl6Xq3vJjJ3pWER5cQu27R/gJ5c7BnQMlgwAqu5kDidgIsxt4BvHY04HEKiRjVr6KqaKnIvk+eKTN1IN2niRa4C0yqfu/wWylwtDo8s38MKHDfiG8STL/ndT2CGLSZmOkHmLs7ntGjAvezkzd9Zpi9s6FOEYEiQTpFtvTHqTp+o74ZslJcaJNf6CdtE5C7nF7zWL74oBUOo/0rKQkyRo4x4XFY8msdJ1U6gIVaSNEE0R8m3xryBxYkKLU3yTF3oySGOZvJ4mEIO+It4PbzDBZNxANShGARiIOIvghRB89cB/fgtIhDeFvz+oIVEl77anRNEYctIY/En8anL8dS0abvQBssjfiyT45Dg6ObcLjsZ5BeRTN58aJiFTeZPsYOR1zb/F6q/9rCUmMcHgk1XEvsyzqq4ih4U30rRmS1Q5wKhF4pUwVTTmQkQ4Bgw13JnGz6cIsVkz/AgEC576WE7L9DaOKBTsmju/9gZCwi4swX4AXj9OtPVhzrWe7iSB7hPSp/J7deZbHCcMyrRSrIFyEl2z/qnL2rogI9yl9UUwRmd8AzGjwaHjxX4bV1JoqpjRxdWUl0ZECd6RgSRpLTB5R6y/1nAiNLCmtu+y021e/bisdle6+wyHHs5pOVucl3WsLtpSfCM/9sojxByFSaQ0ZGwVkDs2dSCAhv3AsP7n24V0STAYM182rT0FOLpL/Pxt3vD6hQCMkgUufoHydQk+YrxXDdCHcCLpUOFNum6ZB8ECbQmw42LZq8q3Y6HzDXBW9oU7yz3nxQjrrDXuV3ZH2M5nbrNdsjUBphJ9daqP26TeeCai1S754xhdVmpFzkNj4H5NdGfmmvDWBRTOUIZHEkIcf/rU21gPGgIaz3FIuShtaqOYLQJeEWemz4tzpI4p5HWv3ShFXoONwpuQ6F4BU2rgzEj+co27rlftAy6pyhaU1iXirqishlgCOYlsv7q0A5TVPj39Zj49nl08vQy2eCN0YMnRgotRyBvuqAE1bbTJTozhie/ApUgRv+mWz9yGkGcqqnrg+k0fyyDUWcSYSKDxMFlARJto5+RlciKGSQTEini+4usgOcV9cPKxAsCcqijubElai3JeD36L1QqGk9EB1MQFlFn5w7rydMx7w/kVF+8VaHT2btSDi9NgbXqRXgEFN1JzaMJ+oNz5E6IK4nD8JANAYSxlDpLrpIYOsZ8YjX78ncUF9W41JbEO/BCb7XhIA74LR0IyUuePE+EqeX5/17SK+Z/cZKbRzKdw4DYtMrwCMP7dulZAGhVxALkr6B5nk6CCREwHojTC35i/+FehJLDubehZy7kqN4iPRoKOO+Ib8m37dZemCsyj+9oagLJHvJne+Hulx9JzCUrM0CF9XRBjsVi7wA4v/S/cZKeZZoANxTH+agU6rhccddv4ZT25ygfwS832idyvo1xej93elmSKlbn2GlilqzLoP932mtw3N6TTQ0U1HtXfUrFRYoMFuGkjvY/KZlz5WdHW4I+O1J6BtrkWOBxFd+nILEXfEH731m4tnIbGUndEcA+V7T+Y3biBI7gMyq7G7sp5piUnEl/4XdMZnDqON5Lzd8A8QfPlFM1+hnCb52xcfxwOQmH4acNfimNT/p7ZKEEZCuj/ipjk0r1ALdZsn61TN/mKWD8uJODvu1ufXCZKfWxqulF9cHp3PrwY9hzppp0dumabMEj5hVVoUAEgIX7YOrB0x/M2UlXS32IGVer9nzjPb7BxXPEgDhbD7QHZH55zubRKWLJogVsOsBINypXARdewqdDdZ04vN6CFPv8di1pkcappKCyjbKHcydJK5XgWVcxJXSrQjqEmMrAKFz0hdncrr0AlTZ9VN6OXLmDUy3cwSta3rtHmN2zfr6F0yn6ujngTgctpRHaHVm7bzEp2SKJHvtVv+2l+MKho8AtzUFvcNIHSHt2WkRVECMgSiJiqT9QX9i615xp4Ok29dJz7p8Job2YPsLB+zNTrUdaPusW1Dv7UenFg6kMNIOR/A0CoKeEHPvc92c7bEHzX4byc2ClWULPxrE8EvTcrTnzQrg34v8WoxmBlsc+yIzDlbuanrX+/VP8ZlKjDthyIYb5AzjjNOA/Vep+x8L16TJR+vk6cmU06sLBoFf2PBm5YjiJ4aAcqIPeaZmsMs4jgZ/4WnTgO4xE4iwxWtxksj1FyeURNOBkVdVFfTLxdNxpRiuaVmpH9A36NEqwOoFaGha1MlltYZOTC/TaBavAm9UXWs45wlwhH+XTeIBkqLSfP5iKVyDUK038KxmUQ308tw9fSYbSNvZvJmV23dNMHNaF9k6lYNrfbeePsKRfbVdZ4twThNS0PgfpFZLLp+kXJv6VrfFX6FjBq7PKmlDmUyif8sVlZ1g4ffGKvm5+0oY73gL/iv3ugZp78HgE6H8Pw8b4gsS5iIUJd2kQAEbFqFKq2uguIPTHl0ZbMFTeGwW4hB50aPNfC9HcsIpICYmfQFdWsWtSKZBaVfR2W2JXxhzYZe1LOhCLpiqVr0TobyA1ogayyPybIsVteE7p5ZsMJFVJTHehyOnMe9i69OpId5cOha1JzJzppLaUJE4W1GXXbb0syQ2SAi+ueuK6ElYfYzcNyf+k6Wwb9rAcCgvU93kTIkH5iUSAN/pg9WwKHL6cs5et2D/kQZc3h9hFu/sEpwI5pNsnuz2UjjzOvz2O8uivzeXIK3Fck0yqFCHy77EoRyNXWfNwvMmuaaEFDLjsdzPB/3yOHwCPG+9KB791KKnwxWoySDCW9uJPKcJ8lvQ1HIyeuoDcLhvAOFTcRWOzShV3P8AALBSmJsI5TiarHmGJRY5vD9g2yOF2U8SZTeaZJVX4F7saa6il9nCKbI/Sd8pbBCRSB/3t1eJbWR2MPiHk0X/ErTpm+rWFHE92HHUosOLQIAoHg0lsmQiSxNYrDxXA8E33t/+gvBgJPYGmhfnFzjL7cb/jF5BO0HFleS18IA7txYX+qd/f6H39LOi4oqJC7ZbQlLa/Aowcdb0W3SzIYH4x/McNSac6C8BGPfMBozQKPUiiCNnVmDOxkEm9EqwvNGsaSCUoHdDA1cw9+Mtqp/9W2y8tlKZak0J9QdMjj3xSwxLo6h7vre/MRiqEAyliFC3pKJvSQDHBcuXWQ+Cmiy19fT59/R4UszWBYw7Wmv/RcgLHKsuWrCL3aLeI5M2ydorWdnyDKJtzsj6W5N4wnSWqzwCGnic4POCEk+5TYnJGrvDX6kTD0atOYyHBM8MMxSTHxHNqmuENJ8gg9AO4WbLuJPKctfpph82BeZDh0Xkc0ynr+5C8B7df6iWo8x6+vHHwbo+6Bfgun9dacyk3bQ7hNcsoykhyHqo/KC887trhzymvjTgvDTQ3hwHsxhNueMqmn67KrlV7PDIOyvUSzisjklzcm5iVnCPTF5ua0g2u4NNo4DLEcXYUxfZtDwL9/saplN+XYwR+LPumBNYWYuVcGfOfapMflPgYSyE4WtqjShfIDFfJ71NnO5h8yca/aVgnZDNm5I0EKoXCfZmxB4dNpFxuw81WDenNHxcexIk60S4D6+EwzUI6oa3JeHkfJERzenI6hpp8PL4KAzCNqzLUj9dIOkE5VsTTZtu7RR3vXHe/eOJAXyd+gDAdzyblEhHp4eeFC/ojG0jy1+bWucPUQsmhxwq4I5lbhdaY3YgbEe7RNYF1u3DvKHceXg7nJ2/T6E6dzLCtnkApLUtE5jXUsage9aUwLG+RMsk4S/dhHLWDlS/nq6Yu2h3e3+FyJJDFDV2VbliKmDAjZ26pU5Oen5gJOuK3S8GcRW/XM7lcc3MpBoTPstjzH8LRb2V5B8cPqduLtpyvSuEdDAJPGEb3CZipftIpBE+DA5IJ0SLPBDyGCe8atuCYf3ug7hfAU+gvGckok2G2X7szZaG/tFbSJj++R+uxm2IH0kmsnwbrJvItSymgI6HZLJxdrQtvwpMl5ssLa70g4kasEJCyRRyTozZ96u55m6brN4qai5qWFcdtGgmBN0WhdyoDwTswViPkUIwp/l6Bhwd2pvgO0SASdtBg3CRHfeEHIMtEkVe1C60kl3PufClMKyaSYXZ4zfdwjtFyJwzLFI4M0Bj/m2SwxKSeyFfPKmWTpe8Znx6aODWBjHWEtTWwxmGF0gvqLd90MeTkicrkPmuNhhNpX7P3tl3SRbBbJp1cZnl0Iw31QUJSWj5aUJVBsw85QAk1WvrcgNhCHF+x5TODtqCS9VJx5r36ys/LVnSL20B+/x1pK9wc3K5uH6Els5Nc7JVn5ZfmUZWWG9d/zfyHpjqj+DfZBkrn2+kVEUU1AMw8m/E1jLFtpnG+hnIGz+Xr2p/JFn6MP6ZoAryFM0D6/mc4VjdnzcPJzGTheXy6gv20RwMkVF0qhsRFRdN8QT886bLyj2GsyeeRQAUBl13OYwYxqTBFrNyH+tze+J/D5VnqE08S+gEJeMeg11W6wCWgky2LXbFFPHXVUqAMgDn3JqdmZyUhLY2VB81/a0+gxLLB9AoXzykq6POIHkm4ylVH20KhJ80Hp1L6HkE9iOzfIFdDgTdxnE9pPUEwAykSwzlrA1WTSTRKyfFELCS93FTHrbKL6bl5o3kJhDk/ezIY9le4FGVtsomlgpXUHOGFM+/2tj3pl7Fvypoi1CjGhU2QpdJebdAZsJYBCWNBZKSgFg7DGW9zvsGssQo1Nf7UwxMuA1nkP5Akc7ZTuIXXB21X4YwbC4F/6NLFCVPALbVlWo5uUf384TDfgSNpu8Otk8zeA378Hkl5Z+x6N9YTpMiUMs1z9BvZgKQwyGhs5MdxV9rJQcaUcksPJ0qbvYbX5ah650uZ1fpBSubtLZJmGFA2sB4UK+woRYFNc4DpMn3S1Ry2NkbItGaP3FSCQu/f6MdGrUupLzhsPxPj12zvaW6o3OpAYCblAE58hSlbdyw9wWw7unjMPPhSUp8Hx5sclnQopfYUOPmlljCOoZds+mUFpLpJsY4KERfpHx/VZ3hPvyo1Dgq4xqBy4pMxhCFlVV+rAx6xCx/v368B9ih27+k0GC/D3LTapwfsDu5k7Su/FZPjGX3qJB6lQwrU6/vpi4iXXU2PzAkFMEXDy3qINArTmeKNNL/25BRKQhMOJjN+g+HUUEYIdsjBi8rDi6yQjJmxMg4hyCMKM/0AgsvE2Q0VJJq9Mg1PICW8hMVZU8VcHeCjSbrA91SamZ554QAqP0iSI7I9n6/3ZGLtyf8uB4twrlpU8CRgWMKOaOxey7t1OrWnuF8M2Fsg6yo8brzKSalZtbdMOtelczu/2/4E7rUbXaRgRwTKm5eRwh0zQbP3X9+ErGZQJBhTmV3KAruLX0fH6ZY4fl2dE36tue/uyBaVyNUM4Xmv0Opdf+dbZite/tCA72Vd5Il/voA5j3sIcc094t2TX/Ngy+mB3DFMS780eFKKmn6wwYE+KZAp+hf+iuckLKlsgBJ+8efcevyZrJ418byvTNqEom8oMEikcvJn2tSosJcmKmYoqF4g9fwRpDL4URoHYRMRCGC7BJhqQdUtREc/1LjN0HP4i0bzUa5HqQmYHfDvrbh/69m9g04YAKXp9ClmTn8kGcIwMBv3QB3nzAZsNkwHoos2GcuW+AE6tJ+uYiFhAY2+1cN0sToUxWEiAoY8OOdlse7bQOZUUK0OKAsLeaTkSnNQ71Dyh/z8XFH5gMsP1USYpPR2+LrhOM1fuD2Ltvlf5q0knAw8/ZntZIgfb/rjVMckDOZPYUmvlYW+CkWmNA1jmM1R20qhdUMlpOXRGIRf0ZnVYOVrpIL8wTf/vuJKpeHK4DYKBXE3Lq2G4QAylHLEaQhsBplfqAb9GsB0HAk2/SV/Y7tm/xbrLnm3hP530jK6h1Hu0YOEQhWOQDzLsM6AyxlGj6QYtKcqkUG4ksx/Z0M1wRqrYzquEc4dQfl6iCsqVu7OiSZEAJOBQaiHNg2JuU2NeuWMrMq+kR9JXz4sAsZcDLQsJWcbXGe6Bk9GEygVs9e6PnQuU81RXk5N57pV36ctr9LFupJE2wbxUJ1zKKmMoHBr5lB6mB1U2KsLxi0lUblTVTVSqOIG3ze9xG2pkTh/QTLVzKPDL//TZS8o4Cayd/6fKhO1cYG/NmZ+nsNiHV8yZNfjxFVafLRoJa7xCSVQSXHirGprW+8zoPhK8RRnzim24T5YyTWaXXtFsC6mSKskULXk+iPPPYtEj0N66h5BPSpmi5FkW7gD5/nXIFwIF6mmNyzBkpc+oesaJ8gc1CcpQ4KnmweZdQ3+S4PaZP9Vn+nd3XOACoU+8wenvqawOf6+yonwkm7SFsKSuekRTU0yYA6U6SOMumWyxseK4igY5yT8sAiZHZ/aHmY/HmzQuVaVmqDqfytKxemUknDouYw8ILQL6zNx+sbDyiVYdWSAsk1DjrIehpFmM4BGkkz0dJ8Xei2e0y6YmwqP3B5U76FKo87Tox0X90xly2sSr98c6NZBJkNi5yPhn2KKjNTRtJemZVd4yrHKyGyzqlFza4xz2zj3qPEh7w7ex1GGsIIL6stLCp2JMZo6psY+kYZMSw58tVRRGLRc5vTjWFAn2KGm2S6AkzvbCNSdnlI+ESQb4/zyJo0H6Xc+AnhDxS6iUeuRktTDS81JJCDkj4iRnbkCsoWeeu/xzYDLOmq2vyj1XamlAsxSIzPJl+sDZdbcbhZd7GIoxN4fC1qIR59GyXIS2yprShf+HIPNnchHB7YeDzg3hUr/OPZh+HPDGw8sup2gxEuHQnkD3CcuGmG+LqyCg08EpTUddoZC6s+0ewaXJ0DuDnL7368YrMEapyBV8r6wQSUbSwUhd54LgiQHN15YqFJlyCF7gXKTh/4gMhjU5MW/eMBMVfmuG98vCdl12F1eFOpIXDXBnbgET3RCLIItaFa7vH4J1rQkx2MAeZAUKdW+6X0ZWhNPDZXMYSApbvDAFqFuYE41RCheb0lycTNfUwvv99g1SZZTNUCTW5xIHQA33busutCs0PnWlCJLz5U7hhDexCbkKK0iNyEFKLquUGe39bpZAx94KCZabwvW5704YoPY1asp5rTE/qsYtqQ5WTPCsTmsTXu1gLpgRThU1KCWBXgESLxy2VfkpH3d5xJ4EmftEAIsSyZdf8fKFYyIri7VhJ4dCF8BKTlXrevOTov0krMo+aaRCBaTIHUjt6F8wVSD0Eih2dltOYyBq8pSA1byMLiVsfwonRCDVpkTly5lKlooHokofgzZsLwkqEDQY5ha1trCJW3k50SP87v5ZaSnvhCogNOv8JttRnCPPqymYIfdOy+woRvpcyIKblL3yCCJDVMp3uQzGq/TNHJHbLP+4IhQQg4w4BReXq26ASt/f7tclK/uyu9gKtTL3w+DDIDIV/r0vamkbObvqDNjoZgcGDBlLpJNpgX9fpYVG9ayVbXruop6wCLauISWO7v9RL7jm1JDhICXWXTqt1Pgl1x6F2UoRcTGzM9XlL2P+msNL3lI2KiTL3576dyJ6bKoavv2TpQYZX3TtjTgb2Qr7j3JmhYfBj6jqYAX9YUPDKS0uG9twZQFYll1f58CnIWNC7NNx3cgg5gACdz9K5RiVJdbROGRiLo2zNUjE3v+kCVRtgW7OKRKJGPqcBMC32Vtl7QeQVSPdvTyEl80WwoD2+cRsd9v3g0/eB9CHvkV7J8vHLTIEDpi/KHj/8Ef0FkGVodswBOALeJdDsv7fw6gF9o22Zq4Lw/EhcKzcockZcwkVYrSu97KBda44JGFHPdv6r1rZwLh8dvLH0eKRiA05v/wGm72BCFefdAVpJ+5ugaLpva5mmiQtAj9u2STTikxfiFtrT6LkkIXr6BhIomfxHwvdQcNSOqqS6gFIz9OPKtFfCYAXuklc4iEgyUsJTq7N4Bf/42rViHwPnr4WwMWTrV4Lcduh5KwQZ5bJtAw6u4/sTrWb9ODnDLYJNKyWqcLDVnvlD5kjx2f7JfmfLXujQvH1QrEXqDEv7T42SJBTARoqb/ffwgsJ8QGMf0gz7wwxwNoKJYRHrcXWHVQwEwATvzOmOJXU7V3lJMFkIcn1xegdnB9coLo1xLBXB92S+zbWU+8omKWrGrBE1z3JsD0C+/+X2HxtWHprRiJ3virjQ/soEkugduyeAeZl8H+QFX3TTq5++Xk0XoR7kVrGLi2by4LxVCcHoYYB7D1mN6c6V2tMoZTE/wdObbetHWGTig5hGZ0OIq4GJccPhEymckpDz+YSico+EoQCfZf3HORsUl/iyFU984KtkwAIZeYW3BrWYn0lDepjhf1Dc7U5x5F9vpBZje3RpSvfHKAzCA6aoPQFAPTdndVJ+67b9QGbsTJfzFX131iM/mkDA5lE50hd6TXgtN9WZdZcroxe6GcvWe1r+T7N8CunJJHWC5jVS3ZJ1CZu84Hup/eiLu0JDbm1UltPFhBbwSU2mHEwSCeGXFksGWfautboj/tXe5VkFsbb77Tz0/O4KeX5zs3eo7KexCWdSivRH+j0clLaemkKFN6T8tqWPG1achKHhYiEEtdpGNKPtFc4YQcw/dXBf4SO0X9XsuRcU48kNdRp1fKfflDlmAhFPKnHztACKSFFppgHxQN8+OyoIYIDM4P4sFIERysw73k9+zXiF5viWOD0LjQonpd/MfsOSpS+NasebZm95JO6/lF7qYyfc2K4VSXWdh2efpgWhg50bbKZJ+mBDpPocz6hGpKg6rrsZHuLzk3Vm+OIxiI8x99DpxPELTeFujTCbqi39dZmrwznmrykzeV+QyBoOIqg/JFkLcLtwxbnxYjQIOUQX30HxQ1wJyxb5HMTbLtllX5NtA1JbgcK82woeYRFRQWqayv5v+Co/myxef0i4bdJvRXaxyqRCfAsXYUDsSXDuYEVfKdw3oaGkeaOhVg5FvZcJhNGwiILcpH5bqZMRMkB3n0foobQb1u8w7VqwXbMVS9+i36dPjpEKSziwc6E6xKYj1LpVdnwjmZ/40uj8ZfQRKZ0w/xquVsBD+vI+oPlj/ktw3cjW0Tf9o0HxZlfqiw/nFe9QoVjPZlfrTUJRVNaPZuAHwV8W2DTDKwvMHEfbI7JENfpKnW4/iHG3VhEIEbXkr3/u1VwR525zB5cQZine57rS9t/jQQnmSeIqYhG672OKjeYF597pPzBJpMKYbaV2tDL5Gzw2NBv6pOxYXdTT4n+hrEtFARBdWdDVnmXgT3RyFV1cjJdhMXqmEna6oyv2I8XdDLNWMT5piTNOcz4LuRPsFOiuttEZFhxsSUYFpOVHaLAghwRkVuQ6hYJaXZjuMKlK0q6jrsbMQCW0c8D4gUXAJsiSVa895mPlHp+Hmp9jAW+UEQeZt2yb7RcH29JNK7U8wCW6wOLO/jvNA8PTsr0/LDNj+SNV4L+s8wecqdIdyE0gdTLA4sbj7617cCkf3vDuejQRHT1FCNk14KnlPaZMq74eBFpnfy0VNVyLD38yAmAH3Npl88mq/jjmK8i5QGu5j25yyJK2Mk+wiT4kwiKVvhXxUwgdPH178Vh4I2KQuBjJt7TPUBh8Y4DwM0UbFevlaLJUbF0v4BowHfzZzRN4+bo98wvcs4FTfp/6ZyhIBU0qe/1F+6Em2WDC8YzycdVVwp4nzWFBVs1/rHaA7pDismbbjN4Ja2ojjVQvE6PlFYEAh+syT6va04YnX9hmFibQ1IJxvECP8zLqtVus/PaROEllHkrcDHm7pPDQJZdrAcedlOmHu1wommJN5T2nXdxPQyqIJnxRLYQQm/e9Hx4sN4ajhr7ivv+qnMOhzoFibFSBXn/SHXcmOMz+ho92LhsoP81/4W1VWP4WwocR11FzAl4xCNXBrJP/Z0+8ZZv3ghk8LYUYdsUvjMCQ9j46l5GMoMnq2XLJkkajwEVtGwxrQ5kXpJDP6Bbfvlb54AditnylUITjrY7788lLAOEY5t68BHKOH5QnUAlVnDqnQyFWC/on/43HzqlzY+j2k05ExczlkSRh0cz8wVtV4TsKSLMWAruEYFyZeWdclI9zX9DUSOflXp9kS9M57yQ/a3ScB130JRXPLOpvOohri2v2OW5GMvQ7vZGFHBjl1NfDWqaAlrYn6vLFiTfFI8WGywnuN2f+TmFQyaPjPsyGRhR5KzKtfCPpoJQc23lyP6lGWj8Fq7RXiNnk6W+H2guBMqec2NZCDAaVGn2mOyyWlHzxSemt7veTlY6rXkI6v1XGFAXseFvPGqIw5g2M9IWt0kcPD3nice+HahJJ3f4qqqlAE6q/2yi+ttVIbRu5eY3ANLg/2swY/QzTgsPw7Mllo4yAx5hupSAKS4UyLlbhJNz4V0XtHtMYpFsfJ43BVskxQuUSk2P0HRbatsYQLmuIWkn34QQLeSjOutUcrCzzcZ3TgwBLf6IlW+qt0BYCHwOqIzYRgASlpEJJ7el3yyAaoksPScUKBEebBTIgrvcLvw0y9Jv70Hdn4t8WgubjLlyWwo0Tw6vrB+wzdUdXhy7nIp5uSAsotr0EWJb1xJqx150yjZW5I0Sw3FqzA35f80zg2lZzGOM4XrloJSfp/R6SJWRvC62EEfE6yVi83RPOFiVAEFpr5P+vzLRD6q2+UYXKtXnS3tosHmB56SNT/oRG9JchglPGpBMeqkI9eI+xW3Knwmsa1bux22Kj0N8XdWHLkTplAh8E3iVwmU/qHxpyPsPq7Mk0enWC+zjjpcU3hG0QeNXLcdQMicYVSu5nxuZt4TZ7yvgkHexSL9jJFnUvhZN0ThI2Eiq6TTeBo0WexP58z9sSvYUu3S2mZEDnKqYS/jv/qrA0Gps+EeZTwA6vEdSIGAm9H57HCbmOfn/4o/ZVcFg6QoIEdSrJrbdneCymjMy7LKFQd3vCN69ESVytBotZnCmw+qOpFHHZ5AeSALNgquXHe96sH3EJTzFOFQYkghpa385rD0OFIAbi81OY2w7wld5zFb5WA7g46HE2zeaZVdX5FeDz1N7xmFQ9pXQChFhSY/q+IFflTdGeUWrfWP6bi5zT2SvONki+2PGx4Jx4fNUJzxdLmcaz5IhrL4FFe2WZMxp3YFHPN8lyMGqy/dx0tAzL30QSPE2UkzHBuIUlTAcdApLYasYuEibRA4gL5p7k+GBzW2A3O6S+ioYnBU0V9WbrqIShd4N2kKcUWHHdQsV4EtC4drKaj0SKq46CUGH5Om0wV47T20ABPP4IE2gGSyrWRAuBPJXeLinOtimtRJxdHZmqy46dRpkra/9pPcPjx76Ajjgl0gVE/89XoZyHaCL3+GmcbhnlrcNbtbuFlaOaO7/LkQWK4T8tLLCijGP1LsiVBmszunQkXRR7dDX663vl6/9HKl7uSs6xrczhlS4CJndaC4BvEH6Q/hZ3MP3cmauBqPOiHwV54UGzx54l6yyCBWGyEMvp0ANzI2UShQ7404leYYM94cxzF24k30rAY6ROfE9gdt5SckuS0L4isYux6i1onO4ZGBaWwemMPdJKa9Y/66toe7JDEdFXM3DqKSO/Qf1UiSHSso3i6PZaxazCvQBv0h2SSD+F0dDObAuo/F64FoTjBS5q8xDG0IelyWC9FVD9b1xgYb4KVw1+u6WIsS+n5h2LdRi5SliBsPtcEBVgl3HSuMP8Y3zFTlNlTgkEV5xjmjO9SP/kGW5EhmRc2lsSc7xoTytIc9oSr+3itb2dAbKPKiO56nv1D+Ls6ZAHYSF5xo/VOjUXEOcuz2cXXxon7cRxrkuULqit28KAF1l9N89JwDpNmRbSdgUARTAOa3heYW/W9sVp7hVsLYCSmoZR/M0lDj/pBRXcsoYZzV4IutpiRiiyM3YJlijTRvZR75qQ7iSwJzd1WHX9Nn5Ho53YYEtXnjiS9GCu7EhF5M8jTL1CFSeVzqzBxZyqFuVYZmpHNH7mz2AiveWKmpnRlLxWJzhqUx6tdj0E1ZQlowBs4YvJT9UYd1O6dthJ+7zdFbkRQ4cfzpsTR3pNPTSOj9SkS2Ls000hgxlFTOTZqdflTsNBK5kW8ibzc5YezQvfpffoAslWzUE1pTMZTS76WIH+qc5Ts4ovDHXehCgMfHGXoGUYdlbDbFFFuAlxY71C7O5/86IV5el5XdswwIUp5sZjlvppOpq9f6r1IIBqepDlJWADq8eXb+hFhDWO1lgRTQLB82B8g==\",profile=\"auth-server\"},[\"auth-server-release-0.17.0\"]={public=\"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\",profile=\"auth-server\"},},}",
  ["ceetos/lib/release_verify.lua"] = "local password=require(\"ceetos.lib.password\")local roots=require(\"ceetos.lib.release_keys\")local profile=require(\"ceetos.lib.profile\")local M={}M.CACHE_ROOT=\"/ceetos-updates\"M.CACHE_FILE=\"/ceetos-updates/release.lua\"M.META_FILE=\"/ceetos-updates/cache.lua\"M.PART_FILE=\"/ceetos-updates/release.part\"M.PENDING_FILE=\"/ceetos-updates/pending.lua\"M.MAX_PACKAGE_BYTES=1024*1024 M.MAX_MANIFEST_BYTES=48*1024 M.CHUNK_BYTES=3072 M.KEY_PARTS=256 local alphabet=\"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\"local decodeMap={}for i=1,#alphabet do decodeMap[alphabet:sub(i,i)]=i-1 end local function read(path)if not fs.exists(path)or fs.isDir(path)then return nil end local handle=fs.open(path,\"r\")if not handle then return nil end local value=handle.readAll();handle.close()return value end local function write(path,value)local parent=fs.getDir(path)if parent~=\"\"and not fs.exists(parent)then fs.makeDir(parent)end local temporary=path..\".tmp\"if fs.exists(temporary)then fs.delete(temporary)end local handle=assert(fs.open(temporary,\"w\"),\"could not create temporary release state\")handle.write(value);handle.close()if fs.exists(path)then fs.delete(path)end fs.move(temporary,path)end local function encode(value)local out,index={},1 while index<=#value do local a,b,c=value:byte(index,index+2)local n=(a or 0)*65536+(b or 0)*256+(c or 0)out[#out+1]=alphabet:sub(math.floor(n/262144)%64+1,math.floor(n/262144)%64+1)out[#out+1]=alphabet:sub(math.floor(n/4096)%64+1,math.floor(n/4096)%64+1)out[#out+1]=b and alphabet:sub(math.floor(n/64)%64+1,math.floor(n/64)%64+1)or\"=\"out[#out+1]=c and alphabet:sub(n%64+1,n%64+1)or\"=\"index=index+3 end return table.concat(out)end local function decode(value)if type(value)~=\"string\"or#value%4~=0 or#value>M.MAX_MANIFEST_BYTES*2 then return nil,\"invalid base64\"end local out={}for index=1,#value,4 do local a,b,c,d=value:sub(index,index),value:sub(index+1,index+1),value:sub(index+2,index+2),value:sub(index+3,index+3)if not decodeMap[a]or not decodeMap[b]or(c~=\"=\"and not decodeMap[c])or(d~=\"=\"and not decodeMap[d])then return nil,\"invalid base64\"end if(c==\"=\"and d~=\"=\")or((c==\"=\"or d==\"=\")and index+3~=#value)then return nil,\"invalid base64 padding\"end local n=decodeMap[a]*262144+decodeMap[b]*4096+(decodeMap[c]or 0)*64+(decodeMap[d]or 0)out[#out+1]=string.char(math.floor(n/65536)%256)if c~=\"=\"then out[#out+1]=string.char(math.floor(n/256)%256)end if d~=\"=\"then out[#out+1]=string.char(n%256)end end return table.concat(out)end local function unhex(value)if type(value)~=\"string\"or#value%2~=0 or value:find(\"[^%x]\")then return nil end return(value:gsub(\"..\",function(pair)return string.char(tonumber(pair,16))end))end local function semver(value)if type(value)~=\"string\"then return nil end local major,minor,patch=value:match(\"^(%d+)%.(%d+)%.(%d+)$\")if not major then return nil end return tonumber(major),tonumber(minor),tonumber(patch)end function M.compareVersions(left,right)local a,b,c=semver(left);local x,y,z=semver(right)if not a or not x then return nil,\"invalid semantic version\"end if a~=x then return a<x and-1 or 1 end if b~=y then return b<y and-1 or 1 end if c~=z then return c<z and-1 or 1 end return 0 end local function canonical(manifest)if manifest._legacyProfile==true then return table.concat({\"ceetos-release-v1\",tostring(manifest.channel),tostring(manifest.version),tostring(manifest.size),tostring(manifest.digest),tostring(manifest.key_id),tostring(manifest.next_key_id),tostring(manifest.next_public),},\"|\")end return table.concat({\"ceetos-release-v2\",tostring(manifest.channel),tostring(manifest.profile),tostring(manifest.version),tostring(manifest.size),tostring(manifest.digest),tostring(manifest.key_id),tostring(manifest.next_key_id),tostring(manifest.next_public),},\"|\")end local function unpackKey(value)local raw,err=decode(value)if not raw or#raw~=M.KEY_PARTS*2*32 then return nil,err or\"invalid public key\"end local parts={}for index=1,M.KEY_PARTS*2 do parts[index]=raw:sub((index-1)*32+1,index*32)end return parts end function M.validPublicKey(value)return unpackKey(value)end local function lookupKey(keyId)local saved=M.trustedKeys()local value=saved[keyId]if value then return unpackKey(value)end value=roots.roots and roots.roots[keyId]if type(value)==\"table\"and type(value.public)==\"string\"then value=value.public end return unpackKey(value)end local function bitAt(digest,index)local byte=digest:byte(math.floor((index-1)/8)+1)return bit32.band(bit32.rshift(byte,7-((index-1)%8)),1)end local function decodeSignature(value)local raw,err=decode(value)if not raw or#raw~=M.KEY_PARTS*32 then return nil,err or\"invalid signature\"end local parts={}for index=1,M.KEY_PARTS do parts[index]=raw:sub((index-1)*32+1,index*32)end return parts end local function verifySignature(manifest,signature)if roots.algorithm~=\"lamport-sha256-v1\"then return false,\"unsupported release signing algorithm\"end local public,publicErr=lookupKey(manifest.key_id)local parts,signatureErr=decodeSignature(signature)if not public or not parts then local prefix=not public and\"invalid release public key: \"or\"invalid release signature: \"return false,prefix..tostring(publicErr or signatureErr or\"unknown release key\")end local digest=unhex(password.sha256(canonical(manifest))or\"\")if not digest then return false,\"could not hash release manifest\"end for index=1,M.KEY_PARTS do local expected=public[(index-1)*2+bitAt(digest,index)+1]local candidate=unhex(password.sha256(parts[index])or\"\")if not candidate or not password.constantTimeEqual(candidate,expected)then return false,\"invalid release signature\"end end return true end local function validateManifest(manifest)if type(manifest)~=\"table\"or manifest.channel~=\"release\"then return nil,\"only signed release packages are accepted\"end if manifest.profile==nil then manifest.profile,manifest._legacyProfile=\"desktop\",true end if type(manifest.profile)~=\"string\"or not profile.IDS[manifest.profile]then return nil,\"invalid release profile\"end if not semver(manifest.version)or type(manifest.size)~=\"number\"or manifest.size<1 or manifest.size>M.MAX_PACKAGE_BYTES then return nil,\"invalid release manifest size or version\"end if type(manifest.digest)~=\"string\"or#manifest.digest~=64 or manifest.digest:find(\"[^%x]\")then return nil,\"invalid release digest\"end if type(manifest.key_id)~=\"string\"or#manifest.key_id<1 or#manifest.key_id>64 then return nil,\"invalid release key id\"end if type(manifest.next_key_id)~=\"string\"or#manifest.next_key_id<1 or#manifest.next_key_id>64 then return nil,\"invalid next release key id\"end local nextKey=unpackKey(manifest.next_public)if not nextKey then return nil,\"invalid successor release key\"end return manifest end function M.parse(source)if type(source)~=\"string\"or#source>M.MAX_PACKAGE_BYTES then return nil,\"release package is too large\"end local encodedManifest,encodedSignature=source:match(\"^%-%- CEETOS_RELEASE_MANIFEST:([^\\r\\n]+)[\\r\\n]+%-%- CEETOS_RELEASE_SIGNATURE:([^\\r\\n]+)\")if not encodedManifest then encodedManifest,encodedSignature=source:match(\"^%-%- CeetOS signed release%. Generated; do not edit%.[\\r\\n]+%-%- CEETOS_RELEASE_MANIFEST:([^\\r\\n]+)[\\r\\n]+%-%- CEETOS_RELEASE_SIGNATURE:([^\\r\\n]+)\")end local first=source:find(\"%-%- CEETOS_RELEASE_PAYLOAD_BEGIN[\\r\\n]+\")if not encodedManifest or not encodedSignature or not first then return nil,\"not a signed CeetOS release package\"end local decoded,decodeErr=decode(encodedManifest)if not decoded then return nil,\"invalid release manifest encoding: \"..tostring(decodeErr)end local loader=load(\"return \"..decoded,\"=release-manifest\",\"t\",{})if not loader then return nil,\"invalid release manifest\"end local ok,manifest=pcall(loader)if not ok then return nil,\"invalid release manifest\"end local valid,manifestErr=validateManifest(manifest)if not valid then return nil,manifestErr end local start=source:find(\"\\n\",first)+1 local finish=start+valid.size-1 local payload=source:sub(start,finish)local suffix=source:sub(finish+1)if suffix~=\"\\n-- CEETOS_RELEASE_PAYLOAD_END\\n\"and suffix~=\"\\n-- CEETOS_RELEASE_PAYLOAD_END\"and suffix~=\"\\r\\n-- CEETOS_RELEASE_PAYLOAD_END\\r\\n\"and suffix~=\"\\r\\n-- CEETOS_RELEASE_PAYLOAD_END\"then return nil,\"release payload boundary mismatch\"end if#payload~=valid.size or not password.constantTimeEqual(password.sha256(payload)or\"\",valid.digest)then return nil,\"release payload digest mismatch\"end local signed,signatureErr=verifySignature(valid,encodedSignature)if not signed then return nil,signatureErr end return{manifest=valid,signature=encodedSignature,payload=payload,source=source}end function M.trustedKeys()local raw=read(M.CACHE_ROOT..\"/trusted-keys.lua\")if not raw then return{}end local loader=load(raw,\"=trusted-release-keys\",\"t\",{})if not loader then return{}end local ok,value=pcall(loader)return ok and type(value)==\"table\"and value or{}end local function saveKeys(value)local encoded=textutils.serialise(value,{compact=true})write(M.CACHE_ROOT..\"/trusted-keys.lua\",\"return \"..encoded)end function M.metadata()local raw=read(M.META_FILE)if not raw then return nil end local loader=load(raw,\"=release-cache\",\"t\",{})if not loader then return nil end local ok,value=pcall(loader)return ok and type(value)==\"table\"and value or nil end function M.cache(source,provider)local package,err=M.parse(source)if not package then return nil,err end if fs.exists(M.CACHE_ROOT)and fs.isDir(M.CACHE_ROOT)then if fs.exists(M.PART_FILE)then fs.delete(M.PART_FILE)end elseif not fs.exists(M.CACHE_ROOT)then fs.makeDir(M.CACHE_ROOT)end write(M.CACHE_FILE,package.source)local metadata={schema=2,channel=package.manifest.channel,profile=package.manifest.profile,version=package.manifest.version,size=package.manifest.size,digest=package.manifest.digest,keyId=package.manifest.key_id,nextKeyId=package.manifest.next_key_id,source=provider and tostring(provider)or\"local\",cachedAt=os.epoch(\"utc\"),}write(M.META_FILE,\"return \"..textutils.serialise(metadata,{compact=true}))local keys=M.trustedKeys();keys[package.manifest.next_key_id]=package.manifest.next_public;saveKeys(keys)return metadata end function M.promotePart(path,provider)if type(path)~=\"string\"or path~=M.PART_FILE then return nil,\"invalid release staging path\"end local source=read(path)local package,err=M.parse(source)if not package then return nil,err end if not fs.exists(M.CACHE_ROOT)then fs.makeDir(M.CACHE_ROOT)end if fs.exists(M.CACHE_FILE)then fs.delete(M.CACHE_FILE)end fs.move(M.PART_FILE,M.CACHE_FILE)local metadata={schema=2,channel=package.manifest.channel,profile=package.manifest.profile,version=package.manifest.version,size=package.manifest.size,digest=package.manifest.digest,keyId=package.manifest.key_id,nextKeyId=package.manifest.next_key_id,source=provider and tostring(provider)or\"local\",cachedAt=os.epoch(\"utc\"),}write(M.META_FILE,\"return \"..textutils.serialise(metadata,{compact=true}))M.adopt(package)return metadata end function M.adopt(package)if type(package)~=\"table\"or type(package.manifest)~=\"table\"then return false,\"invalid verified package\"end local keys=M.trustedKeys()keys[package.manifest.next_key_id]=package.manifest.next_public saveKeys(keys)return true end function M.cachedPackage()local source,metadata=read(M.CACHE_FILE),M.metadata()if not source then return nil end local package,err=M.parse(source)if not package then return nil,err end if not metadata then metadata={schema=2,channel=package.manifest.channel,profile=package.manifest.profile,version=package.manifest.version,size=package.manifest.size,digest=package.manifest.digest,keyId=package.manifest.key_id,nextKeyId=package.manifest.next_key_id,source=\"local installer\",cachedAt=os.epoch(\"utc\"),}write(M.META_FILE,\"return \"..textutils.serialise(metadata,{compact=true}))end M.adopt(package)return package,metadata end function M.clear()if fs.exists(M.PART_FILE)then fs.delete(M.PART_FILE)end if fs.exists(M.CACHE_FILE)then fs.delete(M.CACHE_FILE)end if fs.exists(M.META_FILE)then fs.delete(M.META_FILE)end return true end function M.encode(value)return encode(value)end function M.decode(value)return decode(value)end function M.canonical(manifest)return canonical(manifest)end return M",
  ["ceetos/lib/updater.lua"] = "local store=require(\"ceetos.lib.store\")local release=require(\"ceetos.lib.release_verify\")local version=require(\"ceetos.lib.version\")local net=require(\"ceetos.lib.net\")local profile=require(\"ceetos.lib.profile\")local M={}local STATE_PATH=\"/ceetos/data/update-status.lua\"local OFFER_LIMIT,HISTORY_LIMIT=20,16 local BEACON_INTERVAL,REQUEST_TIMEOUT,REQUEST_LIMIT=30000,3500,3 local MAX_OFFER_AGE=5*60*1000 local CLOUD_RELEASE_ORIGIN=\"https://releases.ceet.uk\"local CLOUD_MANIFEST_URL=CLOUD_RELEASE_ORIGIN..\"/manifest.json\"local CLOUD_SOURCE_ID=\"cloud.releases.ceet.uk\"local state=nil local lastBeacon,lastCacheRead=0,0 local cached=nil local function now()return os.epoch(\"utc\")end local function localId()return tostring(os.getComputerID())end local function localLabel()return os.getComputerLabel()or(\"Computer \"..localId())end local function defaults()return{schema=1,offers={},history={},revision=0,status=\"idle\",message=nil,transfer=nil,cache=nil}end local function loadState()if state then return state end state=store.read(STATE_PATH,defaults())for key,value in pairs(defaults())do if state[key]==nil then state[key]=value end end state.offers=type(state.offers)==\"table\"and state.offers or{}state.history=type(state.history)==\"table\"and state.history or{}return state end local function save()local value=loadState()value.revision=(tonumber(value.revision)or 0)+1 store.write(STATE_PATH,value)end local function record(kind,fields)local value=loadState()local row={at=now(),kind=tostring(kind):sub(1,48)}for key,item in pairs(fields or{})do if key~=\"data\"and key~=\"source\"and key~=\"signature\"then row[key]=item end end value.history[#value.history+1]=row while#value.history>HISTORY_LIMIT do table.remove(value.history,1)end end local function setStatus(status,message)local value=loadState()value.status,value.message=status,message and tostring(message):sub(1,180)or nil end local function validId(value)return type(value)==\"string\"and value:match(\"^[%w_%-%.]+$\")and#value<=96 end local function releaseFilename(profileId,releaseVersion)if profileId==\"desktop\"then return\"desktop/ceetos-release\"..releaseVersion..\".lua\"end return profileId..\"/ceetos-\"..profileId..\"-release\"..releaseVersion..\".lua\"end local function readHttp(url,limit)if not http or type(http.get)~=\"function\"then return nil,\"HTTP is unavailable; enable it to check releases.ceet.uk\"end local ok,response,requestErr=pcall(http.get,url,{[\"Accept\"]=\"application/json\"})if not ok or not response then return nil,tostring(requestErr or\"release server request failed\")end local chunks,total={},0 while true do local part=response.read(math.min(4096,limit-total+1))if not part or part==\"\"then break end total=total+#part if total>limit then pcall(response.close);return nil,\"release server response is too large\"end chunks[#chunks+1]=part end pcall(response.close)return table.concat(chunks)end local function validOffer(body)if type(body)~=\"table\"or type(body.version)~=\"string\"or type(body.size)~=\"number\"or type(body.digest)~=\"string\"or type(body.keyId)~=\"string\"or type(body.profile)~=\"string\"then return nil,\"malformed release offer\"end if body.profile~=profile.id()then return nil,\"release profile does not match this installation\"end local compare=release.compareVersions(body.version,version.string)if compare==nil then return nil,\"invalid release version\"end if body.size<1 or body.size>release.MAX_PACKAGE_BYTES then return nil,\"invalid release size\"end if#body.digest~=64 or body.digest:find(\"[^%x]\")then return nil,\"invalid release digest\"end if#body.keyId<1 or#body.keyId>64 then return nil,\"invalid release key ID\"end local filename=body.filename if filename~=nil and filename~=releaseFilename(body.profile,body.version)then return nil,\"invalid release filename\"end return{profile=body.profile,version=body.version,size=math.floor(body.size),digest=body.digest:lower(),keyId=body.keyId,filename=filename,fingerprint=body.digest:sub(1,16),newer=compare>0}end local function refreshCached()local timestamp=now()if cached and timestamp-lastCacheRead<5000 then return cached end lastCacheRead=timestamp local value=loadState()local previous=value.cache and(tostring(value.cache.version)..\":\"..tostring(value.cache.digest))or nil local package,metadata=release.cachedPackage()if package and package.manifest.profile==profile.id()then cached={package=package,metadata=metadata,offer={version=package.manifest.version,size=#package.source,digest=package.manifest.digest,profile=package.manifest.profile,keyId=package.manifest.key_id,fingerprint=package.manifest.digest:sub(1,16),}}value.cache={profile=package.manifest.profile,version=package.manifest.version,size=#package.source,digest=package.manifest.digest,keyId=package.manifest.key_id,fingerprint=package.manifest.digest:sub(1,16),source=metadata and metadata.source or\"local\",verifiedAt=timestamp}else cached=nil value.cache=nil end local current=value.cache and(tostring(value.cache.version)..\":\"..tostring(value.cache.digest))or nil if current~=previous then save()end return cached end local function providerOffer()local available=refreshCached()if not available then return nil end return{version=available.offer.version,size=available.offer.size,digest=available.offer.digest,profile=available.offer.profile,keyId=available.offer.keyId,label=localLabel(),id=localId(),}end local function upsertOffer(source,body,transport,distance)source=tostring(source)if source==localId()then return false,\"ignored own release offer\"end local offer,err=validOffer(body)if not offer then return false,err end if not offer.newer then return false,\"offered release is not newer\"end local value=loadState()local key=source..\":\"..offer.version..\":\"..offer.digest:sub(1,16)value.offers[key]={id=source,label=tostring(body.label or(\"Computer \"..source)):sub(1,96),version=offer.version,profile=offer.profile,size=offer.size,digest=offer.digest,fingerprint=offer.fingerprint,keyId=offer.keyId,filename=offer.filename,transport=transport,distance=tonumber(distance),seen=now(),key=key,}local rows={}for entryKey,item in pairs(value.offers)do rows[#rows+1]={key=entryKey,seen=tonumber(item.seen)or 0}end table.sort(rows,function(a,b)return a.seen>b.seen end)while#rows>OFFER_LIMIT do value.offers[table.remove(rows).key]=nil end setStatus(\"offers\",\"Signed release \"..offer.version..\" available from \"..tostring(body.label or source))record(\"offer\",{id=source,version=offer.version,transport=transport,distance=distance})save()return true end local function checkCloudManifest()local raw,readErr=readHttp(CLOUD_MANIFEST_URL,release.MAX_MANIFEST_BYTES)if not raw then return 0,readErr end local ok,catalogue=pcall(textutils.unserialiseJSON,raw)if not ok or type(catalogue)~=\"table\"or catalogue.schema~=1 or type(catalogue.releases)~=\"table\"or#catalogue.releases>OFFER_LIMIT then return 0,\"invalid signed-release catalogue\"end local count=0 for index=1,#catalogue.releases do local entry=catalogue.releases[index]if type(entry)==\"table\"and entry.profile==profile.id()then local accepted=upsertOffer(CLOUD_SOURCE_ID,entry,\"cloud\")if accepted then count=count+1 end end end return count end local function offers()local value,timestamp,rows=loadState(),now(),{}for key,item in pairs(value.offers)do if type(item)==\"table\"and timestamp-(tonumber(item.seen)or 0)<=MAX_OFFER_AGE then rows[#rows+1]=item else value.offers[key]=nil end end table.sort(rows,function(left,right)local compare=release.compareVersions(left.version,right.version)or 0 if compare~=0 then return compare>0 end return tostring(left.id)<tostring(right.id)end)return rows end local function findOffer(source)source=tostring(source)for _,item in ipairs(offers())do if item.key==source or tostring(item.id)==source then return item end end return nil,\"release source is unavailable; run update check first\"end local function removePart()if fs.exists(release.PART_FILE)then fs.delete(release.PART_FILE)end end local function appendPart(data)local handle,err=fs.open(release.PART_FILE,\"a\")if not handle then return false,err or\"could not open update staging file\"end handle.write(data);handle.close()return true end local function cachedSource()local available=refreshCached()return available and available.package and available.package.source end local function transferRequest(transfer)local length=math.min(release.CHUNK_BYTES,transfer.size-transfer.offset+1)if length<1 then return false,\"transfer is already complete\"end transfer.awaiting,transfer.requestedAt=true,now()local body={transfer=transfer.id,offset=transfer.offset,length=length}local sent,err if transfer.transport==\"routed\"then sent,err=net.request(transfer.source,\"update_chunk_request\",body,{safe=true})else sent,err=net.publicSend(transfer.source,\"update_chunk_request\",body)end if not sent then transfer.awaiting=false;return false,err end return true end local function beginDownload(source)local offer,err=findOffer(source)if not offer then return false,err end local compare=release.compareVersions(offer.version,version.string)if not compare or compare<=0 then return false,\"refusing a downgrade or current release\"end local free=fs.getFreeSpace and fs.getFreeSpace(\"/\")or nil if type(free)==\"number\"and free<offer.size+4096 then return false,\"not enough free space for one verified release cache\"end removePart()release.clear();cached=nil local value=loadState()if offer.transport==\"cloud\"then setStatus(\"downloading\",\"Downloading signed release \"..offer.version..\" from releases.ceet.uk\")save()local sourceText,readErr=readHttp(CLOUD_RELEASE_ORIGIN..\"/\"..tostring(offer.filename or\"\"),release.MAX_PACKAGE_BYTES)if not sourceText then setStatus(\"error\",readErr);record(\"download_failed\",{id=CLOUD_SOURCE_ID,error=readErr});save();return false,readErr end local parsed,parseErr=release.parse(sourceText)if not parsed or parsed.manifest.profile~=offer.profile or parsed.manifest.version~=offer.version or parsed.manifest.digest:lower()~=offer.digest or parsed.manifest.key_id~=offer.keyId then local reason=parseErr or\"verified package did not match selected release offer\"setStatus(\"error\",reason);record(\"download_failed\",{id=CLOUD_SOURCE_ID,error=reason});save();return false,reason end local metadata,cacheErr=release.cache(sourceText,\"releases.ceet.uk\")if not metadata then setStatus(\"error\",cacheErr);record(\"download_failed\",{id=CLOUD_SOURCE_ID,error=cacheErr});save();return false,cacheErr end cached=nil;refreshCached()setStatus(\"downloaded\",\"Verified release \"..metadata.version..\" is ready for local confirmation\")record(\"download_verified\",{id=CLOUD_SOURCE_ID,version=metadata.version,digest=metadata.digest:sub(1,16)})save()return true,metadata end value.transfer={id=\"update-\"..localId()..\"-\"..tostring(now())..\"-\"..tostring(math.random(1000,9999)),source=tostring(offer.id),label=offer.label,transport=offer.transport==\"routed\"and\"routed\"or\"direct\",profile=offer.profile,version=offer.version,size=offer.size,digest=offer.digest,keyId=offer.keyId,offset=1,received=0,retries=0,awaiting=false,started=now(),}setStatus(\"downloading\",\"Downloading signed release \"..offer.version..\" from \"..offer.label)record(\"download_started\",{id=offer.id,version=offer.version,transport=value.transfer.transport})local sent,sendErr=transferRequest(value.transfer)if not sent then value.transfer=nil;setStatus(\"error\",sendErr);record(\"download_failed\",{id=offer.id,error=sendErr})end save()return sent,sendErr end local function finishTransfer(transfer)local handle=fs.open(release.PART_FILE,\"r\")local source=handle and handle.readAll()or nil if handle then handle.close()end if not source or#source~=transfer.size then return false,\"download size did not match offer\"end local parsed,parseErr=release.parse(source)if not parsed then return false,parseErr end if parsed.manifest.profile~=profile.id()or parsed.manifest.profile~=transfer.profile or parsed.manifest.version~=transfer.version or parsed.manifest.digest:lower()~=transfer.digest:lower()or parsed.manifest.key_id~=transfer.keyId then return false,\"verified package did not match selected release offer\"end local metadata,promoteErr=release.promotePart(release.PART_FILE,transfer.label)if not metadata then return false,promoteErr end cached=nil;refreshCached()local value=loadState();value.transfer=nil setStatus(\"downloaded\",\"Verified release \"..metadata.version..\" is ready for local confirmation\")record(\"download_verified\",{id=transfer.source,version=metadata.version,digest=metadata.digest:sub(1,16)})save()return true,metadata end local function acceptChunk(source,body,transport)local value,transfer=loadState(),loadState().transfer if not transfer or transfer.transport~=transport or tostring(source)~=tostring(transfer.source)then return false,\"unexpected update chunk\"end if type(body)~=\"table\"or body.transfer~=transfer.id or math.floor(tonumber(body.offset)or 0)~=transfer.offset or type(body.data)~=\"string\"then return false,\"malformed update chunk\"end local remaining=transfer.size-transfer.offset+1 if#body.data<1 or#body.data>math.min(release.CHUNK_BYTES,remaining)then return false,\"invalid update chunk length\"end local ok,err=appendPart(body.data)if not ok then transfer.awaiting=false;setStatus(\"error\",err);record(\"download_failed\",{error=err});save();return false,err end transfer.offset,transfer.received,transfer.awaiting,transfer.retries=transfer.offset+#body.data,(transfer.received or 0)+#body.data,false,0 if transfer.offset>transfer.size then local complete,result=finishTransfer(transfer)if not complete then removePart();value.transfer=nil;setStatus(\"error\",result);record(\"download_failed\",{id=source,error=result});save()end return complete,result end local sent,sendErr=transferRequest(transfer)if not sent then setStatus(\"error\",sendErr);record(\"download_failed\",{id=source,error=sendErr});value.transfer=nil;removePart()end save()return sent,sendErr end local function serveChunk(target,body,routed,request)if type(body)~=\"table\"or not validId(body.transfer)or math.floor(tonumber(body.offset)or 0)<1 or math.floor(tonumber(body.length)or 0)<1 or tonumber(body.length)>release.CHUNK_BYTES then return false,\"invalid update chunk request\"end local source=cachedSource()if not source then return false,\"no verified newer signed release is cached\"end local offset,length=math.floor(body.offset),math.floor(body.length)if offset>#source then return false,\"requested update offset is beyond package\"end local data=source:sub(offset,math.min(#source,offset+length-1))local response={transfer=body.transfer,offset=offset,data=data}if routed then return net.reply(request,\"update_chunk\",response,{safe=true})end return net.publicSend(target,\"update_chunk\",response)end function M.publicPacket(packet,distance)local source,body=tostring(packet.from),packet.body or{}if packet.type==\"update_query\"then if body.profile~=profile.id()then return false,\"release profile does not match this installation\"end local offer=providerOffer()local compare=offer and release.compareVersions(offer.version,body.version or\"\")if offer and compare and compare>0 then net.publicSend(source,\"update_offer\",offer)end return elseif packet.type==\"update_offer\"then return upsertOffer(source,body,\"direct\",distance)elseif packet.type==\"update_chunk_request\"then return serveChunk(source,body,false)elseif packet.type==\"update_chunk\"then return acceptChunk(source,body,\"direct\")end end function M.authenticatedPacket(from,peer,packet)local body=packet.body or{}if packet.type==\"update_query\"then if body.profile~=profile.id()then return false,\"release profile does not match this installation\"end local offer=providerOffer()local compare=offer and release.compareVersions(offer.version,body.version or\"\")if offer and compare and compare>0 then return net.reply(packet,\"update_offer\",offer,{safe=true})end elseif packet.type==\"update_offer\"then return upsertOffer(from,body,\"routed\")elseif packet.type==\"update_chunk_request\"then return serveChunk(from,body,true,packet)elseif packet.type==\"update_chunk\"then return acceptChunk(from,body,\"routed\")end end function M.check()local value=loadState()for key in pairs(value.offers)do value.offers[key]=nil end setStatus(\"checking\",\"Looking for newer signed releases\")local cloudOffers,cloudErr=checkCloudManifest()local sent,err=net.publicBroadcast(\"update_query\",{version=version.string,profile=profile.id()})local routed=0 for _,peer in ipairs(net.peers())do if not peer.rekeyRequired then local ok=net.request(peer.id,\"update_query\",{version=version.string,profile=profile.id()},{safe=true})if ok then routed=routed+1 end end end if cloudErr then setStatus(\"warning\",\"Cloud release check failed: \"..tostring(cloudErr):sub(1,120))elseif cloudOffers==0 and not(sent or routed>0)then setStatus(\"idle\",\"No newer signed release is available\")elseif cloudOffers>0 then setStatus(\"offers\",tostring(cloudOffers)..\" signed cloud release offer\"..(cloudOffers==1 and\"\"or\"s\")..\" available\")end record(\"check\",{cloud=cloudOffers,direct=sent==true,routed=routed,cloudError=cloudErr,error=sent and nil or err})save()if cloudErr and not sent and routed==0 then return false,cloudErr end return true,{cloud=cloudOffers,direct=sent==true,routed=routed,cloudError=cloudErr}end function M.download(source)return beginDownload(source)end function M.installLatest()local checked,err=M.check()if not checked then return false,err end local rows=offers()if#rows==0 then return false,\"no newer compatible signed release is available\"end table.sort(rows,function(a,b)local comparison=release.compareVersions(a.version,b.version)or 0 return comparison==0 and tostring(a.id)<tostring(b.id)or comparison>0 end)return beginDownload(rows[1].id)end function M.cancel()local value=loadState()if not value.transfer then return false,\"no update transfer is active\"end record(\"download_cancelled\",{id=value.transfer.source,version=value.transfer.version})value.transfer=nil;removePart();setStatus(\"cancelled\",\"Update download cancelled\");save()return true end function M.clearCache()local value=loadState()if value.transfer then return false,\"cancel the active download first\"end release.clear();cached=nil;value.cache=nil;setStatus(\"idle\",\"Verified release cache cleared\");record(\"cache_cleared\");save()return true end function M.prepareApply()local package,metadata=release.cachedPackage()if not package then return false,metadata or\"no verified signed release is cached\"end local compare=release.compareVersions(package.manifest.version,version.string)if not compare or compare<=0 then return false,\"cached release is not newer than this CeetOS installation\"end if package.manifest.profile~=profile.id()then return false,\"cached release profile does not match this installation\"end local marker={schema=2,state=\"apply\",profile=package.manifest.profile,version=package.manifest.version,digest=package.manifest.digest,keyId=package.manifest.key_id,approvedAt=now()}store.write(release.PENDING_FILE,marker)local value=loadState();setStatus(\"applying\",\"Applying verified release \"..package.manifest.version..\" after reboot\");record(\"apply_approved\",{version=package.manifest.version,digest=package.manifest.digest:sub(1,16),keyId=package.manifest.key_id});save()return true,{version=package.manifest.version,fingerprint=package.manifest.digest:sub(1,16),keyId=package.manifest.key_id}end function M.recover()local marker=store.read(release.PENDING_FILE,nil)if type(marker)~=\"table\"or marker.state~=\"verify\"then return end local value=loadState()if marker.version==version.string and(marker.profile==nil or marker.profile==profile.id())then if fs.exists(release.PENDING_FILE)then fs.delete(release.PENDING_FILE)end setStatus(\"applied\",\"Verified release \"..version.string..\" is now active\")record(\"apply_verified\",{version=version.string})else setStatus(\"error\",\"Update installed but running version did not match \"..tostring(marker.version))record(\"apply_version_mismatch\",{expected=marker.version,actual=version.string})end save()end function M.tick()local value,timestamp=loadState(),now()local offer=providerOffer()if offer and timestamp-lastBeacon>=BEACON_INTERVAL then net.publicBroadcast(\"update_offer\",offer)lastBeacon=timestamp end local transfer=value.transfer if transfer and transfer.awaiting and timestamp-(transfer.requestedAt or timestamp)>=REQUEST_TIMEOUT then transfer.awaiting,transfer.retries=false,(transfer.retries or 0)+1 if transfer.retries>REQUEST_LIMIT then record(\"download_timeout\",{id=transfer.source,version=transfer.version})value.transfer=nil;removePart();setStatus(\"error\",\"Update source timed out\");save()else local sent,err=transferRequest(transfer)if not sent then value.transfer=nil;removePart();setStatus(\"error\",err);record(\"download_failed\",{id=transfer.source,error=err});save()end end end end function M.status()local value=loadState()return{profile=profile.id(),version=version.string,status=value.status,message=value.message,revision=value.revision,offers=offers(),transfer=value.transfer,cache=value.cache,history=value.history,cacheRoot=release.CACHE_ROOT,maxPackageBytes=release.MAX_PACKAGE_BYTES,}end return M",
  ["ceetos/lib/release_broker.lua"] = "local store=require(\"ceetos.lib.store\")local password=require(\"ceetos.lib.password\")local crypto=require(\"ceetos.lib.mesh_crypto\")local cloud=require(\"ceetos.lib.cloud\")local net=require(\"ceetos.lib.net\")local profile=require(\"ceetos.lib.profile\")local M={}local PATH,LIMIT,LIFE=\"/ceetos/data/release-broker.lua\",8,120000 local pending={}local function now()return os.epoch(\"utc\")end local function localId()return tostring(os.getComputerID())end local function localLabel()return os.getComputerLabel()or(\"Computer \"..localId())end local function clean(v,n)return type(v)==\"string\"and#v>0 and#v<=n and not v:find(\"[%z\\1-\\31\\127]\")end local function state()local value=store.read(PATH,{})return{schema=1,enabled=value.enabled==true,promotedAt=tonumber(value.promotedAt)}end local function save(value)return store.write(PATH,value)end local function key(session,target,targetNonce,phrase)return password.hmacSha256(phrase,table.concat({\"ceetos/release-broker/v1\",session,target,localId(),targetNonce},\"|\"))end local function mac(secret,label,value)return password.hmacSha256(secret,label..\"|\"..value)end local function expiry()return now()+LIFE end local function pendingCount()local count=0 for _,row in pairs(pending)do if row.expires>now()then count=count+1 end end return count end function M.status()local value,cloudState=state(),cloud.publicStatus()local rows={}for id,row in pairs(pending)do if row.expires>now()then rows[#rows+1]={id=id,target=row.target,label=row.label,profile=row.profile,expires=row.expires,state=row.state}end end table.sort(rows,function(a,b)return a.expires<b.expires end)return{enabled=value.enabled,enrolled=cloudState.enrolled,pending=rows}end function M.promote(ownerKey)if not clean(ownerKey,256)then return false,\"owner enrollment key is required\"end local result,err=cloud.call(\"auth\",\"/v1/broker/promote\",{ownerKey=ownerKey})if not result then return false,err end save({schema=1,enabled=true,promotedAt=now()})return true,result end function M.revoke(ownerKey)if not clean(ownerKey,256)then return false,\"owner enrollment key is required\"end local result,err=cloud.call(\"auth\",\"/v1/broker/revoke\",{ownerKey=ownerKey})if not result then return false,err end save({schema=1,enabled=false})for id in pairs(pending)do pending[id]=nil end return true,result end function M.handle(packet)local body,source=packet.body or{},tostring(packet.from or\"\")if packet.type==\"broker_query\"then if not state().enabled or not cloud.publicStatus().enrolled then return end if not clean(body.session,96)or not clean(body.nonce,128)or not clean(body.profile,32)or not clean(body.label,96)or body.profile~=profile.id()then return end if pendingCount()>=LIMIT and not pending[body.session]then return end pending[body.session]={session=body.session,target=source,targetNonce=body.nonce,profile=body.profile,label=body.label,expires=expiry(),state=\"waiting\"}return net.publicSend(source,\"broker_offer\",{session=body.session,broker=localId(),label=localLabel(),profile=profile.id(),expires=pending[body.session].expires})elseif packet.type==\"broker_proof\"then local row=pending[body.session]if not row or row.expires<=now()or row.target~=source or row.state~=\"challenged\"or not clean(body.proof,128)then return end if not password.constantTimeEqual(row.expected,body.proof)then pending[body.session]=nil;return net.publicSend(source,\"broker_reject\",{session=body.session,reason=\"phrase proof rejected\"})end local result,err=cloud.call(\"auth\",\"/v1/broker/enroll\",{session=row.session,targetComputerId=row.target,targetLabel=row.label,targetProfile=row.profile,expires=row.expires})if not result or type(result.node)~=\"table\"then pending[body.session]=nil;return net.publicSend(source,\"broker_reject\",{session=body.session,reason=tostring(err or\"cloud provisioning failed\"):sub(1,96)})end local aad=\"ceetos/release-broker/grant/v1|\"..row.session..\"|\"..row.target..\"|\"..localId()local nonce=crypto.nonce(row.key,1,aad)local envelope=nonce and crypto.seal(row.key,nonce,aad,textutils.serialise({node=result.node,urls=cloud.publicStatus().urls}))pending[body.session]=nil if not envelope then return net.publicSend(source,\"broker_reject\",{session=body.session,reason=\"could not secure enrollment\"})end return net.publicSend(source,\"broker_grant\",{session=body.session,broker=localId(),envelope=envelope})end end function M.approve(session,phrase)local row=pending[tostring(session or\"\")]if not row or row.expires<=now()then return false,\"bootstrap session is unavailable or expired\"end if not clean(phrase,128)then return false,\"invalid bootstrap phrase\"end row.key=key(row.session,row.target,row.targetNonce,phrase)row.brokerNonce=password.newSalt(\"broker:\"..row.session)row.expected=mac(row.key,\"proof\",row.brokerNonce)row.state,row.expires=\"challenged\",expiry()local challenge=mac(row.key,\"challenge\",row.brokerNonce)local sent,err=net.publicSend(row.target,\"broker_challenge\",{session=row.session,broker=localId(),nonce=row.brokerNonce,mac=challenge,expires=row.expires})if not sent then row.state=\"waiting\";return false,err end return true end function M.tick()for id,row in pairs(pending)do if row.expires<=now()then pending[id]=nil end end end return M",
  ["ceetos/lib/broker_bootstrap.lua"] = "local password=require(\"ceetos.lib.password\")local crypto=require(\"ceetos.lib.mesh_crypto\")local M={}local CHANNEL,LIFE=45731,120 local function label()return os.getComputerLabel()or(\"Computer \"..tostring(os.getComputerID()))end local function valid(v,n)return type(v)==\"string\"and#v>0 and#v<=n end local function modem()for _,name in ipairs(peripheral.getNames())do if peripheral.getType(name)==\"modem\"then local wrapped=peripheral.wrap(name)if wrapped and wrapped.isWireless and wrapped.isWireless()then return wrapped end end end end local function random(context)return password.newSalt(context..\":\"..tostring(os.epoch(\"utc\")))end function M.enroll(profile)local link=modem()if not link then return nil,\"No wireless modem is attached; a trusted release broker must be directly reachable.\"end link.open(CHANNEL)local session,target,targetNonce=random(\"session\"),tostring(os.getComputerID()),random(\"target\")local phrase=random(\"phrase\"):sub(1,20)print(\"Trusted broker enrollment required.\")print(\"On an enrolled broker run: nx broker approve \"..session..\" \"..phrase)link.transmit(CHANNEL,CHANNEL,{protocol=\"ceetos/update/1\",type=\"broker_query\",from=target,to=\"0\",body={session=session,nonce=targetNonce,profile=profile,label=label()}})local deadline,broker,linkKey=os.epoch(\"utc\")+LIFE*1000,nil,nil while os.epoch(\"utc\")<deadline do local timer=os.startTimer(2)local event={os.pullEventRaw()}if event[1]==\"timer\"and event[2]==timer then link.transmit(CHANNEL,CHANNEL,{protocol=\"ceetos/update/1\",type=\"broker_query\",from=target,to=\"0\",body={session=session,nonce=targetNonce,profile=profile,label=label()}})elseif event[1]==\"modem_message\"and event[3]==CHANNEL and type(event[5])==\"table\"then local packet,body=event[5],event[5].body or{}if packet.protocol==\"ceetos/update/1\"and tostring(packet.to)==target and body.session==session then if packet.type==\"broker_offer\"then broker=tostring(body.broker)elseif packet.type==\"broker_challenge\"and broker and tostring(body.broker)==broker and valid(body.nonce,128)and valid(body.mac,128)then linkKey=password.hmacSha256(phrase,table.concat({\"ceetos/release-broker/v1\",session,target,broker,targetNonce},\"|\"))if password.constantTimeEqual(password.hmacSha256(linkKey,\"challenge|\"..body.nonce),body.mac)then link.transmit(CHANNEL,CHANNEL,{protocol=\"ceetos/update/1\",type=\"broker_proof\",from=target,to=broker,body={session=session,proof=password.hmacSha256(linkKey,\"proof|\"..body.nonce)}})end elseif packet.type==\"broker_grant\"and linkKey and tostring(body.broker)==broker then local aad=\"ceetos/release-broker/grant/v1|\"..session..\"|\"..target..\"|\"..broker local opened=crypto.open(linkKey,body.envelope,aad)local grant=opened and textutils.unserialise(opened)if type(grant)==\"table\"and type(grant.node)==\"table\"and valid(grant.node.id,96)and valid(grant.node.secret,192)then return grant end elseif packet.type==\"broker_reject\"then return nil,tostring(body.reason or\"broker rejected enrollment\")end end end end return nil,broker and\"Broker did not complete enrollment in time.\"or\"No trusted release broker answered. Installation was not changed.\"end return M",
  ["ceetos/lib/mesh_crypto.lua"] = "local password=require(\"ceetos.lib.password\")local M={}local band,bor,bxor=bit32.band,bit32.bor,bit32.bxor local lshift,rshift=bit32.lshift,bit32.rshift local MOD32,LIMB=4294967296,67108864 local function add32(...)local n=0 for i=1,select(\"#\",...)do n=(n+(select(i,...)or 0))%MOD32 end return n end local function rotl(v,bits)return bor(lshift(v,bits),rshift(v,32-bits))end local function le32(data,index)local a,b,c,d=data:byte(index,index+3)return(a or 0)+(b or 0)*256+(c or 0)*65536+(d or 0)*16777216 end local function put32(value)value=value%MOD32 return string.char(value%256,math.floor(value/256)%256,math.floor(value/65536)%256,math.floor(value/16777216)%256)end local function le64(value)value=math.max(0,math.floor(tonumber(value)or 0))local low,high=value%MOD32,math.floor(value/MOD32)%MOD32 return put32(low)..put32(high)end local function hexToRaw(value)if type(value)~=\"string\"or#value%2~=0 or value:find(\"[^%x]\")then return nil end return(value:gsub(\"..\",function(pair)return string.char(tonumber(pair,16))end))end local function rawToHex(value)return(value:gsub(\".\",function(byte)return string.format(\"%02x\",byte:byte())end))end local function xorBytes(left,right)local out={}for i=1,#left do out[i]=string.char(bxor(left:byte(i),right:byte(i)))end return table.concat(out)end local function quarter(x,a,b,c,d)x[a]=add32(x[a],x[b]);x[d]=rotl(bxor(x[d],x[a]),16)x[c]=add32(x[c],x[d]);x[b]=rotl(bxor(x[b],x[c]),12)x[a]=add32(x[a],x[b]);x[d]=rotl(bxor(x[d],x[a]),8)x[c]=add32(x[c],x[d]);x[b]=rotl(bxor(x[b],x[c]),7)end local function chachaBlock(key,counter,nonce)local state={0x61707865,0x3320646e,0x79622d32,0x6b206574}for i=1,8 do state[4+i]=le32(key,(i-1)*4+1)end state[13],state[14],state[15],state[16]=counter%MOD32,le32(nonce,1),le32(nonce,5),le32(nonce,9)local x={}for i=1,16 do x[i]=state[i]end for _=1,10 do quarter(x,1,5,9,13);quarter(x,2,6,10,14);quarter(x,3,7,11,15);quarter(x,4,8,12,16)quarter(x,1,6,11,16);quarter(x,2,7,12,13);quarter(x,3,8,9,14);quarter(x,4,5,10,15)end local out={}for i=1,16 do out[i]=put32(add32(x[i],state[i]))end return table.concat(out)end local function chachaXor(key,nonce,counter,plaintext)local out,position={},1 while position<=#plaintext do local block=chachaBlock(key,counter,nonce)local part=plaintext:sub(position,position+63)out[#out+1]=xorBytes(part,block:sub(1,#part))position,counter=position+#part,(counter+1)%MOD32 end return table.concat(out)end local function poly1305(message,key)local function zero(length)local out={};for i=1,length do out[i]=0 end;return out end local function normalise(value)local carry=0 for i=1,#value do local n=(value[i]or 0)+carry value[i],carry=n%256,math.floor(n/256)end while carry>0 do value[#value+1],carry=carry%256,math.floor(carry/256)end return value end local function add(left,right)local out=zero(math.max(#left,#right)+1)for i=1,#left do out[i]=out[i]+left[i]end for i=1,#right do out[i]=out[i]+right[i]end return normalise(out)end local function multiply(left,right)local out=zero(#left+#right+1)for i=1,#left do for j=1,#right do out[i+j-1]=out[i+j-1]+left[i]*right[j]end end return normalise(out)end local function high130(value)local out={}for i=1,#value-16 do local low=value[i+16]or 0 local high=value[i+17]or 0 out[i]=math.floor(low/4)+(high%4)*64 end return normalise(out)end local function hasHigh(value)if#value>17 then for i=18,#value do if value[i]~=0 then return true end end end return(value[17]or 0)>=4 end local function reduce(value)while hasHigh(value)do local low,high=zero(17),high130(value)for i=1,17 do low[i]=value[i]or 0 end low[17]=low[17]%4 for i=1,#high do high[i]=high[i]*5 end value=add(low,high)end local prime={251}for i=2,16 do prime[i]=255 end prime[17]=3 local greater=false for i=17,1,-1 do if(value[i]or 0)~=prime[i]then greater=(value[i]or 0)>prime[i];break end end if greater then local borrow=0 for i=1,17 do local n=(value[i]or 0)-prime[i]-borrow if n<0 then n,borrow=n+256,1 else borrow=0 end value[i]=n end end return value end local r,h=zero(17),zero(17)for i=1,16 do r[i]=key:byte(i)end r[4],r[8],r[12],r[16]=band(r[4],15),band(r[8],15),band(r[12],15),band(r[16],15)r[5],r[9],r[13]=band(r[5],252),band(r[9],252),band(r[13],252)for at=1,#message,16 do local part,n=message:sub(at,at+15),zero(17)for i=1,#part do n[i]=part:byte(i)end n[#part+1]=1 h=reduce(multiply(add(h,n),r))end local pad,out,carry={},{},0 for i=1,16 do pad[i]=key:byte(i+16)end for i=1,16 do local n=(h[i]or 0)+pad[i]+carry out[i],carry=n%256,math.floor(n/256)end return string.char(table.unpack(out))end local function padded(value)return value..string.rep(\"\\0\",(16-(#value%16))%16)end local function derive(linkKey)local raw=hexToRaw(password.hmacSha256(linkKey,\"ceetos/mesh/aead-key/v1\")or\"\")return raw end function M.nonce(linkKey,counter,context)local raw=hexToRaw(password.hmacSha256(linkKey,\"ceetos/mesh/aead-nonce/v1|\"..tostring(counter)..\"|\"..tostring(context or\"\"))or\"\")return raw and raw:sub(1,12)or nil end function M.seal(linkKey,nonce,aad,plaintext)if type(linkKey)~=\"string\"or type(nonce)~=\"string\"or#nonce~=12 or type(aad)~=\"string\"or type(plaintext)~=\"string\"then return nil,\"invalid mesh envelope input\"end local key=derive(linkKey);if not key or#key~=32 then return nil,\"could not derive mesh envelope key\"end local polyKey=chachaBlock(key,0,nonce):sub(1,32)local ciphertext=chachaXor(key,nonce,1,plaintext)local tag=poly1305(padded(aad)..padded(ciphertext)..le64(#aad)..le64(#ciphertext),polyKey)return{nonce=rawToHex(nonce),ciphertext=rawToHex(ciphertext),tag=rawToHex(tag)}end function M.open(linkKey,envelope,aad)if type(linkKey)~=\"string\"or type(envelope)~=\"table\"or type(aad)~=\"string\"then return nil,\"invalid mesh envelope\"end local nonce,ciphertext,tag=hexToRaw(envelope.nonce or\"\"),hexToRaw(envelope.ciphertext or\"\"),hexToRaw(envelope.tag or\"\")if not nonce or#nonce~=12 or not ciphertext or not tag or#tag~=16 then return nil,\"invalid mesh envelope encoding\"end local key=derive(linkKey);if not key or#key~=32 then return nil,\"could not derive mesh envelope key\"end local polyKey=chachaBlock(key,0,nonce):sub(1,32)local expected=rawToHex(poly1305(padded(aad)..padded(ciphertext)..le64(#aad)..le64(#ciphertext),polyKey))if not password.constantTimeEqual(expected,envelope.tag)then return nil,\"mesh envelope authentication failed\"end return chachaXor(key,nonce,1,ciphertext)end function M.selfTest()local key=hexToRaw(\"85d6be7857556d337f4452fe42d506a80103808afb0db2fd4abff6af4149f51b\")local tag=poly1305(\"Cryptographic Forum Research Group\",key)return rawToHex(tag)==\"a8061dc1305136c6c22b8baf0c0127a9\"end return M",
  ["ceetos/lib/audit.lua"] = "local store=require(\"ceetos.lib.store\")local M={}local PATH=\"/ceetos/data/audit.log\"M.LIMITS={maxEntries=250,maxBytes=64*1024}function M.log(actor,action,detail)return store.append(PATH,{ts=os.epoch(\"utc\"),actor=actor or\"system\",action=action,detail=detail,},M.LIMITS)end function M.recent(limit)limit=math.max(1,math.min(50,tonumber(limit)or 10))local raw=store.readAppend(PATH)local result={}for line in(raw..\"\\n\"):gmatch(\"(.-)\\n\")do if line~=\"\"then local entry=textutils.unserialise(line)if entry then result[#result+1]=entry if#result>limit then table.remove(result,1)end end end end return result end return M",
  ["ceetos/lib/net.lua"] = "local store=require(\"ceetos.lib.store\")local audit=require(\"ceetos.lib.audit\")local password=require(\"ceetos.lib.password\")local meshCrypto=require(\"ceetos.lib.mesh_crypto\")local M={handler=nil,publicHandler=nil}local PATH=\"/ceetos/data/network.lua\"local SECURITY_PATH=\"/ceetos/data/network-security.lua\"local SEEN_PATH=\"/ceetos/data/seen-peers.lua\"local CHANNEL,PROTOCOL=45731,\"ceetos/2\"local PUBLIC_UPDATE_PROTOCOL=\"ceetos/update/1\"local PUBLIC_UPDATE_TYPES={update_query=true,update_offer=true,update_chunk_request=true,update_chunk=true,broker_query=true,broker_offer=true,broker_challenge=true,broker_proof=true,broker_grant=true,broker_reject=true,}local DISCOVERY_CHANNEL=CHANNEL local DISCOVERY_TTL,ROUTE_TTL,PACKET_TTL=120000,120000,8 local DISCOVERY_INTERVAL,PAIR_RETRY,ROUTE_INTERVAL=2000,2000,15000 local MESH_BEACON_INTERVAL,MESH_FULL_INTERVAL=15000,30000 local PROBE_INTERVAL,PROBE_DEGRADED,PROBE_OFFLINE,PROBE_RECOVERY=5000,3,6,10000 local ROUTE_CANDIDATE_LIMIT,ROUTE_BUCKET_LIMIT=4,96 local SAFE_READ_WINDOW,SAFE_READ_LIMIT=1000,16 local MAX_PACKET_STRING,MAX_PACKET_TABLE,MAX_PACKET_DEPTH=8192,128,6 local COUNTER_RESERVATION,MAX_RX_EPOCHS,MAX_REVERSE_ROUTES=64,3,128 local routes,discovered,confirmations,seenPackets,reverseRoutes={},{},{},{},{}local routeSequence,lastRouteAdvertisement,routesDirty=0,0,false local discovery,lastDiscovery=nil,{state=\"inactive\"}local security={tx={},rx={}}local autoEdges,linkHealth,meshOffers,meshSeen,advertisedRoutes,meshEnrollmentSends={},{},{},{},{},{}local lastMeshBeacon,lastMeshFull,meshDirty=0,0,false local transport={modem=false,wireless=false,name=nil,channels={},lastTransmit=nil,lastReceive=nil,lastDistance=nil,lastError=nil,rejected=0,}local traces={discovery={},routing={},transport={}}local TRACE_LIMIT=48 local MESH_STATE_LIMIT=128 local rejectPacket local function now()return os.epoch(\"utc\")end local function id()return tostring(os.getComputerID())end local function label()return os.getComputerLabel()or(\"Computer \"..id())end local function isBroadcast(value)return tostring(value)==\"0\"end local function isForUs(value)return tostring(value)==id()end local function copy(value,depth)if type(value)~=\"table\"then return value end if(depth or 0)>MAX_PACKET_DEPTH then return nil end local out={}for k,v in pairs(value)do out[k]=copy(v,(depth or 0)+1)end return out end local function trace(kind,phase,fields)local bucket=traces[kind]or traces.transport local row={time=now(),phase=phase}for key,value in pairs(fields or{})do if key~=\"phrase\"and key~=\"proof\"and key~=\"key\"and key~=\"secret\"and key~=\"mac\"and key~=\"packet\"and key~=\"tag\"then row[key]=value end end bucket[#bucket+1]=row while#bucket>TRACE_LIMIT do table.remove(bucket,1)end end local function read(path,fallback)if store.invalidate then store.invalidate(path)end return store.read(path,fallback)end local function config()local value=read(PATH,{peers={},channel=CHANNEL})value.peers=type(value.peers)==\"table\"and value.peers or{}value.channel=tonumber(value.channel)or CHANNEL return value end local function save(value)local ok,err=store.write(PATH,value)if ok==false then error(err or\"could not save network configuration\",0)end end local function migrateV1Links()local value,changed=config(),false for _,peer in pairs(value.peers)do if peer.protocol~=2 or peer.rekeyRequired or type(peer.key)~=\"string\"or#peer.key<32 then peer.protocol,peer.rekeyRequired,peer.key=1,true,nil peer.secret=nil changed=true end end if changed then save(value)end return value end local function activePeer(value,peerId)local peer=value.peers[tostring(peerId)]if peer and peer.protocol==2 and peer.rekeyRequired~=true and type(peer.key)==\"string\"and#peer.key>=32 then return peer end return nil end local function activeLink(value,peerId)local manual=activePeer(value,peerId)if manual then return manual,\"direct\"end local edge=autoEdges[tostring(peerId)]if edge and type(edge.key)==\"string\"and#edge.key>=32 and(edge.expires or 0)>now()then return edge,\"mesh\"end return nil end local function healthFor(peerId)peerId=tostring(peerId)local state=linkHealth[peerId]if not state then state={state=\"healthy\",successes=0,misses=0,lastAuthenticated=now(),stableSince=now(),inFlight=0}linkHealth[peerId]=state end return state end local function linkState(peerId)return healthFor(peerId).state or\"healthy\"end local function currentInFlight(state,timestamp)state.inFlightUntil=type(state.inFlightUntil)==\"table\"and state.inFlightUntil or{}local kept={}for _,expires in ipairs(state.inFlightUntil)do if expires>timestamp then kept[#kept+1]=expires end end state.inFlightUntil,state.inFlight=kept,#kept return state.inFlight end local function markInFlight(state)local timestamp=now()currentInFlight(state,timestamp)state.inFlightUntil[#state.inFlightUntil+1]=timestamp+SAFE_READ_WINDOW table.sort(state.inFlightUntil)while#state.inFlightUntil>SAFE_READ_LIMIT do table.remove(state.inFlightUntil,1)end state.inFlight=#state.inFlightUntil end local function linkUsable(value,peerId,allowDegraded)if not activeLink(value,peerId)then return false end local state=linkState(peerId)return state==\"healthy\"or(allowDegraded and state==\"degraded\")end local function touchLink(peerId,distance,source)local state,timestamp=healthFor(peerId),now()state.lastAuthenticated,state.lastDistance,state.lastSource=timestamp,distance,source or\"traffic\"state.misses,state.awaiting,state.probeToken=0,false,nil state.loss=math.max(0,(state.loss or 0)*0.5)if state.state~=\"healthy\"then state.successes=(state.successes or 0)+1 if state.successes>=2 then state.state,state.stableSince,state.recoveredAt=\"healthy\",timestamp,timestamp routesDirty=true trace(\"routing\",\"link_recovered\",{peer=peerId,source=source})end else state.successes=math.min(2,(state.successes or 0)+1)end end local function allDirectLinks(value)local out,seen={},{}for peerId in pairs(value.peers)do if activePeer(value,peerId)then out[#out+1],seen[peerId]=tostring(peerId),true end end for peerId,edge in pairs(autoEdges)do if not seen[peerId]and edge.expires>now()then out[#out+1]=tostring(peerId)end end table.sort(out,function(a,b)return tonumber(a)<tonumber(b)end)return out end local function remember(peer,peerLabel,extra)local value=read(SEEN_PATH,{})local old=value[tostring(peer)]or{}old.label,old.seen=peerLabel or old.label or(\"Computer \"..tostring(peer)),now()if extra then for key,item in pairs(extra)do if key~=\"tag\"and key~=\"proof\"then old[key]=item end end end value[tostring(peer)]=old store.write(SEEN_PATH,value)end local function modem(requireWireless)local candidates,used={},{}local sides=rs and rs.getSides and rs.getSides()or{}for _,name in ipairs(sides)do if peripheral.getType(name)==\"modem\"and not used[name]then candidates[#candidates+1],used[name]=name,true end end if peripheral.getNames then for _,name in ipairs(peripheral.getNames())do if peripheral.getType(name)==\"modem\"and not used[name]then candidates[#candidates+1],used[name]=name,true end end end local fallback for _,name in ipairs(candidates)do local wrapped=peripheral.wrap(name)if wrapped then fallback=fallback or wrapped local ok,wireless=pcall(function()return not wrapped.isWireless or wrapped.isWireless()end)if ok and wireless then return wrapped end end end return requireWireless and nil or fallback end local function modems()local out,seen={},{}local function add(name)if not seen[name]and peripheral.getType(name)==\"modem\"then local wrapped=peripheral.wrap(name)if wrapped then out[#out+1],seen[name]={name=name,modem=wrapped},true end end end for _,name in ipairs(rs and rs.getSides and rs.getSides()or{})do add(name)end for _,name in ipairs(peripheral.getNames and peripheral.getNames()or{})do add(name)end return out end local function wirelessModems()local out={}for _,item in ipairs(modems())do local ok,wireless=pcall(function()return not item.modem.isWireless or item.modem.isWireless()end)if ok and wireless then out[#out+1]=item end end return out end local function openChannels(value)local channels={value.channel or CHANNEL}if channels[1]~=DISCOVERY_CHANNEL then channels[#channels+1]=DISCOVERY_CHANNEL end transport.channels=channels local all=modems()if#all==0 then transport.lastError=\"no modem attached\";trace(\"transport\",\"modem_missing\");return channels end for _,item in ipairs(all)do for _,channel in ipairs(channels)do local ok,err=pcall(item.modem.open,channel)if not ok then transport.lastError=tostring(err)trace(\"transport\",\"channel_open_failed\",{channel=channel,error=transport.lastError,modem=item.name})end end end return channels end local function canonical(value,depth)depth=depth or 0 local kind=type(value)if kind==\"nil\"then return\"n\"end if kind==\"boolean\"then return value and\"b1\"or\"b0\"end if kind==\"number\"then if value~=value or value==math.huge or value==-math.huge then return nil,\"invalid numeric packet value\"end return\"d\"..string.format(\"%.17g\",value)..\";\"end if kind==\"string\"then if#value>MAX_PACKET_STRING then return nil,\"packet string exceeds limit\"end return\"s\"..tostring(#value)..\":\"..value end if kind~=\"table\"then return nil,\"unsupported packet value\"end if depth>=MAX_PACKET_DEPTH then return nil,\"packet nesting exceeds limit\"end local entries={}for key,item in pairs(value)do if#entries>=MAX_PACKET_TABLE then return nil,\"packet table exceeds limit\"end local encodedKey,keyErr=canonical(key,depth+1);if not encodedKey then return nil,keyErr end local encodedValue,valueErr=canonical(item,depth+1);if not encodedValue then return nil,valueErr end entries[#entries+1]=encodedKey..\"=\"..encodedValue end table.sort(entries)return\"t\"..tostring(#entries)..\"{\"..table.concat(entries,\",\")..\"}\"end local function packetMaterial(packet)return canonical({protocol=packet.protocol,type=packet.type,from=packet.from,to=packet.to,origin=packet.origin,destination=packet.destination,ttl=packet.ttl,request_id=packet.request_id,timestamp=packet.timestamp,reply_to=packet.reply_to,auth=packet.auth and{epoch=packet.auth.epoch,counter=packet.auth.counter}or nil,confidential=packet.confidential==true,body=packet.body,})end local function confidentialAad(packet)return table.concat({\"ceetos/confidential/v1\",tostring(packet.type),tostring(packet.origin),tostring(packet.destination),tostring(packet.request_id)},\"|\")end local function sealBody(peer,packet)local encoded=textutils.serialise(packet.body or{},{compact=true})if#encoded>6144 then return nil,\"confidential payload exceeds limit\"end local nonce=meshCrypto.nonce(peer.key,password.newSalt(\"confidential:\"..tostring(packet.request_id)),confidentialAad(packet))if not nonce then return nil,\"could not create confidential nonce\"end local envelope,err=meshCrypto.seal(peer.key,nonce,confidentialAad(packet),encoded)if not envelope then return nil,err end return{envelope=envelope}end local function openBody(peer,packet)if type(packet.body)~=\"table\"or type(packet.body.envelope)~=\"table\"then return nil,\"malformed confidential envelope\"end local decoded,err=meshCrypto.open(peer.key,packet.body.envelope,confidentialAad(packet))if not decoded or#decoded>6144 then return nil,err or\"invalid confidential payload\"end local ok,value=pcall(textutils.unserialise,decoded)if not ok or type(value)~=\"table\"then return nil,\"invalid confidential payload\"end return value end local function loadSecurity()security=read(SECURITY_PATH,{tx={},rx={}})security.tx=type(security.tx)==\"table\"and security.tx or{}security.rx=type(security.rx)==\"table\"and security.rx or{}end local function saveSecurity()local ok,err=store.write(SECURITY_PATH,security)if ok==false then error(err or\"could not persist peer replay state\",0)end end local function counter(peerId)local key=tostring(peerId)local tx=security.tx[key]if not tx then tx={epoch=password.newSalt(\"peer-tx:\"..key),reserved=0};security.tx[key]=tx end tx.next=tonumber(tx.next)or((tonumber(tx.reserved)or 0)+1)if tx.next>(tonumber(tx.reserved)or 0)then tx.reserved=tx.next+COUNTER_RESERVATION-1 saveSecurity()end local value=tx.next;tx.next=value+1 return tx.epoch,value end local function verifyReplay(from,auth)if type(auth)~=\"table\"or type(auth.epoch)~=\"string\"or#auth.epoch<16 or#auth.epoch>128 then return false,\"invalid packet epoch\"end local sequence=tonumber(auth.counter)if not sequence or sequence<1 or sequence%1~=0 then return false,\"invalid packet counter\"end local key=tostring(from)local row=security.rx[key]or{epochs={}}row.epochs=type(row.epochs)==\"table\"and row.epochs or{}local old=tonumber(row.epochs[auth.epoch])if old and sequence<=old then return false,\"replayed packet counter\"end row.epochs[auth.epoch]=sequence local order={}for epoch,max in pairs(row.epochs)do order[#order+1]={epoch=epoch,max=tonumber(max)or 0}end table.sort(order,function(a,b)return a.max>b.max end)while#order>MAX_RX_EPOCHS do row.epochs[table.remove(order).epoch]=nil end security.rx[key]=row saveSecurity()return true end local function newRequestId()return id()..\":\"..password.newSalt(\"peer-request\")end local function sign(peerId,peer,packet)local epoch,sequence=counter(peerId)packet.auth={epoch=epoch,counter=sequence}local material,err=packetMaterial(packet)if not material then return false,err end local mac,macErr=password.hmacSha256(peer.key,material)if not mac then return false,macErr end packet.auth.mac=mac return true end local function verifyPacket(packet,value)if type(packet)~=\"table\"then return false,\"packet is not a table\"end if packet.protocol~=PROTOCOL then return false,packet.protocol==\"ceetos/1\"and\"v1 packet rejected; rekey required\"or\"invalid packet protocol\"end if type(packet.type)~=\"string\"or#packet.type==0 or#packet.type>64 then return false,\"invalid packet type\"end if type(packet.from)~=\"string\"and type(packet.from)~=\"number\"then return false,\"invalid packet sender\"end local from=tostring(packet.from)local peer=activeLink(value,from)if not peer then return false,\"sender is not an active direct or mesh peer\"end if not isForUs(packet.to)then return false,\"packet addressed to another hop\"end if type(packet.origin)~=\"string\"and type(packet.origin)~=\"number\"then return false,\"invalid packet origin\"end if type(packet.destination)~=\"string\"and type(packet.destination)~=\"number\"then return false,\"invalid packet destination\"end if type(packet.request_id)~=\"string\"or#packet.request_id==0 or#packet.request_id>128 then return false,\"invalid packet request ID\"end if packet.reply_to~=nil and(type(packet.reply_to)~=\"string\"or#packet.reply_to==0 or#packet.reply_to>128)then return false,\"invalid reply correlation ID\"end local ttl=tonumber(packet.ttl)if not ttl or ttl%1~=0 or ttl<1 or ttl>PACKET_TTL then return false,\"invalid packet TTL\"end local timestamp=tonumber(packet.timestamp)if not timestamp or math.abs(now()-timestamp)>ROUTE_TTL then return false,\"stale packet timestamp\"end if type(packet.auth)~=\"table\"or type(packet.auth.mac)~=\"string\"or#packet.auth.mac~=64 then return false,\"missing packet MAC\"end local receivedMac=packet.auth.mac packet.auth.mac=nil local material,materialErr=packetMaterial(packet)packet.auth.mac=receivedMac if not material then return false,materialErr end local expected,macErr=password.hmacSha256(peer.key,material)if not expected or not password.constantTimeEqual(expected,receivedMac)then return false,macErr or\"invalid packet MAC\"end local replayOk,replayErr=verifyReplay(from,packet.auth)if not replayOk then return false,replayErr end return true,peer end local function routeBucket(targetId)local bucket=routes[tostring(targetId)]if bucket and bucket.next then bucket={candidates={{next=tostring(bucket.next),hops=tonumber(bucket.hops)or PACKET_TTL,expires=tonumber(bucket.expires)or 0,label=bucket.label,mode=bucket.mode,masterId=bucket.masterId,legacy=true,loss=1,}},primary=tostring(bucket.next),changedAt=now(),reason=\"legacy route\"}routes[tostring(targetId)]=bucket end if not bucket then bucket={candidates={},changedAt=now(),reason=\"new\"}routes[tostring(targetId)]=bucket end bucket.candidates=type(bucket.candidates)==\"table\"and bucket.candidates or{}return bucket end local function candidateHealth(candidate)local state=healthFor(candidate.next)local rank=state.state==\"healthy\"and 0 or(state.state==\"degraded\"and 1 or 2)return rank,state end local function directCandidate(value,targetId)local link,kind=activeLink(value,targetId)if link then return{next=targetId,hops=1,expires=now()+ROUTE_TTL,kind=kind,direct=true,label=link.label,mode=link.mode,masterId=link.masterId}end end local function validCandidates(targetId,value)local timestamp,out,seen=now(),{},{}local direct=directCandidate(value,targetId)if direct then out[#out+1],seen[direct.next..\":\"..direct.kind]=direct,true end local bucket=routes[tostring(targetId)]if bucket then for _,candidate in ipairs(routeBucket(targetId).candidates)do local key=tostring(candidate.next)..\":\"..tostring(candidate.kind or\"route\")if not seen[key]and candidate.expires>timestamp and candidate.hops>=1 and candidate.hops<=PACKET_TTL and activeLink(value,candidate.next)then out[#out+1],seen[key]=candidate,true end end end return out end local function compareCandidates(a,b)local ar,ah=candidateHealth(a)local br,bh=candidateHealth(b)if ar~=br then return ar<br end if(a.hops or PACKET_TTL)~=(b.hops or PACKET_TTL)then return(a.hops or PACKET_TTL)<(b.hops or PACKET_TTL)end if(ah.loss or 0)~=(bh.loss or 0)then return(ah.loss or 0)<(bh.loss or 0)end if(ah.rtt or 0)~=(bh.rtt or 0)then return(ah.rtt or 0)<(bh.rtt or 0)end return tostring(a.next)<tostring(b.next)end local function primaryCandidate(targetId,value,safe)local candidates=validCandidates(targetId,value)if#candidates==0 then return nil end table.sort(candidates,compareCandidates)local target,bucket,selected=tostring(targetId),routeBucket(targetId),candidates[1]local existing for _,item in ipairs(candidates)do if tostring(item.next)==tostring(bucket.primary)then existing=item;break end end if existing then local oldRank,newRank=candidateHealth(existing),candidateHealth(selected)if oldRank<newRank then selected=existing elseif oldRank==newRank and oldRank==0 and(selected.hops or 99)<(existing.hops or 99)then local health=healthFor(selected.next)if(health.successes or 0)<2 or(health.recoveredAt and now()-(health.stableSince or now())<PROBE_RECOVERY)then selected=existing end end end if safe then local rank,_,choices=candidateHealth(selected),nil,{}for _,item in ipairs(candidates)do local itemRank=candidateHealth(item)if itemRank==rank and item.hops==selected.hops then choices[#choices+1]=item end end if#choices>1 then table.sort(choices,function(a,b)local ah,bh=healthFor(a.next),healthFor(b.next)local aLoad,bLoad=currentInFlight(ah,now()),currentInFlight(bh,now())if aLoad~=bLoad then return aLoad<bLoad end return tostring(a.next)<tostring(b.next)end)selected=choices[1]end end if bucket.primary~=tostring(selected.next)then bucket.primary,bucket.changedAt,bucket.reason=tostring(selected.next),now(),existing and\"health/hop selection\"or\"initial selection\"trace(\"routing\",\"route_selected\",{destination=target,next=selected.next,hops=selected.hops,reason=bucket.reason})end return selected end local function nextHop(target,safe)local candidate=primaryCandidate(target,config(),safe)if candidate then local health=healthFor(candidate.next)if safe then markInFlight(health)end health.lastSelected=now()return candidate.next,candidate end routes[tostring(target)]=nil end local function transmit(items,channel,packet)if#items==0 then return false,\"no compatible modem attached\"end local sent,lastError=false,nil for _,item in ipairs(items)do local ok,err=pcall(item.modem.transmit,channel,channel,packet)if ok then sent=true else lastError=tostring(err)end end if not sent then transport.lastError=lastError or\"modem transmit failed\"end return sent,transport.lastError end local function send(target,input,forcedHop)local hop=forcedHop or nextHop(target,input.routeSafe==true and input.balanceSafe==true)if not hop then return false,\"peer is unreachable or requires rekey\"end local value,peer=config(),activeLink(config(),hop)if not peer then return false,\"direct route is unavailable\"end local packet=copy(input)packet.auth,packet.protocol,packet.from,packet.to=nil,PROTOCOL,id(),hop packet.origin,packet.destination=tostring(packet.origin or id()),tostring(packet.destination or target)packet.ttl=math.floor(tonumber(packet.ttl)or PACKET_TTL)packet.timestamp=now()if packet.ttl<1 or packet.ttl>PACKET_TTL then return false,\"invalid packet TTL\"end if type(packet.request_id)~=\"string\"or#packet.request_id==0 then packet.request_id=newRequestId()end if packet.confidential==true then local sealed,sealErr=sealBody(peer,packet)if not sealed then return false,sealErr end packet.body=sealed end local signed,signErr=sign(hop,peer,packet)if not signed then return false,signErr end openChannels(value)local links=wirelessModems()if#links==0 then return false,\"no wireless modem attached\"end local sent,err=transmit(links,value.channel or CHANNEL,packet)if not sent then trace(\"transport\",\"transmit_failed\",{type=packet.type,to=hop,error=err});return false,err end transport.lastTransmit,transport.lastError=now(),nil healthFor(hop).lastTransmit=now()trace(\"routing\",\"packet_sent\",{type=packet.type,to=hop,destination=packet.destination,links=#links})return true end local function rememberReverse(packet,previousHop)if type(packet)~=\"table\"or type(packet.request_id)~=\"string\"or#packet.request_id==0 then return false end local requestId,origin=packet.request_id,tostring(packet.origin)local existing=reverseRoutes[requestId]if existing and(existing.previous~=tostring(previousHop)or existing.origin~=origin)then return false,\"reverse route collision\"end reverseRoutes[requestId]={previous=tostring(previousHop),origin=origin,expires=now()+ROUTE_TTL}local entries={}for key,route in pairs(reverseRoutes)do entries[#entries+1]={id=key,expires=route.expires or 0}end table.sort(entries,function(a,b)return a.expires<b.expires end)while#entries>MAX_REVERSE_ROUTES do reverseRoutes[table.remove(entries,1).id]=nil end return true end local function broadcast(packet)local links=wirelessModems()if#links==0 then transport.lastError=\"no wireless modem attached\";trace(\"discovery\",\"wireless_missing\");return false,transport.lastError end local out=copy(packet)out.protocol,out.from,out.to=PROTOCOL,id(),0 out.timestamp=now()openChannels(config())local sent,err=transmit(links,DISCOVERY_CHANNEL,out)if not sent then trace(\"discovery\",\"transmit_failed\",{type=out.type,error=err});return false,err end transport.lastTransmit,transport.lastError=now(),nil trace(\"discovery\",out.type==\"discover\"and\"announcement_sent\"or\"discovery_packet_sent\",{type=out.type})return true end local function publicTransmit(target,typeName,body)if not PUBLIC_UPDATE_TYPES[typeName]then return false,\"invalid public update packet type\"end local encoded,encodeErr=canonical(body or{})if not encoded then return false,encodeErr or\"invalid public update body\"end local links=wirelessModems()if#links==0 then transport.lastError=\"no wireless modem attached\";return false,transport.lastError end openChannels(config())local packet={protocol=PUBLIC_UPDATE_PROTOCOL,type=typeName,from=id(),to=tostring(target),timestamp=now(),request_id=newRequestId(),body=body or{},}local sent,err=transmit(links,DISCOVERY_CHANNEL,packet)if not sent then transport.lastError=err or\"public update transmit failed\"trace(\"transport\",\"public_update_transmit_failed\",{type=typeName,to=target,error=transport.lastError})return false,transport.lastError end transport.lastTransmit,transport.lastError=now(),nil trace(\"transport\",\"public_update_sent\",{type=typeName,to=target,bytes=#encoded})return true end function M.publicSend(target,typeName,body)if tostring(target)==\"0\"or tostring(target)==\"\"then return false,\"public update target must be a computer ID\"end return publicTransmit(target,typeName,body)end function M.publicBroadcast(typeName,body)return publicTransmit(\"0\",typeName,body)end local function handshake(target,packet)local links=wirelessModems()if#links==0 then transport.lastError=\"no wireless modem attached\";return false,transport.lastError end local out=copy(packet)out.protocol,out.from,out.to,out.timestamp=PROTOCOL,id(),tostring(target),now()out.destination,out.ttl=tostring(target),1 openChannels(config())local sent,err=transmit(links,config().channel or CHANNEL,out)if not sent then trace(\"discovery\",\"handshake_transmit_failed\",{type=out.type,to=target,error=err});return false,err end transport.lastTransmit=now()trace(\"discovery\",out.type..\"_sent\",{to=target})return true end local function phraseTag(phrase,sessionId,hostId)return password.hmacSha256(phrase,\"ceetos/discovery/v2|\"..sessionId..\"|\"..tostring(hostId))end local function joinProof(phrase,sessionId,hostId,joinerId,nonce)return password.hmacSha256(phrase,\"ceetos/pair/join/v2|\"..sessionId..\"|\"..tostring(hostId)..\"|\"..tostring(joinerId)..\"|\"..nonce)end local function confirmProof(phrase,sessionId,hostId,joinerId,nonce,mode,master)return password.hmacSha256(phrase,\"ceetos/pair/confirm/v2|\"..sessionId..\"|\"..tostring(hostId)..\"|\"..tostring(joinerId)..\"|\"..nonce..\"|\"..tostring(mode)..\"|\"..tostring(master or\"\"))end local function linkKey(phrase,sessionId,hostId,joinerId,nonce)return password.derive(phrase,\"ceetos/link/v2|\"..sessionId..\"|\"..tostring(hostId)..\"|\"..tostring(joinerId)..\"|\"..nonce,64)end local function keyId(key)local value=password.hmacSha256(key,\"ceetos/key-id/v2\")return value and value:sub(1,16)or\"\"end local function activeSession()return discovery and(discovery.state==\"hosting\"or discovery.state==\"searching\"or discovery.state==\"candidate\"or discovery.state==\"confirming\")end local function setLast(state,message)lastDiscovery={state=state,message=message,at=now()}end local function persistPair(peerId,key,metadata,masterId)local value=config()value.peers[tostring(peerId)]={protocol=2,rekeyRequired=false,key=key,keyId=keyId(key),role=\"operator\",mode=metadata.mode or\"peer\",master=tostring(masterId or\"\")==id(),masterId=tostring(masterId or\"\"),label=metadata.label,}save(value)remember(peerId,metadata.label,{mode=metadata.mode,masterId=masterId})routes[tostring(peerId)]=nil routesDirty=true audit.log(\"local\",\"peer.rekeyed\",{peer=peerId,mode=metadata.mode})end local function markRejected(reason)if discovery then discovery=nil end setLast(\"cancelled\",reason)end local function discoveryAnnouncement()if not discovery or discovery.state~=\"hosting\"then return false,\"no active host discovery\"end return broadcast({type=\"discover\",session=discovery.id,tag=discovery.tag,label=discovery.label,mode=discovery.mode,master=discovery.masterSide,expires=discovery.expires,})end local function queueConfirmation(peerId,host)confirmations[host.id]=host local sent,err=handshake(peerId,{type=\"pair_confirm\",session=host.id,nonce=host.joinNonce,proof=host.confirmProof,label=host.label,mode=host.mode,master=host.masterId,})host.lastSent,host.retries=now(),0 if not sent then host.error=err end return sent,err end local function expireState(timestamp)if discovery and discovery.expires<=timestamp then local old=discovery.state discovery=nil setLast(\"expired\",old==\"searching\"and\"no matching discovery announcement received\"or\"discovery session expired\")trace(\"discovery\",\"session_expired\",{previous=old})end end local function boundMap(value,limit,timestamp)local entries={}for key,row in pairs(value)do entries[#entries+1]={key=key,at=type(row)==\"number\"and row or(type(row)==\"table\"and(row.expires or row.time or row.seen or row.lastAuthenticated or 0)or 0)}end table.sort(entries,function(a,b)if a.at~=b.at then return a.at<b.at end return tostring(a.key)<tostring(b.key)end)while#entries>limit do value[table.remove(entries,1).key]=nil end end local function pruneRouteBuckets(timestamp)local value=config()local entries={}for peerId,bucket in pairs(routes)do local latest=tonumber(bucket.changedAt)or 0 for _,candidate in ipairs(type(bucket.candidates)==\"table\"and bucket.candidates or{})do latest=math.max(latest,tonumber(candidate.expires)or 0)end entries[#entries+1]={peer=peerId,latest=latest}end table.sort(entries,function(a,b)if a.latest~=b.latest then return a.latest<b.latest end return tostring(a.peer)<tostring(b.peer)end)while#entries>ROUTE_BUCKET_LIMIT do local old=table.remove(entries,1)routes[old.peer],routesDirty=nil,true trace(\"routing\",\"route_bucket_evicted\",{destination=old.peer})end for peerId,state in pairs(linkHealth)do if not activeLink(value,peerId)and not routes[peerId]then linkHealth[peerId]=nil end end for peerId in pairs(advertisedRoutes)do if not activeLink(value,peerId)then advertisedRoutes[peerId]=nil end end boundMap(meshOffers,MESH_STATE_LIMIT,timestamp)boundMap(meshSeen,MESH_STATE_LIMIT,timestamp)end local function controller(value)value=value or config()if value.controller and tostring(value.controller)~=\"\"then local chosen=tostring(value.controller)if chosen==id()or activeLink(value,chosen)then return chosen end local known=routes[chosen]if known then if known.expires and known.expires>now()then return chosen end if type(known.candidates)==\"table\"then for _,candidate in ipairs(known.candidates)do if(candidate.expires or 0)>now()and activeLink(value,candidate.next)then return chosen end end end end trace(\"routing\",\"controller_unreachable\",{controller=chosen})end local selected=tonumber(id())or 0 for peerId in pairs(value.peers)do if activePeer(value,peerId)then local n=tonumber(peerId);if n and n<selected then selected=n end end end for peerId,route in pairs(routes)do local reachable=route.expires and route.expires>now()if type(route.candidates)==\"table\"then reachable=false for _,candidate in ipairs(route.candidates)do if(candidate.expires or 0)>now()then reachable=true;break end end end if reachable then local n=tonumber(peerId);if n and n<selected then selected=n end end end return tostring(selected)end local function meshConfig(value)value=value or config()local mesh=value.mesh if type(mesh)==\"table\"and type(mesh.id)==\"string\"and#mesh.id>=16 and type(mesh.root)==\"string\"and#mesh.root>=32 and tonumber(mesh.epoch)then return mesh end return nil end local function meshMaterial(packet)return canonical({protocol=packet.protocol,type=packet.type,from=packet.from,to=packet.to,mesh=packet.mesh,epoch=packet.epoch,nonce=packet.nonce,offer=packet.offer,timestamp=packet.timestamp})end local function meshTag(mesh,packet)local material=meshMaterial(packet)return material and password.hmacSha256(mesh.root,material)or nil end local function meshPacket(typeName,target,fields)local value,mesh=config(),meshConfig()if not mesh then return false,\"mesh enrollment is pending\"end local packet=copy(fields or{})packet.protocol,packet.type,packet.from,packet.to=PROTOCOL,typeName,id(),target and tostring(target)or 0 packet.mesh,packet.epoch,packet.timestamp=mesh.id,mesh.epoch,now()packet.tag=meshTag(mesh,packet)if not packet.tag then return false,\"could not authenticate mesh packet\"end openChannels(value)local links=wirelessModems()if#links==0 then return false,\"no wireless modem attached\"end local sent,err=transmit(links,value.channel or CHANNEL,packet)if sent then trace(\"routing\",typeName..\"_sent\",{to=packet.to,mesh=mesh.id:sub(1,8)})end return sent,err end local function deriveAutoEdge(mesh,peerId,firstNonce,secondNonce)local left,right=tostring(id()),tostring(peerId)if left>right then left,right=right,left end return password.hmacSha256(mesh.root,\"ceetos/mesh-edge/v1|\"..tostring(mesh.epoch)..\"|\"..left..\"|\"..right..\"|\"..tostring(firstNonce)..\"|\"..tostring(secondNonce))end local function ownsMeshOffer(peerId)local ours,theirs=tonumber(id()),tonumber(peerId)if ours and theirs then return ours<theirs end return tostring(id())<tostring(peerId)end local function installAutoEdge(mesh,peerId,firstNonce,secondNonce,peerLabel)peerId=tostring(peerId)local value=config()if activePeer(value,peerId)then return false,\"manual direct link already exists\"end local key=deriveAutoEdge(mesh,peerId,firstNonce,secondNonce)if not key then return false,\"could not derive mesh edge\"end autoEdges[peerId]={key=key,label=peerLabel,mesh=mesh.id,epoch=mesh.epoch,expires=now()+ROUTE_TTL,firstNonce=firstNonce,secondNonce=secondNonce}local state=healthFor(peerId)state.state,state.successes,state.misses,state.lastAuthenticated,state.stableSince=\"healthy\",2,0,now(),now()routesDirty,meshDirty=true,true trace(\"routing\",\"mesh_edge_active\",{peer=peerId,mesh=mesh.id:sub(1,8)})return true end local function meshEnrollmentAad(from,to,meshId,epoch,nonceCounter)return canonical({protocol=PROTOCOL,type=\"mesh_enroll\",from=tostring(from),to=tostring(to),mesh=meshId,epoch=tonumber(epoch),counter=tonumber(nonceCounter)})end local function sendEnrollment(peerId)local value,mesh,peer=config(),meshConfig(),activePeer(config(),peerId)if not mesh or not peer then return false,\"mesh or direct link unavailable\"end local _,nonceCounter=counter(peerId)local aad=meshEnrollmentAad(id(),peerId,mesh.id,mesh.epoch,nonceCounter)local nonce=meshCrypto.nonce(peer.key,nonceCounter,\"mesh-enroll|\"..id()..\"|\"..tostring(peerId))local plaintext=textutils.serialise({id=mesh.id,epoch=mesh.epoch,root=mesh.root},{compact=true})local envelope,err=meshCrypto.seal(peer.key,nonce,aad,plaintext)if not envelope then return false,err end local sent,sendErr=send(peerId,{type=\"mesh_enroll\",body={mesh=mesh.id,epoch=mesh.epoch,counter=nonceCounter,envelope=envelope},request_id=newRequestId()},peerId)if sent then trace(\"routing\",\"mesh_enrollment_sent\",{to=peerId,epoch=mesh.epoch})end return sent,sendErr end local function acceptEnrollment(from,peer,packet)if not activePeer(config(),from)then return rejectPacket(\"mesh enrollment requires a manual direct link\",packet)end local body=packet.body if type(body)~=\"table\"or type(body.mesh)~=\"string\"or type(body.envelope)~=\"table\"or not tonumber(body.epoch)or not tonumber(body.counter)then return rejectPacket(\"malformed_mesh_enrollment\",packet)end local aad=meshEnrollmentAad(from,id(),body.mesh,body.epoch,body.counter)local plaintext,err=meshCrypto.open(peer.key,body.envelope,aad)if not plaintext then return rejectPacket(err or\"mesh enrollment authentication failed\",packet)end local ok,mesh=pcall(textutils.unserialise,plaintext)if not ok or type(mesh)~=\"table\"or mesh.id~=body.mesh or tonumber(mesh.epoch)~=tonumber(body.epoch)or type(mesh.root)~=\"string\"or#mesh.root<32 then return rejectPacket(\"invalid_mesh_enrollment\",packet)end local value,existing=config(),meshConfig()if existing and existing.id==mesh.id and tonumber(existing.epoch)>tonumber(mesh.epoch)then return end value.mesh={id=mesh.id,epoch=tonumber(mesh.epoch),root=mesh.root,enrolledAt=now(),enrolledBy=tostring(from)}save(value)meshDirty,routesDirty=true,true trace(\"routing\",\"mesh_enrolled\",{from=from,mesh=mesh.id:sub(1,8),epoch=mesh.epoch})end local function ensureMeshEnrollment(timestamp)local value,mesh=config(),meshConfig()if not mesh then if(M._meshBootstrapAt or 0)>timestamp or controller(value)~=id()then return end value.mesh={id=password.newSalt(\"mesh-id:\"..id()),root=password.newSalt(\"mesh-root:\"..id()),epoch=1,createdAt=timestamp,controller=id()}save(value);mesh,meshDirty,routesDirty=value.mesh,true,true trace(\"routing\",\"mesh_created\",{mesh=mesh.id:sub(1,8),epoch=mesh.epoch})end for _,peerId in ipairs(allDirectLinks(value))do if activePeer(value,peerId)then local previous=meshEnrollmentSends[peerId]if not previous or previous.epoch~=mesh.epoch or timestamp-previous.at>=MESH_FULL_INTERVAL then local sent=sendEnrollment(peerId)if sent then meshEnrollmentSends[peerId]={epoch=mesh.epoch,at=timestamp}end end end end end local function meshBeacon(timestamp)local mesh=meshConfig()if not mesh or timestamp-lastMeshBeacon<MESH_BEACON_INTERVAL then return end lastMeshBeacon=timestamp meshPacket(\"mesh_beacon\",nil,{nonce=password.newSalt(\"mesh-beacon:\"..id()),label=label()})end local function verifyMeshPacket(packet)local mesh=meshConfig()if not mesh or type(packet)~=\"table\"or packet.protocol~=PROTOCOL or packet.mesh~=mesh.id or tonumber(packet.epoch)~=tonumber(mesh.epoch)then return false,\"mesh membership proof failed\"end if type(packet.from)~=\"string\"and type(packet.from)~=\"number\"then return false,\"invalid mesh sender\"end if type(packet.nonce)~=\"string\"or#packet.nonce<16 or#packet.nonce>128 then return false,\"invalid mesh nonce\"end if math.abs(now()-(tonumber(packet.timestamp)or 0))>ROUTE_TTL then return false,\"stale mesh packet\"end local material,tag=meshMaterial(packet),packet.tag local expected=material and password.hmacSha256(mesh.root,material)if type(tag)~=\"string\"or not expected or not password.constantTimeEqual(expected,tag)then return false,\"invalid mesh packet tag\"end local replay=tostring(packet.from)..\"|\"..tostring(packet.type)..\"|\"..tostring(packet.nonce)..\"|\"..tostring(packet.offer or\"\")if meshSeen[replay]and meshSeen[replay]+ROUTE_TTL>now()then return false,\"replayed mesh packet\"end meshSeen[replay]=now()return true,mesh end local function handleMeshPacket(packet,distance)local valid,meshOrErr=verifyMeshPacket(packet)if not valid then return rejectPacket(meshOrErr,packet,nil,distance)end local mesh,from=meshOrErr,tostring(packet.from)if from==id()then return end if packet.type==\"mesh_beacon\"then remember(from,packet.label,{mesh=mesh.id})if not activePeer(config(),from)and not autoEdges[from]and ownsMeshOffer(from)then local nonce=password.newSalt(\"mesh-offer:\"..from)meshOffers[from]={offer=nonce,expires=now()+DISCOVERY_TTL}meshPacket(\"mesh_edge_offer\",from,{nonce=nonce,label=label()})end return end if not isForUs(packet.to)then return rejectPacket(\"mesh packet addressed to another peer\",packet,nil,distance)end if packet.type==\"mesh_edge_offer\"then if activePeer(config(),from)then return end local response=password.newSalt(\"mesh-accept:\"..from)installAutoEdge(mesh,from,packet.nonce,response,packet.label)return meshPacket(\"mesh_edge_accept\",from,{nonce=response,offer=packet.nonce,label=label()})end if packet.type==\"mesh_edge_accept\"then local offer=meshOffers[from]if not offer or offer.expires<=now()or offer.offer~=packet.offer then return rejectPacket(\"unexpected mesh edge acceptance\",packet,nil,distance)end meshOffers[from]=nil return installAutoEdge(mesh,from,offer.offer,packet.nonce,packet.label)end end local function probeLinks(timestamp)local value=config()for _,peerId in ipairs(allDirectLinks(value))do local health=healthFor(peerId)local interval=health.state==\"offline\"and PROBE_INTERVAL*3 or PROBE_INTERVAL if timestamp-(health.lastAuthenticated or 0)>=interval and timestamp-(health.lastProbe or 0)>=interval then if health.awaiting then health.misses=(health.misses or 0)+1 health.loss=math.min(1,((health.loss or 0)*3+1)/4)local nextState=health.misses>=PROBE_OFFLINE and\"offline\"or(health.misses>=PROBE_DEGRADED and\"degraded\"or health.state)if nextState~=health.state then health.state,health.successes,health.recoveredAt=nextState,0,nil trace(\"routing\",\"link_\"..nextState,{peer=peerId,misses=health.misses})end routesDirty=true end health.awaiting,health.lastProbe,health.probeToken=true,timestamp,password.newSalt(\"mesh-probe:\"..peerId)send(peerId,{type=\"mesh_probe\",body={token=health.probeToken},request_id=newRequestId()},peerId)end end end local function advertisedMembers(value,peerId,timestamp)local members={}for other,manual in pairs(value.peers)do if other~=peerId and activeLink(value,other)and linkState(other)==\"healthy\"then members[other]={label=manual.label,mode=manual.mode,masterId=manual.masterId,hops=1,kind=\"direct\"}end end for other,edge in pairs(autoEdges)do if other~=peerId and edge.expires>timestamp and linkState(other)==\"healthy\"then members[other]={label=edge.label,mode=\"peer\",hops=1,kind=\"mesh\"}end end for other in pairs(routes)do if other~=peerId and not members[other]then local candidate=primaryCandidate(other,value,false)if candidate and candidate.next~=peerId and linkState(candidate.next)==\"healthy\"then members[other]={label=candidate.label,mode=candidate.mode,masterId=candidate.masterId,hops=candidate.hops,kind=candidate.kind or\"route\"}end end end return members end local function sameAdvertisement(left,right)local leftValue,leftErr=canonical(left)local rightValue,rightErr=canonical(right)return leftValue~=nil and rightValue~=nil and not leftErr and not rightErr and leftValue==rightValue end local function advertiseRoutes(force)local timestamp,value=now(),config()if not force and not routesDirty and timestamp-lastRouteAdvertisement<ROUTE_INTERVAL then return end lastRouteAdvertisement,routesDirty=timestamp,false local full=timestamp-lastMeshFull>=MESH_FULL_INTERVAL if full then lastMeshFull=timestamp end for _,peerId in ipairs(allDirectLinks(value))do if linkUsable(value,peerId,false)then local desired,previous=advertisedMembers(value,peerId,timestamp),advertisedRoutes[peerId]or{}local members,withdrawals={},{}if full then members=desired else for target,meta in pairs(desired)do if not sameAdvertisement(meta,previous[target])then members[target]=meta end end for target in pairs(previous)do if not desired[target]then withdrawals[#withdrawals+1]=target end end end table.sort(withdrawals,function(a,b)return tostring(a)<tostring(b)end)if full or next(members)~=nil or#withdrawals>0 then routeSequence=routeSequence+1 local body={sequence=routeSequence,routeVersion=2,full=full,controller=controller(value),members=members,withdrawals=withdrawals}if send(peerId,{type=\"route_advertise\",body=body,routeSafe=true,request_id=newRequestId()})then advertisedRoutes[peerId]=copy(desired)trace(\"routing\",full and\"route_snapshot_sent\"or\"route_delta_sent\",{to=peerId,changes=(full and 0 or#withdrawals)})end end else advertisedRoutes[peerId]=nil end end end function M.start()migrateV1Links()loadSecurity()assert(meshCrypto.selfTest(),\"CeetOS mesh cryptography self-test failed\")openChannels(config())local status=M.transportStatus()trace(\"transport\",status.wireless and\"wireless_modem_detected\"or\"wireless_modem_missing\",{name=status.name})routesDirty,M._meshBootstrapAt=true,now()end function M.transportStatus()local wireless=wirelessModems()transport.modem,transport.wireless=#modems()>0,#wireless>0 transport.name=wireless[1]and wireless[1].name or nil local out=copy(transport)out.wirelessLinks,out.wirelessLinkCount={},#wireless for _,item in ipairs(wireless)do out.wirelessLinks[#out.wirelessLinks+1]=item.name end out.usesAllWirelessLinks,out.protocol=true,2 out.directPeers,out.manualDirectPeers,out.meshPeers,out.rekeyRequiredPeers,out.routedPeers=0,0,0,0,0 out.healthyLinks,out.degradedLinks,out.offlineLinks=0,0,0 local value=config()for peerId,peer in pairs(value.peers)do if activePeer(value,peerId)then out.directPeers,out.manualDirectPeers=out.directPeers+1,out.manualDirectPeers+1 elseif peer.rekeyRequired then out.rekeyRequiredPeers=out.rekeyRequiredPeers+1 end end for peerId,edge in pairs(autoEdges)do if edge.expires>now()then out.meshPeers,out.directPeers=out.meshPeers+1,out.directPeers+1 end end for peerId,health in pairs(linkHealth)do if health.state==\"healthy\"then out.healthyLinks=out.healthyLinks+1 elseif health.state==\"degraded\"then out.degradedLinks=out.degradedLinks+1 elseif health.state==\"offline\"then out.offlineLinks=out.offlineLinks+1 end end for peerId,bucket in pairs(routes)do local usable=primaryCandidate(peerId,value,false)if usable and not value.peers[peerId]and not autoEdges[peerId]then out.routedPeers=out.routedPeers+1 end end local mesh=meshConfig(value)out.mesh=mesh and{id=mesh.id:sub(1,8),epoch=mesh.epoch,enrolled=true}or{enrolled=false}out.health={healthy=out.healthyLinks,degraded=out.degradedLinks,offline=out.offlineLinks}return out end function M.transportTrace(kind)if kind==\"discovery\"or kind==\"routing\"or kind==\"transport\"then return copy(traces[kind])end return{discovery=copy(traces.discovery),routing=copy(traces.routing),transport=copy(traces.transport)}end function M.discovery()expireState(now())return copy(discovery)end function M.discoveryStatus()expireState(now())local candidate=discovery and discovery.candidate and{id=discovery.candidate.from,label=discovery.candidate.label,mode=discovery.candidate.mode,master=discovery.candidate.master,}or nil local session=discovery and{session=discovery.id,state=discovery.state,mode=discovery.mode,master=discovery.masterSide,expires=discovery.expires,retries=discovery.retries or 0,candidate=candidate and copy(candidate)or nil,}or nil local state=session and session.state or(lastDiscovery and lastDiscovery.state or\"inactive\")return{state=state,active=session~=nil,lastState=lastDiscovery and lastDiscovery.state or\"inactive\",lastMessage=lastDiscovery and lastDiscovery.message or nil,session=session,searching=session and session.state==\"searching\"or false,candidate=candidate,discovered=#M.seenPeers(),transport=M.transportStatus(),message=(discovery and discovery.error)or(lastDiscovery and lastDiscovery.message),}end function M.startDiscovery(mode,masterSide,phrase)if activeSession()then return false,\"cancel the active discovery session first\"end local normalized,phraseErr=M.normalizePhrase(phrase)if not normalized then return false,phraseErr end mode=mode==\"master\"and\"master\"or\"peer\"masterSide=masterSide==\"joiner\"and\"joiner\"or\"initiator\"local sessionId=password.newSalt(\"discovery:\"..id())discovery={id=sessionId,state=\"hosting\",phrase=normalized,tag=phraseTag(normalized,sessionId,id()),mode=mode,masterSide=masterSide,label=label(),initiator=id(),expires=now()+DISCOVERY_TTL,retries=0,lastBroadcast=0,}local sent,err=discoveryAnnouncement()if not sent then discovery=nil;setLast(\"cancelled\",err);return false,err end discovery.lastBroadcast=now()setLast(\"hosting\")return true,{state=\"hosting\",session=sessionId,expires=discovery.expires}end function M.joinDiscovery(phrase)if activeSession()then return false,\"cancel the active discovery session first\"end local normalized,phraseErr=M.normalizePhrase(phrase)if not normalized then return false,phraseErr end local candidate for _,item in pairs(discovered)do if item.expires>now()and password.constantTimeEqual(item.tag or\"\",phraseTag(normalized,item.session,item.from))then candidate=item;break end end discovery={state=\"searching\",phrase=normalized,expires=now()+DISCOVERY_TTL,retries=0,lastQuery=0}if candidate then discovery.state,discovery.candidate=\"candidate\",candidate return true,{state=\"candidate\",candidate={id=candidate.from,label=candidate.label,mode=candidate.mode,master=candidate.master}}end local sent,err=broadcast({type=\"discover_query\"})if not sent then discovery=nil;setLast(\"cancelled\",err);return false,err end discovery.lastQuery=now()setLast(\"searching\",\"waiting for matching discovery announcement\")return true,{state=\"searching\"}end function M.confirmDiscovery()if not discovery or discovery.state~=\"candidate\"or not discovery.candidate then return false,\"no discovery candidate is awaiting confirmation\"end local candidate,value=discovery.candidate,config()local existing=value.peers[tostring(candidate.from)]if existing and not existing.rekeyRequired then return false,\"computer is already a network member\"end if routes[tostring(candidate.from)]and not existing then return false,\"computer is already a routed network member\"end local nonce=password.newSalt(\"pair-join:\"..candidate.session)local proof=joinProof(discovery.phrase,candidate.session,candidate.from,id(),nonce)discovery.state,discovery.joinNonce,discovery.joinProof,discovery.lastSent,discovery.retries=\"confirming\",nonce,proof,now(),0 local sent,err=handshake(candidate.from,{type=\"pair_join\",session=candidate.session,nonce=nonce,proof=proof,label=label()})if not sent then discovery.error=err;return false,err end trace(\"discovery\",\"pair_confirmation_requested\",{peer=candidate.from})return true end function M.cancelDiscovery()if discovery and discovery.state==\"hosting\"then broadcast({type=\"discover_cancel\",session=discovery.id})end discovery=nil setLast(\"cancelled\",\"discovery cancelled\")trace(\"discovery\",\"session_cancelled\")return true end function M.normalizePhrase(value)if type(value)~=\"string\"then return nil,\"phrase is required\"end if value:find(\"[%z\\1-\\31\\127]\")then return nil,\"phrase contains control characters\"end value=value:gsub(\"^%s+\",\"\"):gsub(\"%s+$\",\"\")if#value<8 then return nil,\"phrase must be at least 8 characters\"end if#value>128 then return nil,\"phrase must be at most 128 characters\"end return value end function M.tick()local timestamp=now()expireState(timestamp)if discovery then if discovery.state==\"hosting\"and timestamp-(discovery.lastBroadcast or 0)>=math.min(30000,DISCOVERY_INTERVAL*(2^math.min(discovery.retries or 0,4)))then local sent,err=discoveryAnnouncement()discovery.lastBroadcast=timestamp discovery.retries=(discovery.retries or 0)+1 if not sent then discovery.error=err end elseif discovery.state==\"searching\"and timestamp-(discovery.lastQuery or 0)>=DISCOVERY_INTERVAL then local sent,err=broadcast({type=\"discover_query\"})discovery.lastQuery,discovery.retries=timestamp,(discovery.retries or 0)+1 if not sent then discovery.error=err end elseif discovery.state==\"confirming\"and timestamp-(discovery.lastSent or 0)>=PAIR_RETRY then local candidate=discovery.candidate local sent,err=handshake(candidate.from,{type=\"pair_join\",session=candidate.session,nonce=discovery.joinNonce,proof=discovery.joinProof,label=label()})discovery.lastSent,discovery.retries=timestamp,(discovery.retries or 0)+1 if not sent then discovery.error=err end end end for sessionId,item in pairs(discovered)do if item.expires<=timestamp then discovered[sessionId]=nil end end for sessionId,record in pairs(confirmations)do if record.expires<=timestamp then confirmations[sessionId]=nil elseif timestamp-(record.lastSent or 0)>=PAIR_RETRY then queueConfirmation(record.peer,record)end end for peerId,bucket in pairs(routes)do local kept={}for _,candidate in ipairs(routeBucket(peerId).candidates)do if candidate.expires>timestamp then kept[#kept+1]=candidate end end bucket.candidates=kept if#kept==0 and not activeLink(config(),peerId)then routes[peerId],routesDirty=nil,true end end for peerId,edge in pairs(autoEdges)do if edge.expires<=timestamp then autoEdges[peerId],routesDirty=nil,true;trace(\"routing\",\"mesh_edge_expired\",{peer=peerId})end end for key,seenAt in pairs(meshSeen)do if seenAt+ROUTE_TTL<=timestamp then meshSeen[key]=nil end end for peerId,offer in pairs(meshOffers)do if offer.expires<=timestamp then meshOffers[peerId]=nil end end for requestId,seenAt in pairs(seenPackets)do if seenAt+ROUTE_TTL<=timestamp then seenPackets[requestId]=nil end end for requestId,route in pairs(reverseRoutes)do if route.expires<=timestamp then reverseRoutes[requestId]=nil end end boundMap(meshSeen,MESH_STATE_LIMIT,timestamp)boundMap(meshOffers,MESH_STATE_LIMIT,timestamp)pruneRouteBuckets(timestamp)ensureMeshEnrollment(timestamp)meshBeacon(timestamp)probeLinks(timestamp)advertiseRoutes(false)end function M.peers()local out,value,seen={},config(),read(SEEN_PATH,{})for peerId,peer in pairs(value.peers)do out[#out+1]={id=peerId,label=peer.label or(seen[peerId]and seen[peerId].label),role=peer.role or\"operator\",mode=peer.mode or\"peer\",master=peer.master==true,masterId=peer.masterId,direct=true,active=activePeer(value,peerId)~=nil,rekeyRequired=peer.rekeyRequired==true or peer.protocol~=2,health=linkState(peerId),auto=false,}end for peerId,edge in pairs(autoEdges)do if not value.peers[peerId]and edge.expires>now()then out[#out+1]={id=peerId,label=edge.label or(seen[peerId]and seen[peerId].label),role=\"mesh\",mode=\"peer\",direct=true,auto=true,active=true,health=linkState(peerId)}end end for peerId in pairs(routes)do if not value.peers[peerId]and not autoEdges[peerId]then local route=primaryCandidate(peerId,value,false)if route then out[#out+1]={id=peerId,label=route.label or(seen[peerId]and seen[peerId].label),role=\"routed\",mode=route.mode or\"peer\",masterId=route.masterId,routed=true,hops=route.hops,next=route.next,health=linkState(route.next),alternates=#routeBucket(peerId).candidates-1}end end end table.sort(out,function(a,b)return tonumber(a.id)<tonumber(b.id)end)return out end function M.directPeers()local out={}for _,peer in ipairs(M.peers())do if peer.direct and peer.active then out[#out+1]=peer end end return out end function M.seenPeers()local out={}for peerId,item in pairs(read(SEEN_PATH,{}))do if not item.seen or item.seen+DISCOVERY_TTL*2>now()then out[#out+1]={id=peerId,label=item.label,seen=item.seen,session=item.session}end end table.sort(out,function(a,b)return tonumber(a.id)<tonumber(b.id)end)return out end function M.routeStatus(target)local value,out=config(),{}local function row(peerId)local selected=primaryCandidate(peerId,value,false)if not selected then return end local health=healthFor(selected.next)local alternates={}for _,candidate in ipairs(validCandidates(peerId,value))do if tostring(candidate.next)~=tostring(selected.next)then local state=healthFor(candidate.next)alternates[#alternates+1]={next=candidate.next,hops=candidate.hops,kind=candidate.kind or\"route\",health=state.state,loss=state.loss or 0,rtt=state.rtt,distance=state.lastDistance}end end table.sort(alternates,compareCandidates)while#alternates>ROUTE_CANDIDATE_LIMIT-1 do table.remove(alternates)end out[#out+1]={id=tostring(peerId),label=selected.label,next=selected.next,hops=selected.hops,kind=selected.kind or\"route\",health=health.state,loss=health.loss or 0,rtt=health.rtt,distance=health.lastDistance,selectedAt=routeBucket(peerId).changedAt,reason=routeBucket(peerId).reason,alternates=alternates,}end if target then row(tostring(target))else local ids,seen={},{}for peerId in pairs(value.peers)do ids[#ids+1],seen[peerId]=peerId,true end for peerId in pairs(autoEdges)do if not seen[peerId]then ids[#ids+1],seen[peerId]=peerId,true end end for peerId in pairs(routes)do if not seen[peerId]then ids[#ids+1],seen[peerId]=peerId,true end end table.sort(ids,function(a,b)return tonumber(a)<tonumber(b)end)for _,peerId in ipairs(ids)do row(peerId)end end return out end function M.statusSnapshot()return{protocol=2,transport=M.transportStatus(),discovery=M.discoveryStatus(),peers=M.peers(),routes=M.routeStatus(),trace=M.transportTrace(),controller=M.controller()}end function M.controller()return controller()end function M.isDirectController(from,packet)return tostring(from)==tostring(controller())and type(packet)==\"table\"and tostring(packet.origin or\"\")==tostring(from)end function M.setController(value)assert(tonumber(value),\"controller must be a computer ID\")local saved=config();saved.controller=tostring(value);save(saved);routesDirty=true audit.log(\"local\",\"jobs.controller\",{controller=saved.controller})return saved.controller end function M.request(target,typeName,body,options)if type(typeName)~=\"string\"or#typeName==0 or#typeName>64 then return false,\"invalid request type\"end local destination=tostring(target)local value=config()if not primaryCandidate(destination,value,options and options.safe==true)then return false,\"peer is unreachable, degraded, or requires rekey\"end return send(destination,{type=typeName,body=body or{},origin=id(),routeSafe=options and options.safe==true,balanceSafe=options and options.safe==true,confidential=options and options.confidential==true,request_id=newRequestId()})end function M.reply(request,typeName,body,options)if type(request)~=\"table\"or type(typeName)~=\"string\"or#typeName==0 or#typeName>64 then return false,\"invalid reply\"end local requestId,destination=request.request_id,tostring(request.origin or\"\")local reverse=type(requestId)==\"string\"and reverseRoutes[requestId]or nil if destination==\"\"or not reverse or reverse.expires<=now()or reverse.origin~=destination then return false,\"reverse reply route is unavailable\"end return send(destination,{type=typeName,body=body or{},origin=id(),destination=destination,reply_to=requestId,routeSafe=options and options.safe==true,confidential=options and options.confidential==true,request_id=newRequestId(),},reverse.previous)end function M.offer()return false,\"legacy pairing is disabled; use phrase discovery\"end function M.accept()return false,\"legacy pairing is disabled; use phrase discovery\"end function M.discover()return broadcast({type=\"discover_query\"})end rejectPacket=function(reason,packet,channel,distance)transport.rejected=transport.rejected+1 trace(\"transport\",\"packet_rejected\",{reason=reason,type=type(packet)==\"table\"and packet.type or nil,channel=channel,distance=distance,from=type(packet)==\"table\"and packet.from or nil})end local function handleDiscovery(packet,channel,distance)local from=tostring(packet.from)if packet.type==\"discover_cancel\"and isBroadcast(packet.to)then discovered[tostring(packet.session)]=nil trace(\"discovery\",\"cancel_received\",{from=from,distance=distance})return end if packet.type==\"discover_query\"and isBroadcast(packet.to)and discovery and discovery.state==\"hosting\"then discoveryAnnouncement()trace(\"discovery\",\"query_answered\",{from=from,distance=distance})return end if packet.type==\"discover\"and isBroadcast(packet.to)and from~=id()then if type(packet.session)~=\"string\"or#packet.session<16 or type(packet.tag)~=\"string\"or#packet.tag~=64 then return rejectPacket(\"malformed_discovery\",packet,channel,distance)end local expires=tonumber(packet.expires)or(now()+DISCOVERY_TTL)discovered[packet.session]={session=packet.session,from=from,tag=packet.tag,label=tostring(packet.label or(\"Computer \"..from)):sub(1,128),mode=packet.mode==\"master\"and\"master\"or\"peer\",master=packet.master==\"joiner\"and\"joiner\"or\"initiator\",expires=math.min(expires,now()+DISCOVERY_TTL)}remember(from,packet.label,{session=packet.session})trace(\"discovery\",\"announcement_received\",{from=from,label=packet.label,distance=distance})if discovery and discovery.state==\"searching\"and password.constantTimeEqual(packet.tag,phraseTag(discovery.phrase,packet.session,from))then local value,existing=config(),config().peers[from]if existing and not existing.rekeyRequired then markRejected(\"computer is already a direct peer\")elseif routes[from]and not existing then markRejected(\"computer is already a routed network member\")else discovery.state,discovery.candidate,discovery.error=\"candidate\",discovered[packet.session],nil setLast(\"candidate\",\"matching computer found; confirm pairing\")trace(\"discovery\",\"candidate_found\",{from=from,label=packet.label,distance=distance})end end return end end local function handlePairJoin(packet,distance)if not discovery or discovery.state~=\"hosting\"or packet.session~=discovery.id then return end local from=tostring(packet.from)local value,existing=config(),config().peers[from]if(existing and not existing.rekeyRequired)or(routes[from]and not existing)then handshake(from,{type=\"pair_reject\",session=packet.session,reason=\"already_network_member\"})trace(\"discovery\",\"pair_rejected\",{from=from,reason=\"already_network_member\"})return end if type(packet.nonce)~=\"string\"or#packet.nonce<16 or#packet.nonce>128 or type(packet.proof)~=\"string\"or#packet.proof~=64 then return rejectPacket(\"malformed_pair_join\",packet,nil,distance)end local expected=joinProof(discovery.phrase,discovery.id,id(),from,packet.nonce)if not password.constantTimeEqual(expected,packet.proof)then discovery.failures=(discovery.failures or 0)+1 if discovery.failures>=5 then markRejected(\"too many invalid pairing proofs\")end handshake(from,{type=\"pair_reject\",session=packet.session,reason=\"phrase_mismatch\"})trace(\"discovery\",\"pair_rejected\",{from=from,reason=\"phrase_mismatch\"})return end local masterId=discovery.mode==\"master\"and(discovery.masterSide==\"initiator\"and id()or from)or\"\"local key,keyErr=linkKey(discovery.phrase,discovery.id,id(),from,packet.nonce)if not key then return markRejected(keyErr)end persistPair(from,key,{mode=discovery.mode,label=packet.label},masterId)local record={id=discovery.id,peer=from,joinNonce=packet.nonce,mode=discovery.mode,masterId=masterId,label=label(),expires=now()+DISCOVERY_TTL}record.confirmProof=confirmProof(discovery.phrase,record.id,id(),from,record.joinNonce,record.mode,record.masterId)discovery=nil setLast(\"paired\",\"awaiting pairing acknowledgement\")trace(\"discovery\",\"pair_join_verified\",{from=from,distance=distance})return queueConfirmation(from,record)end local function handlePairConfirm(packet,distance)if not discovery or discovery.state~=\"confirming\"or not discovery.candidate or packet.session~=discovery.candidate.session then return end local from=tostring(packet.from)if from~=tostring(discovery.candidate.from)or packet.nonce~=discovery.joinNonce then return rejectPacket(\"unexpected_pair_confirmation\",packet,nil,distance)end local expected=confirmProof(discovery.phrase,packet.session,from,id(),discovery.joinNonce,packet.mode==\"master\"and\"master\"or\"peer\",packet.master or\"\")if not password.constantTimeEqual(expected,packet.proof or\"\")then return markRejected(\"pair confirmation proof did not match\")end local key,err=linkKey(discovery.phrase,packet.session,from,id(),discovery.joinNonce)if not key then return markRejected(err)end persistPair(from,key,{mode=packet.mode,label=packet.label},packet.master)local sessionId=packet.session discovery=nil setLast(\"paired\",\"paired with \"..tostring(packet.label or from))handshake(from,{type=\"pair_ack\",session=sessionId,proof=password.hmacSha256(key,\"ceetos/pair/ack/v2|\"..sessionId)})trace(\"discovery\",\"pair_confirmed\",{from=from,distance=distance})return true end local function handlePairAck(packet)local record=confirmations[packet.session]if not record or tostring(record.peer)~=tostring(packet.from)then return end local value,peer=config(),activePeer(config(),record.peer)if not peer then return end local expected=password.hmacSha256(peer.key,\"ceetos/pair/ack/v2|\"..packet.session)if password.constantTimeEqual(expected,packet.proof or\"\")then confirmations[packet.session]=nil setLast(\"paired\",\"paired with \"..tostring(record.peer))routesDirty=true trace(\"discovery\",\"pair_acknowledged\",{from=packet.from})end end local function handleRouteAdvertisement(from,packet)local body=packet.body if type(body)~=\"table\"or type(body.sequence)~=\"number\"or type(body.members)~=\"table\"or(body.withdrawals~=nil and type(body.withdrawals)~=\"table\")then return rejectPacket(\"malformed_route_advertisement\",packet)end local key=tostring(from)M._routeSequences=M._routeSequences or{}if(M._routeSequences[key]or-1)>=body.sequence then return end M._routeSequences[key]=body.sequence local value,changed,count=config(),false,0 if body.controller and not value.controller then value.controller,changed=tostring(body.controller),true end local announced={}for advertisedId,meta in pairs(body.members)do count=count+1 if count>MAX_PACKET_TABLE then return rejectPacket(\"route_advertisement_too_large\",packet)end local peerId=tostring(advertisedId)announced[peerId]=true if peerId~=id()and peerId~=key and type(meta)==\"table\"then local hops=math.floor(tonumber(meta.hops)or 0)+1 if hops>1 and hops<=PACKET_TTL and type(meta.label)~=\"table\"and type(meta.mode)~=\"table\"then local bucket,found=routeBucket(peerId),nil for _,candidate in ipairs(bucket.candidates)do if tostring(candidate.next)==key then found=candidate;break end end if found then local metadataChanged=found.hops~=hops or found.label~=meta.label or found.mode~=meta.mode or tostring(found.masterId or\"\")~=tostring(meta.masterId or\"\")or found.kind~=(meta.kind or\"route\")found.hops,found.expires=hops,now()+ROUTE_TTL found.label,found.mode,found.masterId,found.kind=meta.label,meta.mode,meta.masterId,meta.kind or\"route\"if metadataChanged then changed=true end else bucket.candidates[#bucket.candidates+1]={next=key,hops=hops,expires=now()+ROUTE_TTL,label=type(meta.label)==\"string\"and meta.label:sub(1,128)or nil,mode=meta.mode==\"master\"and\"master\"or\"peer\",masterId=meta.masterId and tostring(meta.masterId)or nil,kind=meta.kind==\"mesh\"and\"mesh\"or\"route\"}table.sort(bucket.candidates,compareCandidates)while#bucket.candidates>ROUTE_CANDIDATE_LIMIT do table.remove(bucket.candidates)end changed=true end end end end local withdrawals=body.withdrawals or{}for _,withdrawnId in pairs(withdrawals)do count=count+1 if count>MAX_PACKET_TABLE then return rejectPacket(\"route_advertisement_too_large\",packet)end local bucket=routes[tostring(withdrawnId)]if bucket and type(bucket.candidates)==\"table\"then local kept={}for _,candidate in ipairs(bucket.candidates)do if tostring(candidate.next)~=key then kept[#kept+1]=candidate else changed=true end end bucket.candidates=kept end end if body.full==true then for peerId,bucket in pairs(routes)do if type(bucket.candidates)==\"table\"and not announced[tostring(peerId)]then local kept={}for _,candidate in ipairs(bucket.candidates)do if tostring(candidate.next)~=key then kept[#kept+1]=candidate else changed=true end end bucket.candidates=kept end end end if changed then if value.controller then save(value)end;routesDirty=true;trace(\"routing\",\"route_table_updated\",{from=from})end end local function handleProbe(from,packet)local token=type(packet.body)==\"table\"and packet.body.token if type(token)~=\"string\"or#token<16 or#token>128 then return rejectPacket(\"malformed link probe\",packet)end touchLink(from,nil,\"probe\")return send(from,{type=\"mesh_probe_ack\",body={token=token},request_id=newRequestId()},from)end local function handleProbeAck(from,packet)local health,token=healthFor(from),type(packet.body)==\"table\"and packet.body.token if not health.awaiting or token~=health.probeToken then return rejectPacket(\"unexpected link probe acknowledgement\",packet)end local sample=math.max(0,now()-(health.lastProbe or now()))health.rtt=health.rtt and math.floor((health.rtt*3+sample)/4)or sample health.awaiting,health.misses,health.probeToken=false,0,nil touchLink(from,health.lastDistance,\"probe_ack\")end function M.handle(event)if event[1]~=\"modem_message\"then return end local channel,packet,distance=event[3],event[5],event[6]local expected=false for _,item in ipairs(transport.channels or{})do if channel==item then expected=true;break end end if not expected then return rejectPacket(\"unexpected_channel\",packet,channel,distance)end transport.lastReceive,transport.lastDistance=now(),distance if type(packet)~=\"table\"then return rejectPacket(\"packet_not_table\",packet,channel,distance)end if packet.protocol==PUBLIC_UPDATE_PROTOCOL then if channel~=DISCOVERY_CHANNEL or not PUBLIC_UPDATE_TYPES[packet.type]or type(packet.from)~=\"string\"and type(packet.from)~=\"number\"or not(isForUs(packet.to)or isBroadcast(packet.to))then return rejectPacket(\"invalid_public_update_packet\",packet,channel,distance)end local encoded,encodeErr=canonical(packet.body or{})if not encoded then return rejectPacket(\"invalid_public_update_body:\"..tostring(encodeErr),packet,channel,distance)end trace(\"transport\",\"public_update_received\",{type=packet.type,from=packet.from,distance=distance,bytes=#encoded})if M.publicHandler then return M.publicHandler(packet,distance)end return end local discoveryPacket=packet.type==\"discover\"or packet.type==\"discover_query\"or packet.type==\"discover_cancel\"local handshakePacket=packet.type==\"pair_join\"or packet.type==\"pair_confirm\"or packet.type==\"pair_ack\"or packet.type==\"pair_reject\"local meshPacketType=packet.type==\"mesh_beacon\"or packet.type==\"mesh_edge_offer\"or packet.type==\"mesh_edge_accept\"if discoveryPacket then if packet.protocol~=PROTOCOL or channel~=DISCOVERY_CHANNEL then return rejectPacket(\"invalid_discovery_transport\",packet,channel,distance)end return handleDiscovery(packet,channel,distance)end if handshakePacket then if not isForUs(packet.to)then return end if packet.protocol~=PROTOCOL or channel~=(config().channel or CHANNEL)then return rejectPacket(\"invalid_handshake_transport\",packet,channel,distance)end if packet.type==\"pair_join\"then return handlePairJoin(packet,distance)elseif packet.type==\"pair_confirm\"then return handlePairConfirm(packet,distance)elseif packet.type==\"pair_ack\"then return handlePairAck(packet)elseif packet.type==\"pair_reject\"and discovery then markRejected(\"pairing rejected: \"..tostring(packet.reason or\"remote peer rejected it\"));return end return end if meshPacketType then if packet.protocol~=PROTOCOL or channel~=(config().channel or CHANNEL)then return rejectPacket(\"invalid mesh transport\",packet,channel,distance)end if packet.type~=\"mesh_beacon\"and not isForUs(packet.to)then return end return handleMeshPacket(packet,distance)end if not isForUs(packet.to)then return end local value=config()local valid,peerOrReason=verifyPacket(packet,value)if not valid then return rejectPacket(peerOrReason,packet,channel,distance)end local peer,from=peerOrReason,tostring(packet.from)if packet.confidential==true then local opened,openErr=openBody(peer,packet)if not opened then return rejectPacket(openErr or\"could not open confidential packet\",packet,channel,distance)end packet.body=opened end touchLink(from,distance,\"authenticated_packet\")if autoEdges[from]then autoEdges[from].expires=now()+ROUTE_TTL end trace(\"routing\",\"packet_received\",{type=packet.type,from=from,destination=packet.destination,distance=distance})if packet.type==\"mesh_enroll\"then return acceptEnrollment(from,peer,packet)end if packet.type==\"mesh_probe\"then return handleProbe(from,packet)end if packet.type==\"mesh_probe_ack\"then return handleProbeAck(from,packet)end if packet.type==\"route_advertise\"then return handleRouteAdvertisement(from,packet)end if packet.reply_to and packet.destination and tostring(packet.destination)~=id()then local reverse=reverseRoutes[packet.reply_to]if not reverse or reverse.expires<=now()or reverse.origin~=tostring(packet.destination)then return rejectPacket(\"reverse reply route unavailable\",packet,channel,distance)end local requestId=tostring(packet.request_id or\"\")if seenPackets[requestId]then return trace(\"routing\",\"forward_duplicate\",{from=from,type=packet.type})end seenPackets[requestId]=now()if(tonumber(packet.ttl)or 0)<=1 then return rejectPacket(\"TTL exhausted\",packet,channel,distance)end packet.ttl=packet.ttl-1 trace(\"routing\",\"reverse_reply_forwarded\",{from=from,to=reverse.previous,type=packet.type})return send(packet.destination,packet,reverse.previous)end if packet.destination and tostring(packet.destination)~=id()then local requestId=tostring(packet.request_id or\"\")if requestId==\"\"or seenPackets[requestId]then return trace(\"routing\",\"forward_duplicate\",{from=from,type=packet.type})end seenPackets[requestId]=now()local remembered,rememberErr=rememberReverse(packet,from)if not remembered then return rejectPacket(rememberErr or\"could not record reverse route\",packet,channel,distance)end if(tonumber(packet.ttl)or 0)<=1 then return rejectPacket(\"TTL exhausted\",packet,channel,distance)end packet.ttl=packet.ttl-1 return send(packet.destination,packet)end local requestId=tostring(packet.request_id or\"\")if requestId==\"\"then return rejectPacket(\"missing request ID\",packet,channel,distance)end if seenPackets[requestId]then return trace(\"routing\",\"destination_duplicate\",{from=from,type=packet.type})end seenPackets[requestId]=now()local remembered,rememberErr=rememberReverse(packet,from)if not remembered then return rejectPacket(rememberErr or\"could not record reverse route\",packet,channel,distance)end packet.authenticatedHop,packet.originVerified=from,false if M.handler then return M.handler(from,peer,packet)end end return M",
  ["ceetos/lib/network_ipc.lua"] = "local store=require(\"ceetos.lib.store\")local M={}local ROOT=\"/ceetos/data/network-ipc\"local COMMANDS=ROOT..\"/commands\"local RESULTS=ROOT..\"/results\"local function ensure(path)if not fs.exists(path)then fs.makeDir(path)end end local function safeId(value)return type(value)==\"string\"and value:match(\"^[%w_%-%.]+$\")and#value<=160 end local function path(directory,requestId)return fs.combine(directory,requestId..\".lua\")end function M.submit(request)if type(request)~=\"table\"or not safeId(request.id)then return false,\"invalid IPC request ID\"end ensure(ROOT);ensure(COMMANDS);ensure(RESULTS)local target=path(COMMANDS,request.id)if fs.exists(target)then return false,\"IPC request already exists\"end return store.write(target,request)end function M.take()ensure(ROOT);ensure(COMMANDS);ensure(RESULTS)local names=fs.list(COMMANDS)table.sort(names)for _,name in ipairs(names)do local requestId=name:match(\"^([%w_%-%.]+)%.lua$\")if requestId and safeId(requestId)then local target=fs.combine(COMMANDS,name)store.invalidate(target)local request=store.readFresh(target,nil)fs.delete(target)if type(request)==\"table\"and request.id==requestId then return request end end end end function M.reply(requestId,value)if not safeId(requestId)or type(value)~=\"table\"then return false,\"invalid IPC result\"end ensure(ROOT);ensure(COMMANDS);ensure(RESULTS)return store.write(path(RESULTS,requestId),value)end function M.poll(requestId,consume)if not safeId(requestId)then return nil,\"invalid IPC request ID\"end local target=path(RESULTS,requestId)if not fs.exists(target)then return nil end local handle=fs.open(target,\"r\")if not handle then return nil end local raw=handle.readAll()handle.close()local ok,result=pcall(textutils.unserialise,raw)if not ok or type(result)~=\"table\"or result.id~=requestId then return nil,\"invalid IPC result\"end if consume then fs.delete(target)end return result end function M.prune(limit)ensure(ROOT);ensure(COMMANDS);ensure(RESULTS)limit=math.max(1,math.floor(tonumber(limit)or 32))local names={}for _,name in ipairs(fs.list(RESULTS))do if name:match(\"^[%w_%-%.]+%.lua$\")then names[#names+1]=name end end table.sort(names)while#names>limit do fs.delete(fs.combine(RESULTS,table.remove(names,1)))end end function M.status()ensure(ROOT);ensure(COMMANDS);ensure(RESULTS)local function count(directory)local total=0 for _,name in ipairs(fs.list(directory))do if name:match(\"^[%w_%-%.]+%.lua$\")then total=total+1 end end return total end return{commands=count(COMMANDS),results=count(RESULTS)}end return M",
  ["ceetos/lib/auth_authority.lua"] = "local store=require(\"ceetos.lib.store\")local password=require(\"ceetos.lib.password\")local auth=require(\"ceetos.lib.auth\")local M={}local PATH=\"/ceetos/data/auth-authority-state.lua\"local CHALLENGE_TTL,PRESENCE_INTERVAL,SERVER_TTL,SEED_WINDOW=30000,3000,15000,5000 local state,challenges,sessions,lastPresence,seededAt,lastSeed=nil,{},{},0,0,0 local function now()return os.epoch(\"utc\")end local function selfId()return tostring(os.getComputerID())end local function defaults()return{schema=1,term=1,sequence=0,active=false,primary=nil,servers={},members={},seeded=false,revision=0}end local function load()if state then return state end state=store.read(PATH,defaults())for key,value in pairs(defaults())do if state[key]==nil then state[key]=value end end state.servers=type(state.servers)==\"table\"and state.servers or{}state.members=type(state.members)==\"table\"and state.members or{}return state end local function save()store.write(PATH,load())end local function directory()return auth.authorityDirectory()end local function primary()local ids={selfId()}for id,entry in pairs(load().servers)do if type(entry)==\"table\"and(tonumber(entry.seen)or 0)+SERVER_TTL>now()then ids[#ids+1]=tostring(id)end end table.sort(ids,function(a,b)return tonumber(a)<tonumber(b)end)return ids[1],ids end local function knownVoters()local ids,value={selfId()},load()for id in pairs(value.members)do if tostring(id)~=selfId()then ids[#ids+1]=tostring(id)end end table.sort(ids,function(a,b)return tonumber(a)<tonumber(b)end)return ids end local function onlineVoters()local count=1 for id,entry in pairs(load().servers)do if tostring(id)~=selfId()and type(entry)==\"table\"and(tonumber(entry.seen)or 0)+SERVER_TTL>now()then count=count+1 end end return count end local function announce(net)local value,chosen=load(),primary()value.primary=chosen for _,peer in ipairs(net.peers())do net.request(peer.id,\"auth_presence\",{id=selfId(),term=value.term,sequence=value.sequence,primary=chosen,revision=value.revision},{safe=true,confidential=true})if value.active then net.request(peer.id,\"auth_authority\",{id=chosen,term=value.term,revision=value.revision,directory=directory()},{safe=true,confidential=true})end end end local function challengeProof(record,server,node,request,challenge,expires)return password.hmacSha256(record.verifier,table.concat({\"ceetos/auth/login/v1\",tostring(server),tostring(node),tostring(request),tostring(challenge),tostring(expires)},\"|\"))end local function sessionFor(name,account)local issued=now()return{name=name,role=account.role,token=password.newSalt(\"central-session:\"..name),issued=issued,expires=issued+auth.SESSION_TTL_MS,accountRevision=tonumber(account.revision)or 0,authority=selfId()}end local function reply(net,packet,typeName,body)return net.reply(packet,typeName,body,{safe=true,confidential=true})end function M.start()local value=load();value.servers[selfId()]={seen=now(),term=value.term};value.members[selfId()]=true;value.primary=primary();save()end function M.status()local value,chosen,voters=load(),primary(),knownVoters()return{active=value.active==true,primary=chosen,localPrimary=chosen==selfId(),term=value.term,sequence=value.sequence,voters=#voters,onlineVoters=onlineVoters(),quorum=math.floor(#voters/2)+1,revision=value.revision}end function M.tick(net)local value,timestamp=load(),now()for id,entry in pairs(value.servers)do if(tonumber(entry.seen)or 0)+SERVER_TTL<=timestamp then value.servers[id]=nil end end local chosen,voters=primary(),knownVoters()if value.primary~=chosen then value.primary,value.term=chosen,math.max(1,(tonumber(value.term)or 0)+1)end if not value.seeded then if timestamp-lastSeed>=1000 then for _,peer in ipairs(net.peers())do net.request(peer.id,\"auth_seed_request\",{authority=selfId()},{safe=true,confidential=true})end lastSeed=timestamp end if seededAt==0 then seededAt=timestamp end if timestamp-seededAt>=SEED_WINDOW then value.seeded,value.active,value.revision=true,chosen==selfId(),(tonumber(value.revision)or 0)+1 end else local quorum=math.floor(#voters/2)+1 value.active=chosen==selfId()and onlineVoters()>=quorum end if timestamp-lastPresence>=PRESENCE_INTERVAL then announce(net);lastPresence=timestamp end save()end function M.handle(from,peer,packet,net)local body=type(packet.body)==\"table\"and packet.body or{}local value=load()if packet.type:sub(1,5)==\"auth_\"and packet.confidential~=true then return false,\"authority packets must be confidential\"end if packet.type==\"auth_presence\"then if type(body.id)==\"string\"and body.id==tostring(packet.origin or from)then value.servers[body.id]={seen=now(),term=tonumber(body.term)or 0,revision=tonumber(body.revision)or 0};value.members[body.id]=true;save()end return true elseif packet.type==\"auth_seed_request\"then return reply(net,packet,\"auth_seed\",{snapshot=auth.authoritySnapshot(),request_id=body.request_id})elseif packet.type==\"auth_seed\"or packet.type==\"auth_replica\"then if type(body.snapshot)==\"table\"then auth.merge(body.snapshot,true);value.revision=math.max(tonumber(value.revision)or 0,tonumber(body.revision)or 0)+(packet.type==\"auth_seed\"and 1 or 0);save()end return true elseif packet.type==\"auth_authority\"then if type(body.id)==\"string\"and type(body.directory)==\"table\"then return auth.acceptAuthority(body)end return false,\"invalid authority advertisement\"end if not value.active or value.primary~=selfId()then if body.request_id then return reply(net,packet,packet.type..\"_result\",{request_id=body.request_id,ok=false,error=\"Auth Server is standby or lacks quorum\"})end return false,\"authority is unavailable\"end if packet.type==\"auth_login_begin\"then if body.node~=tostring(packet.origin or from)then return reply(net,packet,\"auth_login_begin_result\",{request_id=body.request_id,ok=false,error=\"node binding failed\"})end local account=auth.authorityAccount(body.username)if not account or account.disabled or type(body.node)~=\"string\"or type(body.request_id)~=\"string\"then return reply(net,packet,\"auth_login_begin_result\",{request_id=body.request_id,ok=false,error=\"invalid credentials\"})end local expires,nonce=now()+CHALLENGE_TTL,password.newSalt(\"auth-challenge:\"..body.username)challenges[body.request_id]={name=body.username,node=body.node,nonce=nonce,expires=expires}return reply(net,packet,\"auth_login_begin_result\",{request_id=body.request_id,ok=true,result={authority=selfId(),salt=account.salt,workFactor=account.workFactor,challenge=nonce,expires=expires}})elseif packet.type==\"auth_login_proof\"then if body.node~=tostring(packet.origin or from)then return reply(net,packet,\"auth_login_proof_result\",{request_id=body.request_id,ok=false,error=\"node binding failed\"})end local challenge,account=challenges[body.request_id],auth.authorityAccount(body.username)challenges[body.request_id]=nil if not challenge or challenge.expires<=now()or challenge.name~=body.username or challenge.node~=body.node or not account or account.disabled then return reply(net,packet,\"auth_login_proof_result\",{request_id=body.request_id,ok=false,error=\"expired or invalid login challenge\"})end local expected=challengeProof(account,selfId(),body.node,body.request_id,challenge.nonce,challenge.expires)if not password.constantTimeEqual(expected or\"\",body.proof or\"\")then return reply(net,packet,\"auth_login_proof_result\",{request_id=body.request_id,ok=false,error=\"invalid credentials\"})end local session=sessionFor(body.username,account)M.rememberSession(session,body.node)return reply(net,packet,\"auth_login_proof_result\",{request_id=body.request_id,ok=true,result={session=session,directory=directory(),revision=value.revision}})elseif packet.type==\"auth_mutate\"then local allowed=false local session=sessions[body.session]if session and session.expires>now()and session.node==body.node then allowed=session.role==\"admin\"or(session.role==\"operator\"and body.action~=\"remove\"and body.fields and body.fields.role~=\"admin\")end if not allowed then return reply(net,packet,\"auth_mutate_result\",{request_id=body.request_id,ok=false,error=\"authority session is not allowed\"})end if type(body.fields)==\"table\"and body.fields.password~=nil then return reply(net,packet,\"auth_mutate_result\",{request_id=body.request_id,ok=false,error=\"plaintext passwords are not accepted\"})end local ok,result=pcall(auth.authorityMutate,body.action,body.fields)if ok then value.revision,value.sequence=(tonumber(value.revision)or 0)+1,(tonumber(value.sequence)or 0)+1 save();announce(net)for _,target in ipairs(net.peers())do net.request(target.id,\"auth_replica\",{snapshot=auth.authoritySnapshot(),revision=value.revision},{safe=true,confidential=true})end return reply(net,packet,\"auth_mutate_result\",{request_id=body.request_id,ok=true,result={directory=result,revision=value.revision}})end return reply(net,packet,\"auth_mutate_result\",{request_id=body.request_id,ok=false,error=tostring(result)})end return false,\"unknown authority packet\"end function M.rememberSession(session,node)sessions[session.token]={node=node,role=session.role,expires=session.expires,name=session.name,revision=session.accountRevision}local total=0 for token,item in pairs(sessions)do total=total+1;if item.expires<=now()or total>128 then sessions[token]=nil end end end return M",
  ["ceetos/lib/auth_client.lua"] = "local store=require(\"ceetos.lib.store\")local password=require(\"ceetos.lib.password\")local networkIpc=require(\"ceetos.lib.network_ipc\")local M={}local REPLIES=\"/ceetos/data/auth-replies.lua\"local sequence=0 local function now()return os.epoch(\"utc\")end local function authority()store.invalidate(\"/ceetos/data/auth-authority.lua\")local value=store.read(\"/ceetos/data/auth-authority.lua\",nil)return type(value)==\"table\"and value.active==true and value or nil end local function requestId(kind)sequence=sequence+1 return table.concat({\"auth\",kind,tostring(os.getComputerID()),tostring(now()),tostring(sequence)},\"-\")end local function take(id)store.invalidate(REPLIES)local replies=store.read(REPLIES,{})local row=replies[id]if row then replies[id]=nil;store.write(REPLIES,replies)end return row end function M.receive(body)if type(body)~=\"table\"or type(body.request_id)~=\"string\"or#body.request_id>160 then return false,\"invalid authority reply\"end local replies=store.read(REPLIES,{})replies[body.request_id]={ok=body.ok==true,result=body.result,error=body.error,at=now()}local rows={}for id,row in pairs(replies)do rows[#rows+1]={id=id,at=tonumber(row.at)or 0}end table.sort(rows,function(a,b)return a.at>b.at end)while#rows>32 do replies[table.remove(rows).id]=nil end return store.write(REPLIES,replies)end local function send(typeName,body,timeout)local server=authority()if not server then return nil,\"no Auth Server is reachable\"end local id=body.request_id or requestId(typeName)body.request_id=id local command={id=\"auth-\"..id,action=\"auth_send\",target=server.id,type=typeName,body=body}local ok,err=networkIpc.submit(command)if not ok then return nil,err or\"could not reach CeetOS network service\"end local deadline=now()+(timeout or 6000)while now()<deadline do local ack=networkIpc.poll(command.id,true)if ack and ack.ok~=true then return nil,ack.error or\"Auth Server request was not sent\"end local reply=take(id)if reply then return reply.ok and reply.result or nil,reply.error or\"Auth Server rejected request\"end sleep(0.05)end return nil,\"Auth Server did not respond\"end local function loginProof(verifier,server,request,challenge,expires)return password.hmacSha256(verifier,table.concat({\"ceetos/auth/login/v1\",tostring(server),tostring(os.getComputerID()),tostring(request),tostring(challenge),tostring(expires)},\"|\"))end function M.login(name,plainPassword)if type(name)~=\"string\"or type(plainPassword)~=\"string\"then return false,\"invalid credentials\"end local cloud=require(\"ceetos.lib.cloud_sync\")if cloud.authOnline()then return cloud.login(name,plainPassword)end local request=requestId(\"begin\")local begin,beginErr=send(\"auth_login_begin\",{request_id=request,username=name,node=tostring(os.getComputerID())})if not begin then return false,beginErr end local verifier,deriveErr=password.derive(plainPassword,begin.salt,begin.workFactor)if not verifier then return false,deriveErr end local proof=loginProof(verifier,begin.authority,request,begin.challenge,begin.expires)local result,proofErr=send(\"auth_login_proof\",{request_id=request,username=name,node=tostring(os.getComputerID()),challenge=begin.challenge,proof=proof})if not result then return false,proofErr end local auth=require((\"ceetos.lib.auth\"))local saved,saveErr=auth.saveCentralSession(result.session)if not saved then return false,saveErr end if type(result.directory)==\"table\"then auth.updateDirectory(result.directory,result.revision)end return true end function M.mutate(action,fields)local auth=require((\"ceetos.lib.auth\"))local cloud=require(\"ceetos.lib.cloud_sync\")if cloud.authOnline()then return cloud.mutate(action,fields)end local current,server=auth.refreshSession(),authority()if not current or not current.central or not server then return false,\"an Auth Server login is required\"end local result,err=send(\"auth_mutate\",{action=action,fields=fields,session=current.nonce,node=tostring(os.getComputerID()),revision=current.accountRevision})if not result then return false,err end if type(result.directory)==\"table\"then auth.updateDirectory(result.directory,result.revision)end return true end return M",
  ["ceetos/lib/telemetry.lua"] = "local store=require(\"ceetos.lib.store\")local M={}local PATH=\"/ceetos/data/config.lua\"local activity=\"desktop\"local function text(value,limit)if type(value)~=\"string\"then return nil end value=value:gsub(\"[%z\\1-\\31\\127]\",\" \")if value==\"\"then return nil end return value:sub(1,limit)end local function config()local value=store.read(PATH,{})if type(value)~=\"table\"then value={}end if value.telemetry==nil then value.telemetry=true store.write(PATH,value)end return value end function M.setEnabled(value)assert(type(value)==\"boolean\",\"telemetry state must be boolean\")require(\"ceetos.lib.auth\").require(\"admin\")local valueForSave=config()valueForSave.telemetry=value return store.write(PATH,valueForSave)end function M.status()return config().telemetry==true end function M.configuration()return{enabled=M.status()}end function M.markActivity(value)activity=text(value,48)or activity return activity end function M.activity()return activity end function M.snapshot(currentActivity)if currentActivity then M.markActivity(currentActivity)end local data={ts=os.epoch(\"utc\"),id=os.getComputerID(),label=text(os.getComputerLabel(),96),activity=activity,}if turtle and type(turtle.getFuelLevel)==\"function\"then local ok,fuel=pcall(turtle.getFuelLevel)if ok and type(fuel)==\"number\"then data.fuel=fuel end end if gps and type(gps.locate)==\"function\"then local ok,x,y,z=pcall(gps.locate,0.2)if ok and type(x)==\"number\"and type(y)==\"number\"and type(z)==\"number\"then data.gps={x=x,y=y,z=z}end end return data end return M",
  ["ceetos/lib/cloud.lua"] = "local store=require(\"ceetos.lib.store\")local password=require(\"ceetos.lib.password\")local profile=require(\"ceetos.lib.profile\")local M={}M.PATH=\"/ceetos/data/cloud.lua\"M.MAX_BODY=48*1024 M.MAX_RESPONSE=64*1024 M.DEFAULTS={auth=\"https://auth.ceet.uk\",recipe=\"https://recipe.ceet.uk\",dashboard=\"https://dash.ceet.uk\",}local function now()return os.epoch(\"utc\")end local function validUrl(value)if type(value)~=\"string\"or#value>160 then return false end return value:match(\"^https://[%w%-%.]+%.ceet%.uk$\")~=nil end local function validId(value)return type(value)==\"string\"and value:match(\"^[%w_.%-]+$\")~=nil and#value<=96 end local function copy(value)local out={}for key,item in pairs(value or{})do out[key]=item end return out end local function normalise(value)value=type(value)==\"table\"and value or{}local urls=type(value.urls)==\"table\"and value.urls or{}local result={schema=1,enabled=value.enabled==true,urls={auth=validUrl(urls.auth)and urls.auth or M.DEFAULTS.auth,recipe=validUrl(urls.recipe)and urls.recipe or M.DEFAULTS.recipe,dashboard=validUrl(urls.dashboard)and urls.dashboard or M.DEFAULTS.dashboard,},node=type(value.node)==\"table\"and copy(value.node)or nil,}if result.node and(not validId(result.node.id)or type(result.node.secret)~=\"string\"or#result.node.secret<32 or#result.node.secret>192)then result.node=nil end return result end function M.load()store.invalidate(M.PATH)return normalise(store.read(M.PATH,{}))end function M.save(value)local clean=normalise(value)return store.write(M.PATH,clean)end function M.configure(urls)local value=M.load()urls=type(urls)==\"table\"and urls or{}for key in pairs(M.DEFAULTS)do if urls[key]~=nil then if not validUrl(urls[key])then return false,\"invalid \"..key..\" cloud URL\"end value.urls[key]=urls[key]end end return M.save(value)end function M.clear()local value=M.load();value.enabled,value.node=false,nil return M.save(value)end function M.publicStatus()local value=M.load()return{enabled=value.enabled,enrolled=value.node~=nil,node=value.node and value.node.id or nil,urls=copy(value.urls),profile=profile.id()}end local function readBounded(handle,limit)local chunks,total={},0 while true do local part=handle.read(math.min(4096,limit-total+1))if not part or part==\"\"then break end total=total+#part if total>limit then return nil,\"cloud response is too large\"end chunks[#chunks+1]=part end return table.concat(chunks)end local function json(value)local ok,result=pcall(textutils.unserialiseJSON,value)return ok and type(result)==\"table\"and result or nil end local function canonical(node,timestamp,nonce,method,path,body)local digest=assert(password.sha256(body))return table.concat({\"ceetos-cloud-node-v1\",node,tostring(timestamp),nonce,method,path,digest},\"\\n\")end local nonceCounter=0 local function nonce()nonceCounter=nonceCounter+1 return password.newSalt(\"cloud:\"..tostring(nonceCounter))end local function perform(url,method,path,body,headers)if not http or type(http.post)~=\"function\"then return nil,\"HTTP is unavailable\"end local response local ok,err=pcall(function()response=http.post(url..path,body,headers)end)if not ok or not response then return nil,tostring(err or\"cloud request failed\")end local raw,readErr=readBounded(response,M.MAX_RESPONSE)pcall(response.close)if not raw then return nil,readErr end local decoded=json(raw)if not decoded then return nil,\"cloud returned invalid JSON\"end if decoded.ok==false then return nil,tostring(decoded.error or\"cloud rejected request\"):sub(1,180)end return decoded end function M.enroll(code)if type(code)~=\"string\"or#code<12 or#code>128 or code:find(\"[%z\\1-\\31\\127]\")then return nil,\"invalid enrollment code\"end local value=M.load()local payload=textutils.serialiseJSON({code=code,profile=profile.id(),label=os.getComputerLabel()or(\"Computer \"..tostring(os.getComputerID())),computerId=tostring(os.getComputerID())})if#payload>M.MAX_BODY then return nil,\"enrollment request is too large\"end local result,err=perform(value.urls.auth,\"POST\",\"/v1/nodes/enroll\",payload,{[\"Content-Type\"]=\"application/json\"})if not result then return nil,err end if type(result.node)~=\"table\"or not validId(result.node.id)or type(result.node.secret)~=\"string\"or#result.node.secret<32 then return nil,\"cloud returned invalid enrollment\"end value.enabled,value.node=true,{id=result.node.id,secret=result.node.secret,enrolledAt=now()}local saved,saveErr=M.save(value)if not saved then return nil,saveErr or\"could not save cloud enrollment\"end return M.publicStatus()end function M.call(service,path,payload)local value=M.load()if not value.enabled or not value.node then return nil,\"cloud node is not enrolled\"end if not M.DEFAULTS[service]or type(path)~=\"string\"or not path:match(\"^/v1/[%w%-%._/]+$\")or#path>128 then return nil,\"invalid cloud endpoint\"end local body=textutils.serialiseJSON(type(payload)==\"table\"and payload or{})if#body>M.MAX_BODY then return nil,\"cloud request is too large\"end local timestamp,requestNonce=now(),nonce()local signature=password.hmacSha256(value.node.secret,canonical(value.node.id,timestamp,requestNonce,\"POST\",path,body))if not signature then return nil,\"could not sign cloud request\"end return perform(value.urls[service],\"POST\",path,body,{[\"Content-Type\"]=\"application/json\",[\"X-CeetOS-Node\"]=value.node.id,[\"X-CeetOS-Time\"]=tostring(timestamp),[\"X-CeetOS-Nonce\"]=requestNonce,[\"X-CeetOS-Signature\"]=signature,})end return M",
  ["ceetos/lib/cloud_sync.lua"] = "local cloud=require(\"ceetos.lib.cloud\")local store=require(\"ceetos.lib.store\")local password=require(\"ceetos.lib.password\")local profile=require(\"ceetos.lib.profile\")local telemetryService=require(\"ceetos.lib.telemetry\")local RECIPE_SERVICE_MODULE=\"ceetos.lib.\"..\"recipe_service\"local M={}local STATE=\"/ceetos/data/cloud-state.lua\"local INTERVALS={telemetry=24*60*60*1000,authority=30000,recipes=30000}local TELEMETRY_RETRIES={15*60*1000,60*60*1000,4*60*60*1000}local function now()return os.epoch(\"utc\")end local function defaults()return{schema=2,revision=0,auth={online=false,next=0},recipe={online=false,next=0,revision=0},telemetry={enabled=nil,next=0,lastSuccess=nil,failures=0,lastError=nil},lastError=nil}end local function state()store.invalidate(STATE)local value=store.read(STATE,defaults())if type(value)~=\"table\"then value=defaults()end if type(value.auth)~=\"table\"then value.auth={}end if type(value.recipe)~=\"table\"then value.recipe={}end if type(value.telemetry)~=\"table\"then value.telemetry={}end for key,fallback in pairs(defaults())do if value[key]==nil then value[key]=fallback end end for key,fallback in pairs(defaults().recipe)do if value.recipe[key]==nil then value.recipe[key]=fallback end end for key,fallback in pairs(defaults().telemetry)do if value.telemetry[key]==nil then value.telemetry[key]=fallback end end return value end local function save(value)value.revision=(tonumber(value.revision)or 0)+1 return store.write(STATE,value)end local function errorState(value,message)value.lastError=tostring(message or\"cloud request failed\"):sub(1,180)end function M.status()local value,cfg=state(),cloud.publicStatus()return{enabled=cfg.enabled,enrolled=cfg.enrolled,node=cfg.node,urls=cfg.urls,authOnline=value.auth.online==true,recipeOnline=value.recipe.online==true,telemetryNext=tonumber(value.telemetry and value.telemetry.next)or 0,telemetryInterval=INTERVALS.telemetry,telemetryEnabled=telemetryService.status(),telemetryLastSuccess=value.telemetry and value.telemetry.lastSuccess or nil,telemetryLastError=value.telemetry and value.telemetry.lastError or nil,telemetryFailures=math.max(0,math.floor(tonumber(value.telemetry and value.telemetry.failures)or 0)),lastSync=value.lastSync,lastError=value.lastError,}end function M.configure(urls)return cloud.configure(urls)end function M.enroll(code)local enrolled,err=cloud.enroll(code)if not enrolled then return nil,err end local value=state()value.telemetry.next,value.telemetry.failures,value.telemetry.lastError=0,0,nil save(value)return enrolled end function M.clear()local ok,err=cloud.clear();if ok then store.write(STATE,defaults())end return ok,err end function M.authOnline()return M.status().authOnline==true end function M.recipeOnline()return M.status().recipeOnline==true end local function cloudAuthority(result)local auth=require(\"ceetos.lib.auth\")if type(result)~=\"table\"or type(result.directory)~=\"table\"then return false,\"invalid cloud authority response\"end local cfg=cloud.publicStatus()local record={id=\"cloud:\"..tostring(cfg.urls.auth),term=tonumber(result.term)or 0,revision=tonumber(result.revision)or 0,directory=result.directory}return pcall(auth.acceptAuthority,record)end function M.login(name,plainPassword)if type(name)~=\"string\"or type(plainPassword)~=\"string\"then return false,\"invalid credentials\"end local begin,beginErr=cloud.call(\"auth\",\"/v1/auth/login/begin\",{username=name,node=tostring(os.getComputerID())})if not begin then return false,beginErr end if type(begin.salt)~=\"string\"or type(begin.workFactor)~=\"number\"or type(begin.challenge)~=\"string\"or type(begin.expires)~=\"number\"then return false,\"invalid cloud login challenge\"end local verifier,deriveErr=password.derive(plainPassword,begin.salt,begin.workFactor)if not verifier then return false,deriveErr end local proof=password.hmacSha256(verifier,table.concat({\"ceetos/auth/login/v1\",tostring(begin.authority),tostring(os.getComputerID()),tostring(begin.requestId),tostring(begin.challenge),tostring(begin.expires)},\"|\"))local result,resultErr=cloud.call(\"auth\",\"/v1/auth/login/proof\",{username=name,node=tostring(os.getComputerID()),requestId=begin.requestId,challenge=begin.challenge,proof=proof})if not result then return false,resultErr end local auth=require(\"ceetos.lib.auth\")local saved,saveErr=auth.saveCentralSession(result.session)if not saved then return false,saveErr end if type(result.directory)==\"table\"then auth.updateDirectory(result.directory,result.revision)end return true end function M.mutate(action,fields)local auth=require(\"ceetos.lib.auth\")local current=auth.refreshSession()if not current or not current.central then return false,\"cloud login is required\"end local result,err=cloud.call(\"auth\",\"/v1/auth/mutate\",{action=action,fields=fields,session=current.nonce,node=tostring(os.getComputerID()),revision=current.accountRevision})if not result then return false,err end if type(result.directory)==\"table\"then auth.updateDirectory(result.directory,result.revision)end return true end function M.recipePlan(item,quantity,stock)if not M.recipeOnline()then return nil,\"cloud Recipe Server is unavailable\"end local result,err=cloud.call(\"recipe\",\"/v1/recipe/plan\",{item=item,quantity=quantity,stock=stock})if not result then return nil,err end return result.plan,result.error end local function telemetry()local snapshot=telemetryService.snapshot()local net=require(\"ceetos.lib.net\")local transport=net.statusSnapshot and net.statusSnapshot().transport or{}snapshot.health={profile=profile.id(),version=require(\"ceetos.lib.version\").string,uptime=os.clock(),freeSpace=fs.getFreeSpace(\"/\"),peers=#(net.peers and net.peers()or{}),healthyLinks=transport and transport.healthyLinks or 0,lastError=M.status().lastError,}local result,err=cloud.call(\"dashboard\",\"/v1/telemetry/ingest\",snapshot)return result~=nil,err end local function syncAuth()if profile.is(\"auth-server\")then local auth=require(\"ceetos.lib.auth\")local snapshot=auth.authoritySnapshot()local pushed,pushErr=cloud.call(\"auth\",\"/v1/auth/replica/merge\",{snapshot=snapshot,source=\"mesh\"})if not pushed then return false,pushErr end if type(pushed.snapshot)==\"table\"then auth.merge(pushed.snapshot,true)end local ok,authErr=cloudAuthority(pushed)if not ok then return false,authErr end return true end local result,err=cloud.call(\"auth\",\"/v1/auth/directory\",{})if not result then return false,err end local ok,authErr=cloudAuthority(result)if not ok then return false,authErr end return true end local function syncRecipes()if not profile.is(\"recipe-server\")then local result,err=cloud.call(\"recipe\",\"/v1/recipe/status\",{})return result~=nil,err end local service=require(RECIPE_SERVICE_MODULE)if type(service.exportOverlay)~=\"function\"then return false,\"recipe export unavailable\"end local payload,exportErr=service.exportOverlay()if not payload then return false,exportErr end local value=state()value.recipe=value.recipe or{revision=0}local result,err=cloud.call(\"recipe\",\"/v1/recipe/replica/merge\",{overlay=payload,baseRevision=math.max(0,math.floor(tonumber(value.recipe.revision)or 0)),})if not result then return false,err end if type(result.overlay)==\"table\"and type(service.replaceOverlay)==\"function\"then local applied,applyErr=service.replaceOverlay(result.overlay)if not applied then return false,applyErr end end return true,nil,math.max(0,math.floor(tonumber(result.revision)or 0))end function M.tick(activity,timestampOverride)local cfg=cloud.publicStatus();if not cfg.enabled or not cfg.enrolled then return false,\"cloud node is not enrolled\"end local value,timestamp=state(),tonumber(timestampOverride)or now()local changed=false if timestamp>=(tonumber(value.auth.next)or 0)then local ok,err=syncAuth();value.auth.online,value.auth.next=ok==true,timestamp+INTERVALS.authority if not ok then errorState(value,err)else value.lastError=nil end changed=true end if timestamp>=(tonumber(value.recipe.next)or 0)then local ok,err,recipeRevision=syncRecipes();value.recipe.online,value.recipe.next=ok==true,timestamp+INTERVALS.recipes if recipeRevision~=nil then value.recipe.revision=recipeRevision end if not ok then errorState(value,err)end changed=true end local telemetryEnabled=telemetryService.status()if not telemetryEnabled then if value.telemetry.enabled~=false or value.telemetry.next~=0 then value.telemetry.enabled,value.telemetry.next,value.telemetry.failures,value.telemetry.lastError=false,0,0,nil changed=true end elseif value.telemetry.enabled~=true then value.telemetry.enabled,value.telemetry.next,value.telemetry.failures,value.telemetry.lastError=true,0,0,nil changed=true elseif timestamp>=(tonumber(value.telemetry.next)or 0)then local ok,err=telemetry()if ok then value.telemetry.next,value.telemetry.lastSuccess,value.telemetry.failures,value.telemetry.lastError=timestamp+INTERVALS.telemetry,timestamp,0,nil else local failures=math.max(0,math.floor(tonumber(value.telemetry.failures)or 0))+1 value.telemetry.failures,value.telemetry.lastError=failures,tostring(err or\"telemetry upload failed\"):sub(1,180)value.telemetry.next=timestamp+(TELEMETRY_RETRIES[failures]or INTERVALS.telemetry)errorState(value,value.telemetry.lastError)end changed=true end if changed then value.lastSync=timestamp;save(value)end return true end return M",
  ["ceetos/lib/version.lua"] = "return{major=0,minor=17,patch=1,string=\"0.17.1\"}",
  ["ceetos/lib/crafting.lua"] = "local M={}M.RECIPE_PACK_MAX_BYTES=64*1024 function M.isLocalPeer(peer,computerId)return tostring(peer)==tostring(computerId)end function M.normalise(rows)local out={}for _,row in pairs(type(rows)==\"table\"and rows or{})do if type(row)==\"table\"then local name,count=row.name or row.id,tonumber(row.count or row.amount or row.quantity or 0)or 0 if type(name)==\"string\"and count>0 then out[name]=(out[name]or 0)+math.floor(count)end end end return out end function M.rows(stock,query,filter,recipes)local output,needle,available={},tostring(query or\"\"):lower(),M.normalise(stock)local names={}for name in pairs(available)do names[name]=true end for name in pairs(type(recipes)==\"table\"and recipes or{})do names[name]=true end for name in pairs(names)do local count=available[name]or 0 local craftable=type(recipes)==\"table\"and recipes[name]~=nil local include=needle==\"\"or name:lower():find(needle,1,true)if filter==\"craftable\"then include=include and craftable elseif filter==\"stocked\"then include=include and count>0 end if include then output[#output+1]={name=name,count=count,craftable=craftable,stocked=count>0}end end table.sort(output,function(a,b)return a.name<b.name end)return output end function M.addToCart(cart,name,count)if type(cart)~=\"table\"or type(name)~=\"string\"or name==\"\"then return nil,\"invalid cart item\"end count=tonumber(count)if not count or count<1 then return nil,\"invalid cart quantity\"end count=math.floor(count)for _,row in ipairs(cart)do if row.name==name then row.count=(tonumber(row.count)or 0)+count;return row end end local row={name=name,count=count}cart[#cart+1]=row return row end function M.cartTotals(cart)local items,quantity=0,0 for _,row in ipairs(type(cart)==\"table\"and cart or{})do items=items+1 quantity=quantity+math.max(0,math.floor(tonumber(row.count)or 0))end return items,quantity end function M.acceptedCount(result,requested)local accepted=tonumber(result)requested=tonumber(requested)if not accepted or not requested or requested<1 then return nil,\"invalid request result\"end accepted=math.floor(accepted)if accepted<1 then return nil,\"Stock Ticker accepted no items\"end return math.min(accepted,math.floor(requested))end function M.displayRow(name,count,width)local suffix=\" x\"..tostring(count)width=math.max(#suffix+1,math.floor(tonumber(width)or#suffix+1))if#name+#suffix<=width then return name..suffix end return name:sub(1,math.max(1,width-#suffix-1))..\"…\"..suffix end function M.cancellationStatus(issued)return issued and\"cancelled-unconfirmed\"or\"cancelled\"end local function validReference(name)return type(name)==\"string\"and name:match(\"^#?[%w_.:%-]+$\")~=nil and not name:find(\"##\",1,true)end local function recipeIngredients(recipe,batches)local combined={}local declared=type(recipe.ingredients)==\"table\"and#recipe.ingredients>0 if declared then for _,ingredient in ipairs(recipe.ingredients)do local name=type(ingredient)==\"table\"and ingredient.id or ingredient local count=type(ingredient)==\"table\"and tonumber(ingredient.count or 1)or 1 if validReference(name)then combined[name]=(combined[name]or 0)+batches*math.max(1,math.floor(count or 1))end end else for slot=1,9 do local name=recipe.grid and recipe.grid[slot]if name and name~=false then combined[name]=(combined[name]or 0)+batches end end end local out={}for name,count in pairs(combined)do out[#out+1]={name=name,count=count}end table.sort(out,function(a,b)return a.name<b.name end)return out end function M.recipeInputs(recipe,batches)return recipeIngredients(recipe or{},math.max(1,math.floor(tonumber(batches)or 1)))end function M.validateRecipes(recipes)if type(recipes)~=\"table\"then return nil,\"recipes must return a table\"end for name,recipe in pairs(recipes)do if type(name)~=\"string\"or not name:match(\"^[%w_.:%-]+$\")or type(recipe)~=\"table\"then return nil,\"invalid recipe item id\"end if type(recipe.alternatives)==\"table\"then if#recipe.alternatives<1 then return nil,\"recipe \"..name..\" has no alternatives\"end for _,alternative in ipairs(recipe.alternatives)do local valid,err=M.validateRecipes({[name]=alternative});if not valid then return nil,err end end else if recipe.mode~=\"crafting\"then return nil,\"recipe \"..name..\" must use mode = crafting\"end if type(recipe.address)~=\"string\"or recipe.address==\"\"or#recipe.address>64 then return nil,\"recipe \"..name..\" requires an address\"end local crafter=recipe.address==\"Crafter\"if crafter and type(recipe.grid)~=\"table\"then return nil,\"recipe \"..name..\" requires a 9-slot grid for Crafter\"end if not crafter and type(recipe.ingredients)~=\"table\"and type(recipe.grid)~=\"table\"then return nil,\"recipe \"..name..\" requires ingredients\"end local output=tonumber(recipe.output or 1)if not output or output<1 or output~=math.floor(output)then return nil,\"invalid output for \"..name end for slot=1,9 do local value=recipe.grid and recipe.grid[slot]if value~=nil and value~=false and not validReference(value)then return nil,\"invalid grid slot in \"..name end end for _,ingredient in ipairs(type(recipe.ingredients)==\"table\"and recipe.ingredients or{})do local id,count=type(ingredient)==\"table\"and ingredient.id or ingredient,type(ingredient)==\"table\"and ingredient.count or 1 if not validReference(id)or not tonumber(count)or tonumber(count)<1 then return nil,\"invalid ingredient in \"..name end end end end return recipes end local function dataParser(source,maximum)if type(source)~=\"string\"then return nil,\"recipe data must be text\"end if#source==0 or#source>(maximum or M.RECIPE_PACK_MAX_BYTES)then return nil,\"recipe data exceeds the 64 KiB import limit\"end local state={source=source,index=1,length=#source,depth=0,nodes=0}local function skip()while state.index<=state.length do local char=state.source:sub(state.index,state.index)if char:match(\"%s\")then state.index=state.index+1 elseif state.source:sub(state.index,state.index+1)==\"--\"then local ending=state.source:find(\"\\n\",state.index+2,true)state.index=ending and ending+1 or state.length+1 else return true end end return true end local function fail(message)return nil,message..\" near byte \"..tostring(state.index)end local function identifier()local name=state.source:match(\"^[%a_][%w_]*\",state.index)if name then state.index=state.index+#name end return name end local parseValue local function parseString()local quote=state.source:sub(state.index,state.index);state.index=state.index+1 local out={}while state.index<=state.length do local char=state.source:sub(state.index,state.index);state.index=state.index+1 if char==quote then return table.concat(out)end if char==\"\\\\\"then local escaped=state.source:sub(state.index,state.index);state.index=state.index+1 local values={n=\"\\n\",r=\"\\r\",t=\"\\t\",[\"\\\\\"]=\"\\\\\",[\"\\\"\"]=\"\\\"\",[\"'\"]=\"'\"}if not values[escaped]then return fail(\"unsupported string escape\")end out[#out+1]=values[escaped]elseif char:byte()<32 then return fail(\"control character in string\")else out[#out+1]=char end end return fail(\"unterminated string\")end local function parseTable()state.depth=state.depth+1 if state.depth>32 then return fail(\"recipe data is nested too deeply\")end state.index=state.index+1 local out,nextIndex,keys={},1,{}skip()while state.index<=state.length and state.source:sub(state.index,state.index)~=\"}\"do state.nodes=state.nodes+1 if state.nodes>4096 then return fail(\"recipe data has too many values\")end local key,value,err local start=state.index if state.source:sub(state.index,state.index)==\"[\"then state.index=state.index+1;key,err=parseValue();if err then return nil,err end skip();if state.source:sub(state.index,state.index)~=\"]\"then return fail(\"missing closing recipe key bracket\")end state.index=state.index+1;skip();if state.source:sub(state.index,state.index)~=\"=\"then return fail(\"missing recipe key assignment\")end state.index=state.index+1;skip();value,err=parseValue();if err then return nil,err end else local named=identifier();skip()if named and state.source:sub(state.index,state.index)==\"=\"then key,state.index=named,state.index+1;skip();value,err=parseValue();if err then return nil,err end else state.index=start;value,err=parseValue();if err then return nil,err end key,nextIndex=nextIndex,nextIndex+1 end end if type(key)~=\"string\"and type(key)~=\"number\"then return fail(\"recipe table key must be text or a number\")end local signature=type(key)..\":\"..tostring(key)if keys[signature]then return fail(\"duplicate recipe table key\")end keys[signature],out[key]=true,value skip();local separator=state.source:sub(state.index,state.index)if separator==\",\"or separator==\";\"then state.index=state.index+1;skip()elseif separator~=\"}\"then return fail(\"expected recipe table separator\")end end if state.source:sub(state.index,state.index)~=\"}\"then return fail(\"unterminated recipe table\")end state.index,state.depth=state.index+1,state.depth-1 return out end parseValue=function()skip();local char=state.source:sub(state.index,state.index)if char==\"{\"then return parseTable()end if char==\"\\\"\"or char==\"'\"then return parseString()end local number=state.source:match(\"^-?%d+\",state.index)if number then state.index=state.index+#number;return tonumber(number)end local name=identifier()if name==\"true\"then return true elseif name==\"false\"then return false elseif name==\"nil\"then return nil elseif name then return fail(\"bare recipe values are not allowed\")end return fail(\"invalid recipe value\")end skip()if identifier()~=\"return\"then return fail(\"recipe data must start with return\")end local value,err=parseValue();if err then return nil,err end skip();if state.index<=state.length then return fail(\"unexpected content after recipe table\")end return value end local function validTags(tags)if tags==nil then return{}end if type(tags)~=\"table\"then return nil,\"recipe tags must be a table\"end local out={}for tag,members in pairs(tags)do local key=tostring(tag):gsub(\"^#\",\"\")if not key:match(\"^[%w_.:%-]+$\")or type(members)~=\"table\"or#members<1 then return nil,\"invalid recipe tag \"..tostring(tag)end out[key]={}for _,member in ipairs(members)do if not validReference(member)or tostring(member):sub(1,1)==\"#\"then return nil,\"invalid member of #\"..key end out[key][#out[key]+1]=member end table.sort(out[key])end return out end function M.validateRecipeGraph(recipes,tags)local tagMap,tagErr=validTags(tags);if not tagMap then return nil,tagErr end local visiting,complete={},{}local function visit(name)if complete[name]then return true end if visiting[name]then return nil,\"recipe cycle at \"..name end local recipe=recipes[name]if type(recipe)==\"table\"and type(recipe.alternatives)==\"table\"then recipe=recipe.alternatives[1]end if not recipe then return true end visiting[name]=true for _,input in ipairs(recipeIngredients(recipe,1))do local ingredient=input.name local members=ingredient:sub(1,1)==\"#\"and tagMap[ingredient:sub(2)]or{ingredient}for _,member in ipairs(members or{})do if recipes[member]then local ok,err=visit(member);if not ok then return nil,err end end end end visiting[name],complete[name]=nil,true return true end for name in pairs(recipes)do local ok,err=visit(name);if not ok then return nil,err end end return true end function M.parseRecipeOverlay(source)local value,err=dataParser(source,M.RECIPE_PACK_MAX_BYTES)if not value then return nil,err end if type(value)~=\"table\"then return nil,\"recipe overlay must return a table\"end local recipes,tags if value.schema==1 and type(value.recipes)==\"table\"then recipes,tags=value.recipes,value.tags or{}else recipes,tags=value,{}end local valid,validationErr=M.validateRecipes(recipes)if not valid then return nil,validationErr end local tagMap,tagsErr=validTags(tags);if not tagMap then return nil,tagsErr end local graphOk,graphErr=M.validateRecipeGraph(recipes,tagMap)if not graphOk then return nil,graphErr end return{schema=1,recipes=recipes,tags=tagMap}end function M.parseRecipeFile(source)local overlay,err=M.parseRecipeOverlay(source)return overlay and overlay.recipes or nil,err end function M.parseRecipePack(source)local pack,err=dataParser(source,M.RECIPE_PACK_MAX_BYTES)if not pack then return nil,err end if type(pack)~=\"table\"or pack.schema~=1 or type(pack.recipes)~=\"table\"then return nil,\"recipe pack must return { schema = 1, recipes = { ... } }\"end local valid,validationErr=M.validateRecipes(pack.recipes)if not valid then return nil,validationErr end local tagMap,tagsErr=validTags(pack.tags or{});if not tagMap then return nil,tagsErr end local graphOk,graphErr=M.validateRecipeGraph(pack.recipes,tagMap)if not graphOk then return nil,graphErr end return{schema=1,recipes=pack.recipes,tags=tagMap}end function M.recipeEqual(left,right)if type(left)~=\"table\"or type(right)~=\"table\"then return false end if type(left.alternatives)==\"table\"then left=left.alternatives[1]end if type(right.alternatives)==\"table\"then right=right.alternatives[1]end if not left or not right or left.mode~=right.mode or left.kind~=right.kind or left.output~=right.output or left.address~=right.address then return false end for slot=1,9 do if left.grid and right.grid and left.grid[slot]~=right.grid[slot]then return false end end local a,b=recipeIngredients(left,1),recipeIngredients(right,1)if#a~=#b then return false end for index=1,#a do if a[index].name~=b[index].name or a[index].count~=b[index].count then return false end end return true end function M.reviewRecipePack(existing,incoming)local validExisting,existingErr=M.validateRecipes(existing)if not validExisting then return nil,existingErr end local validIncoming,incomingErr=M.validateRecipes(incoming)if not validIncoming then return nil,incomingErr end local rows,counts={},{new=0,unchanged=0,conflict=0,invalid=0}for name,recipe in pairs(incoming)do local prior=existing[name]local status=not prior and\"new\"or(M.recipeEqual(prior,recipe)and\"unchanged\"or\"conflict\")counts[status]=counts[status]+1 rows[#rows+1]={name=name,status=status,incoming=recipe,existing=prior,selected=status==\"new\"}end table.sort(rows,function(a,b)return a.name<b.name end)return{rows=rows,counts=counts}end local function cloneRecipe(recipe)if type(recipe)==\"table\"and type(recipe.alternatives)==\"table\"then recipe=recipe.alternatives[1]end local copy={mode=recipe.mode,kind=recipe.kind,output=recipe.output,address=recipe.address,grid={},ingredients={}}for slot=1,9 do copy.grid[slot]=recipe.grid and recipe.grid[slot]end for _,input in ipairs(recipe.ingredients or{})do copy.ingredients[#copy.ingredients+1]={id=input.id,count=input.count,tag=input.tag}end return copy end function M.mergeRecipePack(existing,review,existingTags,incomingTags)if type(existing)~=\"table\"or type(review)~=\"table\"or type(review.rows)~=\"table\"then return nil,\"invalid recipe import review\"end local merged,imported={},0 for name,recipe in pairs(existing)do merged[name]=cloneRecipe(recipe)end for _,row in ipairs(review.rows)do if row.status==\"new\"and row.selected==true then merged[row.name],imported=cloneRecipe(row.incoming),imported+1 elseif row.status==\"conflict\"and row.selected==true then merged[row.name],imported=cloneRecipe(row.incoming),imported+1 end end local tags={}for name,members in pairs(existingTags or{})do tags[name]=members end for name,members in pairs(incomingTags or{})do tags[name]=members end local valid,err=M.validateRecipes(merged);if not valid then return nil,err end local graphOk,graphErr=M.validateRecipeGraph(merged,tags);if not graphOk then return nil,graphErr end return merged,imported,tags end function M.encodeRecipeFile(recipes,tags)local valid,err=M.validateRecipes(recipes);if not valid then return nil,err end local tagMap,tagsErr=validTags(tags or{});if not tagMap then return nil,tagsErr end local graphOk,graphErr=M.validateRecipeGraph(recipes,tagMap);if not graphOk then return nil,graphErr end local ok,encoded=pcall(textutils.serialise,{schema=1,recipes=recipes,tags=tagMap},{compact=true})if not ok or type(encoded)~=\"string\"then return nil,\"could not serialise recipes\"end return\"return \"..encoded..\"\\n\"end function M.writeRecipeFile(path,recipes,tags)if type(path)~=\"string\"or path==\"\"then return false,\"invalid recipe file path\"end local raw,encodeErr=M.encodeRecipeFile(recipes,tags);if not raw then return false,encodeErr end local parent=fs.getDir(path);if parent~=\"\"and not fs.exists(parent)then fs.makeDir(parent)end local temporary,backup=path..\".new\",path..\".bak\"if fs.exists(temporary)then fs.delete(temporary)end local handle=fs.open(temporary,\"w\");if not handle then return false,\"could not stage recipes\"end handle.write(raw);handle.close()local check=fs.open(temporary,\"r\");local staged=check and check.readAll()or nil;if check then check.close()end if not M.parseRecipeOverlay(staged or\"\")then if fs.exists(temporary)then fs.delete(temporary)end;return false,\"could not validate staged recipes\"end if fs.exists(backup)then fs.delete(backup)end if fs.exists(path)then fs.move(path,backup)end local moved,moveErr=pcall(fs.move,temporary,path)if not moved then if fs.exists(backup)and not fs.exists(path)then fs.move(backup,path)end;if fs.exists(temporary)then fs.delete(temporary)end;return false,tostring(moveErr)end if fs.exists(backup)then fs.delete(backup)end return true end local function selectedRecipe(entry)if type(entry)==\"table\"and type(entry.alternatives)==\"table\"then return entry.alternatives[1]end return entry end local function tagMembers(tags,reference)if reference:sub(1,1)~=\"#\"then return{reference}end local members=tags[reference:sub(2)]if type(members)~=\"table\"or#members==0 then return nil,\"no known members for \"..reference end return members end local function chooseMember(reference,tags,available,recipes)local members,err=tagMembers(tags,reference);if not members then return nil,err end local best for _,member in ipairs(members)do if(available[member]or 0)>0 then return member end if recipes[member]and not best then best=member end end return best,best and nil or(\"no stocked or craftable member for \"..reference)end local function requesterGrid(recipe,resolved,tags,available,recipes)local grid={}for slot=1,9 do local value=recipe.grid and recipe.grid[slot]if type(value)==\"string\"and value:sub(1,1)==\"#\"then local concrete=resolved[value]if not concrete then concrete=select(1,chooseMember(value,tags,available,recipes))end if not concrete then return nil,\"no concrete item available for requester tag \"..value end grid[slot]=concrete else grid[slot]=value end end return grid end function M.plan(recipes,stock,item,quantity,options)quantity=tonumber(quantity)if type(item)~=\"string\"or item==\"\"or not quantity or quantity<1 or quantity~=math.floor(quantity)then return nil,\"invalid item or quantity\"end local valid,errorText=M.validateRecipes(recipes)if not valid then return nil,errorText end options=type(options)==\"table\"and options or{}local available,plan,visiting,tags=M.normalise(stock),{},{},type(options.tags)==\"table\"and options.tags or{}local function requireItem(name,needed)local used=options.forceRoot==true and name==item and 0 or math.min(available[name]or 0,needed)available[name]=(available[name]or 0)-used local missing=needed-used if missing<=0 then return true end if visiting[name]then return nil,\"recipe cycle at \"..name end local recipe=selectedRecipe(recipes[name])if not recipe then return nil,\"missing recipe for \"..name..\" (need \"..tostring(missing)..\")\"end if recipe.special or not recipe.output or not recipe.address then return nil,\"recipe \"..name..\" is not supported by Crafting Queue\"end visiting[name]=true local batches=math.ceil(missing/recipe.output)local inputs,resolved={},{}for _,ingredient in ipairs(recipeIngredients(recipe,batches))do local concrete,resolveErr=chooseMember(ingredient.name,tags,available,recipes)if not concrete then return nil,resolveErr end inputs[#inputs+1]={name=concrete,count=ingredient.count,source=ingredient.name,tag=ingredient.name:sub(1,1)==\"#\"and ingredient.name or nil}if ingredient.name:sub(1,1)==\"#\"then resolved[ingredient.name]=concrete end local ok,err=requireItem(concrete,ingredient.count)if not ok then return nil,err end end visiting[name]=nil local produced=batches*recipe.output available[name]=(available[name]or 0)+produced-missing local dispatch=recipe.address==\"Crafter\"and\"requester\"or\"package\"if dispatch==\"requester\"and type(recipe.grid)~=\"table\"then return nil,\"Crafter recipe \"..name..\" has no 3x3 grid\"end local grid,gridErr=recipe.grid,nil if dispatch==\"requester\"then grid,gridErr=requesterGrid(recipe,resolved,tags,available,recipes)if not grid then return nil,gridErr end end plan[#plan+1]={name=name,batches=batches,produced=produced,requested=missing,address=recipe.address,kind=recipe.kind,dispatch=dispatch,grid=grid,sourceGrid=recipe.grid,inputs=inputs,}return true end local ok,err=requireItem(item,quantity)return ok and plan or nil,err end function M.validateExecutionPlan(plan)if type(plan)~=\"table\"or#plan<1 or#plan>96 then return nil,\"invalid remote recipe plan\"end for _,step in ipairs(plan)do if type(step)~=\"table\"or not validReference(step.name)or tostring(step.name):sub(1,1)==\"#\"then return nil,\"invalid remote recipe output\"end if type(step.address)~=\"string\"or step.address==\"\"or#step.address>64 or step.address:find(\"[%c]\")then return nil,\"invalid remote recipe address\"end if step.dispatch~=\"requester\"and step.dispatch~=\"package\"then return nil,\"invalid remote recipe route\"end if(step.address==\"Crafter\")~=(step.dispatch==\"requester\")then return nil,\"invalid remote recipe executor\"end for _,field in ipairs({\"batches\",\"produced\",\"requested\"})do local value=tonumber(step[field])if not value or value<1 or value~=math.floor(value)or value>65536 then return nil,\"invalid remote recipe quantity\"end end if step.dispatch==\"requester\"then if type(step.grid)~=\"table\"then return nil,\"remote Crafter recipe has no grid\"end for slot=1,9 do local value=step.grid[slot]if value~=nil and value~=false and not validReference(value)then return nil,\"invalid remote recipe grid\"end if type(value)==\"string\"and value:sub(1,1)==\"#\"then return nil,\"remote requester grid contains unresolved tag\"end end end if type(step.inputs)~=\"table\"or#step.inputs>64 then return nil,\"invalid remote recipe inputs\"end for _,input in ipairs(step.inputs)do if type(input)~=\"table\"or not validReference(input.name)or tostring(input.name):sub(1,1)==\"#\"then return nil,\"invalid remote recipe input\"end local count=tonumber(input.count)if not count or count<1 or count~=math.floor(count)or count>65536 then return nil,\"invalid remote recipe input quantity\"end if input.source~=nil and not validReference(input.source)then return nil,\"invalid remote recipe input source\"end end end return plan end return M",
  ["ceetos/server.lua"] = "package.path=\"/?.lua;/?/init.lua;\"..package.path local profile=require(\"ceetos.lib.profile\")local auth=require(\"ceetos.lib.auth\")local net=require(\"ceetos.lib.net\")local audit=require(\"ceetos.lib.audit\")local updater=require(\"ceetos.lib.updater\")local networkIpc=require(\"ceetos.lib.network_ipc\")local version=require(\"ceetos.lib.version\")local recipe=profile.is(\"recipe-server\")and require((\"ceetos.lib.recipe_service\"))or nil local authority=profile.is(\"auth-server\")and require((\"ceetos.lib.auth_authority\"))or nil local authClient=require(\"ceetos.lib.auth_client\")local cloudSync=require(\"ceetos.lib.cloud_sync\")local releaseBroker=require(\"ceetos.lib.release_broker\")local function replyIpc(command,ok,result)networkIpc.reply(command.id,{id=command.id,ok=ok==true,result=ok and result or nil,error=ok and nil or tostring(result)})end local function command()local request=networkIpc.take()if not request then return end local ok,result=pcall(function()if request.action==\"server_status\"then return{profile=profile.id(),version=version.string,network=net.statusSnapshot(),authority=authority and authority.status()or auth.authority(),recipes=recipe and recipe.status()or nil}elseif request.action==\"server_discovery_start\"then auth.require(\"admin\");return net.startDiscovery(request.mode or\"peer\",request.master or\"initiator\",request.phrase)elseif request.action==\"server_discovery_join\"then auth.require(\"admin\");return net.joinDiscovery(request.phrase)elseif request.action==\"server_discovery_confirm\"then auth.require(\"admin\");return net.confirmDiscovery()elseif request.action==\"server_update_status\"then return updater.status()elseif request.action==\"server_update_apply\"then auth.require(\"admin\");return updater.prepareApply()elseif request.action==\"broker_status\"then auth.require(\"operator\");return releaseBroker.status()elseif request.action==\"broker_promote\"then auth.require(\"admin\");return releaseBroker.promote(request.ownerKey)elseif request.action==\"broker_revoke\"then auth.require(\"admin\");return releaseBroker.revoke(request.ownerKey)elseif request.action==\"broker_approve\"then auth.require(\"admin\");return releaseBroker.approve(request.session,request.phrase)elseif request.action==\"auth_send\"then local sent,err=net.request(request.target,request.type,request.body or{},{safe=true,confidential=true})if not sent then error(err or\"could not contact Auth Server\")end return true end error(\"unknown protected server command\")end)replyIpc(request,ok and result~=false,ok and result or result)end net.handler=function(from,peer,packet)if packet.type==\"update_query\"or packet.type==\"update_offer\"or packet.type==\"update_chunk_request\"or packet.type==\"update_chunk\"then return updater.authenticatedPacket(from,peer,packet)end if packet.type==\"profile_query\"then return net.reply(packet,\"profile_status\",{profile=profile.id(),version=version.string},{safe=true})end if packet.type==\"recipe_plan_request\"and recipe then return recipe.handle(packet,net)end if packet.type==\"auth_login_begin_result\"or packet.type==\"auth_login_proof_result\"or packet.type==\"auth_mutate_result\"then return authClient.receive(packet.body)end if packet.type==\"auth_seed_request\"then return net.reply(packet,\"auth_seed\",{snapshot=auth.authoritySnapshot(),request_id=packet.body and packet.body.request_id},{safe=true,confidential=true})end if packet.type==\"auth_authority\"then return auth.acceptAuthority(packet.body)end if authority then return authority.handle(from,peer,packet,net)end end net.publicHandler=function(packet,distance)if tostring(packet.type or\"\"):match(\"^broker_\")then return releaseBroker.handle(packet,distance)end return updater.publicPacket(packet,distance)end local function service()net.start()if authority then authority.start()end local tick,update,cloud,advertised=os.startTimer(1),os.startTimer(1),os.startTimer(5),0 while true do local event={os.pullEventRaw()}local ok,err=pcall(net.handle,event)if not ok then audit.log(\"server\",\"network.error\",{error=tostring(err):sub(1,120)})end pcall(command)if event[1]==\"timer\"and event[2]==tick then pcall(net.tick);pcall(releaseBroker.tick);if authority then pcall(authority.tick,net)end if os.epoch(\"utc\")-advertised>=30000 then for _,peer in ipairs(net.peers())do net.request(peer.id,\"profile_status\",{profile=profile.id(),version=version.string},{safe=true})end advertised=os.epoch(\"utc\")end tick=os.startTimer(1)end if event[1]==\"timer\"and event[2]==update then pcall(updater.tick);update=os.startTimer(1)end if event[1]==\"timer\"and event[2]==cloud then pcall(cloudSync.tick);cloud=os.startTimer(5)end end end local function console()shell.run(\"/ceetos/bin/ceetserver.lua\")while true do sleep(1);shell.run(\"/ceetos/bin/ceetserver.lua\")end end local function recoveryHealthProof()local path=\"/ceetos-dev/recovery/transaction.lua\"if not fs.exists(path)then return end local handle=fs.open(path,\"r\")local transaction=handle and textutils.unserialise(handle.readAll())or nil if handle then handle.close()end if type(transaction)~=\"table\"or transaction.state~=\"candidate\"or transaction.expectedVersion~=version.string then return end local temporary=\"/ceetos-dev/recovery/candidate-health.lua.tmp\"local output=fs.open(temporary,\"w\")if output then output.write(textutils.serialise({transaction=transaction.id,version=version.string,profile=profile.id(),at=os.epoch(\"utc\")}))output.close()if fs.exists(\"/ceetos-dev/recovery/candidate-health.lua\")then fs.delete(\"/ceetos-dev/recovery/candidate-health.lua\")end fs.move(temporary,\"/ceetos-dev/recovery/candidate-health.lua\")end end recoveryHealthProof()local function devBridge()local path=\"/ceetos-dev/connection.lua\"while true do if fs.exists(\"/ceetos/bin/ceetdev.lua\")and fs.exists(path)then local handle=fs.open(path,\"r\")local cfg=handle and textutils.unserialise(handle.readAll())or nil if handle then handle.close()end if type(cfg)==\"table\"and type(cfg.url)==\"string\"then local token=type(cfg.token)==\"string\"and cfg.token or\"\"if token~=\"\"then pcall(shell.run,\"/ceetos/bin/ceetdev.lua\",cfg.url,token,\"--background\")else pcall(shell.run,\"/ceetos/bin/ceetdev.lua\",cfg.url,\"--background\")end sleep(3)else sleep(5)end else sleep(5)end end end parallel.waitForAny(service,console,devBridge)",
  ["ceetos/bin/ceetserver.lua"] = "package.path=\"/?.lua;/?/init.lua;\"..package.path local profile=require(\"ceetos.lib.profile\")local auth=require(\"ceetos.lib.auth\")local version=require(\"ceetos.lib.version\")local store=require(\"ceetos.lib.store\")local recipe=profile.is(\"recipe-server\")and require((\"ceetos.lib.recipe_service\"))or nil local function draw(message)term.setBackgroundColor(colors.black);term.setTextColor(colors.white);term.clear();term.setCursorPos(1,1)term.setBackgroundColor(colors.blue);term.clearLine();term.setCursorPos(2,1);term.write(\"CeetOS \"..profile.id()..\" server\")term.setBackgroundColor(colors.black);term.setCursorPos(2,3);term.write(\"Version: \"..version.string)local authority=auth.authority()term.setCursorPos(2,4);term.write(authority and(\"Auth authority: \"..authority.id)or\"Auth authority: distributed / waiting\")local current=auth.refreshSession()term.setCursorPos(2,6);term.write(current and(\"Signed in: \"..current.name..\" (\"..current.role..\")\")or\"Not signed in\")term.setTextColor(colors.lightGray);term.setCursorPos(2,8)if not current then term.write(\"L Login  |  E first-time enrolment  |  Backspace refresh\")elseif current.role==\"admin\"then local actions=recipe and\"I import pack  |  P pair  |  U updates  |  B broker\"or\"P pair  |  U updates  |  B broker\"term.write(actions..\"  |  L logout  |  Backspace refresh\")else term.write(\"L logout  |  Backspace refresh\")end if message then term.setTextColor(colors.red);term.setCursorPos(2,10);term.write(tostring(message):sub(1,48))end end local function login()term.setCursorPos(2,12);term.setTextColor(colors.white);term.write(\"Username: \");local user=read()term.setCursorPos(2,13);term.write(\"Password: \");local pass=read(\"*\")local ok,err=auth.login(user,pass);return ok and\"Logged in.\"or err end local function enrol()if#auth.list()>0 or auth.centralActive()then return\"Enrollment is already complete.\"end term.setCursorPos(2,12);term.setTextColor(colors.white);term.write(\"Create local admin password: \")local pass=read(\"*\")if type(pass)~=\"string\"or#pass<8 then return\"Enrollment password must contain at least 8 characters.\"end local ok,err=pcall(auth.bootstrap,pass)return ok and\"Enrollment complete.\"or err end local function importRecipePack(event)if not recipe then return\"This server has no recipe service.\"end if not auth.allowed(\"admin\")then return\"Recipe import requires an admin session.\"end local files=event and event.getFiles and event.getFiles()or nil if type(files)~=\"table\"or#files~=1 or type(files[1].read)~=\"function\"then return\"Drop exactly one data-only recipe pack.\"end local chunks,total={},0 while true do local part=files[1].read(4096)if not part then break end if type(part)~=\"string\"then return\"Dropped recipe pack has invalid data.\"end total=total+#part if total>64*1024 then return\"Recipe pack exceeds 64 KiB.\"end chunks[#chunks+1]=part end local ok,result=recipe.import(table.concat(chunks))if not ok then return tostring(result)end return\"Imported \"..tostring(result.imported or 0)..\" new recipes; conflicts kept.\"end local message while true do draw(message);message=nil local event,code=os.pullEventRaw()if event==\"key\"then local key=keys.getName(code)if key==\"l\"then if auth.refreshSession()then auth.logout();message=\"Logged out.\"else message=login()end elseif key==\"e\"then message=enrol()elseif key==\"backspace\"then elseif key==\"p\"and auth.allowed(\"admin\")then message=\"Use the Peers page on a desktop to pair; server pairing IPC is available to administrators.\"elseif key==\"u\"and auth.allowed(\"admin\")then message=\"Use nx update latest or the desktop Updates page; apply remains local.\"elseif key==\"b\"and auth.allowed(\"admin\")then message=\"Use nx broker status|enable <owner-key>|approve <session> <phrase>.\"elseif key==\"i\"and recipe and auth.allowed(\"admin\")then message=\"Drop one recipe pack into this console.\"end elseif event==\"file_transfer\"then message=importRecipePack(code)end end",
}
local CEETOS_RELEASE_INSTALLER = true
local CEETOS_PROFILE = "auth-server"

-- `wget --install` may run inside a deliberately restricted CeetShell child
-- environment. Self-cleaning is optional there: never require the shell API
-- merely to install the runtime.
local args = { ... }
local automated = args[1] == "--ceetos-apply"
local self = shell and shell.getRunningProgram and shell.getRunningProgram()
local cache = "/ceetos-updates/release.lua"
-- A signed release launched directly is retained outside the replaceable
-- runtime tree for verified recovery. Development installers deliberately do
-- not become propagation sources. Do this before removing the downloaded
-- program itself, but avoid copying the cache onto itself during recovery.
if CEETOS_RELEASE_INSTALLER and not automated and self and fs.exists(self) and self ~= cache then
  if not fs.exists("/ceetos-updates") then fs.makeDir("/ceetos-updates") end
  if fs.exists(cache) then fs.delete(cache) end
  pcall(fs.copy, self, cache)
end
if self and self ~= cache and fs.exists(self) then pcall(fs.delete, self) end

local bytes = 0
for _, contents in pairs(files) do bytes = bytes + #contents end
print("CeetOS " .. CEETOS_PROFILE .. " installer (" .. bytes .. " bytes)")
if not automated then
  print("This will replace /ceetos and /startup.lua. Continue? [y/N]")
  if read():lower() ~= "y" then print("Cancelled."); return end
end

local backup, migrate, migrationData = nil, false, {}
-- Public signed installers intentionally do not create an enrolled Cloud node
-- by themselves. A completely fresh computer must obtain its one-use node
-- credential from a nearby, explicitly promoted broker before any runtime is
-- replaced. The tiny temporary module tree is removed on every outcome.
if CEETOS_RELEASE_INSTALLER and not automated and not fs.exists("ceetos") then
  local temporary = "/ceetos-bootstrap"
  if fs.exists(temporary) then fs.delete(temporary) end
  fs.makeDir(temporary); fs.makeDir(temporary .. "/ceetos"); fs.makeDir(temporary .. "/ceetos/lib")
  for _, path in ipairs({ "ceetos/lib/password.lua", "ceetos/lib/mesh_crypto.lua", "ceetos/lib/broker_bootstrap.lua" }) do
    local contents = files[path]
    if not contents then fs.delete(temporary); error("installer is missing bootstrap dependency " .. path, 0) end
    local handle = assert(fs.open(temporary .. "/" .. path, "w")); handle.write(contents); handle.close()
  end
  local oldPath = package.path
  package.path = temporary .. "/?.lua;" .. temporary .. "/?/init.lua;" .. package.path
  local loaded, bootstrap = pcall(require, "ceetos.lib.broker_bootstrap")
  local grant, bootstrapErr
  if loaded then grant, bootstrapErr = bootstrap.enroll(CEETOS_PROFILE) else bootstrapErr = bootstrap end
  package.loaded["ceetos.lib.broker_bootstrap"], package.loaded["ceetos.lib.mesh_crypto"], package.loaded["ceetos.lib.password"] = nil, nil, nil
  package.path = oldPath
  if fs.exists(temporary) then fs.delete(temporary) end
  if not grant then printError(tostring(bootstrapErr or "trusted broker enrollment failed")); return end
  migrationData["cloud.lua"] = textutils.serialize({ schema = 1, enabled = true, urls = grant.urls, node = grant.node })
  migrate = true
  print("Trusted broker enrollment received. Installing signed release...")
end
-- Clean stale full-tree backups from older installers before measuring space.
for _, name in ipairs(fs.list(".")) do
  if name:match("^ceetos%.backup") or name == "ceetos-migrate" then fs.delete(name) end
end
-- Development transactions can be much larger than the runtime. Preserve
-- only the reconnect record; staged payloads, test data, and rollback trees
-- are disposable and must not prevent an install from starting.
for _, name in ipairs({ "staging", "backups", "test-data" }) do
  local path = fs.combine("ceetos-dev", name)
  if fs.exists(path) then fs.delete(path) end
end
if fs.exists("ceetos") then
  if automated then migrate = true else
    print("Migrate saved CeetOS configuration? [Y/n]")
    local choice = read():lower()
    migrate = choice ~= "n" and choice ~= "no"
  end
  -- Copy only durable configuration to a small migration area, then remove
  -- the old tree before writing the new one. This works on nearly-full CC
  -- drives where retaining a complete backup would make installation fail.
  if migrate then
    local durable = { "users.lua", "network.lua", "shares.lua", "config.lua", "owner-service.lua", "seen-peers.lua", "jobs.lua", "job-workers.lua", "crafting-state.lua", "auth-authority.lua", "auth-authority-state.lua", "auth-directory.lua", "auth-session.lua", "cloud.lua", "cloud-state.lua" }
    if CEETOS_PROFILE == "recipe-server" then durable[#durable + 1] = "recipes.lua" end
    for _, name in ipairs(durable) do
      local source = fs.combine("ceetos/data", name)
      if fs.exists(source) then
        local input = fs.open(source, "r")
        if input then migrationData[name] = input.readAll(); input.close() end
      end
    end
  end
  fs.delete("ceetos")
end

for path, contents in pairs(files) do
  local parent = fs.getDir(path)
  if parent ~= "" and not fs.exists(parent) then fs.makeDir(parent) end
  local handle = assert(fs.open(path, "w"))
  handle.write(contents)
  handle.close()
end

if migrate then
  -- Preserve durable configuration, but never preserve an active session,
  -- temporary recovery window, remote result cache, or stale audit entries.
  local durable = { "users.lua", "network.lua", "shares.lua", "config.lua", "owner-service.lua", "seen-peers.lua", "jobs.lua", "job-workers.lua", "crafting-state.lua", "auth-authority.lua", "auth-authority-state.lua", "auth-directory.lua", "auth-session.lua", "cloud.lua", "cloud-state.lua" }
  if CEETOS_PROFILE == "recipe-server" then durable[#durable + 1] = "recipes.lua" end
  for _, name in ipairs(durable) do
    local contents = migrationData[name]
    if contents then
      if not fs.exists("ceetos/data") then fs.makeDir("ceetos/data") end
      local output = assert(fs.open(fs.combine("ceetos/data", name), "w"))
      output.write(contents); output.close()
    end
  end
  print("Saved CeetOS configuration migrated. Please log in again.")
end

-- The installer is intentionally transactional while it runs, but retaining
-- a full tree backup after a successful install quickly exhausts small CC
-- drives. Durable data has already been copied, so remove the old tree.
if automated then
  -- The root startup launcher sees this marker and boots the freshly written
  -- runtime rather than replaying the installer. The runtime verifies its
  -- loaded version before clearing it.
  if not fs.exists("/ceetos-updates") then fs.makeDir("/ceetos-updates") end
  local marker = fs.open("/ceetos-updates/pending.lua", "w")
  if marker then marker.write("return {state='verify',version=" .. "0.17.1" .. ",profile=" .. "\"auth-server\"" .. "}"); marker.close() end
  print("CeetOS installed. Rebooting to verify the update.")
  os.reboot()
end
print("CeetOS installed. Reboot to start it.")

-- CEETOS_RELEASE_PAYLOAD_END
