diff --git a/DOC/m2m_robot_samples/1111/README.txt b/DOC/m2m_robot_samples/1111/README.txt new file mode 100644 index 0000000..3147ee3 --- /dev/null +++ b/DOC/m2m_robot_samples/1111/README.txt @@ -0,0 +1,8 @@ +Обязательно заметить в сообщениях: +- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой +- {ВАШ_ДЕПКОД} - ваш депозитарный код +- {ВАШ_ИНН} - ваш инн +- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги +- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги + +Если не заменить на ваши значение - сообщение не пройдет проверку формата. \ No newline at end of file diff --git a/DOC/m2m_robot_samples/1111/error01/config.xml b/DOC/m2m_robot_samples/1111/error01/config.xml new file mode 100644 index 0000000..ec1d4ab --- /dev/null +++ b/DOC/m2m_robot_samples/1111/error01/config.xml @@ -0,0 +1,4 @@ + + request.xml + #M2MTR + \ No newline at end of file diff --git a/DOC/m2m_robot_samples/1111/error01/request.xml b/DOC/m2m_robot_samples/1111/error01/request.xml new file mode 100644 index 0000000..e91771f --- /dev/null +++ b/DOC/m2m_robot_samples/1111/error01/request.xml @@ -0,0 +1,119 @@ + + + + {GUIF_} + 2026-04-30T01:01:01() + {_} + MC0012500000 + + + {_} + + + + + true + + + + + + 21 + 1111 + 111101 + + + + {_} + + + 7722061076 + + + + M2M2233116169101 + RU0007661625 + + RU0007661625 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116869102 + RU000A0JP5V6 + + RU000A0JP5V6 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2211116869103 + RU000A0JPKH7 + + RU000A0JPKH7 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116819104 + RU000A0JPGP8 + + RU000A0JPGP8 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + + diff --git a/DOC/m2m_robot_samples/1111/error05/config.xml b/DOC/m2m_robot_samples/1111/error05/config.xml new file mode 100644 index 0000000..ec1d4ab --- /dev/null +++ b/DOC/m2m_robot_samples/1111/error05/config.xml @@ -0,0 +1,4 @@ + + request.xml + #M2MTR + \ No newline at end of file diff --git a/DOC/m2m_robot_samples/1111/error05/request.xml b/DOC/m2m_robot_samples/1111/error05/request.xml new file mode 100644 index 0000000..c9502c9 --- /dev/null +++ b/DOC/m2m_robot_samples/1111/error05/request.xml @@ -0,0 +1,119 @@ + + + + {GUIF_} + 2026-04-30T01:01:01() + {_} + MC0012500000 + + + {_} + + + + + true + + + + + + 21 + 1111 + 111105 + + + + {_} + + + 7722061076 + + + + M2M2233116169101 + RU0007661625 + + RU0007661625 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116869102 + RU000A0JP5V6 + + RU000A0JP5V6 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2211116869103 + RU000A0JPKH7 + + RU000A0JPKH7 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116819104 + RU000A0JPGP8 + + RU000A0JPGP8 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + + diff --git a/DOC/m2m_robot_samples/2001/README.txt b/DOC/m2m_robot_samples/2001/README.txt new file mode 100644 index 0000000..3147ee3 --- /dev/null +++ b/DOC/m2m_robot_samples/2001/README.txt @@ -0,0 +1,8 @@ +Обязательно заметить в сообщениях: +- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой +- {ВАШ_ДЕПКОД} - ваш депозитарный код +- {ВАШ_ИНН} - ваш инн +- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги +- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги + +Если не заменить на ваши значение - сообщение не пройдет проверку формата. \ No newline at end of file diff --git a/DOC/m2m_robot_samples/2001/all_to_1st_depo/config.xml b/DOC/m2m_robot_samples/2001/all_to_1st_depo/config.xml new file mode 100644 index 0000000..ec1d4ab --- /dev/null +++ b/DOC/m2m_robot_samples/2001/all_to_1st_depo/config.xml @@ -0,0 +1,4 @@ + + request.xml + #M2MTR + \ No newline at end of file diff --git a/DOC/m2m_robot_samples/2001/all_to_1st_depo/request.xml b/DOC/m2m_robot_samples/2001/all_to_1st_depo/request.xml new file mode 100644 index 0000000..5f64962 --- /dev/null +++ b/DOC/m2m_robot_samples/2001/all_to_1st_depo/request.xml @@ -0,0 +1,119 @@ + + + + {GUIF_} + 2026-04-30T01:01:01() + {_} + MC0012500000 + + + {_} + + + + + true + + + + + + 21 + 2001 + 111111 + + + + {_} + + + 7722061076 + + + + M2M2233116169101 + RU0007661625 + + RU0007661625 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116869102 + RU000A0JP5V6 + + RU000A0JP5V6 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2211116869103 + RU000A0JPKH7 + + RU000A0JPKH7 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116819104 + RU000A0JPGP8 + + RU000A0JPGP8 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + + diff --git a/DOC/m2m_robot_samples/2001/mixed_1st_and_2nd_depos/config.xml b/DOC/m2m_robot_samples/2001/mixed_1st_and_2nd_depos/config.xml new file mode 100644 index 0000000..ec1d4ab --- /dev/null +++ b/DOC/m2m_robot_samples/2001/mixed_1st_and_2nd_depos/config.xml @@ -0,0 +1,4 @@ + + request.xml + #M2MTR + \ No newline at end of file diff --git a/DOC/m2m_robot_samples/2001/mixed_1st_and_2nd_depos/request.xml b/DOC/m2m_robot_samples/2001/mixed_1st_and_2nd_depos/request.xml new file mode 100644 index 0000000..22471db --- /dev/null +++ b/DOC/m2m_robot_samples/2001/mixed_1st_and_2nd_depos/request.xml @@ -0,0 +1,119 @@ + + + + {GUIF_} + 2026-04-30T01:01:01() + {_} + MC0012500000 + + + {_} + + + + + true + + + + + + 21 + 2001 + 121212 + + + + {_} + + + 7722061076 + + + + M2M2233116169101 + RU0007661625 + + RU0007661625 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116869102 + RU000A0JP5V6 + + RU000A0JP5V6 + + + 1 + + + + 7702165310 + + + {_} + {__} + {___} + + + SGDN + + + M2M2211116869103 + RU000A0JPKH7 + + RU000A0JPKH7 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + M2M2233116819104 + RU000A0JPGP8 + + RU000A0JPGP8 + + + 1 + + + + 7831000034 + + + {_} + {__} + {___} + + + SGDN + + + + diff --git a/DOC/m2m_robot_samples/2002/accept1st/config.xml b/DOC/m2m_robot_samples/2002/accept1st/config.xml new file mode 100644 index 0000000..e69de29 diff --git a/assets/moex-most-logo/README.md b/assets/moex-most-logo/README.md new file mode 100644 index 0000000..94bcca0 --- /dev/null +++ b/assets/moex-most-logo/README.md @@ -0,0 +1,34 @@ +# Логотип сервиса MOEX МОСТ + +Официальные ассеты и правила размещения логотипа сервиса MOEX МОСТ +(НКО АО НРД). Требование для участников сервиса при интеграции M2M +в свои интерфейсы (личный кабинет, веб-кабинет). + +Источники (10.06.2025): +- Руководство: https://www.nsd.ru/media/docs/rukovodstvo-o-razmeschenii-logotipa.pdf +- Ассеты: https://www.nsd.ru/media/docs/dep/logo-moex-most.zip +- Вопросы: moexmost-logo@nsd.ru + +## Файлы + +- `main/moex-most.{svg,png,pdf,jpg}` — основная (полноцветная, красная) версия. +- White и Add версии в исходном архиве НРД отсутствовали (только метаданные) — + при необходимости запросить у moexmost-logo@nsd.ru. + +## Правила размещения (из руководства) + +- **Наименование всегда полное: «MOEX МОСТ»** (два слова, оба обязательны). +- **Три версии**: Main (основная, на светлом фоне), White (на тёмном/цветном), + Add (дополнительная). Выбор — по фону. +- **Охранное поле**: минимальный отступ до соседних элементов = 0.5×высоты лого. +- **Минимальная высота** логосимвола — 20px. Для очень маленьких носителей + (иконка в моб. приложении) — только логосимвол на плашке (приоритет — красная, + радиус скругления 6px для плашки 24×24). +- **Web-интеграция — единообразие с окружением**: если соседние сервисы показаны + полноцветными лого — MOEX МОСТ тоже полноцветный (main); если линейными + иконками — MOEX МОСТ линейной синей иконкой с полным наименованием. + +## Где применяем у нас + +- Веб-кабинет клиента (отдельный проект) — обязательно, как участник сервиса. +- Личный кабинет / admin bj-server — где показываем канал перевода M2M. diff --git a/assets/moex-most-logo/main/moex-most.jpg b/assets/moex-most-logo/main/moex-most.jpg new file mode 100755 index 0000000..802d248 Binary files /dev/null and b/assets/moex-most-logo/main/moex-most.jpg differ diff --git a/assets/moex-most-logo/main/moex-most.pdf b/assets/moex-most-logo/main/moex-most.pdf new file mode 100755 index 0000000..af24ca0 --- /dev/null +++ b/assets/moex-most-logo/main/moex-most.pdf @@ -0,0 +1,348 @@ +%PDF-1.6 % +1 0 obj <>/OCGs[5 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream + + + + + Adobe Illustrator 29.2 (Macintosh) + 2025-01-23T13:03:26+03:00 + 2025-01-23T13:03:26+03:00 + 2025-01-23T13:03:26+03:00 + + + + 256 + 88 + JPEG + /9j/4AAQSkZJRgABAgEAAAAAAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAAAAAAAAEA AQAAAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAWAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXgf5 ya3rNn52lhtL+5t4RbwkRxTOi1INTRSBmk12SQybEvoXs5psU9KDKMSeI8wGDf4n8y/9Xa9/6SJf +asw/Gn3n5u9/I4P5kP9KHf4n8y/9Xa9/wCkiX/mrHxp95+a/kcH8yH+lDv8T+Zf+rte/wDSRL/z Vj40+8/NfyOD+ZD/AEoen/kRq2q32raol7eT3SpboUWaV5ADz6gMTmx7OnIyNm3lvarT44Y4cMRH 1HkAOj2bNs8S7FXYq+cvzN17Xbbz1q0FvqN1DCkiBIo5pEUfukOyhgBmh1eSQykAl9L7E0mKWkgZ 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 4ZBI1T2Ha3b+n1GmljhxcUq5juIPe9izavFv/9k= + + + + 1 + False + False + + 324.199892 + 109.013000 + Pixels + + + + Magenta + Yellow + + + + + + Default Swatch Group + 0 + + + + Adobe Illustrator + application/pdf + + + MOEX_МОСТ_Main color + + + proof:pdf + xmp.did:fc2d753d-6360-4cd1-a9bb-76267ec41067 + uuid:54ec4919-1a9e-6748-8bd4-618239af9862 + uuid:c3495cf3-d8eb-3c45-a9c9-080ec3185edc + + uuid:b9e9e2fd-4e3a-fb44-9c02-5e956c1ec88b + xmp.did:4be68e55-dc9e-4a06-a979-05206c001353 + uuid:c3495cf3-d8eb-3c45-a9c9-080ec3185edc + default + + + + + saved + xmp.iid:ae87f767-0a1a-4641-bba7-2b5118514c38 + 2025-01-23T11:09:47+03:00 + Adobe Illustrator 29.2 (Macintosh) + / + + + saved + xmp.iid:8462417f-f4e0-4139-a52e-dc089437bc70 + 2025-01-23T11:26:54+03:00 + Adobe Illustrator 29.2 (Macintosh) + / + + + converted + from application/postscript to application/vnd.adobe.illustrator + + + saved + xmp.iid:cc27364a-4203-4fa0-bd63-a0aed7df92c2 + 2025-01-23T13:03:06+03:00 + Adobe Illustrator 29.2 (Macintosh) + / + + + saved + xmp.iid:fc2d753d-6360-4cd1-a9bb-76267ec41067 + 2025-01-23T13:03:24+03:00 + Adobe Illustrator 29.2 (Macintosh) + / + + + + Adobe PDF library 17.00 + + + + + + + + + + + + + + + + + + + + + + + + + endstream endobj 3 0 obj <> endobj 7 0 obj <>/Properties<>>>/Thumb 10 0 R/TrimBox[0.0 0.0 324.2 109.013]/Type/Page/PieceInfo<>>> endobj 8 0 obj <>stream +HlVI9 @ER1y@bllpPK. + +W5_(zH 8؟սo_;HS> ޜBwǙTs(%;$:yUɶ![ڨd*0POz a0XGJ͜@ "K(rD4gsK`>e:{B(Gx}ļl'8Nu]ݑD-R 0~"'UtjBsY}0\vxmXOfPUuH(QsE1(B11PL~z2 W)-Ep;O`q:QQIRl.Pium8OPOC]wʣv>.h1I$P'uuCm  j@B!#G*VDőb\ew7T*tFaY"um#xyZI1/61ZUpnah vAAo.i4L#jX{f +rZk`sƉP!"W6nJCh&67(ą{iԚ% Z֑N)vM#&F-X1n`:3y\p762筻XF6zMAfAM3iY{'lq1z+5ģQpfP||9B25Z1">.{/䉠d`'g4`0p~d֣jK|Ñ'6e}\'|~v&= endstream endobj 10 0 obj <>stream +8;V/Fd1*l6#R0t[kOIXV[n\HH,*A\g_q<.hO endstream endobj 11 0 obj <> endobj 13 0 obj <> endobj 14 0 obj <>stream +%!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 24.0 %%AI8_CreatorVersion: 29.2.1 %%For: (Evgeniya Zelautdinova) () %%Title: (MOEX_МОСТ_Main color.ai) %%CreationDate: 1/23/25 13:03 %%Canvassize: 16383 %%BoundingBox: 26 -108 319 -9 %%HiResBoundingBox: 26.4301984518825 -107.492899999999 318.183391962011 -9.42722999999933 %%DocumentProcessColors: Magenta Yellow %AI5_FileFormat 14.0 %AI12_BuildNumber: 116 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0 0 0 ([Registration]) %AI3_Cropmarks: 10.2619999999997 -112.9666 334.461891550782 -3.95355999999993 %AI3_TemplateBox: 145.5 48.1062999999995 145.5 48.1062999999995 %AI3_TileBox: -223.63805422461 -364.4601 568.36194577539 247.5399 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI24_LargeCanvasScale: 1 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI17_Begin_Content_if_version_gt:24 4 %AI10_OpenToVie: 0.477903812848126 23.9052077639226 5.31251070480716 0 8209.01875275996 8236.97163761074 1716 984 18 1 0 6 67 0 0 0 1 1 0 1 1 0 1 %AI17_Alternate_Content %AI9_OpenToView: 0.477903812848126 23.9052077639226 5.31251070480716 1716 984 18 1 0 6 67 0 0 0 1 1 0 1 1 0 1 %AI17_End_Versioned_Content %AI5_OpenViewLayers: 7 %AI17_Begin_Content_if_version_gt:24 4 %AI17_Alternate_Content %AI17_End_Versioned_Content %%PageOrigin:-152 -377.393700000001 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 15 0 obj <>stream +%AI24_ZStandard_Data(/X4  +撓;Ed"p$1d#Imqd?pH(, l r rm1gv0nkjaU[<7up$+U!ڿ6l~[x㲞e=a?Rn{_1ɼ}^6e[p?#q >eqe]֔ x|?_"iC3?{8>m) æ>|j!G0vfQ|یN @ȴ]vzHaCm4Bv֐iP24Ɔ 2ݸv<̑[e})*,0H[ܙILg{1MN/ؓyQ ^ž&C)jb5& 똤-zglg{ԣ*"hH(E.nQZT4D342$10p:LA4 AbԠ_oS{~v}b;ؿ;ﺫ<(7yKNr;\sՍs&o7{Tԥ&5G-PԟklSPf3a U-}? 2E}l?`'c]u2|}uQoZ&4u7۲ϾzVcSٯj]=_ο\5ܢȦ6 גN3;W̅a Þl%}?rܺU@']?M[Nyt12YWe--2mOd1| CyOk__Vr7S5 +v6L_j'M h:5'~YW$'5'؍Q_I;;Q쏌y[U l(OB~fg$[ p7Yŝ}ޡ-]$ ǭ;R1YE=~Zw`rNP55u|5)_3oAIoKa_qȬ" ZO۪I}l* ˞3{g=u$(XCZ ĻU͐>A}?߻/י_ $;؉";}ad;8@=?$CY7nyO]d|5nr}gaywYmg,p۪=04R=Н)K=BуDQł&T"r;I 5 <ChhapjfnEEA,?By dܰODqj-4i˘@ S!qܗZ;CE MeI8Ӱrht ͠;qFmrf3CHNDYe+%2;a [bi!=E͂A2.}ߣF((&R@_Y~Jy"Hu0a<G s5jHM@;O Ja<,&WH8nD(CsȕQCS@֝8Ұrh.OXжX?0"K؏r1p<jcծ ʾ +^n42Ʃ}^MҀbpJ݈dYR N +w晾PtDMR8yHNaMPKIQXj +@'w NPҎ'vj1A %sԝ)U*uw@ xkCCCց8Kڪ]942 $Qx*pr0q iQ\m + Mt|f!4axrCl8Y( RTc6C%P¡tGaHh G7TH @iI`PP¡J+4=h‰%Pm#(QpXNe -,{ <zДj޳ӛNh +_X]AoF +ꬠ<:Uyy51I`M[^*YdW_?2M_˾kd6[^rvj8fm6?ns^pmg/g//˟Sf2yMe/v+g_ YWs?yٯu&e]Sܲ7^{l~ۈw](Νew؝Ǩ*v?9)8~@ѬSohxx8~V7v&A8.ĺz~}UܺXa. 8{:vPŬCSv6 mݔGuzϚ:VKuʻS?vQL/ߔx5*§Nٸ(v%wr<{bpܺ.&m病O^~b a?.&5?좓?V:)|@m :Q3 #d],ZW3ܵN'K`cTܚ:yag@f[-CNFw=;4oAlYʌ.2e,Y1Oo&7#4G{:|D|xȻg5n,rne{\])e(DlN<̿DV#Yׯ,ÞR| vrwP6 1Cݍ䪀L_pԺ{[;_Ey v-x_'?;$-ؙEX2 2LgvuȳxĶٙEoףQ@,< +.hBjF `3IZAn$Yev~ِ?gmÔό/Rލ|zKb93_\GoN xjQXj~~-o.{3'ي;,_zB&񫓉ܢ[Z_yap ByϝϘ^N^ !_番Cwv&{6}!a]lIxLd1q}&]ϞI..R.eٓKt̞\dV[fԒHK>p\19,C&SQ񊪢j|Һ [UQʀc(}zʐ:D9JqްM$G6 +Q'r%-{6g(gQ,qZjJ7A?6,Հ|R߰bE)I(n`w)}؇Q[Vp)R"AcO(A+ ގ | ukڬN(AP@J}~>IםІ1D2Ę3dad1D>>o 6ņsOu8@eF2.T١MfMdf%EV7 C [QdʰI (r}Xe]ܤ+)Qӆda ⟍ eec!O +y6mmqZQI +Tƿmy` W `$G>a:N l" K:ftG Xȑ B) F2W;f({3YTrI!BNeAZFo!/Ф a"FZ"b|,i7 +.w0yFtMr"]߸ %#H [Gy oy/be9?Ȃ/4p13 7մ[e P+I|2C -F AFho*#rWXp~T?6 +H$P.~z(a"h=7. cPQ,,IJnHN&(,'} Jyz?ČH0+<~V> X7쓀XuԼrJFԃaG#Q:1Ua@i+t!@V=ȚVuHF`!7u\ J4P6& +_g:$W,(J@vxA! +BP8QQ: 5>}c`SdJˌt, 1p< HK`BؘXO+l\6lWCnWɆy#luPLRɪƆi-wuSd`dUeJ9 +Uf"a4$S^ί3=Jc"WSQP2́RR°RDTM?!u[es$HaOƌSZ.@[PV5Va)<3I`@Bdq{:h&^X֧ +'P88q%ᴌ{hƆܡ݉(݉ g҂bJ#H偤xiVy L˃8w@ oNH" 0U~(H#0n{l{R^ nS 9[.m˂^vSIG $g\)" @*:y4t* =;'R#j& +)@Pۓ2{&=SFc!REw $d,@H C$p*232(I58JZ"5Ò1 stF\:Ny ,!)K 6 +04(UIMDiN2:M[P u +M3K\iN +CGeg`TO?Z+P6O% ݉FC-Ccxa6کrVН8MD^ciɡiE(9r`hKs& +v5LH\q;BFTϤ%o` k6 Yә^Bn0l$JD +.V5nR$]JFת97ltڥ-Ӱrh3! +/ DLEͬކ=jϟ@HSsM;!L"H^pƆFoRff[ÅR MI'8^ +,,qA!N8\N#ݖ!jԎiL{&ˎ_)*ıc.SȂ;@;; }k$5uI"* + +Y!Y`0b*GՒV5;E8چiօLGmHt$0"+y@_l$pq*mZ $҂@/~`Ia*x̆ +J,*b$W>Wd:/kCH :xE;$(L+ +{̅` jz=jhH{dB +RU'Fo/&1,8xĦpnW:M; C9'>x<@iv!:/JOMb&^?H׭ͺ&|g11]ZKhi`야- - bA@)p|\2*+@&Y+jrSܔ2p)CSmP$6gJlZP¡$}I>2gR2Cvc%kIB}u@ ?a#7 tc"9".(}9Qȑ."4Js +! +F6^! +%cmSR5o BcCH Jc +ސ>Wa`18EEzA[=y!=Ƈ8 `2P +gs Bji!pv`'z29& +=;́́́;]#huB8Ύݻcpi0ʆi _}IP`ݗ*%^e]W)bRWDwpbQpTNl^}afq)Ie"B*A6BW\Y92.p圯@K \ǃfkǚ0nWe,|K}&ױ[ #*cO@ce=X=} @GPA$"FXhFGWz @*Zp;J +&U;B;xBYIcKS iV#KH) g/d(tB!ɷ~' C8;@ hSHi"s'YXA+lSy<Q臃k0J}nuomf1L.R.{ri->@. @L@4lGˤtEőB&%LfIE+rd+j=#kE-8k% + j6 }g䯋pҪoú}+zkh/lN=:,\oϞ=m6^Lt?$x?Cώ}O8Cm9UAc-7KcO.+}zg0 V,@ph˿Q!O <3$V ]teyΪBVˈуޭQ}7.2ٴx סΐRf㵴GlE՞vTmV=)d[.!_V[UVp҂ :xƝu`vwOE2}LWa~5Ѽ!Yɼ'b䭉s#c$CQ~= ONy= 0 +<#H"PR}GwNEWƜ^hZ3a-"MU{R#z]58`}؈XUш.Z&P{B+p*8G ?;ktroyqrϐox0FrϡcӺJ<Tz,H -?`O3Xr_' əǞĂ>,~4zG+2O_ݞ +EJgr&.! +$xaK;`j?pJ|1S=vEC_ p9enC]%JzRڅp}"~}}=4!@DžcjUM .e.gg;)8e숉 +&TGOQHTO +5_'M:40%LK뙞{N`Ք5O]l.b*{8\Kel92:͇5LC<y1Ӡ!]ze&(8543\Hw JuX.~ 3;x{VKIVaT9AHe\,!i]1%9={w] *I:q= +W[2f ]XB}^FBD8"b Z w31X)+N7tZQb8XzoK$3C!Q :qu`/cj 0[j$}%=c GCfșČ ?gjtU>K~ x 1|1xA55_Sh&Ӗis{Bd*f:*.O鮟gjQV1&eqT.3jD?vۧrLw+׬$Zd%/y:J⤜a;6%ǀN¾hVy +lؐ1?)|1x:w>_Χ'PE9rtBSxBaЎDxPKTh Q܋| rj^+׳"*90h +S*}"l\kh[-p-e9 ZU"O0:eWb!5$GyXFB[YJAPx(L(2:= O#D87~v ~r81C'#igPE_><]EAΎk +g?H'p;m5z&MYedI4>y0SjV_,ߓ@┛,v SnQ^(<% **oNn_dE@,PFNR]iGѦvц9 FӁNä3BoW[B1bMx B«yI.葯 $np\XR5 0l4#q-3o# +bT(<AW[1h#lqC`GgvH̡Gp.}ƍ`NTBu ``~Ȁ4]ήLfȦv4Gpqd@bÁyJQ鴅KOJ8 Jzdt #pt(}b]zr aАs"y{J^Ih^)#mҙ)5Fƪy2'3ʪ ԅP-0Ǖ2U-:3d;B5xPj[m#qT ^Z]8տ[WK ПXuO8S= 9S\zۓ`!YYIGIG@8LF Cݮc7ɷ4YBf)^ 4qj^;݊p.lbALMxWK$ /ŵEWl0*D'ĢF{2 w/ǀ18Gx2\b7Cf2!-8o݂FS}a])Z LMGo}(95/$NHOA8JG--n#E"`O7./UCȢZ˖wZn!vE]r'MS߹QdO{7(6ێ0Z(y};1A(%k5 )SO:3+-AOFlYto`f Q +J&Bwudƈ%b6,F͕rZW-bE`#>G;ܣu'Rh*Q?0>&?>`yHn:=Ci?QR# "3Tl~ٞS8B28p6M$;ːPxt;22Ծ*d;' `k| xbѡj.zٳGnL,iš#+"UXm%'8A/*6c r-Z6*'X2_~]3S'y(tPTԥJ_ PWHʵF'*LK7qԵ 59@] "OBcWI{~$@]E4RgJ "G7/4QQ{.`9N{0vǢ;8[$\_k 2l ZЍޓ+_] "bӥ7݊TXeؼmf|3#ooDqpZ12ږ㜐|NW6R_> y!۲wЅdM$%mGN )pIoܜϪe !4/7N(TP0|ŽYj$f@Y.Y$rꆞ[QK1W~xJbIa.N-,J]vTq-]:ue$ZSW ,6%*uY`zv\l7j -}=..WB pY !?S\G;{%]%3,qgi]%\C_Wg>a vY +N-iF2D%-RE F ۏj #ZPsƩO8Si;^e)":oHqM[OnmB !ƭ,\CIo5Ii_A*5:xo$z.>s+btMq_xkWX +|ܥ|HrhXA)J+HMg7_ M"n*{_JX'+ceVc Ż/kF@LU/>?3f9oatG!ڿ77KɄeyc3qu({c<Ȭn0\=JZey2eC :9n5n${7$z +U(YRޚO`Cs xD!sl=UHҼ(C>+{7aNThZ:np7p5fL|"MѰbxȜ`A6@Иs{g1X~}+îW]=pa)*Ű(r+,ZAO" -arknR[ke(=dnJǢ +*1n_5E@-;3X[I3aD?,(eQ[D [PE͜r_czJL?nVq*GjA숴P:DC/EΌnU NHN wCPBnG }RѳXWBlA Ya0*_=-L7̀@wL|29vn|Oy3:[>{Kh#ܥ؃@T"qZ&m!)*zeH .+`g52 qbqs Ok#c-.v"%{{Xyߑ-0ީ Zχ}O'uwhm@~VD^<|B D{ntBc; T8tM:PϒRY< ~%`|1cbJcF-ZW`z*kU Bnyy{H05T{BkR# )/nQs/ī&*ãe)hx5-_U\ms7!qhգ CAdU3 \+<&hg28ۄƍmItMm&.V 2HV"n~3 Ё,Xnoh#m5JEE'@i7k+"'=]!04bR'4I2. xsBKF#;w5)_ j\'XGY5IR9I? +QŊݰ۟g!Eڥ(,WH7T/(HN!HDAWP z@  Xi;pC8Րr |eueq-J<[۟%  HۂE\0wgG.X㸺\6I>-=iyov̫ٙ8U喹ᚁhU'):¼ C^p&2ۺjLOνGy"n t)sw2HZüa;{aQB z\ړ~,qA@HV0$h0?%e&z4sXX҃Yu=4+ct^eוAVj H["sCĿ6/;tl(`)/h`h+hbmyhQuC>ֽ:{\ۦ v ]We$L +A)&:[]'{>\ Qg]Db}dJ[SV[TgOJ@{ᴉ4Bmd`Ryc:>Q#-/qIu2[EM&U`<+ȳMȧi׋#/ڀK9X^m}ma0rgԙ`^O)S +źdmkF$b<,-ܜ\^ơM/+447Y?sC3$8vL5+4͇2a ̦M3s0omB3]!}]{ ͡L H agֽ| qWY>sF.e~]L>syQ}rX,4#'fxB"CsuwC3BYP?sy a*ŗFm}&4 g]wR5BNc?sSC<#Їf$ UXlB3*OX\}015fqNn3i/syU[L1!3r6䖝 2UAȀOrt+2~s >˱Y!DLIY5.v5{"[xb) is^L1lG:PĂX']*N031eatnL̇ EӒ"vyrO7+c׏!a/#QgD`!hq)h-v"g8[xIr:9Ȅ$~X@GqM64Ĕ40ՈҝWx.)_1B&DXGsyl!Qdy[0$uufG$m|-cH yGwv8BfV}'sgDcluOŪ]dȗ"06+_ }!,o%Sd(,rLe0&h]@}xSTp+ou8MVА +eݔY-iTܛGeQ h;<`I +o<1?2g9:&lN/]n0b0GެJdz4;>;qnx"O0(']RP<`ޙ|m/Qh 'd}'f+=3zvi}q~%y3{]Wn"Ln,-u8bXOS8YOQ *|Qd^vLEi`#Pl5 TFbQKCN(s,-]6b +RSW@X|b91Ut`e0e#LDO9Dd?WOV9=3r +؝'~ cgԖ'tjHf`],1k"@|@#Z⮰1RT?Sf#99(1x\-Xx5#5zF#bÞ4<O9]ԈT*5T7&P}Brx[ŻtLU gG;A2Y-S`6"jĝ8=7ix&ckI+Rk'p!Dm=IQ K_|\v{@Jٴ` @ d=SI/jgGk.Hawx$Sغ w7wK=&P#$D̐AC }l1VK +Jm}@gFapkQYR 2g?hQ7Bx0rbzF v[{'˅yqDY]~UK,j/3&%';ꗇwܼ8LLcڽsd{$JERgCVVK8\k>Sm:B/KaY)yKjd 1EI\%l Ȇ؆-T3ML<%- GZYK|6j$N5X +|; y1,I{` ˄(W#2 \g*L9C_74gQN?QP] !ۜ~bA]Ci/6 Rܛ\$sobVx7T8 $]䃴SLoF^YZ% < +O!ǧ+*,˩ &alElHcvx y_O𣎪 E- +NQZ`| G@[`Ĵ9 pAyؼ+Y6>9*D:G,3|(.s  ld<3~8Y &X!IhbmuPh]_~{yb$/Mb)nNQU.1 Y|F XIVøs )Bojl}\F0ͱR>-4#yy(L%t U0`5Hm- q_g5r_.(Z&IUKN` ,P4yޜ9TzrCla0nl'Vx喰;o*6^vDX[d(h r &G]zSNr* jse;&oNJx#j;4OQeū +|Q?1 5T!qOI +Q0 9R21J*s=S;_^!}x +}9'JZ vTz/3a#abt<` b#wrJb[.9:\6KFR95iem@šB~m94Zp8:1u2+w-`ǵ3W_*V(?zEdݲzJY.qz j/{Lc`3-dHy=PJx6]~昨B |I0<> nxL=f`[pw +v jR= 8J14 cI +=,= =9zy» +s%R-yKyJLrfRL Ps HQB*b9 9A nz PLGKkN~¬c53׈Vs[knAsſh2*Mq er֔/#gxJideSRG>FQGj7qjqyGE̽[Q*|ݣFAiE(^Ϡ4DB +sH1W?FprU+1buؑ0]TG z[YkeBR/xY%.gbB|2ğH,Hxd2PX4>9i2!ֳ2~ \ }{ELϭ"V@ʫD#+`RhßK0c~eaL>M2 Y\H#Imkĉ"K~o0B7HtւSҪ3ĝ\?uN^)Jvs+Yr~Irԯ'tHR\jJ({pTFӔ<_aBBSJp *n&i؉Sbk[qIk/ ]}T`8$JR<]`PR: 8Hs>QЕM^'4R#< |"O ~FcUtX>88f7ˮ%+).q<-/cT %q3*bqqG@mID\/mѕbM Bddz%[\nD~[Wgȧ?jTbΣm +!jޒH (0ްm 4Ancwk‰_0 נ|pNZ<vjQolIqW.Wyy"KOr¡g^v"[-de.N;Dv1LI'k$>YjV\GeIKqiS*AZ +$d 4h3C ެ= 3xkeLQW4v0ZdmQk +_FH|`/ +Z`n‘Ly.I{`<(|)B._9L#˹⊵i}dnC2 aWяF>Q6CYevt(޸t=o,;IL@dW? XF~il!1a=\iG¦m+ZH~5.3gŵm&[`EJ 'd2bO_E3W$j)@pTB;{bɥ~u% R-qV_zV+[($dW;w;@†w63 @ r%ɄҦ+[w@-*Ʉd"Pco +6pK&q"TZ.h2]`ZM)[Vi/[3*`aIbua{0},ɔf 7 +b--;"P?ѫfc ln2DM=?uj}jNx4(TڴݝZeK9N*jh䔪o?UפEƒe Z$'*/`]W-ʄX5rbOlW@1k@OIڀtJ|8<ڙbiT5 }v*#6U4\Gi"W9I)E[GMZKjn\· 0u$oᑮݱmD"d/O7/f}%.D܊.m0 +j^^hɗLl(x qaVG@LLf<%yʻ?֠SuQ4 AFl y_ڋ-yई.&iV`Șo={7c 1,hJ Uf 0Ny!û YJC &+ÛaݭA<F`ߣ i f'H+8ۧl)rء*}H/.O[95S@⫲V]OECeڍxXYb+MprUdIadnd'3!MNhN}=}[aFqoK-,e|6GQEXAg9B|o+r+aWSn'htWVv- SןO0$媑~9E8= |41Cщfc І53&Ȑ1g + +Q!A90!1$O락иx:A>#Y=B;ڀiK-,swE"h.ATqFĬHXڣ!GcaDEOT![Q΄"AH@f|<9$` K E U:h߱]ƝcR%~e~m4h_}kapA7\ZQDچTz BA$xmp{"ڝ+Xvh\D~'4~lZcS 'iLb9. =ۅ}1Cp?ոb^LÜky, {w.qty4 +˺Fo:r<D_S@lVLv+ƿ , /:>姙0+?L/R]l>xe޻4؅w&:c9˭5|_#G?CAC>h \k"z7 >$DyULAi)^SRf=VVj::xIttoNRT0y$!__: FE8,"qAD>˺ +!Xna)ﱱKuB`)M2.'An,% +z^nL1pqb |Xx'oܦAz ]v#|e Hil>/t`Ng3L޿{"6]H)yUa"EO-ͽkUoKs*G2:Xb@&zR~DԾnȥHTuQE\u/sseq]n MO}Y +x6o0꟬sp'IJ+Vx׹cR`vܒQ\X ʠY?!VJ*$'H17 AqMMF9aPz:]gl3;cb 5PuE:YBBՋjإc*N>+z)- X~Tsp'tVv`cOn""rwvpq:o𣛁ǒn.D(1)sv-r&'בd:|n6=A٢fub8 ƂbMFljrP;q'jqKue?Qsau:so> :lQnȍaDVv%gqqQiUeaiy i:`J]uH}] "2\Ŵ,BM:3KDlJH^: ͔P8 2 ȭ.~pLW]ĝOST6&w`Qufe@TU-^pR#%yǼ:šfzǰn㓬xV?(GA/&S`x?^Z@AـɏXNM[:ɇjh!d +' >G :2T$VZ +6h`l }3xx+ o|_iw8?1ljIXvjv({=X~ +vb.ts^4N q0W|Z.(bHzA!{A +Pӭve'US>} 8&__QyB'u~IYȃmy6q_[Z,#ܣ:<[Kg>o( 8l+q,L7Ο FOyʼn»[ș47(jKl`1l!`(u6/潖 hVܢZLkoO'`2.ɇ ] 񈸎f"4,ZDyAU὜|pٗܢkhM nHNe`a[4 +d4[{%agKJs{zPjzOT={3w|+lӒw `[;|GIMkA؂E0TUxB7?IKי ^YkR7CQ8;_NUҗJswLJ:4wH?thp"`~CgN\\15 ʛb޽ӳbƼ#07Rb/]sK_HɍLIO"UHs(u t6f+MR=ۃ&W1jYL5u@]ZAgu9@^+UN>lߡPWг䄎S6,#hTC<m׷!^"3CA#;hg Ɩ^`'ˑVU3M(ߎϙ &}&=BwGaES2/-#H$Ov )}OJh7Zb@:CĶ-~/R]'rߦϚRM sQiUT89B]k隰0A ;|K%H H-sb>-%l9sz-n::NDh2f"*ډpG_AblqXVJrxL1.'C]ȣcnV}k "-6.=Ū^-BtC`,+-#Goày tQĢcOA-)BaF>oKP'66"y:"P>IRrܣ߄>lIžSJsuFxK𕚼LL^%w?Iin">BhRlVSx5OꙀJ})a bty:?J@y_i%4&1NG"ڕHiѥ1Y,(aLuJqJ+an?)=.Yd?Կ!w+u~郸^Jt@)ilp߹41N8G_"\uGҢ|5^JsvڗB)45m +LXS~tz$Y:Gb@TΒ( %!xS-鋯W@k@ؖ** pnw{o]A%/ }d)ͻAKy:XQi +*/*ӣe3S x L彳FT GAYYRU}K\GB9i%CՍ! @g~:mh ,9X/ \T}Y +eh,'rj†jh:Q u +[pTop'lP"Epڜ(UTax̢|mAyuXH;98؆҇Gy T9bۣ )ɧsR):ǁ[ e(HO3 ֕ H>FYg/"qGtEUuפp +>aP[uZ\8!tI4U:(g2nꬳ>iTIp +% 甖d5E*!Uyd1fdcȃr>*2$&& aH `@ +CYCI& #Тp8TOЉ/.q2r +: \ƴZ$b q\M=O@)(& Gj='Se Qş6RI*^˧iCz6-2vd߿jt@ 4E~߳U𖚾|‡%.v!B-pl#bfӊS\GC\7Ǵo;|_bG9]UG|覦 LB*bjS'kPOI1L#kQS9^+oUea\mB'thLRL@BuJ}J`Ǭw7Y:S'U3?N1R*]^q M&tXg MPdo]1Uwe5׺MTFpYiy!A;TwOG}H~nm}h=@mhwn2fmE8qcFb +J$EL$^ @yXd7Fr''UdGg=+dw8!x?CtBʙ^XV@I&Co!pxY)֌ Wfĥ;sDu̔pƚY$$4݅ו(p:#RPj|8ls)$v /օ|PVSO 7&k˦UD7:fl$'!2hDl2D`hI&WWxMmRެ|.WW*avUDF^5iV5m]2ZwUEy )g6:(Bٞ ԭ :?`'$"i vCgVy H=hڏűeYRXo+**j +PhTMEkUU=$`[w4gRbXh<BGپ#T%/7۶.mɴAւΓ-Z`U0騭50̕?u +:4J;Rm%"yW_yӾSE#O}@}Z$E ;x}{SU8izwyd]݆nTBzߩ^S;Xj{;վ{uT ީj5 O5Om;UTKN}jD #ewy53N8ߦ-AXjdu0s02wPNJ$ӷ>Eb]87V&\koXݩ[e@ \YqKI&xR.8B{-3 L MVHNZ1:#ߩJ ?/m{s;`WqxSm/ca=|v)(Mj 9+B FNTJ# >SeTp5// +vS;UL,8@>A|:G滴'P,.Sz%ƒnITyњ `ܴ~7AVs;h_-aw:,&+DSUsВk)s`2,#4S%TMTڢkm fU,RL Lju;TzRZfީʔ\--{If Z T3pjT4NlPWT!qЖ4^N0ghh>u>m"ȾN(d"ð$sq*zg۵HSdM( `YG`ru^I=3*O*WP[HCS-@Ebùٖ飨#{_w<)@ ϞTҊh +9#n'4Ҵ {6T*wm;GQndD_Rmb(w}q{1~J++OҤ下++XߞbEh^թٗ@7%Ae0N;e@;){:d +FK4T]a*pjPaEןQn9L"ZIY[`BRGT&6.$O7[B̝jNZ'jk)ClHc۲ jpF[T7SU%Ӵ )lj@Pgsk#Owx}t-g-eϮA CULDuec +٬6}݌4hWG>5Y (y!*Pk S\s|5jc7dT[ݺ0]zCM^7&>-fGOw(^aVM.DzPn)!.[V/:~r;4+i1aðP3dR*ݲzƤIⳜ(4(#K/}|u^dbLJLyܹtH{D +c:/&.BuE㐡C ix%ETl^\J^C-ㅗ +3sCDnp8+B=20^ poZ> 1Z R7Xm ^z"@Cͼ6eL|FTMJdYp#8,Sd1~d]ld3(7x\ uָo 3Pk 1yo1*6L;9zQ aWčWuQEOhyBtK݌*ʼ* FcblF0]ѵ}9X7~Hdӈ8(GK: ͧx(9>1FLz*)a҃N U, \ rп8%4%gp{X9;b<{=#'ND PR W;T"omu./ +L0ϟ#n'З + ( +&# (a~=" +0Du5ZLpWXיZVTtdVs0|$g -FNz7!-B=$ش?'5`[7ZQh| +|%KH5 ؚf:'L /-zĔ]j}:q䫿eJßcA"( B [ŭ19Z05*Lg; <9lilrJ򑄽3Z/[>[$vLLxkCUn6,fz]23Ub޵czFJ՘["v ptجKrYxTY4W-/H 7u2%YXt5\㝼+ޮ<՘q_^, +I °pƔQYDsEKw=y/qdEPaF#ۂȵcgJS%Ycjmkq)WclHļ6ϩM.Lq|rVԏe +y>,--^>cyfZ(rjq3v*vxiE2eqp q~뙲DF;e`e!1~du٢FK-n*%9S}ZL,C#:-¥%sFqT:c&2eYOIߔd0)kjQ-8%dS#s]Q: +K%kᗘ]K8t+G:1Ivاj`Hۖ#Em"D0K(f:>DH;mJUU,fmoՕ< ٙh_pcX}ș@153r|.- cbu@,t2Q3~⟱,ߧ+g+43WA\7 TlK|xfXx]R_Aɥ'&NB-T<nsf*[[R [j!5s%]9 +r+*VĖkJyT3pwYEA__~r+:&-)Uf.bP42_T͛ l8)uR*@=` \!ЁH"S?Gm騉4e|Vj /xiaBkz)wr +B6q-`փ=~gCŦICV% uhSkݲ4 tYg$ؙ' t5Xc2Lh3z9 Sz%"+.@[w>xSv5;ptX-l?"b%,u˝;b!ASɯAt)kшE@xg*/spP|Rabn11M??dGsF<3&(.xn@x 7<Ԛȴ +2ơ#cD2l3'/l7 uG@ܕgA!8$h1w[A?ZkGYK&jU݄p89%5f@[8blt4wȖ'<98 +xN ~}8x냠M='<n>'8eC@jΨՍX4 ȗǜ?di(P&>֍OXܴ6\Vw/߬nEPG\X7B,֗MaupÖʟl{oѺe_{xj4Ʊ!CT-}[la4=vg@KIԤ)`Ѭ441>WN80C~hbֱSh.J VS_y_Me Pᴾ&9 0c@y'ϴEEL c&(e|.>"yEG9ܯ˼>CAi^tǹ?GߗWZ9ZPLN1^!Sl)gKɭ2 l]5(S#CF_F< (6*z8NTIGg|Tj`q|Ʋ¹  a'jhꡌ& y!&J5?+G&S2H\Z u0[">=q^!R9ܵJ,m. /"Nj '$16;|edUJ=3Jlޣϰ[59AْfP?WnrV»{ Ug3:K\'Uz+3KYI{pO>.%Yw8.@['Sf'^ȍyk,U7ԩD,qhvw@cas~<4kd]k“u?p(4+VмkoZ[vWX>YZl7"D*\~ȝ1 43E?w,tGµrt[)w:@9vvx? B3^Ϭow8-pY?gМZV3KzB34Kf).jNvs._|unϗ]0܇3STxp(Lwje39HFʯoxA?רmȵPePI?<^)<{ww@;9;eʱHY܈՗D<8"2ې|Ӊ9~dB[ ̒.6Wx):N6LUʋlCa pye@+PȺJDt @hp /E|J䀹'>Vaĕ: +@4WHPt]_f$Q)!ebѸvݦ(6bY)q ˕ )p) %S_W茂UX.IPNeܝL΀j J`2W';nQbW4 [mߦ0{f0$a4۾\N&<%d. e9}WS QK+NH$hQTLЬ "aĂAoVyXφ-=&kX>KQn&,NRZMQȵ y@BVԿhhO%0K4C|I(l*kSMԡHO_&1v$Z?tڤG26K`Ynf}Ats\hKrSsDrU'qAr@:r-h4Hs!Ģe"R߂~BM"70Gϓ;6?o5w[Viy2IoX@ 1S]ON}#QRlbw22b<{OӡC +Pϰ./i.@FeVE+(%EX)<UG'+K#ظ%ޞ ǗEqHs)-~aihOl ~*[Um:b3 w6 D!9ͥUVs0ŀ"hĒTQ#UdZa?nx[Xnw4@$bI!,Pk4A]G {bf#*}:N>ɓJzqUuF.J֮-9̠3J^D ;[X僙&Eྚoˏ5aGKzF)}&ppU#!S,K!_wLئs9}87![g nE@kg+XV,TlEnELJŠ>m G@lC"^lEvnE*@؊\\l94]nţ يqt+cBl)nE,Pb+ʝϭG|fEa+&ā[Y>[Cn[e+f} TOqmlEĽ}RD4R>C/t85&on27ey~ +v,wgz"PdS YCrˑQ@s- 8y7 9e3,ÉbkN7L=Bտ {8gmZĄ x+}TVA#aV,GhZqxytȞKDޖmkɈYes 3*lwv·w6);{ bz0 + =9e>[-6ESjྞ~c|x Ⱦ>'ʸު-);y(3[?G+ X1؇at{"ZFyKnFDRz&2DG-*EZHH&ZDMV0 aMG6ǯQt1B`zorEaE :̯w|ev{<9htDQBc>1G.nwFӲ/n+(ӈN1兮 Xrɳ\td +#M3> +o#6SWaBh[ҁPu XS$$%yiiY;(.Xr@C$C'*͜/uXI {g|oۢV^)1/C᳦H&,9q36P9 СK)L^9( /fT5u@f*ig_oQߍŁڠ& $e]l!}ƀGY52z$@M +TJcSeNݾ] +XA`YXq`[>,w ]rv~ ϨK&eC,F/7l'tn뾃ǥHܕ,4["[t_.2l,s&w'xkf35i0Z3RQ6mIn*R)o EٝY5( 3iL#4Iׯ N1 4D.X{4iI/䀮I=W0 i+IFBN" u0jǯH^xDnwm+燚ϋ:!&KRwiJ9 "{c+D`/(Ne^37PI 5gˌ Bdfj:M d&2dY ir]I][=[1))uIpeq#@:h~xIt_ [DsLDvgH7}6pcV.+ К,I,!YblQX_9:H[CDȱYCc[Fdäx< R ,δe$2ObG|?ejBȹz!$jRZ+?B‡vF9F $XVF7*YvH#}'״r:*|u¨GbYMslAD24MD!iV14暸'lLpHK?;C8 [Yw<$fp\S\Z~ +{딕yZd̠.@7Eqj6xeO47 Ij-QP5c?涤)eZCO>)4d4 I}aPr ?~dƓFq\2E:Gc<0R>aheJ @ f{u b+Ε,PTeF/\~ S41#9ge5Bp@^ `j]GrSè[m]X׮2 I'HO` mLbĬnw0کsk;D$~d+undQP\X( (, H(IyC\Uas&ݝHֶfslmPhy|>=;uWX9s+v|*% +<$LFK Sh+QSyI<IU{#C>-k>ia)2!6I` "TP1&1!@G*#JJ +t7H~F֩@6s-l5ς Si2;77 < +1OvwX9=~][Ā|D3 fbn"ɺ7O]t9`;l=j'yvs'i'D'юJzvNh_L 9|Cmmuw"?(~kIZs{gG|¤^h= x}6(cl^6=?x 3B6 B:*5FxD#+ HPj chI]E쿴6o@42=D GLtܝ 4 +xkpp}3ƕ'l8  +a ]?P:Յ͌Q +>-3QrL;(ɍbP ]l\z ؓ;\VxiLO8${o\S#tRS_i@ /GJq[@txK>, T|Y*xagW&j +=|qfêB{  obb0OMhڤ'P|ya{g@GWm#Cfqu߬#>stream +8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 +b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` +E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn +6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( +l[$6Nn+Z_Nq0]s7hs]`XX$6Ra!<<'!!!*'!!rrmPX()~> endstream endobj 5 0 obj <> endobj 17 0 obj [/View/Design] endobj 18 0 obj <>>> endobj 9 0 obj <> endobj 6 0 obj [5 0 R] endobj 19 0 obj <> endobj xref +0 20 +0000000000 65535 f +0000000016 00000 n +0000000144 00000 n +0000022510 00000 n +0000000000 00000 f +0000071005 00000 n +0000071303 00000 n +0000022561 00000 n +0000022932 00000 n +0000071191 00000 n +0000023940 00000 n +0000024257 00000 n +0000070431 00000 n +0000024331 00000 n +0000024475 00000 n +0000026117 00000 n +0000070479 00000 n +0000071075 00000 n +0000071106 00000 n +0000071326 00000 n +trailer <]>> startxref 71548 %%EOF \ No newline at end of file diff --git a/assets/moex-most-logo/main/moex-most.png b/assets/moex-most-logo/main/moex-most.png new file mode 100755 index 0000000..9216280 Binary files /dev/null and b/assets/moex-most-logo/main/moex-most.png differ diff --git a/assets/moex-most-logo/main/moex-most.svg b/assets/moex-most-logo/main/moex-most.svg new file mode 100644 index 0000000..e69de29 diff --git a/cmd/bj-artifactory/main.go b/cmd/bj-artifactory/main.go new file mode 100644 index 0000000..95cecc0 --- /dev/null +++ b/cmd/bj-artifactory/main.go @@ -0,0 +1,114 @@ +// Command bj-artifactory — простой сервер раздачи релизов и обновлений. +// +// Раскладка хранилища (--root), один подкаталог на канал: +// +// /stable/manifest.json — подписанный SignedManifest +// /stable/bj-server — артефакты, перечисленные в манифесте +// /stable/crypto-service.jar +// /beta/manifest.json +// ... +// +// HTTP API (потребляет bj-server auto-update и install.sh): +// +// GET /v1//manifest.json — манифест канала +// GET /v1//files/ — артефакт по имени +// GET /healthz — проверка живости +// +// Подпись манифеста делает bj-release; здесь только статическая раздача. +// Перед прод-выкаткой ставится за TLS-reverse-proxy (nginx, см. +// deploy/artifactory/nginx.conf). +package main + +import ( + "flag" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +func main() { + addr := flag.String("addr", ":8090", "адрес прослушивания") + root := flag.String("root", "./releases", "корень хранилища релизов") + flag.Parse() + + abs, err := filepath.Abs(*root) + if err != nil { + log.Fatalf("bj-artifactory: root: %v", err) + } + if _, err := os.Stat(abs); err != nil { + log.Fatalf("bj-artifactory: каталог релизов %s недоступен: %v", abs, err) + } + srv := &server{root: abs} + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + mux.HandleFunc("/v1/", srv.handleV1) + + log.Printf("bj-artifactory: раздаю %s на %s", abs, *addr) + httpSrv := &http.Server{Addr: *addr, Handler: logging(mux), ReadHeaderTimeout: 10 * time.Second} + log.Fatal(httpSrv.ListenAndServe()) +} + +type server struct{ root string } + +// handleV1 разбирает /v1//manifest.json и /v1//files/. +func (s *server) handleV1(w http.ResponseWriter, r *http.Request) { + rest := strings.TrimPrefix(r.URL.Path, "/v1/") + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 2 { + http.NotFound(w, r) + return + } + channel := parts[0] + if !safeName(channel) { + http.Error(w, "bad channel", http.StatusBadRequest) + return + } + switch { + case len(parts) == 2 && parts[1] == "manifest.json": + s.serveFile(w, r, filepath.Join(s.root, channel, "manifest.json"), "application/json") + case len(parts) == 3 && parts[1] == "files": + name := parts[2] + if !safeName(name) { + http.Error(w, "bad name", http.StatusBadRequest) + return + } + s.serveFile(w, r, filepath.Join(s.root, channel, name), "application/octet-stream") + default: + http.NotFound(w, r) + } +} + +func (s *server) serveFile(w http.ResponseWriter, r *http.Request, path, ctype string) { + f, err := os.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + fi, err := f.Stat() + if err != nil || fi.IsDir() { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", ctype) + http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) +} + +// safeName запрещает обход каталогов (.., /, пустые). +func safeName(s string) bool { + if s == "" || s == "." || s == ".." { + return false + } + return !strings.ContainsAny(s, "/\\") && !strings.Contains(s, "..") +} + +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path) + }) +} diff --git a/cmd/bj-installer/main.go b/cmd/bj-installer/main.go new file mode 100644 index 0000000..a4af4a2 --- /dev/null +++ b/cmd/bj-installer/main.go @@ -0,0 +1,111 @@ +// Package main — bj-installer. +// +// Web-инсталлятор для bj-server: на машине клиента после установки +// Debian/Astra поднимает локальный HTTP на 127.0.0.1:8181, проводит +// через 5-страничный wizard (welcome → precheck → config → install → done) +// и за кадром выполняет 20+ шагов установки Валидаты + bj-server + ИШ. +// +// Прогресс шагов прилетает в UI через Server-Sent Events. Каждый шаг +// идемпотентен — можно повторно запускать инсталлятор на уже настроенной +// машине, он пропустит то, что сделано. +// +// Запуск: sudo ./bj-installer [--addr 127.0.0.1:8181] [--no-browser] +// Артефакты ожидаются рядом с бинарём в каталоге ./artifacts/: +// +// artifacts/ClientL_Other/zpki-*.deb +// artifacts/ClientL_Other/zsdk-*.deb +// artifacts/bj-server (Go-бинарь) +// artifacts/crypto-service.jar (Java-сайдкар) +// artifacts/ish/igate_*.deb (ИШ НРД) +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "runtime" + "syscall" + "time" +) + +const banner = ` +====================================================================== + bj-installer — мастер установки Bridge-and-Join-s +====================================================================== +` + +func main() { + addr := flag.String("addr", "127.0.0.1:8181", "адрес web-инсталлятора") + noBrowser := flag.Bool("no-browser", false, "не пытаться открыть браузер автоматически") + artifactsDir := flag.String("artifacts", "./artifacts", "каталог с дистрибутивами (Validata deb, bj-server, ish)") + flag.Parse() + + if os.Geteuid() != 0 { + fmt.Fprintln(os.Stderr, "Установщик должен быть запущен от root (sudo).") + os.Exit(1) + } + + fmt.Print(banner) + fmt.Printf(" адрес: http://%s\n", *addr) + fmt.Printf(" артефакты: %s\n", *artifactsDir) + fmt.Println("======================================================================") + + st := newState(*artifactsDir) + + srv := newServer(st) + httpSrv := &http.Server{ + Addr: *addr, + Handler: srv, + ReadHeaderTimeout: 10 * time.Second, + } + + // SIGINT/SIGTERM → корректный shutdown + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go func() { + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP-сервер упал: %v", err) + } + }() + + url := "http://" + *addr + log.Printf("Откройте в браузере: %s", url) + if !*noBrowser { + tryOpenBrowser(url) + } + + <-ctx.Done() + log.Println("Завершаем работу...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) +} + +// tryOpenBrowser — без фанатизма. Если xdg-open/sensible-browser есть и +// $DISPLAY поднят (xrdp, Fly DE) — откроем. Иначе пользователь увидит URL +// в выводе и перейдёт сам с другого компа (типичный сценарий headless). +func tryOpenBrowser(url string) { + if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + return + } + var bin string + switch runtime.GOOS { + case "linux": + for _, cand := range []string{"xdg-open", "sensible-browser", "x-www-browser"} { + if p, err := exec.LookPath(cand); err == nil { + bin = p + break + } + } + } + if bin == "" { + return + } + _ = exec.Command(bin, url).Start() +} diff --git a/cmd/bj-installer/precheck.go b/cmd/bj-installer/precheck.go new file mode 100644 index 0000000..bacd592 --- /dev/null +++ b/cmd/bj-installer/precheck.go @@ -0,0 +1,132 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" +) + +// runPrechecks — все системные проверки на стадии "Проверка системы". +// Возвращает срез результатов, по каждому видно ✓/✗ + объяснение. +// +// Ничего не модифицирует — просто читает /etc/os-release, проверяет +// наличие нужных бинарей, права root, свободное место, артефакты в +// artifactsDir и т.п. UI отрисовывает таблицей. +func runPrechecks(artifactsDir string) []PrecheckResult { + var out []PrecheckResult + + out = append(out, checkRoot()) + out = append(out, checkArch()) + out = append(out, checkDistro()) + out = append(out, checkAptAvailable()) + out = append(out, checkSystemd()) + out = append(out, checkDiskSpace()) + out = append(out, checkArtifacts(artifactsDir)) + + return out +} + +func checkRoot() PrecheckResult { + if os.Geteuid() == 0 { + return PrecheckResult{ID: "root", Title: "Запуск от root", OK: true} + } + return PrecheckResult{ID: "root", Title: "Запуск от root", OK: false, Message: "Требуется sudo"} +} + +func checkArch() PrecheckResult { + if runtime.GOARCH == "amd64" { + return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: true, Message: runtime.GOARCH} + } + return PrecheckResult{ID: "arch", Title: "Архитектура amd64", OK: false, Message: "Валидата собрана только под amd64, у вас " + runtime.GOARCH} +} + +func checkDistro() PrecheckResult { + id, pretty := readOSRelease() + switch id { + case "debian", "astra": + return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty} + case "ubuntu": + return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: true, Message: pretty + " (поддерживается на свой страх)"} + default: + return PrecheckResult{ID: "distro", Title: "Поддерживаемая ОС", OK: false, Message: "ОС не в списке поддерживаемых: " + pretty} + } +} + +func checkAptAvailable() PrecheckResult { + if _, err := exec.LookPath("apt-get"); err != nil { + return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: false, Message: "apt-get не найден — это не Debian-семейство"} + } + return PrecheckResult{ID: "apt", Title: "Доступен apt-get", OK: true} +} + +func checkSystemd() PrecheckResult { + if _, err := os.Stat("/run/systemd/system"); err != nil { + return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: false, Message: "/run/systemd/system нет"} + } + return PrecheckResult{ID: "systemd", Title: "systemd работает", OK: true} +} + +func checkDiskSpace() PrecheckResult { + var fs syscall.Statfs_t + if err := syscall.Statfs("/var", &fs); err != nil { + return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: err.Error()} + } + freeBytes := fs.Bavail * uint64(fs.Bsize) + freeGiB := freeBytes / (1 << 30) + if freeGiB < 2 { + return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: false, Message: fmt.Sprintf("Свободно %d GiB, нужно ≥ 2", freeGiB)} + } + return PrecheckResult{ID: "disk", Title: "Свободное место в /var", OK: true, Message: fmt.Sprintf("%d GiB свободно", freeGiB)} +} + +func checkArtifacts(dir string) PrecheckResult { + required := []struct { + Glob string + Name string + }{ + {filepath.Join(dir, "ClientL_Other", "zpki-*.deb"), "zpki (Валидата)"}, + {filepath.Join(dir, "bj-server"), "bj-server (Go-бинарь)"}, + {filepath.Join(dir, "crypto-service.jar"), "crypto-service.jar"}, + } + var missing []string + for _, r := range required { + matches, _ := filepath.Glob(r.Glob) + if len(matches) == 0 { + missing = append(missing, r.Name) + } + } + if len(missing) > 0 { + return PrecheckResult{ + ID: "artifacts", + Title: "Артефакты дистрибутива", + OK: false, + Message: "Отсутствуют: " + strings.Join(missing, ", ") + " (положите в " + dir + ")", + } + } + return PrecheckResult{ID: "artifacts", Title: "Артефакты дистрибутива", OK: true, Message: "Все на месте в " + dir} +} + +func readOSRelease() (id, pretty string) { + b, err := os.ReadFile("/etc/os-release") + if err != nil { + return "", "неизвестно" + } + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + v = strings.Trim(v, `"`) + switch k { + case "ID": + id = v + case "PRETTY_NAME": + pretty = v + } + } + return +} diff --git a/cmd/bj-installer/server.go b/cmd/bj-installer/server.go new file mode 100644 index 0000000..463cca8 --- /dev/null +++ b/cmd/bj-installer/server.go @@ -0,0 +1,129 @@ +package main + +import ( + "embed" + "encoding/json" + "io/fs" + "log" + "net/http" + "strings" +) + +//go:embed web +var webFS embed.FS + +type server struct { + state *State + mux *http.ServeMux +} + +func newServer(st *State) *server { + s := &server{state: st, mux: http.NewServeMux()} + + // Статика (HTML/CSS/JS из embed) + sub, _ := fs.Sub(webFS, "web") + s.mux.Handle("/", http.FileServer(http.FS(sub))) + + // API + s.mux.HandleFunc("/api/state", s.handleState) + s.mux.HandleFunc("/api/precheck", s.handlePrecheck) + s.mux.HandleFunc("/api/config", s.handleConfig) + s.mux.HandleFunc("/api/install", s.handleInstall) + s.mux.HandleFunc("/api/events", s.handleSSE) + s.mux.HandleFunc("/api/reset", s.handleReset) + + return s +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Защита: только localhost (даже если addr 0.0.0.0 поставят) + host := r.RemoteAddr + if i := strings.LastIndex(host, ":"); i != -1 { + host = host[:i] + } + switch host { + case "127.0.0.1", "::1", "[::1]", "localhost": + // ok + default: + http.Error(w, "installer is local-only", http.StatusForbidden) + return + } + s.mux.ServeHTTP(w, r) +} + +// GET /api/state — полный snapshot для холодного открытия страницы. +func (s *server) handleState(w http.ResponseWriter, r *http.Request) { + snap := s.state.Snapshot() + writeJSON(w, snap) +} + +// POST /api/precheck — запускает все pre-check проверки и возвращает результат. +// Wizard переходит на стадию precheck. +func (s *server) handlePrecheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + s.state.setStage(StagePrecheck) + results := runPrechecks(s.state.artifactsDir) + s.state.setPrecheck(results) + writeJSON(w, results) +} + +// POST /api/config — сохраняет org INN, email, license. Переход на стадию config. +func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + var c Config + if err := json.NewDecoder(r.Body).Decode(&c); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + s.state.setConfig(c) + s.state.setStage(StageConfig) + writeJSON(w, map[string]bool{"ok": true}) +} + +// POST /api/install — стартует установку (в горутине), переход на стадию installing. +// UI слушает /api/events для прогресса. +func (s *server) handleInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + s.state.setStage(StageInstalling) + go func() { + if err := runInstallation(s.state); err != nil { + log.Printf("install error: %v", err) + s.state.setError(err.Error()) + return + } + s.state.setStage(StageDone) + }() + writeJSON(w, map[string]bool{"ok": true}) +} + +// POST /api/reset — сброс wizard'а на welcome (после ошибки). +func (s *server) handleReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + s.state.mu.Lock() + s.state.Stage = StageWelcome + s.state.ErrorMsg = "" + s.state.Precheck = nil + s.state.Steps = buildStepList() + s.state.mu.Unlock() + s.state.bus.publish(event{Type: "reset", Data: "{}"}) + writeJSON(w, map[string]bool{"ok": true}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} diff --git a/cmd/bj-installer/sse.go b/cmd/bj-installer/sse.go new file mode 100644 index 0000000..8bf7df5 --- /dev/null +++ b/cmd/bj-installer/sse.go @@ -0,0 +1,86 @@ +package main + +import ( + "fmt" + "net/http" + "sync" +) + +// event — одно событие, отдаваемое подписчикам через SSE. +// Type становится `event:` строкой, Data — `data:`. +type event struct { + Type string + Data string +} + +// eventBus — простой fan-out для SSE. Подписчик создаётся в момент +// открытия GET /api/events и живёт до закрытия соединения. +type eventBus struct { + mu sync.Mutex + subscribers map[chan event]struct{} +} + +func newEventBus() *eventBus { + return &eventBus{subscribers: make(map[chan event]struct{})} +} + +func (b *eventBus) subscribe() chan event { + ch := make(chan event, 64) + b.mu.Lock() + b.subscribers[ch] = struct{}{} + b.mu.Unlock() + return ch +} + +func (b *eventBus) unsubscribe(ch chan event) { + b.mu.Lock() + delete(b.subscribers, ch) + close(ch) + b.mu.Unlock() +} + +func (b *eventBus) publish(e event) { + b.mu.Lock() + defer b.mu.Unlock() + for ch := range b.subscribers { + select { + case ch <- e: + default: + // Подписчик отстаёт — пропускаем (UI догонится снапшотом по GET /api/state) + } + } +} + +// handleSSE — GET /api/events. Держит соединение, в каждом событии +// отдаёт event: \ndata: \n\n. +func (s *server) handleSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + ch := s.state.bus.subscribe() + defer s.state.bus.unsubscribe(ch) + + // сразу шлём snapshot, чтобы UI догнал состояние + snap := s.state.Snapshot() + fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", mustJSON(snap)) + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case e := <-ch: + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", e.Type, e.Data); err != nil { + return + } + flusher.Flush() + } + } +} diff --git a/cmd/bj-installer/state.go b/cmd/bj-installer/state.go new file mode 100644 index 0000000..8d7cc1a --- /dev/null +++ b/cmd/bj-installer/state.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "sync" + "time" +) + +// WizardStage — какой странице wizard'а соответствует текущее состояние. +// Переходы: welcome → precheck → config → installing → done. +// Из любого можно вернуться в welcome (полный reset). +type WizardStage string + +const ( + StageWelcome WizardStage = "welcome" + StagePrecheck WizardStage = "precheck" + StageConfig WizardStage = "config" + StageInstalling WizardStage = "installing" + StageDone WizardStage = "done" + StageError WizardStage = "error" +) + +// Config — данные, которые wizard собирает на стадии config. +type Config struct { + OrgINN string `json:"orgInn"` // ИНН организации + OrgName string `json:"orgName"` // отображаемое имя + AdminEmail string `json:"adminEmail"` // куда писать алерты + LicenseKey string `json:"licenseKey"` // годовой ключ (опционально, можно пропустить) +} + +// StepStatus — текущее состояние конкретного шага установки. +type StepStatus string + +const ( + StepPending StepStatus = "pending" + StepRunning StepStatus = "running" + StepDone StepStatus = "done" + StepSkipped StepStatus = "skipped" + StepFailed StepStatus = "failed" +) + +// StepState — снимок одного шага для отдачи в UI. +type StepState struct { + ID string `json:"id"` + Title string `json:"title"` + Status StepStatus `json:"status"` + Message string `json:"message,omitempty"` + Started *time.Time `json:"started,omitempty"` + Finished *time.Time `json:"finished,omitempty"` +} + +// PrecheckResult — результат одной системной проверки на стадии precheck. +type PrecheckResult struct { + ID string `json:"id"` + Title string `json:"title"` + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} + +// State — потокобезопасное состояние wizard'а. Хранит всё что нужно +// отрисовать на любой из страниц + текущий прогресс установки. +type State struct { + mu sync.RWMutex + + artifactsDir string + + Stage WizardStage `json:"stage"` + ErrorMsg string `json:"errorMsg,omitempty"` + Precheck []PrecheckResult `json:"precheck"` + Config Config `json:"config"` + Steps []StepState `json:"steps"` + + bus *eventBus +} + +func newState(artifactsDir string) *State { + return &State{ + artifactsDir: artifactsDir, + Stage: StageWelcome, + Steps: buildStepList(), + bus: newEventBus(), + } +} + +// Snapshot — потокобезопасная копия для GET /api/state. +func (s *State) Snapshot() State { + s.mu.RLock() + defer s.mu.RUnlock() + cp := *s + cp.Precheck = append([]PrecheckResult(nil), s.Precheck...) + cp.Steps = append([]StepState(nil), s.Steps...) + return cp +} + +func (s *State) setStage(st WizardStage) { + s.mu.Lock() + s.Stage = st + s.mu.Unlock() + s.bus.publish(event{Type: "stage", Data: mustJSON(map[string]string{"stage": string(st)})}) +} + +func (s *State) setError(msg string) { + s.mu.Lock() + s.Stage = StageError + s.ErrorMsg = msg + s.mu.Unlock() + s.bus.publish(event{Type: "error", Data: mustJSON(map[string]string{"message": msg})}) +} + +func (s *State) setPrecheck(items []PrecheckResult) { + s.mu.Lock() + s.Precheck = items + s.mu.Unlock() + s.bus.publish(event{Type: "precheck", Data: mustJSON(items)}) +} + +func (s *State) setConfig(c Config) { + s.mu.Lock() + s.Config = c + s.mu.Unlock() +} + +func (s *State) updateStep(id string, fn func(*StepState)) { + s.mu.Lock() + var snap StepState + for i := range s.Steps { + if s.Steps[i].ID == id { + fn(&s.Steps[i]) + snap = s.Steps[i] + break + } + } + s.mu.Unlock() + s.bus.publish(event{Type: "step", Data: mustJSON(snap)}) +} + +func mustJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/cmd/bj-installer/steps.go b/cmd/bj-installer/steps.go new file mode 100644 index 0000000..dd0a6ec --- /dev/null +++ b/cmd/bj-installer/steps.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "time" +) + +// Step — описание одного шага установки. Run выполняет шаг, может +// проверить idempotency и вернуть Skipped. Логи прокидываются через +// log-функцию, которая публикует event в SSE. +type Step struct { + ID string + Title string + Run func(s *State, log func(string)) (StepStatus, error) +} + +// buildStepList — фиксированный порядок шагов установки. Соответствует +// install-validata.sh + установка bj-server/crypto-service/ИШ. Меняется +// атомарно (если что-то добавляется — добавляем сюда). +func buildStepList() []StepState { + steps := allSteps() + out := make([]StepState, len(steps)) + for i, s := range steps { + out[i] = StepState{ID: s.ID, Title: s.Title, Status: StepPending} + } + return out +} + +func allSteps() []Step { + return []Step{ + {ID: "deps", Title: "Установка системных зависимостей", Run: stepInstallDeps}, + {ID: "validata-deb", Title: "Установка пакетов Валидаты (zpki + zsdk)", Run: stepInstallValidataDebs}, + {ID: "execstack", Title: "execstack -c libvdcsp.so", Run: stepExecstack}, + {ID: "bj-user", Title: "Создание пользователя bj и каталогов", Run: stepCreateBJUser}, + {ID: "pcscd-dropin", Title: "Настройка pcscd (always-on)", Run: stepPcscdDropin}, + {ID: "bj-crypto-dropins", Title: "Drop-ins для bj-crypto sandbox", Run: stepBJCryptoDropins}, + {ID: "bj-server-dropin", Title: "Drop-in для bj-server", Run: stepBJServerDropin}, + {ID: "spki-ini", Title: "Создание spki.ini", Run: stepSPKIIni}, + {ID: "pki1-prep", Title: "Подготовка pki1.conf для bj", Run: stepPKI1Prep}, + {ID: "usb-mount", Title: "Авто-mount USB через udev + systemd", Run: stepUSBMount}, + {ID: "bj-server-binary", Title: "Установка bj-server бинаря в /opt/bj/", Run: stepInstallBJServer}, + {ID: "crypto-jar", Title: "Установка crypto-service.jar", Run: stepInstallCryptoJar}, + {ID: "systemd-units", Title: "systemd unit bj-crypto.service + bj-server.service", Run: stepSystemdUnits}, + {ID: "ish-install", Title: "Установка ИШ НРД (если есть .deb)", Run: stepInstallISH}, + {ID: "save-config", Title: "Сохранение setup.json", Run: stepSaveConfig}, + {ID: "systemd-start", Title: "Запуск сервисов (pcscd, bj-crypto, bj-server)", Run: stepStartServices}, + {ID: "health", Title: "Финальный health-check", Run: stepHealthCheck}, + } +} + +// runInstallation — основной цикл установки. Перебирает шаги, обновляет +// статусы через State, прокидывает логи в SSE. Останавливается при первой +// ошибке (UI покажет какой шаг + сообщение). +func runInstallation(s *State) error { + steps := allSteps() + for _, step := range steps { + now := time.Now() + s.updateStep(step.ID, func(ss *StepState) { + ss.Status = StepRunning + ss.Started = &now + ss.Message = "" + }) + + logFn := func(line string) { + s.updateStep(step.ID, func(ss *StepState) { + ss.Message = line + }) + } + + status, err := step.Run(s, logFn) + finished := time.Now() + s.updateStep(step.ID, func(ss *StepState) { + ss.Status = status + ss.Finished = &finished + if err != nil { + ss.Message = err.Error() + } + }) + if err != nil { + return fmt.Errorf("шаг %q: %w", step.Title, err) + } + } + return nil +} diff --git a/cmd/bj-installer/steps_impl.go b/cmd/bj-installer/steps_impl.go new file mode 100644 index 0000000..486ef79 --- /dev/null +++ b/cmd/bj-installer/steps_impl.go @@ -0,0 +1,448 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// --------------------------------------------------------------------- // +// Хелперы +// --------------------------------------------------------------------- // + +// runCmd — запускает команду, прокидывает stdout/stderr построчно в log. +// Возвращает ошибку с последними строками stderr для удобства отображения. +func runCmd(logFn func(string), name string, args ...string) error { + logFn(fmt.Sprintf("$ %s %s", name, strings.Join(args, " "))) + cmd := exec.Command(name, args...) + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimRight(string(out), "\n"), "\n") { + if line != "" { + logFn(line) + } + } + if err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil +} + +// writeFileIfChanged — пишет файл только если содержимое отличается. Возвращает +// true если файл был создан/изменён (для решения «нужен ли daemon-reload»). +func writeFileIfChanged(path string, content string, mode os.FileMode) (bool, error) { + existing, err := os.ReadFile(path) + if err == nil && string(existing) == content { + return false, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + return false, err + } + return true, nil +} + +// --------------------------------------------------------------------- // +// Шаги +// --------------------------------------------------------------------- // + +func stepInstallDeps(s *State, log func(string)) (StepStatus, error) { + log("Обновляю apt-кеш...") + if err := runCmd(log, "apt-get", "update", "-qq"); err != nil { + return StepFailed, err + } + + deps := []string{ + "libgtk-3-0", "libpcsclite1", "libccid", "pcscd", + "libcurl4", "libkrb5-3", "libgssapi-krb5-2", + "libsasl2-modules", "libsasl2-modules-gssapi-mit", + "execstack", "p7zip-full", + } + if hasAPTPackage("libldap-2.4-2") { + deps = append(deps, "libldap-2.4-2") + } else { + deps = append(deps, "libldap-2.5-0") + log("libldap-2.4-2 не найден → ставлю 2.5-0, для zpki будет --force-depends") + } + + args := append([]string{"install", "-y", "--no-install-recommends"}, deps...) + if err := runCmd(log, "apt-get", args...); err != nil { + return StepFailed, err + } + return StepDone, nil +} + +func stepInstallValidataDebs(s *State, log func(string)) (StepStatus, error) { + zpki, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zpki-*.amd64.deb")) + zsdk, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ClientL_Other", "zsdk-*.amd64.deb")) + if len(zpki) == 0 { + return StepFailed, fmt.Errorf("zpki-*.amd64.deb не найден в %s/ClientL_Other/", s.artifactsDir) + } + useForce := !hasAPTPackage("libldap-2.4-2") + for _, deb := range append(zpki, zsdk...) { + args := []string{"-i", deb} + if useForce { + args = append([]string{"--force-depends"}, args...) + } + if err := runCmd(log, "dpkg", args...); err != nil { + return StepFailed, err + } + } + if _, err := os.Stat("/opt/Validata/VDCSP/lib/amd64"); err != nil { + return StepFailed, fmt.Errorf("/opt/Validata не появился после установки") + } + return StepDone, nil +} + +func stepExecstack(s *State, log func(string)) (StepStatus, error) { + target := "/opt/Validata/VDCSP/lib/amd64/libvdcsp.so" + // Проверка состояния + out, err := exec.Command("execstack", "-q", target).Output() + if err == nil && strings.HasPrefix(strings.TrimSpace(string(out)), "-") { + log("executable-stack уже снят") + return StepSkipped, nil + } + return StepDone, runCmd(log, "execstack", "-c", target) +} + +func stepCreateBJUser(s *State, log func(string)) (StepStatus, error) { + if _, err := exec.LookPath("id"); err == nil { + if exec.Command("id", "bj").Run() == nil { + log("Пользователь bj уже существует") + } else { + if err := runCmd(log, "useradd", "--system", "--create-home", + "--home-dir", "/var/lib/bj", "--shell", "/bin/bash", "bj"); err != nil { + return StepFailed, err + } + } + } + dirs := []struct { + Path string + Mode os.FileMode + }{ + {"/var/lib/bj/usb", 0o755}, + {"/var/lib/bj/.Validata", 0o700}, + {"/var/lib/bj/.Validata/vdkeys", 0o700}, + {"/var/lib/bj/profiles", 0o755}, + {"/var/log/bj", 0o755}, + {"/var/lib/bj/.bj", 0o700}, + } + for _, d := range dirs { + if err := os.MkdirAll(d.Path, d.Mode); err != nil { + return StepFailed, err + } + } + return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj", "/var/log/bj") +} + +func stepPcscdDropin(s *State, log func(string)) (StepStatus, error) { + const dropin = `[Unit] +Requires= +After= +Sockets= + +[Service] +ExecStart= +ExecStart=/usr/sbin/pcscd --foreground +` + changed, err := writeFileIfChanged("/etc/systemd/system/pcscd.service.d/no-autoexit.conf", dropin, 0o644) + if err != nil { + return StepFailed, err + } + if !changed { + log("Drop-in уже актуален") + return StepSkipped, nil + } + log("Создан /etc/systemd/system/pcscd.service.d/no-autoexit.conf") + return StepDone, nil +} + +func stepBJCryptoDropins(s *State, log func(string)) (StepStatus, error) { + files := map[string]string{ + "/etc/systemd/system/bj-crypto.service.d/validata-paths.conf": `[Service] +WorkingDirectory=/opt/Validata/VDCSP/etc +ReadWritePaths=/opt/Validata/VDCSP/etc +ReadWritePaths=/var/lib/bj +`, + "/etc/systemd/system/bj-crypto.service.d/usb-access.conf": `[Service] +ReadOnlyPaths=/media +ReadOnlyPaths=/var/lib/bj/usb +`, + "/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf": `[Service] +PrivateTmp=true +BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock +`, + } + for path, content := range files { + if _, err := writeFileIfChanged(path, content, 0o644); err != nil { + return StepFailed, err + } + } + return StepDone, nil +} + +func stepBJServerDropin(s *State, log func(string)) (StepStatus, error) { + const dropin = `[Service] +ReadWritePaths=/opt/Validata/VDCSP/etc +` + _, err := writeFileIfChanged("/etc/systemd/system/bj-server.service.d/pki1conf.conf", dropin, 0o644) + if err != nil { + return StepFailed, err + } + return StepDone, nil +} + +func stepSPKIIni(s *State, log func(string)) (StepStatus, error) { + const path = "/opt/Validata/VDCSP/etc/spki.ini" + if _, err := os.Stat(path); err == nil { + log("Файл уже существует") + return StepSkipped, nil + } + const content = `[store] +count = 0 + +[Parameters] +PkiLdapTimeout = 10 +PkiHttpTimeout = 60 +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return StepFailed, err + } + return StepDone, nil +} + +func stepPKI1Prep(s *State, log func(string)) (StepStatus, error) { + const path = "/opt/Validata/VDCSP/etc/pki1.conf" + if _, err := os.Stat(path); err != nil { + log("Файл pki1.conf отсутствует — Валидата создаст при первом запуске") + return StepSkipped, nil + } + if err := runCmd(log, "chgrp", "bj", path); err != nil { + return StepFailed, err + } + if err := runCmd(log, "chmod", "g+w", path); err != nil { + return StepFailed, err + } + + existing, _ := os.ReadFile(path) + if !strings.Contains(string(existing), "# --- bj-server: BEGIN ---") { + appended := string(existing) + "\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются bj-server при импорте через /admin/setup.\n# --- bj-server: END ---\n" + if err := os.WriteFile(path, []byte(appended), 0o664); err != nil { + return StepFailed, err + } + } + return StepDone, nil +} + +func stepUSBMount(s *State, log func(string)) (StepStatus, error) { + files := map[string]string{ + "/etc/udev/rules.d/99-bj-usb.rules": `# Авто-mount USB-флешек в /var/lib/bj/usb/ с владельцем bj. +ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \ + ENV{ID_FS_TYPE}!="", \ + ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service" +ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \ + ENV{ID_FS_TYPE}!="", \ + ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service" +`, + "/etc/systemd/system/bj-usb-mount@.service": `[Unit] +Description=Mount USB %i to /var/lib/bj/usb/%i for bj +DefaultDependencies=no +After=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/bash -c 'mkdir -p /var/lib/bj/usb/%i && /usr/bin/mount -o uid=$(id -u bj),gid=$(id -g bj),fmask=0133,dmask=0022 UUID=%i /var/lib/bj/usb/%i' +ExecStop=/usr/bin/umount /var/lib/bj/usb/%i || true +`, + "/etc/systemd/system/bj-usb-umount@.service": `[Unit] +Description=Umount USB %i from /var/lib/bj/usb/%i +DefaultDependencies=no + +[Service] +Type=oneshot +ExecStart=/usr/bin/bash -c '/usr/bin/umount /var/lib/bj/usb/%i 2>/dev/null; /usr/bin/rmdir /var/lib/bj/usb/%i 2>/dev/null; true' +`, + } + anyChanged := false + for path, content := range files { + ch, err := writeFileIfChanged(path, content, 0o644) + if err != nil { + return StepFailed, err + } + anyChanged = anyChanged || ch + } + if anyChanged { + _ = runCmd(log, "udevadm", "control", "--reload-rules") + _ = runCmd(log, "udevadm", "trigger") + } + return StepDone, nil +} + +func stepInstallBJServer(s *State, log func(string)) (StepStatus, error) { + src := filepath.Join(s.artifactsDir, "bj-server") + if _, err := os.Stat(src); err != nil { + return StepSkipped, nil // нет бинаря — может ставится через rpm/deb + } + if err := os.MkdirAll("/opt/bj", 0o755); err != nil { + return StepFailed, err + } + if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0755", src, "/opt/bj/bj-server"); err != nil { + return StepFailed, err + } + return StepDone, nil +} + +func stepInstallCryptoJar(s *State, log func(string)) (StepStatus, error) { + src := filepath.Join(s.artifactsDir, "crypto-service.jar") + if _, err := os.Stat(src); err != nil { + return StepSkipped, nil + } + if err := os.MkdirAll("/opt/bj", 0o755); err != nil { + return StepFailed, err + } + if err := runCmd(log, "install", "-o", "bj", "-g", "bj", "-m", "0644", src, "/opt/bj/crypto-service.jar"); err != nil { + return StepFailed, err + } + return StepDone, nil +} + +func stepSystemdUnits(s *State, log func(string)) (StepStatus, error) { + units := map[string]string{ + "/etc/systemd/system/bj-crypto.service": `[Unit] +Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L) +Before=bj-server.service +After=network-online.target pcscd.service +Wants=network-online.target + +[Service] +Type=simple +User=bj +Group=bj +RuntimeDirectory=bj +RuntimeDirectoryMode=0750 +Environment=BJ_CRYPTO_SOCKET=/run/bj/crypto.sock +Environment=BJ_CRYPTO_PROVIDER=validata +Environment=LD_LIBRARY_PATH=/opt/Validata/VDCSP/lib/amd64 +ExecStart=/usr/bin/java -Djava.library.path=/opt/Validata/VDCSP/lib/amd64 -jar /opt/bj/crypto-service.jar +Restart=on-failure +RestartSec=5 +StandardOutput=append:/var/log/bj/crypto-service.log +StandardError=append:/var/log/bj/crypto-service.err +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/run/bj /var/log/bj +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +`, + "/etc/systemd/system/bj-server.service": `[Unit] +Description=Bridge-and-Join-s — единый сервис M2M-переводов +After=network-online.target bj-crypto.service +Wants=network-online.target + +[Service] +Type=simple +User=bj +Group=bj +WorkingDirectory=/var/lib/bj +ExecStart=/opt/bj/bj-server +Restart=on-failure +RestartSec=5 +Environment=BJ_HTTP_ADDR=:8080 +Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/bj /var/log/bj + +[Install] +WantedBy=multi-user.target +`, + } + for path, content := range units { + if _, err := writeFileIfChanged(path, content, 0o644); err != nil { + return StepFailed, err + } + } + return StepDone, runCmd(log, "systemctl", "daemon-reload") +} + +func stepInstallISH(s *State, log func(string)) (StepStatus, error) { + matches, _ := filepath.Glob(filepath.Join(s.artifactsDir, "ish", "igate_*.deb")) + if len(matches) == 0 { + log("Дистрибутив ИШ не найден — пропускаю (можно установить позже)") + return StepSkipped, nil + } + if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil { + // допустим, что зависимости подтянутся + _ = runCmd(log, "apt-get", "-f", "install", "-y") + if err := runCmd(log, "dpkg", "-i", matches[0]); err != nil { + return StepFailed, err + } + } + return StepDone, nil +} + +func stepSaveConfig(s *State, log func(string)) (StepStatus, error) { + cfg := s.Snapshot().Config + if cfg.OrgINN == "" && cfg.AdminEmail == "" && cfg.LicenseKey == "" { + return StepSkipped, nil + } + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return StepFailed, err + } + if err := os.MkdirAll("/var/lib/bj/.bj", 0o700); err != nil { + return StepFailed, err + } + if err := os.WriteFile("/var/lib/bj/.bj/setup.json", b, 0o600); err != nil { + return StepFailed, err + } + return StepDone, runCmd(log, "chown", "-R", "bj:bj", "/var/lib/bj/.bj") +} + +func stepStartServices(s *State, log func(string)) (StepStatus, error) { + // disable+stop socket activation для pcscd + _ = runCmd(log, "systemctl", "stop", "pcscd.socket") + _ = runCmd(log, "systemctl", "disable", "pcscd.socket") + + for _, svc := range []string{"pcscd", "bj-crypto", "bj-server"} { + if err := runCmd(log, "systemctl", "enable", svc); err != nil { + return StepFailed, err + } + if err := runCmd(log, "systemctl", "restart", svc); err != nil { + return StepFailed, err + } + } + return StepDone, nil +} + +func stepHealthCheck(s *State, log func(string)) (StepStatus, error) { + var bad []string + for _, svc := range []string{"pcscd", "vdcrysvc", "bj-crypto", "bj-server"} { + if err := exec.Command("systemctl", "is-active", "--quiet", svc).Run(); err != nil { + bad = append(bad, svc) + } else { + log(svc + ": active") + } + } + if len(bad) > 0 { + return StepFailed, fmt.Errorf("сервисы не запустились: %s", strings.Join(bad, ", ")) + } + return StepDone, nil +} + +// hasAPTPackage — проверяет наличие пакета в apt-cache (доступен ли для установки). +func hasAPTPackage(name string) bool { + out, err := exec.Command("apt-cache", "show", name).CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "Package: "+name) +} diff --git a/cmd/bj-installer/web/app.js b/cmd/bj-installer/web/app.js new file mode 100644 index 0000000..43222cd --- /dev/null +++ b/cmd/bj-installer/web/app.js @@ -0,0 +1,168 @@ +// Минимальный клиент wizard'а: рендерит страницы, ловит события из SSE, +// отправляет POST'ы на backend для перехода между стадиями. + +let state = { + stage: "welcome", + precheck: [], + config: {}, + steps: [], + errorMsg: "", +}; + +const STAGE_ORDER = ["welcome", "precheck", "config", "installing", "done"]; +const STEP_ICONS = { + pending: "○", + running: "◐", + done: "✓", + skipped: "—", + failed: "✗", +}; + +function $(sel) { return document.querySelector(sel); } +function $$(sel) { return [...document.querySelectorAll(sel)]; } + +function render() { + // stepper + $$("#stepper span").forEach(el => { + el.classList.remove("active", "done"); + const stage = el.dataset.stage; + if (stage === state.stage) el.classList.add("active"); + if (STAGE_ORDER.indexOf(stage) < STAGE_ORDER.indexOf(state.stage)) el.classList.add("done"); + }); + // pages + $$(".page").forEach(p => p.classList.toggle("active", p.dataset.stage === state.stage)); + + if (state.stage === "precheck") renderPrecheck(); + if (state.stage === "installing" || state.stage === "done") renderSteps(); + if (state.stage === "error") $("#error-message").textContent = state.errorMsg || "(нет деталей)"; + if (state.stage === "done") { + // подставляем хост машины в админскую ссылку + const adminURL = window.location.protocol + "//" + window.location.hostname + ":8080/admin/setup"; + $("#adminLink").href = adminURL; + $("#adminLink").textContent = "Перейти в " + adminURL + " →"; + } +} + +function renderPrecheck() { + const root = $("#precheck-results"); + root.innerHTML = ""; + let allOK = true; + for (const r of state.precheck || []) { + const div = document.createElement("div"); + div.className = "check " + (r.ok ? "ok" : "bad"); + div.innerHTML = ` + ${r.ok ? "✓" : "✗"} +
+
${escapeHTML(r.title)}
+ ${r.message ? `
${escapeHTML(r.message)}
` : ""} +
`; + root.appendChild(div); + if (!r.ok) allOK = false; + } + $("#goConfigBtn").disabled = !allOK; +} + +function renderSteps() { + const root = $("#step-list"); + root.innerHTML = ""; + let done = 0; + for (const s of state.steps || []) { + const li = document.createElement("li"); + li.className = "step-" + s.status; + li.innerHTML = ` + ${STEP_ICONS[s.status] || "○"} +
+
${escapeHTML(s.title)}
+ ${s.message ? `
${escapeHTML(s.message)}
` : ""} +
`; + root.appendChild(li); + if (s.status === "done" || s.status === "skipped") done++; + } + const total = state.steps.length; + const pct = total ? Math.round(100 * done / total) : 0; + $("#progress-bar").style.width = pct + "%"; +} + +function escapeHTML(s) { + return String(s).replace(/[&<>"']/g, c => ({ + "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" + }[c])); +} + +// ------------- transitions ------------- + +async function startPrecheck() { + await fetch("/api/precheck", { method: "POST" }); +} + +function goWelcome() { + state.stage = "welcome"; + render(); +} + +function goPrecheck() { + state.stage = "precheck"; + render(); +} + +async function goConfig() { + state.stage = "config"; + render(); +} + +async function startInstall() { + const form = $("#config-form"); + const data = Object.fromEntries(new FormData(form).entries()); + await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + await fetch("/api/install", { method: "POST" }); +} + +async function resetWizard() { + await fetch("/api/reset", { method: "POST" }); +} + +// ------------- SSE ------------- + +function connectSSE() { + const es = new EventSource("/api/events"); + es.addEventListener("snapshot", e => { + const snap = JSON.parse(e.data); + state.stage = snap.stage; + state.precheck = snap.precheck || []; + state.config = snap.config || {}; + state.steps = snap.steps || []; + state.errorMsg = snap.errorMsg || ""; + render(); + }); + es.addEventListener("stage", e => { + state.stage = JSON.parse(e.data).stage; + render(); + }); + es.addEventListener("precheck", e => { + state.precheck = JSON.parse(e.data); + render(); + }); + es.addEventListener("step", e => { + const s = JSON.parse(e.data); + const idx = state.steps.findIndex(x => x.id === s.id); + if (idx >= 0) state.steps[idx] = s; + render(); + }); + es.addEventListener("error", e => { + state.errorMsg = JSON.parse(e.data).message; + state.stage = "error"; + render(); + }); + es.addEventListener("reset", () => { + location.reload(); + }); + es.onerror = () => { + // авто-реконнект делает EventSource сам, ничего не делаем + }; +} + +connectSSE(); diff --git a/cmd/bj-installer/web/index.html b/cmd/bj-installer/web/index.html new file mode 100644 index 0000000..aaab131 --- /dev/null +++ b/cmd/bj-installer/web/index.html @@ -0,0 +1,110 @@ + + + + + +bj-installer — мастер установки Bridge-and-Join-s + + + +
+ +
мастер установки
+
+ +
+ + + + +
+

Добро пожаловать

+

Этот мастер установит на сервер СКЗИ «Валидата Клиент L», + bj-server, bj-crypto и ИШ НРД, настроит + systemd-сервисы и подготовит окружение для подписи документов + по ГОСТ 34.10-2012.

+

После установки откроется /admin/setup в bj-server, где можно + загрузить тестовый профиль от MOEX (.7z) и активировать подпись.

+
+ +
+
+ + +
+

Проверка системы

+
+
+ + +
+
+ + +
+

Настройка

+
+ + + + +
+ + +
+
+
+ + +
+

Установка

+
    +
    +
    + + +
    +

    ✓ Готово

    +

    bj-server и все сервисы запущены. Откройте панель администратора и + импортируйте профиль:

    + +

    Что дальше:

    +
      +
    1. Подключите USB с .vdk → он автоматически смонтируется в /var/lib/bj/usb/
    2. +
    3. На /admin/setup загрузите .7z с профилем от MOEX и введите пароль
    4. +
    5. Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность
    6. +
    +
    + + +
    +

    ✗ Установка прервана

    +

    Произошла ошибка:

    +
    
    +    

    Логи: journalctl -u bj-installer и journalctl -u bj-crypto

    +
    + +
    +
    +
    + + + + diff --git a/cmd/bj-installer/web/style.css b/cmd/bj-installer/web/style.css new file mode 100644 index 0000000..6730da9 --- /dev/null +++ b/cmd/bj-installer/web/style.css @@ -0,0 +1,179 @@ +:root { + --bg: #f6f7fb; + --card: #ffffff; + --text: #1d2330; + --muted: #6b7280; + --accent: #2563eb; + --accent-dark: #1d4ed8; + --ok: #16a34a; + --err: #dc2626; + --border: #e5e7eb; +} +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; +} +.topbar { + display: flex; align-items: baseline; gap: 16px; + padding: 18px 32px; + background: #0f172a; + color: #fff; + border-bottom: 1px solid #1e293b; +} +.logo { font-weight: 700; font-size: 18px; letter-spacing: 0.3px; } +.subtitle { color: #94a3b8; font-size: 14px; } +main { + max-width: 760px; + margin: 24px auto; + padding: 0 16px; +} +.stepper { + display: flex; gap: 8px; + margin-bottom: 24px; + padding: 12px 14px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + font-size: 13px; + overflow-x: auto; +} +.stepper span { + padding: 6px 12px; + border-radius: 6px; + color: var(--muted); + white-space: nowrap; +} +.stepper span.active { + background: var(--accent); + color: #fff; + font-weight: 600; +} +.stepper span.done { + color: var(--ok); +} +.page { + display: none; + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px 32px; +} +.page.active { display: block; } +h1 { margin: 0 0 16px; font-size: 22px; } +p, label { font-size: 15px; } +.muted { color: var(--muted); font-size: 13px; } +code { + background: #f1f5f9; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; +} +.buttons { + display: flex; gap: 12px; + margin-top: 24px; + justify-content: flex-end; +} +button, .primary-link { + padding: 10px 18px; + font-size: 14px; + font-weight: 500; + border: 1px solid var(--border); + background: #fff; + color: var(--text); + border-radius: 8px; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +button.primary, .primary-link { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +button.primary:hover, .primary-link:hover { background: var(--accent-dark); } +button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Precheck */ +.checks { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; } +.check { + display: flex; gap: 12px; align-items: center; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 8px; +} +.check.ok { border-color: var(--ok); } +.check.bad { border-color: var(--err); } +.check-icon { font-size: 18px; } +.check.ok .check-icon { color: var(--ok); } +.check.bad .check-icon { color: var(--err); } +.check-title { font-weight: 500; } +.check-msg { font-size: 13px; color: var(--muted); } + +/* Config form */ +#config-form { display: flex; flex-direction: column; gap: 16px; } +#config-form label { + display: flex; flex-direction: column; gap: 4px; + font-weight: 500; +} +#config-form input { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + background: #fff; +} +#config-form input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); } + +/* Installing steps */ +.steps { list-style: none; padding: 0; margin: 16px 0; } +.steps li { + display: flex; gap: 12px; align-items: flex-start; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} +.steps li:last-child { border-bottom: none; } +.step-icon { font-size: 16px; min-width: 24px; line-height: 1.5; } +.step-title { font-weight: 500; } +.step-msg { font-size: 12px; color: var(--muted); margin-top: 2px; word-break: break-word; } +.step-pending .step-icon { color: var(--muted); } +.step-running .step-icon { color: var(--accent); animation: spin 1.2s linear infinite; } +.step-done .step-icon { color: var(--ok); } +.step-skipped .step-icon { color: var(--muted); } +.step-failed .step-icon { color: var(--err); } +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.progress { + margin-top: 16px; + background: var(--border); + border-radius: 4px; + height: 6px; + overflow: hidden; +} +.progress-bar { + height: 100%; + width: 0%; + background: var(--accent); + transition: width 0.3s ease; +} + +/* Done */ +.next-link { margin: 20px 0; } + +/* Error */ +.error { + background: #fef2f2; + border: 1px solid var(--err); + color: var(--err); + padding: 12px; + border-radius: 8px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 13px; + overflow-x: auto; + white-space: pre-wrap; +} diff --git a/cmd/bj-license-server/main.go b/cmd/bj-license-server/main.go new file mode 100644 index 0000000..fb96b9b --- /dev/null +++ b/cmd/bj-license-server/main.go @@ -0,0 +1,96 @@ +// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий. +// +// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и +// срок сам. Этот сервер нужен для: +// - реестра выданных лицензий (учёт); +// - ОТЗЫВА (revocation) до окончания срока; +// - проверки клиентом «не отозвана ли» (опциональный online-чек). +// +// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде — +// PostgreSQL). API: +// +// GET /v1/check?id= → {"revoked":bool} +// GET /healthz +// +// Управление отзывом — правкой файла revoked.json (или будущим admin API). +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "os" + "sync" + "time" +) + +type store struct { + mu sync.RWMutex + path string + revoked map[string]bool +} + +func newStore(path string) *store { + s := &store{path: path, revoked: map[string]bool{}} + s.load() + return s +} + +func (s *store) load() { + s.mu.Lock() + defer s.mu.Unlock() + b, err := os.ReadFile(s.path) + if err != nil { + return // файла нет — пустой список + } + var ids []string + if err := json.Unmarshal(b, &ids); err != nil { + log.Printf("license-server: разбор %s: %v", s.path, err) + return + } + s.revoked = map[string]bool{} + for _, id := range ids { + s.revoked[id] = true + } + log.Printf("license-server: загружено отозванных лицензий: %d", len(s.revoked)) +} + +func (s *store) isRevoked(id string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.revoked[id] +} + +func main() { + addr := flag.String("addr", ":8091", "адрес прослушивания") + file := flag.String("revoked", "./revoked.json", "JSON-файл со списком отозванных license ID") + flag.Parse() + + st := newStore(*file) + + // Перечитываем файл отзывов раз в минуту (горячее применение). + go func() { + t := time.NewTicker(time.Minute) + defer t.Stop() + for range t.C { + st.load() + } + }() + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) + mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"revoked": st.isRevoked(id)}) + }) + + log.Printf("license-server: слушаю %s, отзывы из %s", *addr, *file) + srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} + log.Fatal(srv.ListenAndServe()) +} diff --git a/cmd/bj-license/main.go b/cmd/bj-license/main.go new file mode 100644 index 0000000..dc09886 --- /dev/null +++ b/cmd/bj-license/main.go @@ -0,0 +1,192 @@ +// Command bj-license — инструмент издателя: генерация ключей подписи, +// выпуск годовых лицензий и проверка. +// +// bj-license keygen -out ./keys/license +// bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \ +// -features updates,web-cabinet -key ./keys/license.priv -keyid main +// bj-license verify -key-file license.key -pub ./keys/license.pub +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license" +) + +// newUUID — UUID v4 без внешних зависимостей. +func newUUID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 // версия 4 + b[8] = (b[8] & 0x3f) | 0x80 // вариант + h := hex.EncodeToString(b[:]) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32] +} + +func main() { + if len(os.Args) < 2 { + usage() + } + switch os.Args[1] { + case "keygen": + keygen(os.Args[2:]) + case "issue": + issue(os.Args[2:]) + case "verify": + verify(os.Args[2:]) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "bj-license keygen -out ") + fmt.Fprintln(os.Stderr, "bj-license issue -tenant -plan free|pro|enterprise -days -features a,b -key [-keyid id] [-max-nodes n] [-note txt]") + fmt.Fprintln(os.Stderr, "bj-license verify -key-file -pub ") + os.Exit(2) +} + +func keygen(args []string) { + out := "license" + for i := 0; i < len(args)-1; i++ { + if args[i] == "-out" { + out = args[i+1] + } + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fatal("keygen: %v", err) + } + if err := os.WriteFile(out+".priv", []byte(base64.StdEncoding.EncodeToString(priv.Seed())+"\n"), 0o600); err != nil { + fatal("write priv: %v", err) + } + if err := os.WriteFile(out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil { + fatal("write pub: %v", err) + } + fmt.Printf("Приватный ключ лицензий: %s.priv (СЕКРЕТ)\n", out) + fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub)) +} + +func issue(args []string) { + a := parseArgs(args) + tenant := a["tenant"] + keyPath := a["key"] + if tenant == "" || keyPath == "" { + fatal("issue: требуются -tenant и -key") + } + plan := license.Plan(orDefault(a["plan"], "pro")) + days := atoiDefault(a["days"], 365) + keyID := orDefault(a["keyid"], "main") + + priv, err := license.LoadPrivateKey(keyPath) + if err != nil { + fatal("load key: %v", err) + } + + now := time.Now().UTC() + var feats []string + if a["features"] != "" { + feats = strings.Split(a["features"], ",") + } + l := &license.License{ + Schema: license.CurrentSchema, + ID: newUUID(), + Tenant: tenant, + Product: "bj-server", + Plan: plan, + IssuedAt: now, + ExpiresAt: now.AddDate(0, 0, days), + Features: feats, + MaxNodes: atoiDefault(a["max-nodes"], 0), + Note: a["note"], + } + tok, err := license.Sign(l, priv, keyID) + if err != nil { + fatal("sign: %v", err) + } + fmt.Printf("Лицензия выпущена: tenant=%q plan=%s до %s (%d дней)\n", + tenant, plan, l.ExpiresAt.Format("02.01.2006"), days) + fmt.Printf("ID: %s\n", l.ID) + fmt.Println("Ключ для клиента (вставить в bj-server → Лицензия):") + fmt.Println(tok.Encode()) +} + +func verify(args []string) { + a := parseArgs(args) + if a["key-file"] == "" || a["pub"] == "" { + fatal("verify: требуются -key-file и -pub") + } + raw, err := os.ReadFile(a["key-file"]) + if err != nil { + fatal("read key-file: %v", err) + } + pubB, err := os.ReadFile(a["pub"]) + if err != nil { + fatal("read pub: %v", err) + } + pub, err := license.ParsePublicKey(strings.TrimSpace(string(pubB))) + if err != nil { + fatal("pub: %v", err) + } + tok, err := license.DecodeToken(string(raw)) + if err != nil { + fatal("decode: %v", err) + } + l, err := license.Verify(tok, pub) + if err != nil { + fatal("verify: %v", err) + } + now := time.Now().UTC() + fmt.Printf("Подпись валидна. tenant=%q plan=%s\n", l.Tenant, l.Plan) + fmt.Printf("Действует: %s — %s (осталось %d дней)\n", + l.IssuedAt.Format("02.01.2006"), l.ExpiresAt.Format("02.01.2006"), l.DaysLeft(now)) + if err := l.Valid(now); err != nil { + fmt.Printf("СТАТУС: %v\n", err) + } else { + fmt.Printf("СТАТУС: активна, обновления %v\n", l.AllowsUpdates()) + } +} + +// --- helpers --- + +func parseArgs(args []string) map[string]string { + m := map[string]string{} + for i := 0; i < len(args); i++ { + if strings.HasPrefix(args[i], "-") && i+1 < len(args) { + m[strings.TrimPrefix(args[i], "-")] = args[i+1] + i++ + } + } + return m +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + var n int + _, err := fmt.Sscanf(s, "%d", &n) + if err != nil { + return def + } + return n +} + +func fatal(format string, a ...any) { + fmt.Fprintf(os.Stderr, "bj-license: "+format+"\n", a...) + os.Exit(1) +} diff --git a/cmd/bj-release/main.go b/cmd/bj-release/main.go new file mode 100644 index 0000000..65f5d36 --- /dev/null +++ b/cmd/bj-release/main.go @@ -0,0 +1,169 @@ +// Command bj-release — инструмент издателя: генерация ключей подписи, +// сборка манифеста релиза из каталога артефактов и его подпись Ed25519. +// +// Использование: +// +// bj-release keygen -out ./keys/signing +// → создаёт signing.priv (base64 seed) и signing.pub (base64 pubkey) +// +// bj-release build -dir ./dist -version 1.2.0 -channel stable \ +// -key ./keys/signing.priv -keyid main -out ./dist/manifest.json +// → хеширует все файлы в ./dist, собирает Manifest, подписывает, +// пишет SignedManifest в manifest.json +// +// Манифест подписывается целиком; клиент (bj-server auto-update) проверяет +// подпись зашитым публичным ключом ДО доверия версиям/хешам. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + switch os.Args[1] { + case "keygen": + keygen(os.Args[2:]) + case "build": + build(os.Args[2:]) + default: + usage() + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "bj-release keygen -out ") + fmt.Fprintln(os.Stderr, "bj-release build -dir -version -channel -key -keyid -out [-notes ]") + os.Exit(2) +} + +func keygen(args []string) { + fs := flag.NewFlagSet("keygen", flag.ExitOnError) + out := fs.String("out", "signing", "префикс файлов ключей (создаст .priv и .pub)") + _ = fs.Parse(args) + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fatal("keygen: %v", err) + } + seed := priv.Seed() + if err := os.WriteFile(*out+".priv", []byte(base64.StdEncoding.EncodeToString(seed)+"\n"), 0o600); err != nil { + fatal("write priv: %v", err) + } + if err := os.WriteFile(*out+".pub", []byte(base64.StdEncoding.EncodeToString(pub)+"\n"), 0o644); err != nil { + fatal("write pub: %v", err) + } + fmt.Printf("Приватный ключ: %s.priv (НЕ КОММИТИТЬ, держать в секрете)\n", *out) + fmt.Printf("Публичный ключ: %s.pub\n", *out) + fmt.Printf("Публичный ключ (зашить в bj-server):\n %s\n", base64.StdEncoding.EncodeToString(pub)) +} + +func build(args []string) { + fs := flag.NewFlagSet("build", flag.ExitOnError) + dir := fs.String("dir", "./dist", "каталог с артефактами") + version := fs.String("version", "", "версия релиза, напр. 1.2.0") + channel := fs.String("channel", "stable", "канал: stable|beta") + keyPath := fs.String("key", "", "путь к приватному ключу (base64 seed)") + keyID := fs.String("keyid", "main", "идентификатор ключа") + out := fs.String("out", "", "путь для записи manifest.json (по умолчанию /manifest.json)") + notes := fs.String("notes", "", "заметки к релизу") + _ = fs.Parse(args) + + if *version == "" || *keyPath == "" { + fatal("build: требуются -version и -key") + } + if *out == "" { + *out = filepath.Join(*dir, "manifest.json") + } + + priv, err := release.LoadPrivateKey(*keyPath) + if err != nil { + fatal("load key: %v", err) + } + + // Имена артефактов, которые издаём (логическое имя → ставить +x). + known := map[string]bool{ + "bj-server": true, // Go-бинарь + "crypto-service.jar": false, // Java сайдкар + "install-validata.sh": true, + "install.sh": true, + "configure-ish.sql": false, + } + + entries, err := os.ReadDir(*dir) + if err != nil { + fatal("read dir: %v", err) + } + var arts []release.Artifact + for _, e := range entries { + if e.IsDir() || e.Name() == "manifest.json" { + continue + } + full := filepath.Join(*dir, e.Name()) + sha, size, err := release.HashFile(full) + if err != nil { + fatal("hash %s: %v", e.Name(), err) + } + exec, ok := known[e.Name()] + if !ok { + // неизвестный файл — включаем, +x по расширению + exec = strings.HasSuffix(e.Name(), ".sh") + } + arts = append(arts, release.Artifact{ + Name: e.Name(), + File: e.Name(), + Version: *version, + SHA256: sha, + Size: size, + Exec: exec, + }) + } + sort.Slice(arts, func(i, j int) bool { return arts[i].Name < arts[j].Name }) + if len(arts) == 0 { + fatal("build: в каталоге %s нет артефактов", *dir) + } + + m := &release.Manifest{ + Schema: release.CurrentSchema, + Version: *version, + Channel: *channel, + ReleasedAt: time.Now().UTC(), + Notes: *notes, + Artifacts: arts, + } + sm, err := release.Sign(m, priv, *keyID) + if err != nil { + fatal("sign: %v", err) + } + b, err := json.MarshalIndent(sm, "", " ") + if err != nil { + fatal("marshal: %v", err) + } + if err := os.WriteFile(*out, b, 0o644); err != nil { + fatal("write manifest: %v", err) + } + fmt.Printf("Манифест %s: версия %s, канал %s, артефактов %d, подписан ключом %s\n", + *out, *version, *channel, len(arts), *keyID) + for _, a := range arts { + fmt.Printf(" %-22s %10d B %s\n", a.Name, a.Size, a.SHA256[:16]) + } +} + +func fatal(format string, a ...any) { + fmt.Fprintf(os.Stderr, "bj-release: "+format+"\n", a...) + os.Exit(1) +} diff --git a/cmd/bj-server/main.go b/cmd/bj-server/main.go index 3ee9e08..cfa3ffe 100644 --- a/cmd/bj-server/main.go +++ b/cmd/bj-server/main.go @@ -40,17 +40,10 @@ func main() { DefaultSender: defaultSender, DefaultReceiver: defaultReceiver, SetupPath: setupPath, - CheckOptions: func() lkgateway.CheckOptions { - return lkgateway.CheckOptions{ - PostgresDSN: os.Getenv("BJ_DSN"), - CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"), - NSDAdapterURL: os.Getenv("BJ_NSD_ADAPTER_URL"), - LKCallbackURL: os.Getenv("BJ_LK_CALLBACK_URL"), - Profile: getenv("BJ_NSD_PROFILE", "demo (mock NSD)"), - CryptoProvider: getenv("BJ_CRYPTO_PROVIDER", "stub"), - Timeout: 2 * time.Second, - } - }, + // CheckOptions не задаём — server.go использует свой снапшот-based + // вариант, который читает актуальные значения из setup.json + // (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки + // статуса совпадают с тем, что реально настроено в UI. } srv, err := lkgateway.NewServer(cfg) diff --git a/deploy/artifactory/README.md b/deploy/artifactory/README.md new file mode 100644 index 0000000..0432342 --- /dev/null +++ b/deploy/artifactory/README.md @@ -0,0 +1,79 @@ +# Артефактория Bridge-and-Join-s + +Сервис раздачи релизов и обновлений (#18). Клиенты (bj-server auto-update, +install.sh) скачивают **подписанный** манифест канала, проверяют подпись +зашитым публичным ключом и обновляют компоненты. + +## Компоненты + +- `internal/release` — формат манифеста + подпись Ed25519 (sign/verify, хеши). +- `cmd/bj-release` — инструмент издателя: генерация ключей, сборка и подпись манифеста. +- `cmd/bj-artifactory` — HTTP-сервер раздачи манифеста и артефактов. +- `deploy/artifactory/` — nginx (TLS) + systemd unit. + +## Модель доверия + +Один корневой Ed25519-ключ. Приватный (`signing.priv`) держит издатель в +секрете (НЕ в git). Публичный (`signing.pub`) зашивается в bj-server и в +install.sh. Манифест подписывается целиком — клиент проверяет подпись ДО +доверия версиям и хешам артефактов, затем сверяет sha256 каждого скачанного +файла с манифестом. + +## Релизный цикл (издатель) + +```bash +# 1. Однократно — сгенерировать ключи подписи (приватный хранить в секрете!) +bj-release keygen -out ./keys/signing +# → keys/signing.priv (секрет), keys/signing.pub +# Публичный base64 из вывода — зашить в bj-server (auto-update, #20) + +# 2. Собрать артефакты релиза в каталог +mkdir -p dist/stable +cp bj-server crypto-service.jar dist/stable/ +cp deploy/linux/install-validata.sh deploy/ish/configure-ish.sql dist/stable/ + +# 3. Собрать + подписать манифест +bj-release build -dir dist/stable -version 1.0.0 -channel stable \ + -key keys/signing.priv -keyid main -out dist/stable/manifest.json \ + -notes "Первый релиз" + +# 4. Выложить каталог в хранилище артефактории +rsync -a dist/stable/ server:/var/lib/bj-artifactory/releases/stable/ +``` + +## Сервер + +```bash +bj-artifactory --addr 127.0.0.1:8090 --root /var/lib/bj-artifactory/releases +``` + +Раскладка хранилища: + +``` +releases/ + stable/ + manifest.json ← подписанный SignedManifest + bj-server + crypto-service.jar + install-validata.sh + configure-ish.sql + beta/ + manifest.json + ... +``` + +## HTTP API + +| Метод | Путь | Ответ | +|---|---|---| +| GET | `/v1//manifest.json` | подписанный манифест канала | +| GET | `/v1//files/` | артефакт по имени | +| GET | `/healthz` | `ok` | + +За TLS-reverse-proxy (`nginx.conf`). Прод: `updates.example.com` → 127.0.0.1:8090. + +## Дальше + +- **#19 License-сервер** — манифест/обновления гейтятся годовым ключом. +- **#20 Auto-update в bj-server** — горутина: качает манифест канала, проверяет + подпись, сравнивает версии, atomic-replace бинарей, systemctl restart. diff --git a/deploy/artifactory/artifactory.service b/deploy/artifactory/artifactory.service new file mode 100644 index 0000000..dd93b42 --- /dev/null +++ b/deploy/artifactory/artifactory.service @@ -0,0 +1,21 @@ +[Unit] +Description=Bridge-and-Join-s — Artifactory (раздача релизов и обновлений) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=bj-updates +Group=bj-updates +ExecStart=/opt/bj-artifactory/bj-artifactory --addr 127.0.0.1:8090 --root /var/lib/bj-artifactory/releases +Restart=on-failure +RestartSec=5 + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadOnlyPaths=/var/lib/bj-artifactory +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/artifactory/nginx.conf b/deploy/artifactory/nginx.conf new file mode 100644 index 0000000..5c26341 --- /dev/null +++ b/deploy/artifactory/nginx.conf @@ -0,0 +1,44 @@ +# nginx.conf — reverse-proxy для bj-artifactory с TLS. +# Раздаёт релизы и обновления bj-server по HTTPS. +# +# Установка: положить в /etc/nginx/sites-available/, заменить server_name +# и пути сертификатов, выпустить TLS через certbot, symlink в sites-enabled. +# +# updates.example.com → bj-artifactory на 127.0.0.1:8090 +# +# bj-artifactory запускается как systemd-сервис (см. artifactory.service). + +server { + listen 80; + server_name updates.example.com; + # Редирект на HTTPS (кроме ACME-челленджа certbot). + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl http2; + server_name updates.example.com; + + ssl_certificate /etc/letsencrypt/live/updates.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/updates.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + # Манифесты маленькие — не кэшируем агрессивно (быстрое распространение релизов). + location /v1/ { + proxy_pass http://127.0.0.1:8090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # Артефакты могут быть крупными (jar ~20МБ) — без буферизации тела. + proxy_buffering off; + client_max_body_size 0; + } + + location /healthz { + proxy_pass http://127.0.0.1:8090; + } + + # Всё остальное — 404. + location / { return 404; } +} diff --git a/deploy/ish/channel-reference.txt b/deploy/ish/channel-reference.txt new file mode 100644 index 0000000..35b4dd5 --- /dev/null +++ b/deploy/ish/channel-reference.txt @@ -0,0 +1 @@ +TEST3 GOST|TEST3GOST|WSL|t diff --git a/deploy/ish/configure-ish.sql b/deploy/ish/configure-ish.sql new file mode 100644 index 0000000..76d8cd1 --- /dev/null +++ b/deploy/ish/configure-ish.sql @@ -0,0 +1,132 @@ +-- configure-ish.sql — автонастройка ИШ НРД без GUI. +-- +-- Снято как эталон с рабочей GUI-конфигурации (deploy/ish/params-reference.txt) +-- и параметризовано. Воспроизводит то, что оператор делал бы мышкой в +-- igate.exe (Avalonia): PostgreSQL + Web API + WSL-канал TEST3-GOST. +-- +-- Применяется к свежей БД ИШ ПОСЛЕ того как схема создана через +-- `igate-cli --data ` (он накатывает EF-миграции при первом подключении). +-- +-- Подстановки (заменяются установщиком через psql -v): +-- :channel_name — отображаемое имя канала, напр. 'TEST3 GOST' +-- :channel_code — локальный код канала, напр. 'TEST3GOST' +-- :wsl_endpoint — URL службы WSL НРД (TEST3-GOST) +-- :crypto_profile — имя профиля Валидаты ('moex') +-- :repository_code— код депонента из письма НРД ('MC0079200000') +-- :exchange_dir — рабочая папка обмена ('/var/lib/igate/exchange') +-- :web_port — порт Web API ('8090') +-- +-- Пример: +-- psql -h 127.0.0.1 -U igate -d igate \ +-- -v channel_name="'TEST3 GOST'" -v channel_code="'TEST3GOST'" \ +-- -v wsl_endpoint="'https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo'" \ +-- -v crypto_profile="'moex'" -v repository_code="'MC0079200000'" \ +-- -v exchange_dir="'/var/lib/igate/exchange'" -v web_port="'8090'" \ +-- -f configure-ish.sql + +BEGIN; + +-- Чистим прежнюю конфигурацию (идемпотентность) +DELETE FROM parameters; +DELETE FROM channels; + +-- --- Глобальные параметры: Web API (КРИТИЧНО — runEngineOnStartApp=True, +-- иначе движок не стартует в headless-режиме и Kestrel не поднимается) --- +INSERT INTO parameters(name, value, chanel_id) VALUES + ('runEngineOnStartApp', 'True', NULL), + ('server.useServer', 'True', NULL), + ('server.host', 'localhost', NULL), + ('server.port', :web_port, NULL), + ('server.scheme', 'Http', NULL), + ('server.authentication.enable', 'False', NULL), + ('server.authentication.userName', '', NULL), + ('server.authentication.password', '', NULL), + ('server.certificate.storage', 'File', NULL), + ('server.certificate.store.location', 'CurrentUser', NULL), + ('server.certificate.store.name', 'My', NULL), + ('server.certificate.file.path', '', NULL), + ('server.certificate.file.password', '', NULL), + ('wsl.httpsMode', 'Auto', NULL), + ('wsl.maxConnsPerServer', '4', NULL), + ('wsl.proxy.mode', 'None', NULL), + ('wsl.proxy.address', '', NULL), + ('wsl.proxy.port', '0', NULL), + ('wsl.proxy.username', '', NULL), + ('wsl.proxy.password', '', NULL), + ('enableDbLogging', 'False', NULL), + ('cleanAutomatically', 'False', NULL), + ('cleanAtTime', '00:30:00', NULL), + ('cleanWhenLarger', '1024', NULL), + ('cleanVacuum', 'False', NULL), + ('storePeriod', '15', NULL), + ('archiveAutomatically', 'False', NULL); + +-- --- WSL-канал --- +-- ВАЖНО: ИШ резолвит канал по СОСТАВНОМУ коду = <код канала> + <код депонента> +-- (так формирует ИШ-GUI: TEST3 + MC0413600000 = TEST3MC0413600000). С коротким +-- кодом ИШ падает 'more than one Channel' и admin API не видит канал. +INSERT INTO channels(name, code, type, enable) +VALUES (:channel_name, :channel_code || :repository_code, 'WSL', true); + +-- Параметры канала привязываем к его id (находим по составному коду) +INSERT INTO parameters(name, value, chanel_id) +SELECT n, v, c.id FROM channels c, (VALUES + ('enable', 'True'), + ('wslEndpoint', :wsl_endpoint), + ('cryptography.type', 'GOST'), + ('cryptography.profile', :crypto_profile), + ('cryptography.pincode', ''), + ('cryptography.clientCertificateSerialNumber', ''), + ('repositoryCode', :repository_code), + ('fetchInterval', '00:01:00'), + ('attemptInterval', '30000'), + ('sendAttempts', '3'), + ('maxPartSize', '500'), + ('loadOldMessagesDepth', '3'), + ('isIncomingEnabled', 'True'), + ('isOutgoingEnabled', 'True'), + ('isTransitTerminalChannel', 'False'), + ('useDirectories', 'True'), + ('dir', :exchange_dir), + ('inboxDirName', 'INBOX'), + ('outboxDirName', 'OUTBOX'), + ('sentDirName', 'SENT'), + ('errorDirName', 'ERRORS'), + ('archive1042sDirName', :exchange_dir || '/Archives1042S'), + ('enableLockFile', 'True'), + ('enableAutoResponse', 'True'), + ('enable1042ReportProcessing', 'True'), + ('RenameOutgoingFiles', 'True'), + ('generateReceivedPackageInfo', 'True'), + ('generateSentPackageInfo', 'False'), + ('moveReceiptsToSentFolder', 'False'), + ('applyAddHashOfPackageToFolder', 'False'), + ('ignorePackageDirectoryStructure', 'False'), + ('checkReceivedPackageNsdSign', 'False'), + ('checkReceivedPackageSenderSign', 'False'), + ('autoUpdateTransitMember', 'False'), + ('automaticcalyLoadCrls', 'False'), + ('autoInPkgReportOffload', 'False'), + ('monitoringThreshold', '00:00:10'), + -- Пустые параметры для полного соответствия эталону GUI (движок ожидает + -- их наличие; отсутствие части может дать «Invalid value» при старте). + ('autoLoadCrlsTime', ''), + ('fetchThreadCount', ''), + ('forceCryPackageEncryption', ''), + ('inPkgReportDirectory', ''), + ('inPkgReportOffloadInterval', ''), + ('maxPackagesPerJob', ''), + ('nsdCertificateSerialNumbers', ''), + ('pkiDecryptMode', ''), + ('pkiEncryptMode', ''), + ('pkiSignMode', ''), + ('pkiVerifyMode', ''), + ('receiveProcThreadCount', ''), + ('sendProcThreadCount', ''), + ('updateTransitMemberListTime', '') +) AS p(n, v) +WHERE c.code = :channel_code || :repository_code; + +COMMIT; + +\echo 'ИШ настроен. Перезапустите igate-svc: systemctl restart igate' diff --git a/deploy/ish/params-reference.txt b/deploy/ish/params-reference.txt new file mode 100644 index 0000000..b12c47d --- /dev/null +++ b/deploy/ish/params-reference.txt @@ -0,0 +1,83 @@ +archiveAtTime||NULL +archiveAutomatically|False|NULL +archiveRecordsOlderThan||NULL +archiveWhenLarger||NULL +cleanAtTime|00:30:00|NULL +cleanAutomatically|False|NULL +cleanVacuum|False|NULL +cleanWhenLarger|1024|NULL +enableDbLogging|False|NULL +httpTimeout||NULL +packageBackupFolder||NULL +runEngineOnStartApp|False|NULL +server.authentication.enable|False|NULL +server.authentication.password||NULL +server.authentication.userName||NULL +server.certificate.file.password||NULL +server.certificate.file.path||NULL +server.certificate.storage|File|NULL +server.certificate.store.location|CurrentUser|NULL +server.certificate.store.name|My|NULL +server.host|localhost|NULL +server.port|8090|NULL +server.scheme|Http|NULL +server.useServer|True|NULL +storePeriod|15|NULL +wsl.httpsMode|Auto|NULL +wsl.maxConnsPerServer|4|NULL +wsl.proxy.address||NULL +wsl.proxy.mode|None|NULL +wsl.proxy.password||NULL +wsl.proxy.port|0|NULL +wsl.proxy.username||NULL +applyAddHashOfPackageToFolder|False|33 +archive1042sDirName|/var/lib/igate/exchange/Archives1042S|33 +attemptInterval|30000|33 +autoInPkgReportOffload|False|33 +autoLoadCrlsTime||33 +automaticcalyLoadCrls|False|33 +autoUpdateTransitMember|False|33 +checkReceivedPackageNsdSign|False|33 +checkReceivedPackageSenderSign|False|33 +cryptography.clientCertificateSerialNumber||33 +cryptography.pincode||33 +cryptography.profile|My|33 +cryptography.type|GOST|33 +dir|/var/lib/igate/exchange|33 +enable|True|33 +enable1042ReportProcessing|True|33 +enableAutoResponse|True|33 +enableLockFile|True|33 +errorDirName|ERRORS|33 +fetchInterval|00:01:00|33 +fetchThreadCount||33 +forceCryPackageEncryption||33 +generateReceivedPackageInfo|True|33 +generateSentPackageInfo|False|33 +ignorePackageDirectoryStructure|False|33 +inboxDirName|INBOX|33 +inPkgReportDirectory||33 +inPkgReportOffloadInterval||33 +isIncomingEnabled|True|33 +isOutgoingEnabled|True|33 +isTransitTerminalChannel|False|33 +loadOldMessagesDepth|3|33 +maxPackagesPerJob||33 +maxPartSize|500|33 +monitoringThreshold|00:00:10|33 +moveReceiptsToSentFolder|False|33 +nsdCertificateSerialNumbers||33 +outboxDirName|OUTBOX|33 +pkiDecryptMode||33 +pkiEncryptMode||33 +pkiSignMode||33 +pkiVerifyMode||33 +receiveProcThreadCount||33 +RenameOutgoingFiles|True|33 +repositoryCode|MC0079200000|33 +sendAttempts|3|33 +sendProcThreadCount||33 +sentDirName|SENT|33 +updateTransitMemberListTime||33 +useDirectories|True|33 +wslEndpoint|https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo|33 diff --git a/deploy/license/README.md b/deploy/license/README.md new file mode 100644 index 0000000..697ae72 --- /dev/null +++ b/deploy/license/README.md @@ -0,0 +1,71 @@ +# Лицензирование Bridge-and-Join-s (#19) + +Годовые лицензии с офлайн-проверкой по подписи Ed25519 + опциональный +онлайн-отзыв. + +## Компоненты + +- `internal/license` — формат лицензии + подпись/проверка (offline). +- `cmd/bj-license` — издательский CLI: keygen, issue, verify. +- `cmd/bj-license-server` — онлайн-реестр отзывов (revocation). +- Интеграция в bj-server: `internal/lkgateway/licensecheck.go` — проверка + лицензии + гейт обновлений; UI раздел «Лицензия». + +## Модель + +Лицензия — самодостаточный подписанный токен: `payload.signature.keyid`. +bj-server проверяет подпись зашитым публичным ключом и срок **офлайн** — +работает без связи с сервером. Online-сервер нужен только для отзыва. + +**Гейт обновлений:** если лицензирование включено (есть публичный ключ), +авто-обновление (#20) выполняется только при валидной лицензии с фичей +`updates`. Без лицензирования (публичный ключ не зашит) — открытый режим, +гейты не действуют (бесплатная редакция / разработка). + +## Издателю + +```bash +# 1. Ключи лицензий (однократно; приватный — в секрете!) +bj-license keygen -out ./keys/license +# публичный base64 — зашить в bj-server (DefaultLicensePublicKey) + +# 2. Выпустить годовую лицензию клиенту +bj-license issue -tenant "ООО Ромашка" -plan pro -days 365 \ + -features updates,web-cabinet -key ./keys/license.priv -keyid main +# → выводит ключ payload.signature.keyid — отдать клиенту + +# 3. Проверить +bj-license verify -key-file license.key -pub ./keys/license.pub +``` + +Планы: `free` (без фич), `pro` (перечисленные features), `enterprise` +(всё включено). Фичи: `updates`, `web-cabinet`, … + +## Клиенту (on-prem bj-server) + +Админ → Настройка → **Лицензия** → вставить ключ → «Активировать». +Проверка офлайн; статус (организация, план, срок, обновления) виден сразу. + +## Онлайн-отзыв (опционально) + +```bash +bj-license-server --addr :8091 --revoked /var/lib/bj-license/revoked.json +``` + +`revoked.json` — JSON-массив отозванных license ID: + +```json +["28db4973-fde8-434c-b102-e83623eede2c"] +``` + +`GET /v1/check?id=` → `{"revoked":true|false}`. Перечитывается раз в +минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва. + +## Зашивка публичного ключа в релиз + +```bash +go build -ldflags "\ + -X .../lkgateway.DefaultLicensePublicKey= \ + -X .../lkgateway.DefaultUpdatePublicKey= \ + -X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/ +``` diff --git a/deploy/linux/install-validata.sh b/deploy/linux/install-validata.sh new file mode 100755 index 0000000..37664b4 --- /dev/null +++ b/deploy/linux/install-validata.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +# install-validata.sh — установка АПК «Валидата Клиент L» под bj-crypto. +# +# Поддерживаемые ОС: +# - Debian 11 / 12 (основная, бесплатная) +# - Astra Linux SE 1.7 (платная, для регуляторно-обязанных) +# - Astra Linux CE 1.8 (бесплатная) +# - Ubuntu 22.04 / 24.04 (с предупреждением) +# +# Что делает: +# 1. Ставит зависимости (pcscd, libpcsclite, libgtk-3, libldap, p7zip, execstack) +# 2. Ставит zpki + zsdk deb-пакеты Валидаты +# 3. execstack -c libvdcsp.so (исправление GNU_STACK с RWE на RW) +# 4. Создаёт системного пользователя bj (если ещё нет) +# 5. Кладёт 5 systemd drop-ins (pcscd no-autoexit + 3×bj-crypto + 1×bj-server) +# 6. Создаёт /opt/Validata/VDCSP/etc/spki.ini (Валидата с ним капризничает) +# 7. Дописывает заголовочную секцию в pki1.conf +# 8. Включает pcscd в режиме always-on (без socket-активации) — Валидата +# ожидает постоянно живой демон, иначе ловит 0x8010001D +# 9. Ставит udev-rule + systemd-mount unit для авто-mount USB-флешек с .vdk +# в /var/lib/bj/usb/