Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 586ffb3a31 | |||
| a694f475a8 | |||
| 9737c787f9 | |||
| 6e503433d4 | |||
| bac55cbdfd | |||
| 7a7aa0cf6c | |||
| de41aea00c | |||
| 5fa6ea6ab1 | |||
| 1ffe62133c | |||
| 19a2b6dda4 | |||
| 93f3ec240c | |||
| f1e05c0ca3 | |||
| 2142c4f586 | |||
| cb0f7efd4c |
+10
@@ -1,6 +1,7 @@
|
|||||||
# Сборки
|
# Сборки
|
||||||
/bin/
|
/bin/
|
||||||
/dist/
|
/dist/
|
||||||
|
!/dist/ish/README.md
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
@@ -58,3 +59,12 @@ test-results/
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Doc-watcher: бэкапы при переустановке свежих версий
|
||||||
|
DOC/*.pdf.bak
|
||||||
|
DOC/*.bak.pdf
|
||||||
|
|
||||||
|
# Дистрибутив ИШ НРД (большой, ~120 МБ) — не коммитим в git
|
||||||
|
/dist/ish/*.deb
|
||||||
|
/dist/ish/*.SGN
|
||||||
|
/dist/ish/*.exe
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
Обязательно заметить в сообщениях:
|
||||||
|
- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой
|
||||||
|
- {ВАШ_ДЕПКОД} - ваш депозитарный код
|
||||||
|
- {ВАШ_ИНН} - ваш инн
|
||||||
|
- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги
|
||||||
|
- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги
|
||||||
|
|
||||||
|
Если не заменить на ваши значение - сообщение не пройдет проверку формата.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<config>
|
||||||
|
<name>request.xml</name>
|
||||||
|
<package>#M2MTR</package>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
|
||||||
|
<rt:Header>
|
||||||
|
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
|
||||||
|
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄�)</m2m:CreationTimestamp>
|
||||||
|
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
|
||||||
|
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
|
||||||
|
<m2m:CostInfo>
|
||||||
|
<m2m:Yes>
|
||||||
|
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
|
||||||
|
</m2m:Yes>
|
||||||
|
</m2m:CostInfo>
|
||||||
|
</rt:Header>
|
||||||
|
<rt:Data>
|
||||||
|
<m2m:IsM2M>true</m2m:IsM2M>
|
||||||
|
<m2m:InvestorInformation>
|
||||||
|
<m2m:LastName>翌腙桧</m2m:LastName>
|
||||||
|
<m2m:FirstName>丸觇蜞</m2m:FirstName>
|
||||||
|
<m2m:MiddleName>理囹铍�忤�</m2m:MiddleName>
|
||||||
|
<m2m:IdentityDocument>
|
||||||
|
<m2m:DocumentType>21</m2m:DocumentType>
|
||||||
|
<m2m:DocumentSeries>1111</m2m:DocumentSeries>
|
||||||
|
<m2m:DocumentNumber>111101</m2m:DocumentNumber>
|
||||||
|
</m2m:IdentityDocument>
|
||||||
|
</m2m:InvestorInformation>
|
||||||
|
<m2m:TransferringDepository>
|
||||||
|
<m2m:INN>{吕豞韧蛚</m2m:INN>
|
||||||
|
</m2m:TransferringDepository>
|
||||||
|
<m2m:ReceivingDepository>
|
||||||
|
<m2m:INN>7722061076</m2m:INN>
|
||||||
|
</m2m:ReceivingDepository>
|
||||||
|
<m2m:TransferredSecurities>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU0007661625</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
</m2m:TransferredSecurities>
|
||||||
|
</rt:Data>
|
||||||
|
</rt:M2MTransferRequest>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<config>
|
||||||
|
<name>request.xml</name>
|
||||||
|
<package>#M2MTR</package>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
|
||||||
|
<rt:Header>
|
||||||
|
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
|
||||||
|
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄�)</m2m:CreationTimestamp>
|
||||||
|
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
|
||||||
|
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
|
||||||
|
<m2m:CostInfo>
|
||||||
|
<m2m:Yes>
|
||||||
|
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
|
||||||
|
</m2m:Yes>
|
||||||
|
</m2m:CostInfo>
|
||||||
|
</rt:Header>
|
||||||
|
<rt:Data>
|
||||||
|
<m2m:IsM2M>true</m2m:IsM2M>
|
||||||
|
<m2m:InvestorInformation>
|
||||||
|
<m2m:LastName>翌腙桧</m2m:LastName>
|
||||||
|
<m2m:FirstName>丸觇蜞</m2m:FirstName>
|
||||||
|
<m2m:MiddleName>理囹铍�忤�</m2m:MiddleName>
|
||||||
|
<m2m:IdentityDocument>
|
||||||
|
<m2m:DocumentType>21</m2m:DocumentType>
|
||||||
|
<m2m:DocumentSeries>1111</m2m:DocumentSeries>
|
||||||
|
<m2m:DocumentNumber>111105</m2m:DocumentNumber>
|
||||||
|
</m2m:IdentityDocument>
|
||||||
|
</m2m:InvestorInformation>
|
||||||
|
<m2m:TransferringDepository>
|
||||||
|
<m2m:INN>{吕豞韧蛚</m2m:INN>
|
||||||
|
</m2m:TransferringDepository>
|
||||||
|
<m2m:ReceivingDepository>
|
||||||
|
<m2m:INN>7722061076</m2m:INN>
|
||||||
|
</m2m:ReceivingDepository>
|
||||||
|
<m2m:TransferredSecurities>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU0007661625</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
</m2m:TransferredSecurities>
|
||||||
|
</rt:Data>
|
||||||
|
</rt:M2MTransferRequest>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Обязательно заметить в сообщениях:
|
||||||
|
- {GUID_ПЕРЕВОДА} - идентификтор перевода. Всегда меняйте его перед отправкой
|
||||||
|
- {ВАШ_ДЕПКОД} - ваш депозитарный код
|
||||||
|
- {ВАШ_ИНН} - ваш инн
|
||||||
|
- {ВАШ_ДЕПОЗИТАРНЫЙ_СЧЕТ} - депозитарный счет, с которого переводятся бумаги
|
||||||
|
- {ВАШ_РАЗДЕЛ_ДЕПОЗИТАРНОГО_СЧЕТА} - раздел депозитарного счета, с которого переводятся бумаги
|
||||||
|
|
||||||
|
Если не заменить на ваши значение - сообщение не пройдет проверку формата.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<config>
|
||||||
|
<name>request.xml</name>
|
||||||
|
<package>#M2MTR</package>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
|
||||||
|
<rt:Header>
|
||||||
|
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
|
||||||
|
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄�)</m2m:CreationTimestamp>
|
||||||
|
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
|
||||||
|
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
|
||||||
|
<m2m:CostInfo>
|
||||||
|
<m2m:Yes>
|
||||||
|
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
|
||||||
|
</m2m:Yes>
|
||||||
|
</m2m:CostInfo>
|
||||||
|
</rt:Header>
|
||||||
|
<rt:Data>
|
||||||
|
<m2m:IsM2M>true</m2m:IsM2M>
|
||||||
|
<m2m:InvestorInformation>
|
||||||
|
<m2m:LastName>翌腙桧</m2m:LastName>
|
||||||
|
<m2m:FirstName>丸觇蜞</m2m:FirstName>
|
||||||
|
<m2m:MiddleName>理囹铍�忤�</m2m:MiddleName>
|
||||||
|
<m2m:IdentityDocument>
|
||||||
|
<m2m:DocumentType>21</m2m:DocumentType>
|
||||||
|
<m2m:DocumentSeries>2001</m2m:DocumentSeries>
|
||||||
|
<m2m:DocumentNumber>111111</m2m:DocumentNumber>
|
||||||
|
</m2m:IdentityDocument>
|
||||||
|
</m2m:InvestorInformation>
|
||||||
|
<m2m:TransferringDepository>
|
||||||
|
<m2m:INN>{吕豞韧蛚</m2m:INN>
|
||||||
|
</m2m:TransferringDepository>
|
||||||
|
<m2m:ReceivingDepository>
|
||||||
|
<m2m:INN>7722061076</m2m:INN>
|
||||||
|
</m2m:ReceivingDepository>
|
||||||
|
<m2m:TransferredSecurities>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU0007661625</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
</m2m:TransferredSecurities>
|
||||||
|
</rt:Data>
|
||||||
|
</rt:M2MTransferRequest>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<config>
|
||||||
|
<name>request.xml</name>
|
||||||
|
<package>#M2MTR</package>
|
||||||
|
</config>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<rt:M2MTransferRequest xmlns:m2m="http://nsd.ru/schemas/m2m/types" xmlns:rt="http://nsd.ru/schemas/m2m/request" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://nsd.ru/schemas/m2m/request M2MTransferRequest.xsd">
|
||||||
|
<rt:Header>
|
||||||
|
<m2m:GUID>{GUIF_吓信挛睦}</m2m:GUID>
|
||||||
|
<m2m:CreationTimestamp>2026-04-30T01:01:01(萄�)</m2m:CreationTimestamp>
|
||||||
|
<m2m:SenderCode>{吕豞呐鲜文}</m2m:SenderCode>
|
||||||
|
<m2m:ReceiverCode>MC0012500000</m2m:ReceiverCode>
|
||||||
|
<m2m:CostInfo>
|
||||||
|
<m2m:Yes>
|
||||||
|
<m2m:Code>{吕豞呐鲜文}</m2m:Code>
|
||||||
|
</m2m:Yes>
|
||||||
|
</m2m:CostInfo>
|
||||||
|
</rt:Header>
|
||||||
|
<rt:Data>
|
||||||
|
<m2m:IsM2M>true</m2m:IsM2M>
|
||||||
|
<m2m:InvestorInformation>
|
||||||
|
<m2m:LastName>翌腙桧</m2m:LastName>
|
||||||
|
<m2m:FirstName>丸觇蜞</m2m:FirstName>
|
||||||
|
<m2m:MiddleName>理囹铍�忤�</m2m:MiddleName>
|
||||||
|
<m2m:IdentityDocument>
|
||||||
|
<m2m:DocumentType>21</m2m:DocumentType>
|
||||||
|
<m2m:DocumentSeries>2001</m2m:DocumentSeries>
|
||||||
|
<m2m:DocumentNumber>121212</m2m:DocumentNumber>
|
||||||
|
</m2m:IdentityDocument>
|
||||||
|
</m2m:InvestorInformation>
|
||||||
|
<m2m:TransferringDepository>
|
||||||
|
<m2m:INN>{吕豞韧蛚</m2m:INN>
|
||||||
|
</m2m:TransferringDepository>
|
||||||
|
<m2m:ReceivingDepository>
|
||||||
|
<m2m:INN>7722061076</m2m:INN>
|
||||||
|
</m2m:ReceivingDepository>
|
||||||
|
<m2m:TransferredSecurities>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116169101</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU0007661625</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU0007661625</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116869102</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JP5V6</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JP5V6</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7702165310</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2211116869103</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPKH7</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPKH7</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
<m2m:Security>
|
||||||
|
<m2m:ReferenceId>M2M2233116819104</m2m:ReferenceId>
|
||||||
|
<m2m:SecurityCode>RU000A0JPGP8</m2m:SecurityCode>
|
||||||
|
<m2m:SecurityDetails>
|
||||||
|
<m2m:ISIN>RU000A0JPGP8</m2m:ISIN>
|
||||||
|
</m2m:SecurityDetails>
|
||||||
|
<m2m:Quantity>
|
||||||
|
<m2m:Whole>1</m2m:Whole>
|
||||||
|
</m2m:Quantity>
|
||||||
|
<m2m:SettlementAccount>
|
||||||
|
<m2m:SettlementRequisites>
|
||||||
|
<m2m:INN>7831000034</m2m:INN>
|
||||||
|
</m2m:SettlementRequisites>
|
||||||
|
<m2m:SettlementLocation>
|
||||||
|
<m2m:DeponentCode>{吕豞呐鲜文}</m2m:DeponentCode>
|
||||||
|
<m2m:AccountId>{吕豞呐衔侨依型凵_炎乓}</m2m:AccountId>
|
||||||
|
<m2m:SectionId>{吕豞欣悄潘_呐衔侨依型蚊蝊炎乓纝</m2m:SectionId>
|
||||||
|
</m2m:SettlementLocation>
|
||||||
|
</m2m:SettlementAccount>
|
||||||
|
<m2m:IsolationStatus>SGDN</m2m:IsolationStatus>
|
||||||
|
</m2m:Security>
|
||||||
|
</m2m:TransferredSecurities>
|
||||||
|
</rt:Data>
|
||||||
|
</rt:M2MTransferRequest>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,254 @@
|
|||||||
|
# Bridge-and-Join-s — отчёт о ходе работ
|
||||||
|
|
||||||
|
**Дата:** 14.05.2026 (3-я редакция за день — скачан дистрибутив ИШ + полная документация ИШ)
|
||||||
|
**Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083
|
||||||
|
**Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Готовность по областям
|
||||||
|
|
||||||
|
| Область | Готовность | Статус |
|
||||||
|
|---|---:|---|
|
||||||
|
| Контракты и модели M2M (XSD → Go) | **100%** | ✅ Готово |
|
||||||
|
| Журнал сделок (PostgreSQL + in-memory) | **100%** | ✅ Готово |
|
||||||
|
| Бизнес-логика FSM (стейт-машина заявок) | **100%** | ✅ Готово |
|
||||||
|
| Веб-интерфейс администратора | **95%** | ✅ Готово |
|
||||||
|
| Мастер настройки (wizard) для оператора | **100%** | ✅ Готово |
|
||||||
|
| Установка и конфигурация КриптоПро CSP через UI | **100%** | ✅ Готово |
|
||||||
|
| Авто-загрузка сертификатов УЦ (мониторинг + ежесуточное обновление) | **100%** | ✅ Готово |
|
||||||
|
| Контейнеры КриптоПро с флешки (импорт в HDIMAGE) | **80%** | ⚠ Без UI-импорта сертификата из контейнера |
|
||||||
|
| Лента новостей + мониторинг сайта НРД (doc-watcher) | **100%** | ✅ Готово |
|
||||||
|
| Эмулятор робота-автотеста НРД (внутренний mock) | **90%** | ⚠ Сценарий 3333 — частично |
|
||||||
|
| Реальное подключение к роботу на TEST3 НРД | **30%** | ⚠ REST-клиент ИШ готов, ждём сам ИШ + сертификат |
|
||||||
|
| REST-клиент ИШ НРД (по DOC/instr-ish-rest-api.pdf) | **100%** | ✅ POST file, GET status, GET list, GET package, упаковщик ZIP, 10/10 тестов |
|
||||||
|
| Дистрибутив ИШ НРД и полная документация | **100%** | ✅ Скачаны: `igate_100.0-765_amd64.deb` (117 МБ) + 6 PDF |
|
||||||
|
| Установка ИШ на наш стенд | **30%** | ⚠ Скрипты установки готовы, ждём Astra Linux ВМ от инфра-команды |
|
||||||
|
| Авто-установщик «одной командой» | **100%** | ✅ `curl … \| sudo bash` на свежей Astra/Debian/Ubuntu — bj-server + БД + ИШ через 5-10 мин |
|
||||||
|
| Получение СКЗИ «Валидата CSP» для Linux | **0%** | ⏳ Запрос в soed@nsd.ru / pki@moex.com — см. блокер #2 |
|
||||||
|
| Сертификат УЦ Московской Биржи для подписи | **0%** | ⏳ Не получен — см. блокер #3 |
|
||||||
|
| Подключение реального ЛК ESIA Finance | **20%** | ⚠ Эмулятор lk-emulator работает, реальный URL не указан |
|
||||||
|
| Контракт с Fansy (ETL) | **30%** | ⚠ Контракт документирован, ETL не реализован стороной Fansy |
|
||||||
|
| Уведомления (e-mail, мессенджеры) | **0%** | ⏳ M3-M4 |
|
||||||
|
| Тесты, CI/CD | **40%** | ⚠ Unit-тесты компонентов, нет E2E против реального НРД |
|
||||||
|
|
||||||
|
**Общая готовность системы:** **≈ 75%** (по объёму функциональности)
|
||||||
|
**Готовность к интеграционному тесту с роботом:** **≈ 88%** (зависит только от внешних факторов: Astra Linux ВМ, Валидата CSP, сертификат УЦ МБ — на нашей стороне установщик готов)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что сделано (28 коммитов)
|
||||||
|
|
||||||
|
### Архитектура и ядро (M1)
|
||||||
|
- Реализованы Go-модели всех M2M-сообщений (M2MTransferRequest, Response, Decision, History, Movement) с валидацией.
|
||||||
|
- Стейт-машина обработки заявок (FSM): `draft → validated → submitted_to_nsd → awaiting_decision → confirmed/rejected/timed_out → done` + ветка ручного разбора.
|
||||||
|
- Один исполняемый бинарник `bj-server` (вместо запланированных микросервисов) — рассчитано на нагрузку до 1000 сделок/день.
|
||||||
|
- Хранилище: PostgreSQL 16 в контейнере podman (один клик «Поднять автоматически» в UI), миграции для двух схем — `fansy.*` (данные от Fansy) и `m2m_core.*` (журнал сделок). Fallback на in-memory для дева.
|
||||||
|
|
||||||
|
### Криптография
|
||||||
|
- Переход с КриптоПро JCP (~82 000₽, Java) на КриптоПро CSP (~30-50 000₽, нативный) — экономия лицензии в ~2 раза. Подходит для нашего объёма (100-1000 сделок/день).
|
||||||
|
- Go-клиент к СКЗИ через стандартный PKCS#11 интерфейс (`internal/cryptocli`). Один клиент работает с КриптоПро CSP, Рутокен ЭЦП 2.0, Валидата, ViPNet — меняется только путь к .so модулю.
|
||||||
|
- UI-кнопка «Загрузить дистрибутив КриптоПро»: загружаешь tar/tgz/rpm, система сама распаковывает и устанавливает через `sudo rpm -Uvh`.
|
||||||
|
- Активация лицензии через UI (`cpconfig -license -set` под капотом).
|
||||||
|
- Импорт сертификатов (.pfx/.p12 с PIN, .cer/.crt без) в хранилища `uMy`/`mroot`/`uRoot` через `certmgr -inst`.
|
||||||
|
- **Авто-обнаружение контейнеров КриптоПро на USB-флешках** (формат `name.000`): сканирует `/run/media/$USER/`, `/media/`, `/mnt/`; кнопка «Скопировать в локальное хранилище» переносит контейнер в `/var/opt/cprocsp/keys/$USER/`.
|
||||||
|
- **Авто-обнаружение сертификатов на Рутокене ЭЦП 2.0** — список заполняется автоматически после подключения токена в USB.
|
||||||
|
|
||||||
|
### Сертификаты УЦ
|
||||||
|
- Авто-загрузка корневых и подписных сертификатов УЦ по списку URL: SHA-256 дедуп, импорт в `mroot`/`uRoot` через `certmgr`.
|
||||||
|
- Ежесуточная фоновая горутина обновляет сертификаты, в ленте новостей появляется уведомление «Обновлён сертификат УЦ: <CN>».
|
||||||
|
- За 14 дней до истечения сертификата — отдельное предупреждение в ленте.
|
||||||
|
|
||||||
|
### Веб-интерфейс администратора (порт 8080)
|
||||||
|
6 разделов меню:
|
||||||
|
- **Дашборд** — счётчики сделок, состояние подсистем, последние заявки, блок «Новости» сверху.
|
||||||
|
- **Мастер настройки** — пошаговая настройка (5 шагов) с прогресс-баром, подсказки «?» и «Где взять?» рядом с каждым полем.
|
||||||
|
- **Настройка** — расширенные параметры всех подсистем.
|
||||||
|
- **Заявки** — журнал + карточка заявки с историей FSM.
|
||||||
|
- **Статус системы** — health-check всех подсистем.
|
||||||
|
- **Инструкции** — 5 help-страниц: БД, API ЛК, КриптоПро, Внешние системы, **Тестирование с роботом**.
|
||||||
|
- **Новости** — лента событий + кнопка «Проверить обновления документации НРД сейчас».
|
||||||
|
|
||||||
|
Все надписи на русском.
|
||||||
|
|
||||||
|
### Мониторинг НРД (doc-watcher)
|
||||||
|
- Раз в сутки скачивает страницы с сайта НРД, парсит ссылки на PDF, обновляет файлы в `DOC/` (старые версии переименовываются в `.YYYY-MM-DD.pdf.bak` для аудита).
|
||||||
|
- Каждое обновление публикуется как новость в ленту.
|
||||||
|
- Уже скачаны три свежие инструкции от 12.05.2026:
|
||||||
|
- `instruktsiya-po-testirovaniyu-s-robotom.pdf` — инструкция по роботу-автотесту
|
||||||
|
- `instruktsiya-...-fizicheskim-litsom-samomu-sebe.pdf` — обмен при self-transfer
|
||||||
|
- `servis-most-m2m.pdf` — обзор сервиса
|
||||||
|
|
||||||
|
### Робот-автотест MOEX МОСТ
|
||||||
|
- Реализован **внутренний робот-эмулятор**: bj-server понимает код робота `MC0012500000` и 4 тестовых сценария (1111/2001/2002/3333) через DocumentSeries.
|
||||||
|
- Это позволяет проверить нашу логику обработки ответов **до того**, как у нас появится реальный ИШ + сертификат + доступ к TEST3.
|
||||||
|
- Help-страница `/admin/help/robot` с полной документацией (коды ошибок M2M01-M2M09, тестовые наборы депозитариев, схема обмена).
|
||||||
|
- Когда подключим реальный ИШ — переключение прозрачное, те же заявки пойдут на реальный TEST3.
|
||||||
|
|
||||||
|
### REST-клиент ИШ НРД (готов на нашей стороне)
|
||||||
|
- По свежей спецификации НРД (`DOC/instr-ish-rest-api.pdf`) реализован Go-клиент в `internal/nsdadapter/igw`:
|
||||||
|
- `POST /api/package/{channel}/file` — отправка ZIP (Type=archive, File=base64)
|
||||||
|
- `GET /api/package/status/{id}` — статус: NEW / SENT / ERROR
|
||||||
|
- `GET /api/package?channel=&type=M2MTD&...` — список входящих от НРД
|
||||||
|
- `GET /api/package/{id}` — скачать ZIP пакета (поддерживает и raw ZIP, и base64-в-JSON)
|
||||||
|
- Упаковщик (`pack.go`): `M2MTransferRequest → ZIP (XML + config.xml)` по разделу 2.3 инструкции
|
||||||
|
- Распаковщик: ZIP → DocXML + winf.xml + .sgn (отсоединённая подпись НРД)
|
||||||
|
- Парсеры: `ParseDecision`, `ParseResponse` — из XML в Go-структуры через `nsdxml.Unmarshal`
|
||||||
|
- Покрыто тестами: 10/10 PASS (httptest + zip round-trip + 4xx без ретраев + retry на 5xx)
|
||||||
|
- Готов к переключению: как только получим живой ИШ от НРД, нужно только указать BaseURL и Channel в `/admin/setup` — код уже всё умеет
|
||||||
|
|
||||||
|
### Авто-установщик «одной командой» (14.05.2026, поздний вечер)
|
||||||
|
|
||||||
|
Главная цель — оператор без знания Linux должен поднять систему **одной командой**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Через 5-10 минут на свежей Astra Linux / Debian / Ubuntu ВМ работает веб-админка на :8080. Установщик `deploy/astra/install.sh`:
|
||||||
|
|
||||||
|
1. **Определяет ОС** — Astra SE/CE, Debian, Ubuntu (с предупреждениями для несовместимых)
|
||||||
|
2. **Ставит зависимости через apt** — podman, postgresql-client, git, curl
|
||||||
|
3. **Скачивает Go 1.24+** с go.dev (~70 МБ)
|
||||||
|
4. **Создаёт пользователя bj** и каталоги /opt/bj /var/lib/bj /var/log/bj
|
||||||
|
5. **Клонирует репо** в /opt/bj/src
|
||||||
|
6. **Собирает bj-server** через go build
|
||||||
|
7. **Поднимает PostgreSQL 16** в podman-контейнере, накатывает миграции
|
||||||
|
8. **Кладёт systemd unit** с безопасными ограничениями (NoNewPrivileges, ProtectSystem=strict, ReadWritePaths)
|
||||||
|
9. **Скачивает ИШ НРД** (~120 МБ) с `old.nsd.ru` и пытается установить через `dpkg -i`
|
||||||
|
10. **Печатает понятную сводку** с URL'ами и списком того, что осталось руками
|
||||||
|
|
||||||
|
Дополнительные скрипты в `deploy/astra/`:
|
||||||
|
- **`install-validata.sh`** — установка СКЗИ Валидата CSP когда придёт от НРД. Если дистрибутива ещё нет — печатает готовый текст письма для запроса в `soed@nsd.ru`
|
||||||
|
- **`install-ish.sh`** — ручная установка ИШ из локального .deb (если автоскачивание не сработало)
|
||||||
|
- **`healthcheck.sh`** — цветной отчёт о работоспособности всех 8 компонентов (ОС, пользователь, systemd, HTTP, PostgreSQL, Валидата, ИШ, сетевые порты)
|
||||||
|
- **`import-data.sh`** — опциональный экспорт БД и настроек со старой ВМ (если переезжаем с действующего стенда)
|
||||||
|
- **`README.md`** — TL;DR + полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ (10 этапов, оценочно 2-3 недели от старта)
|
||||||
|
|
||||||
|
После запуска `install.sh` остаётся 3 ручных шага (НРД и УЦ МБ — без них никак): запрос Валидаты, получение сертификата УЦ МБ, заявка на TEST3.
|
||||||
|
|
||||||
|
### Дистрибутив ИШ и полная документация (получены 14.05.2026)
|
||||||
|
По наводке от заказчика на странице `https://www.nsd.ru/workflow/system/programs/web-service/` найдены и скачаны все официальные материалы:
|
||||||
|
|
||||||
|
- **Дистрибутив ИШ Linux**: `dist/ish/igate_100.0-765_amd64.deb` (117 МБ, для Astra Linux)
|
||||||
|
- **Электронная подпись к дистрибутиву**: `dist/ish/igate_95.0-716_amd64.SGN`
|
||||||
|
- **DOC/ruk_install_ish_2025_11_10.pdf** (4.7 МБ) — Руководство по установке ИШ от 10.11.2025. Главное:
|
||||||
|
- Поддерживаемые ОС: Windows 10/Server, **Astra Linux SE 1.6/1.7** (РЕД ОС не упомянута)
|
||||||
|
- СКЗИ: **«Валидата CSP» + АПК «Валидата Клиент L»** (НЕ КриптоПро)
|
||||||
|
- БД: SQLite или PostgreSQL (PostgreSQL обязателен для REST API)
|
||||||
|
- Только ГОСТ-криптография под Linux (RSA — только Windows)
|
||||||
|
- Только сертификаты от УЦ МБ
|
||||||
|
- **DOC/ruk_pol_ish.pdf** (3.5 МБ) — Руководство пользователя ИШ
|
||||||
|
- **DOC/QA_ish.pdf** (2.5 МБ) — Q&A
|
||||||
|
- **DOC/test-case_ish.pdf** (1.3 МБ) — Тест-кейсы для проверки работоспособности ИШ
|
||||||
|
- **DOC/instr_int_sh_01072025.pdf** (0.4 МБ) — Инструкция по созданию заявки на тестирование
|
||||||
|
- **DOC/web_service_nrd_standard_soap_rest.pdf** (2.2 МБ) — Техрекомендации Web-сервиса ONYX
|
||||||
|
|
||||||
|
`dist/ish/.deb` не коммитится в git (большой), но `dist/ish/README.md` содержит все ссылки на повторное скачивание.
|
||||||
|
|
||||||
|
### Безопасность и надёжность
|
||||||
|
- Баннер «🟡 РЕЖИМ ЭМУЛЯЦИИ» отображается на каждой странице админки пока не настроен ИШ или СКЗИ — оператор не сможет случайно принять mock-результат за реальный.
|
||||||
|
- Контекстная навигация после действий (после POST возврат на ту же страницу, не в /admin/setup).
|
||||||
|
- HTTP-клиенты для запросов на nsd.ru/cryptopro.ru идут напрямую (игнорируют корпоративный прокси), браузерный User-Agent для обхода антибот-фильтров.
|
||||||
|
- systemd-unit `deploy/systemd/bj-server.service` с `Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64`, ProtectSystem=strict, NoNewPrivileges, и т.п.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что в процессе и в очереди
|
||||||
|
|
||||||
|
### Внешние блокеры (без них не двинемся к реальному НРД)
|
||||||
|
|
||||||
|
1. **Astra Linux ВМ для ИШ** ⭐ новый блокер
|
||||||
|
- Дистрибутив ИШ — только `igate_100.0-765_amd64.deb` (под Astra Linux SE 1.6/1.7). РЕД ОС официально не поддерживается, RPM-версии нет.
|
||||||
|
- Что нужно: поднять отдельную Astra Linux ВМ (10.10.10.23?) или попробовать запустить ИШ в Docker-контейнере с Astra-образом.
|
||||||
|
- Альтернативы: Windows 10/Server (есть .exe-дистрибутив, но это шаг назад от Linux).
|
||||||
|
- Срок: зависит от инфра-команды; ~1 день поднять ВМ + ~30 мин установить ИШ.
|
||||||
|
|
||||||
|
2. **СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»** ⭐ новый блокер
|
||||||
|
- ИШ требует именно Валидату, **НЕ КриптоПро CSP** (у нас стоит КриптоПро, придётся ставить параллельно или вместо).
|
||||||
|
- Где взять: только по запросу — `soed@nsd.ru` (НРД) или `pki@moex.com` (МБ). Временная лицензия выдаётся.
|
||||||
|
- Что нужно: отправить письмо с реквизитами организации и обоснованием (подключение к MOEX МОСТ M2M).
|
||||||
|
- Срок: ~1-3 дня на ответ НРД.
|
||||||
|
|
||||||
|
3. **Сертификат подписи УЦ Московской Биржи** (ca.moex.com)
|
||||||
|
- Нужен для подписи отправляемых сообщений. Кладётся в Справочник сертификатов АПК Валидата Клиент L, экспортируется в системное хранилище.
|
||||||
|
- Что нужно: оформить заявку в УЦ МБ от организации, получить сертификат + приватный ключ.
|
||||||
|
- Срок: зависит от УЦ МБ.
|
||||||
|
|
||||||
|
4. **Заявка на тестирование в TEST3 НРД**
|
||||||
|
- Форма: `https://www.nsd.ru/workflow/zayavka-na-testirovanie/`
|
||||||
|
- Инструкция: `DOC/instr_int_sh_01072025.pdf`
|
||||||
|
- Получаем код депонента-тестера и доступ к контурам GUEST/TEST3.
|
||||||
|
|
||||||
|
5. **Сертификаты УЦ НРД** (для проверки квитанций)
|
||||||
|
- Где взять: `https://www.nsd.ru/workflow/system/cryptography/` — сейчас отдаёт 404 на нашем дев-стенде (вероятно перенесено в ЛК НРД депонента).
|
||||||
|
- В коде уже есть форма «Авто-загрузка сертификатов УЦ» в `/admin/setup` — как только получим прямые URL .cer, добавим их.
|
||||||
|
|
||||||
|
6. **Окно техработ TEST3: 18.05.2026 — 22.05.2026**
|
||||||
|
- Полевое тестирование в этот период невозможно. Реальные прогоны — до 18-го или после 22-го мая.
|
||||||
|
|
||||||
|
7. **Доступ к API реального ЛК ESIA Finance**
|
||||||
|
- Сейчас bj-server работает с встроенным эмулятором `lk-emulator` на :8083.
|
||||||
|
- Что нужно: URL продакшен/тест ЛК, Basic-auth учётка.
|
||||||
|
|
||||||
|
### Внутренние задачи (можем делать параллельно)
|
||||||
|
|
||||||
|
| Задача | Приоритет | Эффект |
|
||||||
|
|---|---|---|
|
||||||
|
| Завершить сценарий 3333 робота — приёмная сторона bj-server (входящие M2MTransferRequest) | средний | Полное покрытие тестов с роботом |
|
||||||
|
| UI для импорта сертификата из контейнера КриптоПро (после копирования с флешки) | низкий | Сейчас делается вручную через certmgr |
|
||||||
|
| Уведомления: SMTP, Yandex Messenger, Telegram (плагины через единый интерфейс Notifier) | средний (M3) | Операторам — критичные события в мессенджеры |
|
||||||
|
| Расширение тестов: unit + интеграционные с mock-роботом, нагрузочные | низкий | Уверенность перед прод |
|
||||||
|
| Документация для команды Fansy (ETL): передача контракта, согласование SLA, прописывание IP в pg_hba.conf | средний | Запуск ETL-потока |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что после получения ИШ и сертификата (план первичного тестирования с роботом)
|
||||||
|
|
||||||
|
1. **День 0** (получили дистрибутив ИШ + сертификат + руководство)
|
||||||
|
- Поставить ИШ на dev-ВМ.
|
||||||
|
- Положить сертификат в ИШ-хранилище.
|
||||||
|
- В bj-server: `/admin/setup` → ИШ профиль `test3-gost`, URL ИШ.
|
||||||
|
2. **День 1** (smoke-тест)
|
||||||
|
- Отправить через bj-server заявку с ReceiverCode = `MC0012500000`, DocumentSeries = `2001`, DocumentNumber = `111111` → ожидаем «принять все бумаги» от робота.
|
||||||
|
- Проверить: квитанция от НРД, Decision от робота, callback в `lk-emulator`, статус в журнале → `confirmed`.
|
||||||
|
3. **День 2-3** (полное покрытие сценариев)
|
||||||
|
- 1111 (отказ M2M01..M2M09) — все коды ошибок.
|
||||||
|
- 2001 / 2002 — все депозитарии, все варианты частичного приёма.
|
||||||
|
- 3333 — встречный перевод (когда доделаем приёмную сторону).
|
||||||
|
4. **День 4-5** (нагрузка)
|
||||||
|
- 50-100 одновременных заявок, проверка очередей, БД, корректность статусов.
|
||||||
|
5. **День 6** (живой контрагент)
|
||||||
|
- Согласовать с любым подключённым к НРД депозитарием тестовый обмен.
|
||||||
|
- Это последний шаг перед присоединением к Правилам ЭДО НРД (продакшен).
|
||||||
|
|
||||||
|
**Реалистичный срок от получения ИШ до готовности к продакшену: 2-3 недели** (включая обкатку, fix багов, документацию).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стоимостная сводка
|
||||||
|
|
||||||
|
| Статья | Сумма (руб) | Статус |
|
||||||
|
|---|---:|---|
|
||||||
|
| Лицензия КриптоПро CSP (сервер) | ~30 000-50 000 | Демо 3 мес. — активна |
|
||||||
|
| Лицензия КриптоПро CSP (рабочее место оператора, опц.) | ~2 000-3 000 | Не куплена |
|
||||||
|
| Рутокен ЭЦП 2.0 для оператора (железо, опц.) | ~3 000-5 000 | Не куплено |
|
||||||
|
| Сертификат УЦ МБ для организации | по тарифам УЦ | Не получен |
|
||||||
|
| **Сэкономлено** против КриптоПро JCP | ~30 000-50 000 | (отказ от Java-стека) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ключевые архитектурные решения
|
||||||
|
|
||||||
|
1. **Один бинарник вместо микросервисов** — рассчитано на наш объём (100-1000 сделок/день). Упрощает деплой, отладку, мониторинг. Все компоненты в одном процессе с понятными границами пакетов (`internal/m2mcore`, `internal/nsdadapter`, `internal/lkgateway`, ...).
|
||||||
|
2. **PKCS#11 как единый интерфейс к СКЗИ** — позволяет менять провайдер (CSP/Рутокен/Валидата) без изменения кода bj-server.
|
||||||
|
3. **Двух-уровневая БД** (`fansy.*` для входных данных, `m2m_core.*` для журнала сделок) — позволяет команде Fansy писать в свою схему без знания о нашем pipeline.
|
||||||
|
4. **Mock-робот внутри bj-server** — даёт возможность работать без живого НРД для значительной части интеграционного тестирования.
|
||||||
|
5. **«Дружественный» UI** — установка/настройка не требует SSH-доступа: всё через веб (КриптоПро, лицензии, сертификаты, контейнеры с флешки).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готов к интеграционному тестированию с роботом на TEST3:** да, как только будет ИШ + сертификат.
|
||||||
|
**Готов к продакшену:** ориентировочно через 3-4 недели после получения всех внешних компонентов.
|
||||||
|
|
||||||
|
— Команда разработки Bridge-and-Join-s
|
||||||
@@ -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.
|
||||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Executable
+348
File diff suppressed because one or more lines are too long
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,114 @@
|
|||||||
|
// Command bj-artifactory — простой сервер раздачи релизов и обновлений.
|
||||||
|
//
|
||||||
|
// Раскладка хранилища (--root), один подкаталог на канал:
|
||||||
|
//
|
||||||
|
// <root>/stable/manifest.json — подписанный SignedManifest
|
||||||
|
// <root>/stable/bj-server — артефакты, перечисленные в манифесте
|
||||||
|
// <root>/stable/crypto-service.jar
|
||||||
|
// <root>/beta/manifest.json
|
||||||
|
// ...
|
||||||
|
//
|
||||||
|
// HTTP API (потребляет bj-server auto-update и install.sh):
|
||||||
|
//
|
||||||
|
// GET /v1/<channel>/manifest.json — манифест канала
|
||||||
|
// GET /v1/<channel>/files/<name> — артефакт по имени
|
||||||
|
// 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/<channel>/manifest.json и /v1/<channel>/files/<name>.
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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: <Type>\ndata: <Data>\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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/<UUID> с владельцем 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)
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<span class="check-icon">${r.ok ? "✓" : "✗"}</span>
|
||||||
|
<div>
|
||||||
|
<div class="check-title">${escapeHTML(r.title)}</div>
|
||||||
|
${r.message ? `<div class="check-msg">${escapeHTML(r.message)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
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 = `
|
||||||
|
<span class="step-icon">${STEP_ICONS[s.status] || "○"}</span>
|
||||||
|
<div>
|
||||||
|
<div class="step-title">${escapeHTML(s.title)}</div>
|
||||||
|
${s.message ? `<div class="step-msg">${escapeHTML(s.message)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
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();
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>bj-installer — мастер установки Bridge-and-Join-s</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="logo">Bridge-and-Join-s</div>
|
||||||
|
<div class="subtitle">мастер установки</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="app">
|
||||||
|
<!-- ===== Stepper ===== -->
|
||||||
|
<nav class="stepper" id="stepper">
|
||||||
|
<span data-stage="welcome">1. Старт</span>
|
||||||
|
<span data-stage="precheck">2. Проверка</span>
|
||||||
|
<span data-stage="config">3. Настройка</span>
|
||||||
|
<span data-stage="installing">4. Установка</span>
|
||||||
|
<span data-stage="done">5. Готово</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ===== Welcome ===== -->
|
||||||
|
<section class="page" data-stage="welcome">
|
||||||
|
<h1>Добро пожаловать</h1>
|
||||||
|
<p>Этот мастер установит на сервер <b>СКЗИ «Валидата Клиент L»</b>,
|
||||||
|
<b>bj-server</b>, <b>bj-crypto</b> и <b>ИШ НРД</b>, настроит
|
||||||
|
systemd-сервисы и подготовит окружение для подписи документов
|
||||||
|
по ГОСТ 34.10-2012.</p>
|
||||||
|
<p class="muted">После установки откроется <code>/admin/setup</code> в bj-server, где можно
|
||||||
|
загрузить тестовый профиль от MOEX (.7z) и активировать подпись.</p>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="primary" onclick="startPrecheck()">Начать →</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Precheck ===== -->
|
||||||
|
<section class="page" data-stage="precheck">
|
||||||
|
<h1>Проверка системы</h1>
|
||||||
|
<div id="precheck-results" class="checks"></div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button onclick="goWelcome()">← Назад</button>
|
||||||
|
<button class="primary" id="goConfigBtn" onclick="goConfig()">Дальше →</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Config ===== -->
|
||||||
|
<section class="page" data-stage="config">
|
||||||
|
<h1>Настройка</h1>
|
||||||
|
<form id="config-form" onsubmit="event.preventDefault(); startInstall();">
|
||||||
|
<label>ИНН организации
|
||||||
|
<input type="text" name="orgInn" placeholder="7702077840" pattern="\d{10}|\d{12}">
|
||||||
|
</label>
|
||||||
|
<label>Название организации (для отображения)
|
||||||
|
<input type="text" name="orgName" placeholder="ПАО Московская Биржа">
|
||||||
|
</label>
|
||||||
|
<label>Email администратора
|
||||||
|
<input type="email" name="adminEmail" placeholder="admin@example.com">
|
||||||
|
</label>
|
||||||
|
<label>Лицензионный ключ (опционально)
|
||||||
|
<input type="text" name="licenseKey" placeholder="BJ-XXXX-XXXX-XXXX">
|
||||||
|
<span class="muted">Без ключа сервис работает, но обновления заблокированы. Получить можно в личном кабинете.</span>
|
||||||
|
</label>
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="button" onclick="goPrecheck()">← Назад</button>
|
||||||
|
<button class="primary" type="submit">Установить →</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Installing ===== -->
|
||||||
|
<section class="page" data-stage="installing">
|
||||||
|
<h1>Установка</h1>
|
||||||
|
<ol id="step-list" class="steps"></ol>
|
||||||
|
<div class="progress"><div id="progress-bar" class="progress-bar"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Done ===== -->
|
||||||
|
<section class="page" data-stage="done">
|
||||||
|
<h1>✓ Готово</h1>
|
||||||
|
<p>bj-server и все сервисы запущены. Откройте панель администратора и
|
||||||
|
импортируйте профиль:</p>
|
||||||
|
<div class="next-link">
|
||||||
|
<a href="" id="adminLink" class="primary-link">Перейти в /admin/setup →</a>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Что дальше:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Подключите USB с .vdk → он автоматически смонтируется в <code>/var/lib/bj/usb/</code></li>
|
||||||
|
<li>На <code>/admin/setup</code> загрузите .7z с профилем от MOEX и введите пароль</li>
|
||||||
|
<li>Нажмите «Активировать» — bj-crypto подтянет ключ и подтвердит готовность</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Error ===== -->
|
||||||
|
<section class="page" data-stage="error">
|
||||||
|
<h1>✗ Установка прервана</h1>
|
||||||
|
<p>Произошла ошибка:</p>
|
||||||
|
<pre id="error-message" class="error"></pre>
|
||||||
|
<p class="muted">Логи: <code>journalctl -u bj-installer</code> и <code>journalctl -u bj-crypto</code></p>
|
||||||
|
<div class="buttons">
|
||||||
|
<button onclick="resetWizard()">Начать заново</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Command bj-license-server — онлайн-сервис учёта и отзыва лицензий.
|
||||||
|
//
|
||||||
|
// Базовая модель лицензирования офлайновая: bj-server проверяет подпись и
|
||||||
|
// срок сам. Этот сервер нужен для:
|
||||||
|
// - реестра выданных лицензий (учёт);
|
||||||
|
// - ОТЗЫВА (revocation) до окончания срока;
|
||||||
|
// - проверки клиентом «не отозвана ли» (опциональный online-чек).
|
||||||
|
//
|
||||||
|
// Хранилище — JSON-файл со списком отозванных ID (для каркаса; в проде —
|
||||||
|
// PostgreSQL). API:
|
||||||
|
//
|
||||||
|
// GET /v1/check?id=<license-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())
|
||||||
|
}
|
||||||
@@ -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 <prefix>")
|
||||||
|
fmt.Fprintln(os.Stderr, "bj-license issue -tenant <name> -plan free|pro|enterprise -days <n> -features a,b -key <priv> [-keyid id] [-max-nodes n] [-note txt]")
|
||||||
|
fmt.Fprintln(os.Stderr, "bj-license verify -key-file <license.key> -pub <pubkey.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)
|
||||||
|
}
|
||||||
@@ -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 <prefix>")
|
||||||
|
fmt.Fprintln(os.Stderr, "bj-release build -dir <artifacts> -version <v> -channel <c> -key <priv> -keyid <id> -out <manifest.json> [-notes <txt>]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keygen(args []string) {
|
||||||
|
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
|
||||||
|
out := fs.String("out", "signing", "префикс файлов ключей (создаст <out>.priv и <out>.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 (по умолчанию <dir>/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)
|
||||||
|
}
|
||||||
+13
-15
@@ -40,17 +40,10 @@ func main() {
|
|||||||
DefaultSender: defaultSender,
|
DefaultSender: defaultSender,
|
||||||
DefaultReceiver: defaultReceiver,
|
DefaultReceiver: defaultReceiver,
|
||||||
SetupPath: setupPath,
|
SetupPath: setupPath,
|
||||||
CheckOptions: func() lkgateway.CheckOptions {
|
// CheckOptions не задаём — server.go использует свой снапшот-based
|
||||||
return lkgateway.CheckOptions{
|
// вариант, который читает актуальные значения из setup.json
|
||||||
PostgresDSN: os.Getenv("BJ_DSN"),
|
// (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки
|
||||||
CryptoSocket: getenv("BJ_CRYPTO_SOCKET", "/run/bj/crypto.sock"),
|
// статуса совпадают с тем, что реально настроено в UI.
|
||||||
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,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
srv, err := lkgateway.NewServer(cfg)
|
srv, err := lkgateway.NewServer(cfg)
|
||||||
@@ -110,15 +103,20 @@ func runNSDPoller(ctx context.Context, profileName string) {
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
for _, kind := range nsdadapter.IncomingPackageKinds() {
|
for _, kind := range nsdadapter.IncomingPackageKinds() {
|
||||||
pkgs, err := client.ListIncoming(ctx, profile.Channel, since, string(kind))
|
pkgs, err := client.ListIncoming(ctx, igw.ListFilter{
|
||||||
|
Channel: profile.Channel,
|
||||||
|
Date: since,
|
||||||
|
Type: string(kind),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
|
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, p := range pkgs {
|
for _, p := range pkgs {
|
||||||
log.Printf("%s: NSD входящий пакет %s типа %s (канал %s, получен %s)",
|
log.Printf("%s: NSD входящий пакет id=%d (%s) типа %s, канал %s, state %s",
|
||||||
serviceName, p.PackageID, p.PackageType, p.Channel, p.ReceivedAt.Format(time.RFC3339))
|
serviceName, p.ID, p.Name, p.Type, p.Channel, p.State)
|
||||||
// TODO(M3): парсить тело пакета, передавать в lkgateway.Service.ApplyDecision
|
// TODO(M3): GetPackage(p.ID) → unpack ZIP → парсить XML →
|
||||||
|
// передавать в lkgateway.Service.ApplyDecision
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
since = time.Now().UTC()
|
since = time.Now().UTC()
|
||||||
|
|||||||
@@ -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/<channel>/manifest.json` | подписанный манифест канала |
|
||||||
|
| GET | `/v1/<channel>/files/<name>` | артефакт по имени |
|
||||||
|
| 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.
|
||||||
@@ -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
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Bridge-and-Join-s — установка одной командой
|
||||||
|
|
||||||
|
## TL;DR — на свежей Astra Linux / Debian / Ubuntu ВМ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Через **5-10 минут** будет работать веб-админка на `http://<ip>:8080/admin/`.
|
||||||
|
|
||||||
|
Установщик сам:
|
||||||
|
- Определит ОС (Astra SE/CE, Debian, Ubuntu)
|
||||||
|
- Поставит зависимости (apt: podman, postgresql-client, git)
|
||||||
|
- Скачает и установит Go 1.24+
|
||||||
|
- Создаст системного пользователя `bj` и каталоги
|
||||||
|
- Склонирует репозиторий в `/opt/bj/src`
|
||||||
|
- Соберёт `bj-server` из исходников
|
||||||
|
- Поднимет PostgreSQL 16 в podman-контейнере и накатит миграции
|
||||||
|
- Поставит systemd unit и запустит сервис
|
||||||
|
- Скачает дистрибутив ИШ НРД (~120 МБ) и попытается установить через `dpkg`
|
||||||
|
|
||||||
|
После завершения скрипта тебе печатается понятная сводка с URL'ами и
|
||||||
|
списком того, что осталось сделать руками.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Какая нужна ВМ
|
||||||
|
|
||||||
|
| Параметр | Минимум | Рекомендуется |
|
||||||
|
|---|---|---|
|
||||||
|
| ОС | Debian 11+ / Astra CE 1.8 / Astra SE 1.6+ / Ubuntu 22.04+ | **Astra Linux SE 1.7** (для прод) |
|
||||||
|
| CPU | 2 ядра | 4 ядра |
|
||||||
|
| RAM | 2 ГБ | 4 ГБ |
|
||||||
|
| Диск | 20 ГБ | 50 ГБ SSD |
|
||||||
|
| Сеть | прямой выход в интернет | + статический IP |
|
||||||
|
|
||||||
|
**Что я понимаю про лицензии Astra Linux:**
|
||||||
|
|
||||||
|
- **Astra SE** — платная (~2-5 тыс. ₽/лицензия), сертифицирована ФСТЭК/ФСБ → нужна для прода с гос-требованиями
|
||||||
|
- **Astra CE** — бесплатная, без сертификации, тот же базовый дистрибутив → можно использовать для дева и тестов, а для прода докупить SE
|
||||||
|
- **Debian 12** — полностью бесплатный, технически на 95% совместим с Astra (один и тот же базовый дистрибутив), ИШ скорее всего тоже взлетит, но НРД официально не поддерживает
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Скрипты в этом каталоге
|
||||||
|
|
||||||
|
| Файл | Когда запускать | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| **`install.sh`** | сразу после поднятия ВМ | Главный скрипт. Делает всё одной командой |
|
||||||
|
| **`install-validata.sh`** | когда придёт Валидата от НРД | Установка СКЗИ Валидата CSP |
|
||||||
|
| **`install-ish.sh`** | если `install.sh` не установил ИШ автоматически | Ручная установка ИШ из локального .deb |
|
||||||
|
| **`healthcheck.sh`** | для проверки состояния | Цветной отчёт о работоспособности всех компонентов |
|
||||||
|
| **`import-data.sh`** | (опционально) если переносишь с другой ВМ | Экспорт БД и настроек со старой ВМ для импорта на новую |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что произойдёт ПОСЛЕ автоматической установки
|
||||||
|
|
||||||
|
`install.sh` дойдёт до точки, где **bj-server работает, но в режиме эмуляции** — потому что Валидата и сертификат УЦ МБ автоматически получить нельзя. В админке сверху будет жёлтая плашка «РЕЖИМ ЭМУЛЯЦИИ». Это ожидаемо.
|
||||||
|
|
||||||
|
### Что нужно сделать пользователю руками
|
||||||
|
|
||||||
|
#### 1. Запросить Валидата CSP в НРД (1 письмо)
|
||||||
|
Email: `soed@nsd.ru` или `pki@moex.com`. Текст подскажет сам скрипт `install-validata.sh` — есть шаблон. Срок ответа НРД — 1-3 дня.
|
||||||
|
|
||||||
|
Когда придёт .deb пакет:
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/bj/src/deploy/astra/install-validata.sh /path/to/validata.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Получить сертификат УЦ Московской Биржи
|
||||||
|
`https://ca.moex.com/` — оформить заявку от организации. Срок — зависит от УЦ.
|
||||||
|
|
||||||
|
#### 3. Подать заявку на тестирование в TEST3 НРД
|
||||||
|
`https://www.nsd.ru/workflow/zayavka-na-testirovanie/` — получить код депонента-тестера.
|
||||||
|
|
||||||
|
#### 4. Когда всё пришло — настроить ИШ через его GUI
|
||||||
|
По `DOC/ruk_install_ish_2025_11_10.pdf` (раздел 10):
|
||||||
|
- Указать БД PostgreSQL (DSN уже в `/var/lib/bj/.bj/setup.json`)
|
||||||
|
- Создать канал WSL с URL `https://gost.nsd.ru/onyxt3/WslService` (TEST3)
|
||||||
|
- Импортировать сертификат УЦ МБ из системного хранилища
|
||||||
|
- Запустить ИШ как сервис: `sudo systemctl enable --now igate`
|
||||||
|
|
||||||
|
#### 5. Привязать bj-server к ИШ
|
||||||
|
`http://<ip>:8080/admin/setup` → раздел «ИШ НРД»:
|
||||||
|
- URL ИШ: `http://localhost:8090` (порт REST API ИШ)
|
||||||
|
- Имя канала: то что задал в ИШ на шаге 4
|
||||||
|
|
||||||
|
После этого жёлтая плашка «РЕЖИМ ЭМУЛЯЦИИ» исчезнет — сообщения пойдут в реальный НРД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Параметры установки
|
||||||
|
|
||||||
|
`install.sh` принимает флаги:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash install.sh --bind=:8080 --skip-ish --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
| Флаг | По умолчанию | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bind=:8080` | `:8080` | На каком адресе/порту слушать |
|
||||||
|
| `--branch=main` | `main` | Из какой ветки репо собирать |
|
||||||
|
| `--skip-ish` | (выкл) | Не скачивать дистрибутив ИШ (если стоят жёсткие ограничения по интернету) |
|
||||||
|
| `--yes` / `-y` | (выкл) | Не задавать вопросов, отвечать «да» автоматически |
|
||||||
|
|
||||||
|
Также через переменные окружения: `REPO_URL`, `BRANCH`, `BIND_ADDR`, `ISH_DEB_URL`, `NON_INTERACTIVE`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Если что-то сломалось
|
||||||
|
|
||||||
|
| Симптом | Решение |
|
||||||
|
|---|---|
|
||||||
|
| `bj-server.service не active` | `journalctl -u bj-server -n 50` |
|
||||||
|
| HTTP 200 не отвечает | проверь что :8080 открыт; `ss -tlnp \| grep 8080` |
|
||||||
|
| Миграции не накатились | `podman exec bj-postgres psql -U bj -l` и `\dt fansy.*` |
|
||||||
|
| ИШ не скачался | положи `igate_100.0-765_amd64.deb` в `/opt/bj/src/dist/ish/` и перезапусти `install.sh` |
|
||||||
|
| Валидата не установлена | это **нормально** на старте — заказывай у НРД, потом `install-validata.sh` |
|
||||||
|
| Не определилась ОС | поддерживаются: Astra, Debian, Ubuntu. Для других — открой issue |
|
||||||
|
|
||||||
|
Health-check всё сразу:
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/bj/src/deploy/astra/healthcheck.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Полный путь от чистой ВМ до прохождения теста с роботом MOEX МОСТ
|
||||||
|
|
||||||
|
| Этап | Что делается | Срок |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. Поднять Astra Linux ВМ | у инфра-команды | 1 день |
|
||||||
|
| 2. Запустить `install.sh` | автоматически | 5-10 мин |
|
||||||
|
| 3. Запросить Валидату в НРД | письмо в `soed@nsd.ru` | 1-3 дня ожидания |
|
||||||
|
| 4. Получить сертификат УЦ МБ | заявка в `ca.moex.com` | 1-2 недели ожидания |
|
||||||
|
| 5. Подать заявку на TEST3 | форма на сайте НРД | 2-5 дней |
|
||||||
|
| 6. Установить Валидату | `install-validata.sh` | 5 мин |
|
||||||
|
| 7. Импортировать сертификат | GUI Валидаты, экспорт в системное хранилище | 15 мин |
|
||||||
|
| 8. Настроить ИШ | GUI ИШ, создать канал WSL | 30 мин |
|
||||||
|
| 9. Привязать bj-server к ИШ | `/admin/setup` через UI | 5 мин |
|
||||||
|
| 10. Прогнать тест с роботом | `/admin/setup` → кнопка | 1 мин |
|
||||||
|
|
||||||
|
**Итог: 2-3 недели от старта до зелёного теста с роботом MOEX МОСТ.** На нашей стороне всё уже готово — задержки только во внешних запросах.
|
||||||
Executable
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# healthcheck.sh — проверка готовности bj-server после установки на Astra Linux.
|
||||||
|
# Запускается на самой Astra Linux ВМ, печатает зелёные/жёлтые/красные галочки.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ok() { echo -e " \033[1;32m✓\033[0m $*"; }
|
||||||
|
warn() { echo -e " \033[1;33m⚠\033[0m $*"; }
|
||||||
|
fail() { echo -e " \033[1;31m✗\033[0m $*"; }
|
||||||
|
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Bridge-and-Join-s — проверка состояния"
|
||||||
|
echo "================================================================"
|
||||||
|
|
||||||
|
# 1. ОС
|
||||||
|
echo
|
||||||
|
echo "[1] Операционная система"
|
||||||
|
if [ -r /etc/astra_version ]; then
|
||||||
|
ok "Astra Linux: $(cat /etc/astra_version)"
|
||||||
|
else
|
||||||
|
warn "Не Astra Linux — ИШ может не запуститься"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Пользователь bj
|
||||||
|
echo
|
||||||
|
echo "[2] Пользователь и каталоги"
|
||||||
|
id bj >/dev/null 2>&1 && ok "пользователь bj существует" || fail "пользователь bj не создан"
|
||||||
|
[ -d /opt/bj ] && ok "/opt/bj существует" || fail "/opt/bj не найден"
|
||||||
|
[ -x /opt/bj/bj-server ] && ok "/opt/bj/bj-server исполняемый" || fail "/opt/bj/bj-server отсутствует"
|
||||||
|
[ -d /var/lib/bj/.bj ] && ok "/var/lib/bj/.bj существует" || warn "/var/lib/bj/.bj не создан"
|
||||||
|
|
||||||
|
# 3. systemd
|
||||||
|
echo
|
||||||
|
echo "[3] systemd сервис"
|
||||||
|
if systemctl is-enabled --quiet bj-server 2>/dev/null; then
|
||||||
|
ok "bj-server.service enabled"
|
||||||
|
else
|
||||||
|
warn "bj-server.service не enabled"
|
||||||
|
fi
|
||||||
|
if systemctl is-active --quiet bj-server 2>/dev/null; then
|
||||||
|
ok "bj-server.service active"
|
||||||
|
else
|
||||||
|
fail "bj-server.service не active — systemctl status bj-server"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. HTTP
|
||||||
|
echo
|
||||||
|
echo "[4] HTTP-эндпоинты"
|
||||||
|
HTTP_OK=0
|
||||||
|
for path in / /admin/ /admin/wizard /admin/help/architecture; do
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:8080$path" 2>/dev/null || echo "—")
|
||||||
|
if [ "$code" = "200" ] || [ "$code" = "303" ]; then
|
||||||
|
ok "GET $path → $code"
|
||||||
|
HTTP_OK=$((HTTP_OK+1))
|
||||||
|
else
|
||||||
|
fail "GET $path → $code"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5. PostgreSQL
|
||||||
|
echo
|
||||||
|
echo "[5] PostgreSQL"
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
|
||||||
|
ok "контейнер bj-postgres работает"
|
||||||
|
else
|
||||||
|
warn "контейнер bj-postgres не запущен"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "podman не установлен"
|
||||||
|
fi
|
||||||
|
if pg_isready -h 127.0.0.1 -p 5432 -U bj >/dev/null 2>&1; then
|
||||||
|
ok "PostgreSQL отвечает на :5432"
|
||||||
|
else
|
||||||
|
warn "PostgreSQL :5432 недоступен"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. Валидата
|
||||||
|
echo
|
||||||
|
echo "[6] СКЗИ Валидата (для ИШ)"
|
||||||
|
VAL_FOUND=0
|
||||||
|
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP; do
|
||||||
|
[ -d "$path" ] && { ok "найдена в $path"; VAL_FOUND=1; break; }
|
||||||
|
done
|
||||||
|
[ "$VAL_FOUND" = 0 ] && warn "не установлена (запроси у НРД soed@nsd.ru, потом sudo bash deploy/astra/install-validata.sh)"
|
||||||
|
|
||||||
|
# 7. ИШ
|
||||||
|
echo
|
||||||
|
echo "[7] Интеграционный шлюз (ИШ)"
|
||||||
|
if command -v igate >/dev/null 2>&1; then
|
||||||
|
ok "igate в PATH: $(which igate)"
|
||||||
|
elif [ -x /opt/igate/igate ]; then
|
||||||
|
ok "igate в /opt/igate/"
|
||||||
|
else
|
||||||
|
warn "ИШ не установлен (sudo bash deploy/astra/install-ish.sh)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 8. Сетевые порты
|
||||||
|
echo
|
||||||
|
echo "[8] Сетевые порты"
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
PORTS=$(ss -tlnp 2>/dev/null | awk 'NR>1{print $4}')
|
||||||
|
echo "$PORTS" | grep -q ':8080$' && ok ":8080 (bj-server) слушает" || fail ":8080 не слушает"
|
||||||
|
echo "$PORTS" | grep -q ':5432$' && ok ":5432 (postgres) слушает" || warn ":5432 не слушает"
|
||||||
|
echo "$PORTS" | grep -q ':8090$' && ok ":8090 (предполагаемый ИШ) слушает" || warn ":8090 (ИШ) не слушает"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Итог
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Готово. Подробнее:"
|
||||||
|
echo " journalctl -u bj-server -f"
|
||||||
|
echo " http://$(hostname -I | awk '{print $1}'):8080/admin/"
|
||||||
|
echo "================================================================"
|
||||||
Executable
+134
@@ -0,0 +1,134 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# migrate-from-redos.sh — экспорт состояния со старой ВМ (РЕД ОС 10.10.10.22)
|
||||||
|
# для переноса на новую Astra Linux ВМ.
|
||||||
|
#
|
||||||
|
# Запускать на СТАРОЙ ВМ (РЕД ОС). Создаст архив /tmp/bj-migration-YYYY-MM-DD.tar.gz
|
||||||
|
# с:
|
||||||
|
# - дампом БД (pg_dump на оба схема: fansy.* и m2m_core.*)
|
||||||
|
# - содержимым ~bj/.bj/setup.json (или ~/.bj/setup.json для dev)
|
||||||
|
# - логами /var/log/bj/ (за последние 7 дней)
|
||||||
|
# - списком установленных пакетов (для справки)
|
||||||
|
#
|
||||||
|
# Архив надо перенести на новую ВМ (scp/rsync), там распаковать и натравить
|
||||||
|
# на install-astra.sh с флагом --import=/path/to/archive.tar.gz (TODO).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OUT_DIR="/tmp/bj-migration-$(date +%Y-%m-%d-%H%M)"
|
||||||
|
OUT_TAR="${OUT_DIR}.tar.gz"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
log() { echo -e "\033[1;34m[migrate-export]\033[0m $*"; }
|
||||||
|
warn() { echo -e "\033[1;33m[migrate-export WARN]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
# ---- 1. Дамп БД ----
|
||||||
|
log "1/5: дамп PostgreSQL"
|
||||||
|
DSN="${BJ_PG_DSN:-postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable}"
|
||||||
|
if podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
|
||||||
|
log " через podman exec bj-postgres"
|
||||||
|
podman exec bj-postgres pg_dump -U bj -d bj --clean --if-exists > "$OUT_DIR/bj.sql" \
|
||||||
|
|| warn " pg_dump упал — проверь контейнер bj-postgres"
|
||||||
|
else
|
||||||
|
log " напрямую pg_dump"
|
||||||
|
pg_dump "$DSN" --clean --if-exists > "$OUT_DIR/bj.sql" \
|
||||||
|
|| warn " pg_dump упал — проверь DSN"
|
||||||
|
fi
|
||||||
|
[ -f "$OUT_DIR/bj.sql" ] && log " размер дампа: $(du -h "$OUT_DIR/bj.sql" | awk '{print $1}')"
|
||||||
|
|
||||||
|
# ---- 2. Конфигурация ----
|
||||||
|
log "2/5: ~/.bj/setup.json"
|
||||||
|
for candidate in /var/lib/bj/.bj/setup.json ~/.bj/setup.json /root/.bj/setup.json; do
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
cp "$candidate" "$OUT_DIR/setup.json"
|
||||||
|
log " скопировано из $candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- 3. Логи ----
|
||||||
|
log "3/5: логи за 7 дней"
|
||||||
|
mkdir -p "$OUT_DIR/logs"
|
||||||
|
if [ -d /var/log/bj ]; then
|
||||||
|
find /var/log/bj -type f -mtime -7 -exec cp {} "$OUT_DIR/logs/" \; 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
journalctl -u bj-server --since "7 days ago" --no-pager > "$OUT_DIR/logs/journal.log" 2>/dev/null || true
|
||||||
|
|
||||||
|
# ---- 4. Пакеты, версии (для справки) ----
|
||||||
|
log "4/5: метаинформация"
|
||||||
|
{
|
||||||
|
echo "=== ОС ==="
|
||||||
|
cat /etc/os-release 2>/dev/null || echo "no os-release"
|
||||||
|
echo
|
||||||
|
echo "=== uname ==="
|
||||||
|
uname -a
|
||||||
|
echo
|
||||||
|
echo "=== Установленные RPM (только наши пакеты) ==="
|
||||||
|
rpm -qa 2>/dev/null | grep -iE "cprocsp|crypto|postgresql|podman|go" || true
|
||||||
|
echo
|
||||||
|
echo "=== Версия bj-server ==="
|
||||||
|
/opt/bj/bj-server --version 2>/dev/null || echo "не определена"
|
||||||
|
echo
|
||||||
|
echo "=== Дата создания дампа ==="
|
||||||
|
date
|
||||||
|
} > "$OUT_DIR/meta.txt"
|
||||||
|
|
||||||
|
# ---- 5. README ----
|
||||||
|
cat > "$OUT_DIR/README.md" <<EOF
|
||||||
|
# Миграция bj-server с РЕД ОС на Astra Linux
|
||||||
|
|
||||||
|
Дамп создан: $(date)
|
||||||
|
Источник: $(hostname) ($(hostname -I | awk '{print $1}'))
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
- \`bj.sql\` — дамп PostgreSQL базы bj (схемы fansy + m2m_core)
|
||||||
|
- \`setup.json\` — настройки bj-server (DSN, IGW URL, и т.п.)
|
||||||
|
- \`logs/\` — последние логи bj-server
|
||||||
|
- \`meta.txt\` — версии ОС, пакетов
|
||||||
|
|
||||||
|
## Восстановление на Astra Linux
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 1. На новой ВМ — ставим bj-server
|
||||||
|
sudo bash deploy/astra/install.sh
|
||||||
|
|
||||||
|
# 2. Восстанавливаем БД
|
||||||
|
podman exec -i bj-postgres psql -U bj -d bj < bj.sql
|
||||||
|
|
||||||
|
# 3. Восстанавливаем настройки
|
||||||
|
sudo cp setup.json /var/lib/bj/.bj/setup.json
|
||||||
|
sudo chown bj:bj /var/lib/bj/.bj/setup.json
|
||||||
|
sudo chmod 0600 /var/lib/bj/.bj/setup.json
|
||||||
|
|
||||||
|
# 4. Перезапуск
|
||||||
|
sudo systemctl restart bj-server
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Что НЕ переносится автоматически
|
||||||
|
|
||||||
|
- Сертификаты КриптоПро CSP (\`/var/opt/cprocsp/keys/$USER/\`)
|
||||||
|
— это нормально, на Astra Linux будет другая СКЗИ (Валидата CSP)
|
||||||
|
- \`/opt/cprocsp/\` (КриптоПро CSP)
|
||||||
|
— на Astra нужна Валидата вместо КриптоПро
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ---- Финал ----
|
||||||
|
log "5/5: создание архива $OUT_TAR"
|
||||||
|
tar -czf "$OUT_TAR" -C "$(dirname "$OUT_DIR")" "$(basename "$OUT_DIR")"
|
||||||
|
rm -rf "$OUT_DIR"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Экспорт готов"
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Архив: $OUT_TAR"
|
||||||
|
echo " Размер: $(du -h "$OUT_TAR" | awk '{print $1}')"
|
||||||
|
echo
|
||||||
|
echo " Перенести на новую Astra Linux ВМ:"
|
||||||
|
echo " scp $OUT_TAR user@<astra-ip>:/tmp/"
|
||||||
|
echo
|
||||||
|
echo " На Astra Linux распаковать и читать README.md:"
|
||||||
|
echo " cd /tmp"
|
||||||
|
echo " tar -xzf $(basename "$OUT_TAR")"
|
||||||
|
echo " cat $(basename "$OUT_DIR")/README.md"
|
||||||
|
echo "================================================================"
|
||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install-ish.sh — установка ПО «Интеграционный шлюз НРД» (ИШ) на Astra Linux.
|
||||||
|
#
|
||||||
|
# Документ-источник: DOC/ruk_install_ish_2025_11_10.pdf (раздел 7.3.2).
|
||||||
|
#
|
||||||
|
# Пред-требования:
|
||||||
|
# 1. ОС: Astra Linux SE 1.6 или 1.7
|
||||||
|
# 2. УСТАНОВЛЕНА Валидата CSP + АПК Валидата Клиент L (см. install-validata.sh)
|
||||||
|
# 3. Корневой сертификат УЦ МБ загружен в Справочник сертификатов
|
||||||
|
# 4. Пользовательский сертификат экспортирован в системное хранилище
|
||||||
|
#
|
||||||
|
# Что делает скрипт:
|
||||||
|
# 1. Проверяет наличие Валидаты
|
||||||
|
# 2. Устанавливает igate_*.deb через dpkg
|
||||||
|
# 3. Создаёт каталог настроек ~/igate
|
||||||
|
# 4. Подсказывает следующие шаги (запуск настройщика каналов)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEB_PATH="${1:-}"
|
||||||
|
|
||||||
|
log() { echo -e "\033[1;34m[ish-install]\033[0m $*"; }
|
||||||
|
warn() { echo -e "\033[1;33m[ish-install WARN]\033[0m $*" >&2; }
|
||||||
|
fail() { echo -e "\033[1;31m[ish-install FAIL]\033[0m $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---- 1. Поиск .deb ----
|
||||||
|
if [ -z "$DEB_PATH" ]; then
|
||||||
|
# Поиск в стандартных местах
|
||||||
|
for candidate in \
|
||||||
|
./dist/ish/igate_*_amd64.deb \
|
||||||
|
/opt/bj/src/dist/ish/igate_*_amd64.deb \
|
||||||
|
~/Downloads/igate_*_amd64.deb \
|
||||||
|
/tmp/igate_*_amd64.deb; do
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
DEB_PATH="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$DEB_PATH" ] || [ ! -f "$DEB_PATH" ]; then
|
||||||
|
fail "Не найден .deb пакет ИШ. Скачайте с https://www.nsd.ru/workflow/system/programs/web-service/ и передайте путь:
|
||||||
|
sudo bash $0 /path/to/igate_100.0-765_amd64.deb"
|
||||||
|
fi
|
||||||
|
log "Дистрибутив ИШ: $DEB_PATH"
|
||||||
|
|
||||||
|
# ---- 2. Проверка ОС ----
|
||||||
|
if [ -r /etc/astra_version ]; then
|
||||||
|
log "Astra Linux: $(cat /etc/astra_version)"
|
||||||
|
else
|
||||||
|
warn "Это не Astra Linux. ИШ под Astra Linux может не запуститься на других ОС."
|
||||||
|
warn "Продолжить? (y/N)"
|
||||||
|
read -r REPLY < /dev/tty
|
||||||
|
[ "$REPLY" = "y" ] || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 3. Проверка Валидаты ----
|
||||||
|
log "Проверка СКЗИ Валидата CSP..."
|
||||||
|
VAL_FOUND=0
|
||||||
|
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP /usr/local/Validata; do
|
||||||
|
[ -d "$path" ] && { log " ✓ Валидата найдена в $path"; VAL_FOUND=1; break; }
|
||||||
|
done
|
||||||
|
if [ "$VAL_FOUND" = 0 ]; then
|
||||||
|
warn "Валидата CSP не найдена. ИШ всё равно поставится, но не запустится без СКЗИ."
|
||||||
|
warn "Получите дистрибутив Валидаты у НРД (soed@nsd.ru) и поставьте через install-validata.sh."
|
||||||
|
warn "Продолжить установку ИШ? (y/N)"
|
||||||
|
read -r REPLY < /dev/tty
|
||||||
|
[ "$REPLY" = "y" ] || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 4. dpkg -i ----
|
||||||
|
log "Установка ИШ через dpkg..."
|
||||||
|
[ "$EUID" -eq 0 ] || fail "Запускать от root (sudo bash $0)"
|
||||||
|
dpkg -i "$DEB_PATH" 2>&1 | tee /tmp/igate-install.log || {
|
||||||
|
warn "dpkg -i вернул ошибку, пытаюсь починить зависимости через apt-get install -f"
|
||||||
|
apt-get install -f -y
|
||||||
|
dpkg -i "$DEB_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- 5. Проверка ----
|
||||||
|
if command -v igate >/dev/null 2>&1; then
|
||||||
|
log "✓ ИШ установлен: $(which igate)"
|
||||||
|
elif [ -x /opt/igate/igate ]; then
|
||||||
|
log "✓ ИШ установлен в /opt/igate/"
|
||||||
|
else
|
||||||
|
warn "Бинарник igate не нашёл в PATH. Возможно установлен в /opt/igate или ~/igate."
|
||||||
|
warn "Проверьте: dpkg -L igate | grep -E 'bin|igate$'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 6. Финал ----
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo " ИШ установлен"
|
||||||
|
echo "================================================================"
|
||||||
|
echo
|
||||||
|
echo " Следующие шаги (по DOC/ruk_install_ish_2025_11_10.pdf раздел 10):"
|
||||||
|
echo " 1. Запустить ИШ в GUI: igate & (или через меню Пуск/Astra)"
|
||||||
|
echo " 2. Настройки БД → PostgreSQL (URL/логин/пароль из bj-server)"
|
||||||
|
echo " 3. Создать канал WSL → URL https://gost.nsd.ru/onyxt3/WslService (TEST3)"
|
||||||
|
echo " 4. Указать сертификат УЦ МБ из системного хранилища"
|
||||||
|
echo " 5. Активировать ИШ как сервис:"
|
||||||
|
echo " sudo systemctl enable --now igate"
|
||||||
|
echo
|
||||||
|
echo " REST API ИШ (для bj-server):"
|
||||||
|
echo " http://localhost:8090 (порт по умолчанию — см. настройки ИШ)"
|
||||||
|
echo
|
||||||
|
echo " После настройки канала в ИШ: открыть"
|
||||||
|
echo " http://<этот-сервер>:8080/admin/setup → раздел «Интеграционный шлюз НРД»"
|
||||||
|
echo " и указать URL ИШ + имя канала."
|
||||||
|
echo "================================================================"
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install-validata.sh — установка СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»
|
||||||
|
# для работы Интеграционного шлюза НРД на Astra Linux.
|
||||||
|
#
|
||||||
|
# ВАЖНО: дистрибутив Валидаты не выложен публично. Получается по запросу:
|
||||||
|
# - НРД: soed@nsd.ru
|
||||||
|
# - МБ: pki@moex.com
|
||||||
|
# В письме указать: «Запрос дистрибутива СКЗИ Валидата CSP для Linux +
|
||||||
|
# временной лицензии для подключения к ЭДО НРД в рамках MOEX МОСТ M2M.»
|
||||||
|
#
|
||||||
|
# Скрипт ожидает что архив с дистрибутивом уже скачан и лежит:
|
||||||
|
# dist/validata/<любые>.deb
|
||||||
|
# или передан как первый аргумент.
|
||||||
|
#
|
||||||
|
# Запуск:
|
||||||
|
# sudo bash deploy/astra/install-validata.sh
|
||||||
|
# sudo bash deploy/astra/install-validata.sh /path/to/validata-csp.deb
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { echo -e "\033[1;34m[validata-install]\033[0m $*"; }
|
||||||
|
warn() { echo -e "\033[1;33m[validata-install WARN]\033[0m $*" >&2; }
|
||||||
|
fail() { echo -e "\033[1;31m[validata-install FAIL]\033[0m $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$EUID" -eq 0 ] || fail "Запускать от root"
|
||||||
|
|
||||||
|
SEARCH_PATH="${1:-./dist/validata}"
|
||||||
|
|
||||||
|
if [ -f "$SEARCH_PATH" ] && [ "${SEARCH_PATH##*.}" = "deb" ]; then
|
||||||
|
# Передан конкретный файл
|
||||||
|
DEBS=( "$SEARCH_PATH" )
|
||||||
|
elif [ -d "$SEARCH_PATH" ]; then
|
||||||
|
mapfile -t DEBS < <(find "$SEARCH_PATH" -maxdepth 2 -name '*.deb' 2>/dev/null | sort)
|
||||||
|
else
|
||||||
|
fail "Не найден дистрибутив Валидаты. Положи .deb пакеты в dist/validata/ или передай путь аргументом.
|
||||||
|
|
||||||
|
Если у тебя ещё нет дистрибутива — запроси у НРД:
|
||||||
|
Email: soed@nsd.ru (или pki@moex.com)
|
||||||
|
Тема: Запрос дистрибутива Валидата CSP для Linux
|
||||||
|
Текст: Просим предоставить дистрибутив СКЗИ Валидата CSP v.6 для Linux
|
||||||
|
(Astra Linux SE 1.7) + временную лицензию для подключения к
|
||||||
|
ЭДО НРД через ПО Интеграционный шлюз в рамках сервиса
|
||||||
|
MOEX МОСТ M2M (см. инструкцию nsd.ru/workflow/system/programs/web-service/).
|
||||||
|
Реквизиты организации: <ИНН, ОГРН, контактное лицо>.
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#DEBS[@]}" = 0 ]; then
|
||||||
|
fail "В каталоге $SEARCH_PATH не найдено ни одного .deb пакета"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Найдено ${#DEBS[@]} пакетов Валидаты:"
|
||||||
|
for f in "${DEBS[@]}"; do
|
||||||
|
echo " $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Установка через dpkg..."
|
||||||
|
for f in "${DEBS[@]}"; do
|
||||||
|
log " $f"
|
||||||
|
dpkg -i "$f" || {
|
||||||
|
warn " → пытаюсь починить зависимости"
|
||||||
|
apt-get install -f -y
|
||||||
|
dpkg -i "$f"
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
# Проверка
|
||||||
|
log "Проверка установки..."
|
||||||
|
VAL_FOUND=0
|
||||||
|
for path in /opt/Validata /opt/validata-csp /opt/Validata-CSP; do
|
||||||
|
[ -d "$path" ] && { log " ✓ Валидата в $path"; VAL_FOUND=1; }
|
||||||
|
done
|
||||||
|
if [ "$VAL_FOUND" = 0 ]; then
|
||||||
|
warn "Каталог Валидаты не нашёл — проверь dpkg -L <имя-пакета>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Валидата установлена"
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Следующие шаги:"
|
||||||
|
echo " 1. Запустить Справочник сертификатов АПК Валидата Клиент"
|
||||||
|
echo " (GUI приложение)"
|
||||||
|
echo " 2. Загрузить корневой сертификат УЦ Московской Биржи"
|
||||||
|
echo " (взять у УЦ МБ — ca.moex.com — для своей организации)"
|
||||||
|
echo " 3. Импортировать пользовательский сертификат с приватным ключом"
|
||||||
|
echo " 4. Меню Сервис → Экспортировать сертификаты в системное хранилище"
|
||||||
|
echo " 5. Установить ИШ: sudo bash deploy/astra/install-ish.sh"
|
||||||
|
echo "================================================================"
|
||||||
Executable
+384
@@ -0,0 +1,384 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install.sh — установка Bridge-and-Join-s одной командой.
|
||||||
|
#
|
||||||
|
# ЦЕЛЕВАЯ АУДИТОРИЯ: оператор без знания Linux/Go. Просто запускает строку:
|
||||||
|
#
|
||||||
|
# curl -sSL https://git.zetit.ru/zuevav/Bridge-and-Join-s/raw/main/deploy/astra/install.sh | sudo bash
|
||||||
|
#
|
||||||
|
# и всё работает.
|
||||||
|
#
|
||||||
|
# Поддерживаемые ОС:
|
||||||
|
# - Astra Linux Special Edition 1.6 / 1.7 (платная, для прод)
|
||||||
|
# - Astra Linux Common Edition / 1.8 (бесплатная)
|
||||||
|
# - Debian 11 / 12
|
||||||
|
# - Ubuntu 22.04 / 24.04 (с предупреждением)
|
||||||
|
#
|
||||||
|
# Что устанавливается АВТОМАТИЧЕСКИ:
|
||||||
|
# 1. Системные зависимости (apt: curl, git, podman, postgresql-client)
|
||||||
|
# 2. Go 1.24+ (скачивается с go.dev)
|
||||||
|
# 3. PostgreSQL 16 в podman-контейнере + миграции
|
||||||
|
# 4. bj-server (компилируется из исходников, ставится в /opt/bj/)
|
||||||
|
# 5. Дистрибутив ИШ НРД (скачивается с сайта НРД, ~120 МБ)
|
||||||
|
# 6. Сам ИШ устанавливается через dpkg -i (но не запускается без Валидаты)
|
||||||
|
# 7. systemd unit + автозапуск
|
||||||
|
#
|
||||||
|
# Что НЕ автоматизируется (только пользователь):
|
||||||
|
# - СКЗИ Валидата CSP — выдаётся НРД по запросу (soed@nsd.ru)
|
||||||
|
# - Сертификат подписи УЦ Московской Биржи (ca.moex.com)
|
||||||
|
# - Регистрация в TEST3 (заявка через nsd.ru)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- параметры ----
|
||||||
|
REPO_URL="${REPO_URL:-https://git.zetit.ru/zuevav/Bridge-and-Join-s.git}"
|
||||||
|
BRANCH="${BRANCH:-main}"
|
||||||
|
BIND_ADDR="${BIND_ADDR:-:8080}"
|
||||||
|
ISH_DEB_URL="${ISH_DEB_URL:-https://old.nsd.ru/upload/docs/edo/po/igate_100.0-765_amd64.deb}"
|
||||||
|
SKIP_ISH=0
|
||||||
|
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--skip-ish) SKIP_ISH=1 ;;
|
||||||
|
--bind=*) BIND_ADDR="${arg#*=}" ;;
|
||||||
|
--branch=*) BRANCH="${arg#*=}" ;;
|
||||||
|
--yes|-y) NON_INTERACTIVE=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- утилиты вывода ----
|
||||||
|
NS=$(date +%s)
|
||||||
|
step() { local n=$(( $(date +%s) - NS )); printf "\033[1;36m[%4ds]\033[0m \033[1;34m▶\033[0m %s\n" "$n" "$*"; }
|
||||||
|
ok() { printf " \033[1;32m✓\033[0m %s\n" "$*"; }
|
||||||
|
warn() { printf " \033[1;33m⚠\033[0m %s\n" "$*"; }
|
||||||
|
fail() { printf " \033[1;31m✗\033[0m %s\n" "$*" >&2; exit 1; }
|
||||||
|
ask() {
|
||||||
|
[ "$NON_INTERACTIVE" = "1" ] && return 0
|
||||||
|
printf " \033[1;35m?\033[0m %s [y/N]: " "$*"
|
||||||
|
read -r REPLY < /dev/tty || REPLY=n
|
||||||
|
[ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- баннер ----
|
||||||
|
clear 2>/dev/null || true
|
||||||
|
cat <<'BANNER'
|
||||||
|
╔══════════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ Bridge-and-Join-s — установка с нуля ║
|
||||||
|
║ сервис M2M-переводов с НКО АО НРД ║
|
||||||
|
║ ║
|
||||||
|
║ Установка займёт ~5-10 минут ║
|
||||||
|
║ Скачается ~150-250 МБ (Go + ИШ + миграции) ║
|
||||||
|
║ ║
|
||||||
|
╚══════════════════════════════════════════════════════════════════╝
|
||||||
|
BANNER
|
||||||
|
echo
|
||||||
|
|
||||||
|
[ "$EUID" -eq 0 ] || fail "Запускать от root: sudo bash $0"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 1/9. Определение ОС
|
||||||
|
# ============================================================
|
||||||
|
step "1/9: определение операционной системы"
|
||||||
|
OS_KIND=""
|
||||||
|
OS_NAME="неизвестно"
|
||||||
|
|
||||||
|
if [ -r /etc/astra_version ]; then
|
||||||
|
OS_NAME="Astra Linux $(cat /etc/astra_version)"
|
||||||
|
OS_KIND="astra"
|
||||||
|
elif [ -r /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
OS_NAME="$PRETTY_NAME"
|
||||||
|
case "${ID:-}" in
|
||||||
|
astra) OS_KIND="astra" ;;
|
||||||
|
debian) OS_KIND="debian" ;;
|
||||||
|
ubuntu) OS_KIND="ubuntu" ;;
|
||||||
|
*)
|
||||||
|
case "${ID_LIKE:-}" in
|
||||||
|
*debian*) OS_KIND="debian-like" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
ok "Обнаружено: $OS_NAME"
|
||||||
|
|
||||||
|
case "$OS_KIND" in
|
||||||
|
astra)
|
||||||
|
ok "Astra Linux — полностью поддерживается, ИШ заработает официально"
|
||||||
|
;;
|
||||||
|
debian|"debian-like")
|
||||||
|
warn "Debian-based — bj-server установится, ИШ скорее всего тоже"
|
||||||
|
warn "(но официально НРД его не поддерживает на Debian; для прод-инфры лучше Astra Linux SE)"
|
||||||
|
;;
|
||||||
|
ubuntu)
|
||||||
|
warn "Ubuntu — bj-server установится, но ИШ может потребовать допилов"
|
||||||
|
ask "Продолжить?" || exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Неподдерживаемая ОС. Поддерживаются: Astra Linux (SE/CE), Debian, Ubuntu"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 2/9. Системные пакеты
|
||||||
|
# ============================================================
|
||||||
|
step "2/9: установка системных пакетов через apt"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq >/dev/null
|
||||||
|
apt-get install -y -qq \
|
||||||
|
ca-certificates curl wget git tar gzip \
|
||||||
|
podman postgresql-client \
|
||||||
|
>/dev/null 2>&1
|
||||||
|
# podman-compose доступен либо как apt-пакет, либо как pip — пробуем оба
|
||||||
|
if ! command -v podman-compose >/dev/null 2>&1; then
|
||||||
|
apt-get install -y -qq podman-compose >/dev/null 2>&1 || \
|
||||||
|
apt-get install -y -qq python3-pip >/dev/null 2>&1 && pip3 install --quiet podman-compose 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
command -v podman >/dev/null && ok "podman: $(podman --version | awk '{print $3}')" || fail "podman не установился"
|
||||||
|
command -v git >/dev/null && ok "git: $(git --version | awk '{print $3}')" || fail "git не установился"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 3/9. Go 1.24+
|
||||||
|
# ============================================================
|
||||||
|
step "3/9: Go 1.24+"
|
||||||
|
need_go=1
|
||||||
|
if command -v go >/dev/null 2>&1; then
|
||||||
|
GO_HAVE=$(go version | awk '{print $3}' | sed 's/go//')
|
||||||
|
if printf '%s\n%s' "1.24" "$GO_HAVE" | sort -V | head -1 | grep -q '^1.24$'; then
|
||||||
|
ok "Go $GO_HAVE — подходит"
|
||||||
|
need_go=0
|
||||||
|
else
|
||||||
|
warn "Go $GO_HAVE — слишком старый, обновляю"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$need_go" = 1 ]; then
|
||||||
|
GO_VER="1.24.0"
|
||||||
|
ok "качаю Go $GO_VER с go.dev (~70 МБ)..."
|
||||||
|
curl -sSL "https://go.dev/dl/go${GO_VER}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \
|
||||||
|
|| fail "не получилось скачать Go (нужен интернет)"
|
||||||
|
rm -rf /usr/local/go
|
||||||
|
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||||
|
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||||
|
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||||
|
rm -f /tmp/go.tar.gz
|
||||||
|
ok "Go $GO_VER установлен в /usr/local/go"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 4/9. Пользователь bj и каталоги
|
||||||
|
# ============================================================
|
||||||
|
step "4/9: системный пользователь bj и каталоги"
|
||||||
|
if ! id bj >/dev/null 2>&1; then
|
||||||
|
useradd --system --create-home --home-dir /var/lib/bj --shell /bin/bash bj
|
||||||
|
ok "создан пользователь bj"
|
||||||
|
else
|
||||||
|
ok "пользователь bj уже существует"
|
||||||
|
fi
|
||||||
|
install -d -o bj -g bj -m 0755 /opt/bj /var/lib/bj /var/log/bj
|
||||||
|
install -d -o bj -g bj -m 0700 /var/lib/bj/.bj
|
||||||
|
ok "каталоги: /opt/bj /var/lib/bj /var/log/bj"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 5/9. Клон репо и сборка bj-server
|
||||||
|
# ============================================================
|
||||||
|
step "5/9: клон репозитория и сборка bj-server"
|
||||||
|
SRC=/opt/bj/src
|
||||||
|
if [ -d "$SRC/.git" ]; then
|
||||||
|
sudo -u bj -H git -C "$SRC" fetch --quiet origin
|
||||||
|
sudo -u bj -H git -C "$SRC" reset --hard --quiet "origin/$BRANCH"
|
||||||
|
ok "репо обновлено до $BRANCH"
|
||||||
|
else
|
||||||
|
sudo -u bj -H git clone --quiet --branch "$BRANCH" "$REPO_URL" "$SRC" \
|
||||||
|
|| fail "git clone failed"
|
||||||
|
ok "репо склонирован"
|
||||||
|
fi
|
||||||
|
chown -R bj:bj "$SRC"
|
||||||
|
|
||||||
|
ok "компиляция bj-server..."
|
||||||
|
sudo -u bj -H bash -c "cd $SRC && /usr/local/bin/go build -o /opt/bj/bj-server ./cmd/bj-server" \
|
||||||
|
|| fail "go build failed"
|
||||||
|
chown bj:bj /opt/bj/bj-server
|
||||||
|
chmod 0755 /opt/bj/bj-server
|
||||||
|
ok "бинарник: /opt/bj/bj-server ($(du -h /opt/bj/bj-server | awk '{print $1}'))"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 6/9. PostgreSQL в podman + миграции
|
||||||
|
# ============================================================
|
||||||
|
step "6/9: PostgreSQL в podman + миграции БД"
|
||||||
|
cd "$SRC"
|
||||||
|
if ! podman ps --format '{{.Names}}' 2>/dev/null | grep -qx bj-postgres; then
|
||||||
|
sudo -u bj -H podman-compose -f deploy/docker-compose/docker-compose.yml up -d postgres \
|
||||||
|
2>/dev/null || {
|
||||||
|
warn "podman-compose не сработал, пробую podman run напрямую"
|
||||||
|
sudo -u bj -H podman run -d --name bj-postgres \
|
||||||
|
-e POSTGRES_USER=bj -e POSTGRES_PASSWORD=bj_dev -e POSTGRES_DB=bj \
|
||||||
|
-p 127.0.0.1:5432:5432 \
|
||||||
|
docker.io/library/postgres:16-alpine
|
||||||
|
}
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
# Ждём pg_isready
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if sudo -u bj -H podman exec bj-postgres pg_isready -U bj -d bj >/dev/null 2>&1; then
|
||||||
|
ok "PostgreSQL готов"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# Накат миграций
|
||||||
|
MIG_COUNT=0
|
||||||
|
for mig in migrations/fansy-store/*.sql migrations/m2m-core/*.sql; do
|
||||||
|
if [ -f "$mig" ]; then
|
||||||
|
sudo -u bj -H podman exec -i bj-postgres psql -U bj -d bj -v ON_ERROR_STOP=0 < "$mig" >/dev/null 2>&1 && \
|
||||||
|
MIG_COUNT=$((MIG_COUNT+1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ok "миграций накачено: $MIG_COUNT"
|
||||||
|
|
||||||
|
# Сохраняем DSN
|
||||||
|
cat > /var/lib/bj/.bj/setup.json <<EOF
|
||||||
|
{
|
||||||
|
"postgres": {"dsn": "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"},
|
||||||
|
"crypto": {"provider": "stub", "socket_path": "/run/bj/crypto.sock"},
|
||||||
|
"nsd": {},
|
||||||
|
"lk": {},
|
||||||
|
"ca_certs": {},
|
||||||
|
"news": {},
|
||||||
|
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chown bj:bj /var/lib/bj/.bj/setup.json
|
||||||
|
chmod 0600 /var/lib/bj/.bj/setup.json
|
||||||
|
ok "DSN сохранён в /var/lib/bj/.bj/setup.json"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 7/9. systemd unit
|
||||||
|
# ============================================================
|
||||||
|
step "7/9: systemd unit для bj-server"
|
||||||
|
cat > /etc/systemd/system/bj-server.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Bridge-and-Join-s — единый сервис M2M-переводов
|
||||||
|
Documentation=$REPO_URL
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=bj
|
||||||
|
Group=bj
|
||||||
|
WorkingDirectory=$SRC
|
||||||
|
ExecStart=/opt/bj/bj-server
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
Environment=BJ_HTTP_ADDR=$BIND_ADDR
|
||||||
|
Environment=BJ_SETUP_PATH=/var/lib/bj/.bj/setup.json
|
||||||
|
Environment=BJ_M2M_SENDER=MC0079200000
|
||||||
|
Environment=BJ_M2M_RECEIVER=MC0010300000
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/bj /var/log/bj
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
StandardOutput=append:/var/log/bj/bj-server.log
|
||||||
|
StandardError=append:/var/log/bj/bj-server.err
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable bj-server >/dev/null 2>&1
|
||||||
|
systemctl restart bj-server
|
||||||
|
sleep 2
|
||||||
|
if systemctl is-active --quiet bj-server; then
|
||||||
|
ok "bj-server.service active"
|
||||||
|
else
|
||||||
|
warn "bj-server не стартанул, см. journalctl -u bj-server -n 30"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 8/9. ИШ НРД — скачивание и установка
|
||||||
|
# ============================================================
|
||||||
|
if [ "$SKIP_ISH" = "1" ]; then
|
||||||
|
step "8/9: ИШ НРД — пропущено (--skip-ish)"
|
||||||
|
else
|
||||||
|
step "8/9: Интеграционный шлюз НРД (ИШ)"
|
||||||
|
ISH_LOCAL="$SRC/dist/ish/igate_100.0-765_amd64.deb"
|
||||||
|
if [ -f "$ISH_LOCAL" ]; then
|
||||||
|
ok "дистрибутив ИШ уже в репо: $ISH_LOCAL"
|
||||||
|
else
|
||||||
|
ok "качаю дистрибутив ИШ с НРД (~120 МБ)..."
|
||||||
|
mkdir -p "$(dirname "$ISH_LOCAL")"
|
||||||
|
if curl -sSL -A "Mozilla/5.0" "$ISH_DEB_URL" -o "$ISH_LOCAL" --max-time 600; then
|
||||||
|
ok "скачан: $(du -h "$ISH_LOCAL" | awk '{print $1}')"
|
||||||
|
else
|
||||||
|
warn "не получилось скачать ИШ автоматически"
|
||||||
|
warn "скачайте вручную: $ISH_DEB_URL"
|
||||||
|
warn "и положите в $ISH_LOCAL, потом перезапустите этот скрипт"
|
||||||
|
ISH_LOCAL=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ISH_LOCAL" ] && [ -f "$ISH_LOCAL" ]; then
|
||||||
|
ok "установка ИШ через dpkg..."
|
||||||
|
if dpkg -i "$ISH_LOCAL" >/dev/null 2>&1; then
|
||||||
|
ok "ИШ установлен"
|
||||||
|
else
|
||||||
|
# Часто dpkg падает на зависимостях — пробуем apt-get install -f
|
||||||
|
apt-get install -f -y >/dev/null 2>&1
|
||||||
|
if dpkg -i "$ISH_LOCAL" >/dev/null 2>&1; then
|
||||||
|
ok "ИШ установлен (после починки зависимостей)"
|
||||||
|
else
|
||||||
|
warn "ИШ не встал — возможно нет Валидаты или системных пакетов"
|
||||||
|
warn "это нормально на текущем этапе — продолжаем"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАГ 9/9. Финальная проверка
|
||||||
|
# ============================================================
|
||||||
|
step "9/9: проверка готовности"
|
||||||
|
sleep 1
|
||||||
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1${BIND_ADDR}/admin/" 2>/dev/null || echo "—")
|
||||||
|
[ "$CODE" = "200" ] && ok "веб-админка отвечает: HTTP 200" || warn "веб-админка пока не отвечает (HTTP $CODE) — проверь логи"
|
||||||
|
|
||||||
|
IP=$(hostname -I | awk '{print $1}')
|
||||||
|
echo
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ УСТАНОВКА BJ-SERVER ЗАВЕРШЕНА ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo
|
||||||
|
echo " Веб-админка: http://$IP${BIND_ADDR}/admin/"
|
||||||
|
echo " Мастер настройки: http://$IP${BIND_ADDR}/admin/wizard"
|
||||||
|
echo " Архитектура: http://$IP${BIND_ADDR}/admin/help/architecture"
|
||||||
|
echo " Новости: http://$IP${BIND_ADDR}/admin/news"
|
||||||
|
echo
|
||||||
|
echo " Логи: tail -f /var/log/bj/bj-server.log"
|
||||||
|
echo " Сервис: systemctl status bj-server"
|
||||||
|
echo
|
||||||
|
echo " ──── ЧТО ОСТАЛОСЬ СДЕЛАТЬ (НЕ АВТОМАТИЧЕСКИ) ───────────────"
|
||||||
|
echo
|
||||||
|
echo " 1. Запросить СКЗИ Валидата CSP у НРД:"
|
||||||
|
echo " Email: soed@nsd.ru"
|
||||||
|
echo " Текст: «Запрос дистрибутива Валидата CSP для Linux + временной"
|
||||||
|
echo " лицензии для подключения к ЭДО НРД в рамках MOEX МОСТ M2M.»"
|
||||||
|
echo
|
||||||
|
echo " 2. Получить сертификат подписи в УЦ Московской Биржи:"
|
||||||
|
echo " https://ca.moex.com/"
|
||||||
|
echo
|
||||||
|
echo " 3. Подать заявку на тестирование в TEST3:"
|
||||||
|
echo " https://www.nsd.ru/workflow/zayavka-na-testirovanie/"
|
||||||
|
echo
|
||||||
|
echo " 4. Когда придёт Валидата — поставить:"
|
||||||
|
echo " sudo bash $SRC/deploy/astra/install-validata.sh /path/to/validata-*.deb"
|
||||||
|
echo
|
||||||
|
echo " 5. Когда заработает ИШ — указать его URL в /admin/setup → «ИШ НРД»"
|
||||||
|
echo
|
||||||
|
echo " ──── ПРОВЕРКА СОСТОЯНИЯ ВСЕГО ──────────────────────────────"
|
||||||
|
echo " sudo bash $SRC/deploy/astra/healthcheck.sh"
|
||||||
|
echo
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
TEST3 GOST|TEST3GOST|WSL|t
|
||||||
@@ -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 <dir>` (он накатывает 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'
|
||||||
@@ -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
|
||||||
@@ -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=<id>` → `{"revoked":true|false}`. Перечитывается раз в
|
||||||
|
минуту. В проде заменить файл на PostgreSQL + admin API выпуска/отзыва.
|
||||||
|
|
||||||
|
## Зашивка публичного ключа в релиз
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -ldflags "\
|
||||||
|
-X .../lkgateway.DefaultLicensePublicKey=<base64-pub> \
|
||||||
|
-X .../lkgateway.DefaultUpdatePublicKey=<base64-pub-артефактории> \
|
||||||
|
-X .../lkgateway.BuildVersion=1.0.0" -o bj-server ./cmd/bj-server/
|
||||||
|
```
|
||||||
Executable
+388
@@ -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/<label>/ с владельцем bj — это убирает необходимость
|
||||||
|
# пробрасывать /media/<gui-user>/
|
||||||
|
# 10. systemctl daemon-reload + enable/start bj-crypto + bj-server
|
||||||
|
#
|
||||||
|
# Идемпотентный — повторный запуск ничего не ломает.
|
||||||
|
#
|
||||||
|
# Запуск:
|
||||||
|
# sudo bash install-validata.sh [path-to-validata-zpki.deb]
|
||||||
|
#
|
||||||
|
# Если путь не передан — ищет:
|
||||||
|
# ./ClientL_Other/zpki-*.deb
|
||||||
|
# /opt/bj/src/dist/validata/*.deb
|
||||||
|
# ~/Загрузки/ClientL_Other/*.deb
|
||||||
|
# ~/Downloads/ClientL_Other/*.deb
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# Логирование
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log() { echo -e "\033[1;34m[validata-install]\033[0m $*"; }
|
||||||
|
ok() { echo -e "\033[1;32m[validata-install OK]\033[0m $*"; }
|
||||||
|
warn() { echo -e "\033[1;33m[validata-install WARN]\033[0m $*" >&2; }
|
||||||
|
fail() { echo -e "\033[1;31m[validata-install FAIL]\033[0m $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$EUID" -eq 0 ] || fail "Запускать от root (sudo)"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 1. Детект ОС
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
DISTRO=""
|
||||||
|
DISTRO_VERSION=""
|
||||||
|
if [ -r /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
case "$ID" in
|
||||||
|
astra) DISTRO=astra; DISTRO_VERSION="${VERSION_ID:-unknown}";;
|
||||||
|
debian) DISTRO=debian; DISTRO_VERSION="${VERSION_ID:-unknown}";;
|
||||||
|
ubuntu) DISTRO=ubuntu; DISTRO_VERSION="${VERSION_ID:-unknown}";;
|
||||||
|
*) DISTRO="$ID"; DISTRO_VERSION="${VERSION_ID:-unknown}";;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
case "$DISTRO" in
|
||||||
|
debian|astra) ok "ОС: $PRETTY_NAME ($DISTRO $DISTRO_VERSION) — поддерживается";;
|
||||||
|
ubuntu) warn "ОС: $PRETTY_NAME — поддерживается на свой страх (Validata в формуляре нет)";;
|
||||||
|
*) warn "ОС: $PRETTY_NAME — не проверена, продолжаю на свой страх";;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 2. Поиск deb-пакетов Валидаты
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
ZPKI_DEB=""
|
||||||
|
ZSDK_DEB=""
|
||||||
|
|
||||||
|
if [ -n "${1:-}" ] && [ -f "$1" ]; then
|
||||||
|
# Конкретный файл передан аргументом
|
||||||
|
ZPKI_DEB="$1"
|
||||||
|
# zsdk ищем рядом
|
||||||
|
ZSDK_DEB="$(dirname "$1")/$(basename "$1" | sed 's/zpki/zsdk/')"
|
||||||
|
else
|
||||||
|
for d in \
|
||||||
|
./ClientL_Other \
|
||||||
|
./ClientL_ALSE \
|
||||||
|
/opt/bj/src/dist/validata \
|
||||||
|
/home/*/Загрузки/ClientL_Other \
|
||||||
|
/home/*/Загрузки/ClientL_ALSE \
|
||||||
|
/home/*/Downloads/ClientL_Other \
|
||||||
|
/home/*/Downloads/ClientL_ALSE \
|
||||||
|
/tmp/ClientL_Other; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
cand_zpki=$(find "$d" -maxdepth 1 -name "zpki-*.amd64.deb" 2>/dev/null | head -1)
|
||||||
|
cand_zsdk=$(find "$d" -maxdepth 1 -name "zsdk-*.amd64.deb" 2>/dev/null | head -1)
|
||||||
|
if [ -n "$cand_zpki" ]; then
|
||||||
|
ZPKI_DEB="$cand_zpki"
|
||||||
|
ZSDK_DEB="$cand_zsdk"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$ZPKI_DEB" ] || fail "Не найден zpki-*.amd64.deb. Скачайте https://fs.moex.com/cdp/po/ClientL_Other.zip и распакуйте рядом со скриптом или в /opt/bj/src/dist/validata/"
|
||||||
|
|
||||||
|
log "Найдено:"
|
||||||
|
log " zpki: $ZPKI_DEB"
|
||||||
|
[ -n "$ZSDK_DEB" ] && log " zsdk: $ZSDK_DEB" || warn " zsdk не найден — будет пропущен"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 3. Системные зависимости
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "Обновляю apt-кеш и ставлю зависимости..."
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
# Базовые зависимости (одинаковые на Debian/Astra)
|
||||||
|
DEPS=(
|
||||||
|
libgtk-3-0
|
||||||
|
libpcsclite1
|
||||||
|
libccid
|
||||||
|
pcscd
|
||||||
|
libcurl4
|
||||||
|
libkrb5-3
|
||||||
|
libgssapi-krb5-2
|
||||||
|
libsasl2-modules
|
||||||
|
libsasl2-modules-gssapi-mit
|
||||||
|
execstack
|
||||||
|
p7zip-full
|
||||||
|
util-linux
|
||||||
|
uuid-runtime
|
||||||
|
)
|
||||||
|
|
||||||
|
# libldap-2.4-2 в Bullseye/Astra; в Bookworm уже libldap-2.5-0.
|
||||||
|
# Зависимость в zpki жёстко на 2.4-2 → используем --force-depends на этом этапе
|
||||||
|
# и ставим libldap-2.5-0 как замену (ABI совместим в нашем use-case).
|
||||||
|
if apt-cache show libldap-2.4-2 >/dev/null 2>&1; then
|
||||||
|
DEPS+=(libldap-2.4-2)
|
||||||
|
USE_FORCE=0
|
||||||
|
else
|
||||||
|
DEPS+=(libldap-2.5-0)
|
||||||
|
USE_FORCE=1
|
||||||
|
warn "libldap-2.4-2 недоступен → ставлю libldap-2.5-0 и буду форсить --force-depends при установке zpki"
|
||||||
|
fi
|
||||||
|
|
||||||
|
apt-get install -y --no-install-recommends "${DEPS[@]}"
|
||||||
|
ok "Зависимости установлены"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 4. Установка Валидаты
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "Ставлю zpki..."
|
||||||
|
if [ "$USE_FORCE" = "1" ]; then
|
||||||
|
dpkg --force-depends -i "$ZPKI_DEB"
|
||||||
|
else
|
||||||
|
dpkg -i "$ZPKI_DEB" || { apt-get -f install -y; dpkg -i "$ZPKI_DEB"; }
|
||||||
|
fi
|
||||||
|
if [ -n "$ZSDK_DEB" ]; then
|
||||||
|
log "Ставлю zsdk..."
|
||||||
|
dpkg -i "$ZSDK_DEB" || { apt-get -f install -y; dpkg -i "$ZSDK_DEB"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -d /opt/Validata/VDCSP/lib/amd64 ] || fail "Валидата не установилась в /opt/Validata — проверьте dpkg -L zpki"
|
||||||
|
ok "Валидата в /opt/Validata/VDCSP"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 5. execstack libvdcsp.so (GNU_STACK RWE → RW)
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "execstack -c libvdcsp.so (требование Валидаты)..."
|
||||||
|
if execstack -q /opt/Validata/VDCSP/lib/amd64/libvdcsp.so 2>/dev/null | grep -q '^X'; then
|
||||||
|
execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
|
||||||
|
ok "executable-stack снят"
|
||||||
|
else
|
||||||
|
ok "executable-stack уже снят"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 6. Системный пользователь bj
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
if ! id bj >/dev/null 2>&1; then
|
||||||
|
log "Создаю системного пользователя bj..."
|
||||||
|
useradd --system --create-home --home-dir /var/lib/bj --shell /bin/bash bj
|
||||||
|
ok "Пользователь bj создан (home=/var/lib/bj)"
|
||||||
|
else
|
||||||
|
ok "Пользователь bj уже есть"
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -o bj -g bj -m 0755 /var/lib/bj/usb
|
||||||
|
install -d -o bj -g bj -m 0700 /var/lib/bj/.Validata
|
||||||
|
install -d -o bj -g bj -m 0700 /var/lib/bj/.Validata/vdkeys
|
||||||
|
install -d -o bj -g bj -m 0755 /var/lib/bj/profiles
|
||||||
|
install -d -o bj -g bj -m 0755 /var/log/bj
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 7. pcscd: убираем --auto-exit и socket-активацию
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "Настраиваю pcscd как always-on демон..."
|
||||||
|
install -d /etc/systemd/system/pcscd.service.d
|
||||||
|
cat >/etc/systemd/system/pcscd.service.d/no-autoexit.conf <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
# Отвязываем сервис от сокет-юнита, чтобы можно было держать его постоянно живым
|
||||||
|
Requires=
|
||||||
|
After=
|
||||||
|
Sockets=
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Убираем --auto-exit — Валидата ожидает постоянно живой pcscd, иначе
|
||||||
|
# получает 0x8010001D «Диспетчер ресурсов смарт-карт не выполняется»
|
||||||
|
# при попытке найти ключевой носитель (.vdk файл выглядит для неё как
|
||||||
|
# виртуальная смарт-карта)
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=/usr/sbin/pcscd --foreground
|
||||||
|
EOF
|
||||||
|
ok "pcscd drop-in: /etc/systemd/system/pcscd.service.d/no-autoexit.conf"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 8. bj-crypto drop-ins
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "Кладу drop-ins для bj-crypto..."
|
||||||
|
install -d /etc/systemd/system/bj-crypto.service.d
|
||||||
|
|
||||||
|
cat >/etc/systemd/system/bj-crypto.service.d/validata-paths.conf <<'EOF'
|
||||||
|
[Service]
|
||||||
|
# Валидата ищет pki1.conf в текущей рабочей директории — работаем оттуда
|
||||||
|
WorkingDirectory=/opt/Validata/VDCSP/etc
|
||||||
|
# Валидата пишет в /opt/Validata/VDCSP/etc/pki1.conf при инициализации
|
||||||
|
# профиля. ProtectSystem=strict делает /opt read-only — открываем точечно.
|
||||||
|
ReadWritePaths=/opt/Validata/VDCSP/etc
|
||||||
|
ReadWritePaths=/var/lib/bj
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >/etc/systemd/system/bj-crypto.service.d/usb-access.conf <<'EOF'
|
||||||
|
[Service]
|
||||||
|
# Без этого PrivateTmp + ProtectSystem закроет /media и /var/lib/bj/usb,
|
||||||
|
# а нам нужно туда смотреть в поисках .vdk на флешке.
|
||||||
|
ReadOnlyPaths=/media
|
||||||
|
ReadOnlyPaths=/var/lib/bj/usb
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >/etc/systemd/system/bj-crypto.service.d/share-crysvc.conf <<'EOF'
|
||||||
|
[Service]
|
||||||
|
# Валидата общается с криптодрайвером (vdcrysvc) через Unix-сокет
|
||||||
|
# /tmp/.crysvc.sock — но PrivateTmp=true даёт нам приватный /tmp.
|
||||||
|
# Прокидываем именно этот сокет внутрь нашего namespace.
|
||||||
|
PrivateTmp=true
|
||||||
|
BindPaths=/tmp/.crysvc.sock:/tmp/.crysvc.sock
|
||||||
|
EOF
|
||||||
|
ok "bj-crypto drop-ins: validata-paths.conf, usb-access.conf, share-crysvc.conf"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 9. bj-server drop-in
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "Кладу drop-in для bj-server..."
|
||||||
|
install -d /etc/systemd/system/bj-server.service.d
|
||||||
|
cat >/etc/systemd/system/bj-server.service.d/pki1conf.conf <<'EOF'
|
||||||
|
[Service]
|
||||||
|
# bj-server при импорте профиля дописывает секцию в pki1.conf.
|
||||||
|
# ProtectSystem=strict закрывает /opt — открываем точечно.
|
||||||
|
ReadWritePaths=/opt/Validata/VDCSP/etc
|
||||||
|
EOF
|
||||||
|
ok "bj-server drop-in: pki1conf.conf"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 10. spki.ini — Валидата требует, при отсутствии mkstores падает
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
SPKI=/opt/Validata/VDCSP/etc/spki.ini
|
||||||
|
if [ ! -f "$SPKI" ]; then
|
||||||
|
log "Создаю $SPKI (Валидата без него падает в mkstores/zpki1utl)..."
|
||||||
|
cat >"$SPKI" <<'EOF'
|
||||||
|
[store]
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
[Parameters]
|
||||||
|
PkiLdapTimeout = 10
|
||||||
|
PkiHttpTimeout = 60
|
||||||
|
EOF
|
||||||
|
chmod 644 "$SPKI"
|
||||||
|
ok "spki.ini создан"
|
||||||
|
else
|
||||||
|
ok "spki.ini уже есть"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 11. pki1.conf — делаем доступным для записи группе bj
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
PKI1=/opt/Validata/VDCSP/etc/pki1.conf
|
||||||
|
if [ -f "$PKI1" ]; then
|
||||||
|
chgrp bj "$PKI1"
|
||||||
|
chmod g+w "$PKI1"
|
||||||
|
ok "pki1.conf: group=bj, g+w"
|
||||||
|
if ! grep -q "^# --- bj-server: BEGIN ---" "$PKI1"; then
|
||||||
|
printf '\n# --- bj-server: BEGIN ---\n# Секции профилей дописываются автоматически при импорте через /admin/setup.\n# --- bj-server: END ---\n' >> "$PKI1"
|
||||||
|
ok "В pki1.conf добавлены маркеры bj-server"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 12. udev-rule для авто-mount USB с .vdk
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "Ставлю udev-rule для авто-mount USB → /var/lib/bj/usb/..."
|
||||||
|
cat >/etc/udev/rules.d/99-bj-usb.rules <<'EOF'
|
||||||
|
# Авто-mount USB-флешек в /var/lib/bj/usb/<label> с владельцем bj.
|
||||||
|
# Применяется только к USB-устройствам (SUBSYSTEMS=="usb") с файловой
|
||||||
|
# системой. Mountpoint выбирается по метке тома или UUID.
|
||||||
|
ACTION=="add", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
|
||||||
|
ENV{ID_FS_TYPE}!="", \
|
||||||
|
ENV{SYSTEMD_WANTS}="bj-usb-mount@$env{ID_FS_UUID}.service", \
|
||||||
|
ENV{SYSTEMD_USER_WANTS}=""
|
||||||
|
ACTION=="remove", KERNEL=="sd?[0-9]", SUBSYSTEMS=="usb", \
|
||||||
|
ENV{ID_FS_TYPE}!="", \
|
||||||
|
ENV{SYSTEMD_WANTS}="bj-usb-umount@$env{ID_FS_UUID}.service"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Систэмный template-сервис, который монтирует и umonтирует
|
||||||
|
cat >/etc/systemd/system/bj-usb-mount@.service <<'EOF'
|
||||||
|
[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
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >/etc/systemd/system/bj-usb-umount@.service <<'EOF'
|
||||||
|
[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'
|
||||||
|
EOF
|
||||||
|
|
||||||
|
udevadm control --reload-rules
|
||||||
|
udevadm trigger
|
||||||
|
ok "udev-rule + systemd-mount шаблон установлены"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 13. Установка bj-crypto.service unit (если его ещё нет — берём из репы)
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_UNIT="$SELF_DIR/../systemd/bj-crypto.service"
|
||||||
|
if [ ! -f /etc/systemd/system/bj-crypto.service ] && [ -f "$REPO_UNIT" ]; then
|
||||||
|
log "Устанавливаю /etc/systemd/system/bj-crypto.service из репы..."
|
||||||
|
install -m 0644 "$REPO_UNIT" /etc/systemd/system/bj-crypto.service
|
||||||
|
ok "bj-crypto.service установлен"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 14. daemon-reload + старт сервисов
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
log "systemctl daemon-reload..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
log "Отключаю pcscd.socket (его подменяет наш drop-in always-on)..."
|
||||||
|
systemctl disable pcscd.socket 2>/dev/null || true
|
||||||
|
systemctl stop pcscd.socket 2>/dev/null || true
|
||||||
|
|
||||||
|
log "Запускаю pcscd..."
|
||||||
|
systemctl enable pcscd
|
||||||
|
systemctl restart pcscd
|
||||||
|
|
||||||
|
if [ -f /etc/systemd/system/bj-crypto.service ]; then
|
||||||
|
log "Запускаю bj-crypto..."
|
||||||
|
systemctl enable bj-crypto
|
||||||
|
systemctl restart bj-crypto
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
# 15. Финальная проверка
|
||||||
|
# --------------------------------------------------------------------- #
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo " Валидата установлена, окружение настроено"
|
||||||
|
echo "================================================================"
|
||||||
|
for svc in pcscd vdcrysvc bj-crypto; do
|
||||||
|
if systemctl is-active --quiet "$svc" 2>/dev/null; then
|
||||||
|
echo " ✓ $svc — active"
|
||||||
|
else
|
||||||
|
echo " ✗ $svc — НЕ active (проверьте journalctl -u $svc)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo " Дальнейшие шаги:"
|
||||||
|
echo " 1. Подключите USB с .vdk → авто-маунт в /var/lib/bj/usb/<UUID>/"
|
||||||
|
echo " 2. Откройте /admin/setup в bj-server"
|
||||||
|
echo " 3. Загрузите .7z с профилем → bj-server сам всё извлечёт и импортирует"
|
||||||
|
echo " 4. Нажмите «Активировать профиль»"
|
||||||
|
echo "================================================================"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<!--
|
||||||
|
АНКЕТА УЧАСТНИКА СЕРВИСА M2M (M2MTransferParticipantForm)
|
||||||
|
Назначение: регистрация нашего депозитарного кода в справочнике участников
|
||||||
|
M2M на стороне НРД. Без этой регистрации сервис МОСТ отклоняет запросы с
|
||||||
|
кодом M2M14 «Код ЭДО НРД отправителя отсутствует в справочнике участников M2M».
|
||||||
|
|
||||||
|
ВНИМАНИЕ:
|
||||||
|
1. Перед отправкой замените все значения ЗАПОЛНИТЬ_* на реальные реквизиты
|
||||||
|
организации. Это юридические данные — заполняет уполномоченное лицо.
|
||||||
|
2. Файл должен быть в кодировке windows-1251 (как объявлено в прологе).
|
||||||
|
Наш редактор хранит его в UTF-8 для удобства — перекодируйте перед
|
||||||
|
отправкой: iconv -f utf8 -t cp1251 form.xml > form.win1251.xml
|
||||||
|
3. Известное значение уже подставлено: депозитарный код MC0413600000
|
||||||
|
(выдан НРД для тестового контура TEST3, период 21.05.2026–01.09.2026).
|
||||||
|
4. Схема: DOC/M2MSchemas_260408/M2MTransferParticipantForm.xsd
|
||||||
|
-->
|
||||||
|
<pf:M2MTransferParticipantForm
|
||||||
|
xmlns:m2m="http://nsd.ru/schemas/m2m/types"
|
||||||
|
xmlns:pf="http://nsd.ru/schemas/m2m/participant/form"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://nsd.ru/schemas/m2m/participant/form M2MTransferParticipantForm.xsd">
|
||||||
|
|
||||||
|
<!-- Дата/время формирования анкеты по МСК. T — латиница, МСК — кириллица. -->
|
||||||
|
<pf:CreationTimestamp>2026-06-17T12:00:00(МСК)</pf:CreationTimestamp>
|
||||||
|
|
||||||
|
<pf:Participant>
|
||||||
|
<!-- ИНН организации (10 цифр для ЮЛ). -->
|
||||||
|
<m2m:INN>ЗАПОЛНИТЬ_ИНН</m2m:INN>
|
||||||
|
|
||||||
|
<m2m:Names>
|
||||||
|
<m2m:Rus>
|
||||||
|
<!-- Полное наименование по уставу. -->
|
||||||
|
<m2m:FullName>ЗАПОЛНИТЬ_ПОЛНОЕ_НАИМЕНОВАНИЕ</m2m:FullName>
|
||||||
|
<!-- Сокращённое наименование (необязательно). -->
|
||||||
|
<m2m:ShortName>ЗАПОЛНИТЬ_СОКРАЩЁННОЕ</m2m:ShortName>
|
||||||
|
<!-- Отображаемое короткое имя (показывается контрагенту). -->
|
||||||
|
<m2m:DisplayName>ЗАПОЛНИТЬ_ОТОБРАЖАЕМОЕ</m2m:DisplayName>
|
||||||
|
</m2m:Rus>
|
||||||
|
<!-- Английский блок необязателен; заполните, если есть. -->
|
||||||
|
<m2m:Eng>
|
||||||
|
<m2m:FullName>ЗАПОЛНИТЬ_FULL_NAME_EN</m2m:FullName>
|
||||||
|
<m2m:DisplayName>ЗАПОЛНИТЬ_DISPLAY_EN</m2m:DisplayName>
|
||||||
|
</m2m:Eng>
|
||||||
|
</m2m:Names>
|
||||||
|
|
||||||
|
<!-- Депозитарное место: наш код участника в НРД. УЖЕ ЗАПОЛНЕНО. -->
|
||||||
|
<m2m:DepositoryPlace>
|
||||||
|
<m2m:ParticipantCode>MC0413600000</m2m:ParticipantCode>
|
||||||
|
</m2m:DepositoryPlace>
|
||||||
|
|
||||||
|
<!-- Брокерское место — только если выступаем брокером. Иначе удалить блок. -->
|
||||||
|
<!--
|
||||||
|
<m2m:BrokerPlace>
|
||||||
|
<m2m:ParticipantCode>ЗАПОЛНИТЬ_БРОКЕРСКИЙ_КОД</m2m:ParticipantCode>
|
||||||
|
</m2m:BrokerPlace>
|
||||||
|
-->
|
||||||
|
</pf:Participant>
|
||||||
|
</pf:M2MTransferParticipantForm>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Регистрация в справочнике участников M2M (НРД)
|
||||||
|
|
||||||
|
Закрывает блокер заявок с кодом **M2M14** — «Код ЭДО НРД отправителя
|
||||||
|
отсутствует в справочнике участников M2M». Технически наш контур (ИШ, СКЗИ,
|
||||||
|
канал, REST) работает; не хватает только регистрации нашего депозитарного
|
||||||
|
кода в справочнике сервиса МОСТ на стороне НРД.
|
||||||
|
|
||||||
|
## Что известно
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|-------------------------|---------------------------------------|
|
||||||
|
| Депозитарный код | `MC0413600000` |
|
||||||
|
| Тестовый контур | TEST3 (ГОСТ-криптография) |
|
||||||
|
| Период тестирования | 21.05.2026 — 01.09.2026 |
|
||||||
|
| Счёт (account_id) | `HL171004001C` |
|
||||||
|
| Раздел (section_id) | `36MC0413600000F00` |
|
||||||
|
|
||||||
|
## Что нужно заполнить (юридические реквизиты организации)
|
||||||
|
|
||||||
|
В файле `M2MTransferParticipantForm.example.xml` замените:
|
||||||
|
|
||||||
|
- `ЗАПОЛНИТЬ_ИНН` — ИНН организации (10 цифр для ЮЛ);
|
||||||
|
- `ЗАПОЛНИТЬ_ПОЛНОЕ_НАИМЕНОВАНИЕ` — полное наименование по уставу;
|
||||||
|
- `ЗАПОЛНИТЬ_СОКРАЩЁННОЕ` / `ЗАПОЛНИТЬ_ОТОБРАЖАЕМОЕ` — сокращённое и
|
||||||
|
отображаемое имя;
|
||||||
|
- английский блок `<m2m:Eng>` — при наличии, иначе удалить;
|
||||||
|
- `<m2m:BrokerPlace>` — только если выступаем брокером, иначе оставить
|
||||||
|
закомментированным.
|
||||||
|
|
||||||
|
Депозитарный код `MC0413600000` уже подставлен.
|
||||||
|
|
||||||
|
## Подготовка файла к отправке
|
||||||
|
|
||||||
|
Файл хранится в UTF-8 для удобства, а НРД ждёт **windows-1251** (как объявлено
|
||||||
|
в прологе XML). Перекодируйте перед отправкой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
iconv -f utf8 -t cp1251 M2MTransferParticipantForm.example.xml \
|
||||||
|
> M2MTransferParticipantForm.win1251.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
(Опционально) проверьте по схеме, если установлен xmllint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xmllint --noout \
|
||||||
|
--schema ../../DOC/M2MSchemas_260408/M2MTransferParticipantForm.xsd \
|
||||||
|
M2MTransferParticipantForm.win1251.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Куда отправлять
|
||||||
|
|
||||||
|
Письмо на **M2MOST@nsd.ru** (служба сервиса МОСТ M2M), приложив заполненную
|
||||||
|
анкету. Текст письма — в `email-draft.txt`.
|
||||||
|
|
||||||
|
После того как НРД внесёт код `MC0413600000` в справочник участников M2M,
|
||||||
|
тестовый робот начнёт отвечать `M2MTransferDecision` вместо `M2MTransferResponse`
|
||||||
|
с ошибкой M2M14 — и заявки в bj-server будут доходить до статуса
|
||||||
|
«Подтверждена/Отклонена» по решению контрагента, а не «Отклонена (M2M14)».
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
Кому: M2MOST@nsd.ru
|
||||||
|
Тема: Регистрация участника сервиса M2M (тестовый контур TEST3, код MC0413600000)
|
||||||
|
|
||||||
|
Добрый день!
|
||||||
|
|
||||||
|
Просим зарегистрировать нашу организацию в справочнике участников сервиса
|
||||||
|
M2M (МОСТ) для тестового контура TEST3.
|
||||||
|
|
||||||
|
При отправке тестовых запросов M2MTransferRequest роботом возвращается
|
||||||
|
M2MTransferResponse со статусом ERROR и кодом M2M14 «Код ЭДО НРД отправителя
|
||||||
|
отсутствует в справочнике участников M2M». Технически интеграция настроена
|
||||||
|
(Интеграционный шлюз, ГОСТ-криптография, REST-обмен работают), требуется
|
||||||
|
только внесение нашего депозитарного кода в справочник участников M2M.
|
||||||
|
|
||||||
|
Реквизиты для регистрации:
|
||||||
|
- Депозитарный код участника: MC0413600000
|
||||||
|
- Тестовый контур: TEST3 (ГОСТ-криптография)
|
||||||
|
- Период тестирования: 21.05.2026 — 01.09.2026
|
||||||
|
- Полное наименование организации: ЗАПОЛНИТЬ
|
||||||
|
- ИНН: ЗАПОЛНИТЬ
|
||||||
|
|
||||||
|
Заполненная анкета участника (M2MTransferParticipantForm) во вложении.
|
||||||
|
|
||||||
|
После регистрации просим подтвердить — повторим тестовый сценарий с роботом.
|
||||||
|
|
||||||
|
С уважением,
|
||||||
|
ЗАПОЛНИТЬ_ФИО, ЗАПОЛНИТЬ_ДОЛЖНОСТЬ
|
||||||
|
ЗАПОЛНИТЬ_ОРГАНИЗАЦИЯ
|
||||||
|
ЗАПОЛНИТЬ_КОНТАКТ (телефон / e-mail)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251"?>
|
||||||
|
<M2MTransferRequest xmlns="http://nsd.ru/schemas/m2m/request">
|
||||||
|
<Header xmlns="http://nsd.ru/schemas/m2m/request">
|
||||||
|
<GUID xmlns="http://nsd.ru/schemas/m2m/types">b26440be-8a1e-4403-a35e-bc9df0da4a33</GUID>
|
||||||
|
<CreationTimestamp xmlns="http://nsd.ru/schemas/m2m/types">2026-06-18T13:13:50(МСК)</CreationTimestamp>
|
||||||
|
<SenderCode xmlns="http://nsd.ru/schemas/m2m/types">MC0413600000</SenderCode>
|
||||||
|
<ReceiverCode xmlns="http://nsd.ru/schemas/m2m/types">MC0012500000</ReceiverCode>
|
||||||
|
<CostInfo xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Yes xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Code xmlns="http://nsd.ru/schemas/m2m/types">MC0413600000</Code>
|
||||||
|
</Yes>
|
||||||
|
</CostInfo>
|
||||||
|
</Header>
|
||||||
|
<Data xmlns="http://nsd.ru/schemas/m2m/request">
|
||||||
|
<IsM2M xmlns="http://nsd.ru/schemas/m2m/types">true</IsM2M>
|
||||||
|
<InvestorInformation xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<LastName xmlns="http://nsd.ru/schemas/m2m/types">Петров</LastName>
|
||||||
|
<FirstName xmlns="http://nsd.ru/schemas/m2m/types">Пётр</FirstName>
|
||||||
|
<MiddleName xmlns="http://nsd.ru/schemas/m2m/types">Петрович</MiddleName>
|
||||||
|
<IdentityDocument xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<DocumentType xmlns="http://nsd.ru/schemas/m2m/types">21</DocumentType>
|
||||||
|
<DocumentSeries xmlns="http://nsd.ru/schemas/m2m/types">2001</DocumentSeries>
|
||||||
|
<DocumentNumber xmlns="http://nsd.ru/schemas/m2m/types">111111</DocumentNumber>
|
||||||
|
</IdentityDocument>
|
||||||
|
</InvestorInformation>
|
||||||
|
<TransferringDepository xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702165310</INN>
|
||||||
|
</TransferringDepository>
|
||||||
|
<ReceivingDepository xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<INN xmlns="http://nsd.ru/schemas/m2m/types">7722061076</INN>
|
||||||
|
</ReceivingDepository>
|
||||||
|
<TransferredSecurities xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Security xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618RGVNK</ReferenceId>
|
||||||
|
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU0007661625</SecurityCode>
|
||||||
|
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU0007661625</ISIN>
|
||||||
|
</SecurityDetails>
|
||||||
|
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
|
||||||
|
</Quantity>
|
||||||
|
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
|
||||||
|
</SettlementRequisites>
|
||||||
|
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
|
||||||
|
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
|
||||||
|
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
|
||||||
|
</SettlementLocation>
|
||||||
|
</SettlementAccount>
|
||||||
|
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
|
||||||
|
</Security>
|
||||||
|
<Security xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618G5DW6</ReferenceId>
|
||||||
|
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JP5V6</SecurityCode>
|
||||||
|
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JP5V6</ISIN>
|
||||||
|
</SecurityDetails>
|
||||||
|
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
|
||||||
|
</Quantity>
|
||||||
|
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
|
||||||
|
</SettlementRequisites>
|
||||||
|
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
|
||||||
|
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
|
||||||
|
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
|
||||||
|
</SettlementLocation>
|
||||||
|
</SettlementAccount>
|
||||||
|
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
|
||||||
|
</Security>
|
||||||
|
<Security xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618CTDHY</ReferenceId>
|
||||||
|
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPKH7</SecurityCode>
|
||||||
|
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPKH7</ISIN>
|
||||||
|
</SecurityDetails>
|
||||||
|
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
|
||||||
|
</Quantity>
|
||||||
|
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
|
||||||
|
</SettlementRequisites>
|
||||||
|
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
|
||||||
|
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
|
||||||
|
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
|
||||||
|
</SettlementLocation>
|
||||||
|
</SettlementAccount>
|
||||||
|
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
|
||||||
|
</Security>
|
||||||
|
<Security xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ReferenceId xmlns="http://nsd.ru/schemas/m2m/types">M2M20260618HQZ1Q</ReferenceId>
|
||||||
|
<SecurityCode xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPGP8</SecurityCode>
|
||||||
|
<SecurityDetails xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<ISIN xmlns="http://nsd.ru/schemas/m2m/types">RU000A0JPGP8</ISIN>
|
||||||
|
</SecurityDetails>
|
||||||
|
<Quantity xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<Whole xmlns="http://nsd.ru/schemas/m2m/types">1</Whole>
|
||||||
|
</Quantity>
|
||||||
|
<SettlementAccount xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<SettlementRequisites xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<INN xmlns="http://nsd.ru/schemas/m2m/types">7702070139</INN>
|
||||||
|
</SettlementRequisites>
|
||||||
|
<SettlementLocation xmlns="http://nsd.ru/schemas/m2m/types">
|
||||||
|
<DeponentCode xmlns="http://nsd.ru/schemas/m2m/types">DP100200</DeponentCode>
|
||||||
|
<AccountId xmlns="http://nsd.ru/schemas/m2m/types">31MC0010000000A01</AccountId>
|
||||||
|
<SectionId xmlns="http://nsd.ru/schemas/m2m/types">A001</SectionId>
|
||||||
|
</SettlementLocation>
|
||||||
|
</SettlementAccount>
|
||||||
|
<IsolationStatus xmlns="http://nsd.ru/schemas/m2m/types">SGDN</IsolationStatus>
|
||||||
|
</Security>
|
||||||
|
</TransferredSecurities>
|
||||||
|
</Data>
|
||||||
|
</M2MTransferRequest>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="windows-1251" standalone="yes"?>
|
||||||
|
<M2MTransferResponse xmlns:ns2="http://nsd.ru/schemas/m2m/types" xmlns:ns3="http://nsd.ru/schemas/m2m/response">
|
||||||
|
<ns3:GUID>00000000-0000-0000-0000-000000000000</ns3:GUID>
|
||||||
|
<ns3:StatusCode>ERROR</ns3:StatusCode>
|
||||||
|
<ns3:Response>
|
||||||
|
<ns2:Code>M2M14</ns2:Code>
|
||||||
|
<ns2:Text>Код ЭДО НРД отправителя сообщения отсутствует в справочнике участников M2M</ns2:Text>
|
||||||
|
</ns3:Response>
|
||||||
|
</M2MTransferResponse>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
ПАКЕТ ДЛЯ ТЕХПОДДЕРЖКИ НРД (сервис MOEX МОСТ / M2M)
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
Цель: показать НРД, ЧТО мы отправляем роботу и КАКОЙ ответ получаем, чтобы
|
||||||
|
поддержка однозначно поняла суть обращения и подтвердила/выполнила регистрацию
|
||||||
|
нашего кода в справочнике участников M2M тестового контура.
|
||||||
|
|
||||||
|
НАШИ РЕКВИЗИТЫ
|
||||||
|
-------------
|
||||||
|
Депозитарный код (= Код ЭДО НРД отправителя): MC0413600000
|
||||||
|
Тестовый контур: TEST3 (ГОСТ-криптография)
|
||||||
|
Канал ИШ: TEST3MC0413600000
|
||||||
|
Период тестирования: 21.05.2026 — 01.09.2026
|
||||||
|
|
||||||
|
ЧТО МЫ ОТПРАВЛЯЕМ → файл 01_outgoing_M2MTransferRequest.xml
|
||||||
|
--------------------------------------------------------------
|
||||||
|
Тип документа: M2MTransferRequest (#M2MTR)
|
||||||
|
GUID запроса: b26440be-8a1e-4403-a35e-bc9df0da4a33
|
||||||
|
CreationTimestamp: 2026-06-18T13:13:50(МСК)
|
||||||
|
SenderCode: MC0413600000 (наш код ЭДО НРД)
|
||||||
|
ReceiverCode: MC0012500000 (тестовый робот НРД)
|
||||||
|
Сценарий робота: 2001 (DocumentSeries), «принять все бумаги»
|
||||||
|
Бумаг в пакете: 4
|
||||||
|
Пакет подписан Интеграционным шлюзом (ИШ) сертификатом УЦ МБ.
|
||||||
|
|
||||||
|
ЧТО ОТВЕЧАЕТ НРД → файл 02_incoming_M2MTransferResponse.xml
|
||||||
|
--------------------------------------------------------------
|
||||||
|
Тип документа: M2MTransferResponse (#M2MER), подпись НРД — VALID
|
||||||
|
GUID: 00000000-0000-0000-0000-000000000000 (нулевой)
|
||||||
|
StatusCode: ERROR
|
||||||
|
Код: M2M14
|
||||||
|
Текст: «Код ЭДО НРД отправителя сообщения отсутствует в
|
||||||
|
справочнике участников M2M»
|
||||||
|
|
||||||
|
СУТЬ ОБРАЩЕНИЯ И ВОПРОС К НРД
|
||||||
|
----------------------------
|
||||||
|
Технически обмен работает: запрос доходит до сервиса МОСТ, НРД его принимает,
|
||||||
|
проверяет нашу подпись и отвечает. Отказ — на уровне справочника участников:
|
||||||
|
наш код ЭДО MC0413600000 в справочнике участников M2M (тестовый контур) пока
|
||||||
|
отсутствует.
|
||||||
|
|
||||||
|
Просим:
|
||||||
|
1) подтвердить, что отправителем M2M для нашего контура должен выступать
|
||||||
|
именно депозитарный код MC0413600000 (либо сообщить корректный код);
|
||||||
|
2) зарегистрировать наш код в справочнике участников M2M тестового контура,
|
||||||
|
чтобы робот (MC0012500000) распознавал отправителя и возвращал
|
||||||
|
M2MTransferDecision вместо ошибки M2M14.
|
||||||
|
|
||||||
|
Для поиска нашего запроса на стороне НРД: GUID b26440be-8a1e-4403-a35e-bc9df0da4a33
|
||||||
|
(ответ M2M14 приходит с нулевым GUID, поэтому указываем GUID исходного запроса).
|
||||||
|
|
||||||
|
ВЛОЖЕНИЯ
|
||||||
|
--------
|
||||||
|
01_outgoing_M2MTransferRequest.xml — наш запрос (то, что мы отправляем)
|
||||||
|
02_incoming_M2MTransferResponse.xml — ответ НРД (M2M14)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
Кому: M2MOST@nsd.ru
|
||||||
|
Копия: soed@nsd.ru
|
||||||
|
Тема: M2M Автотестирование (МОСТ): ошибка M2M14 — регистрация кода MC0413600000 в справочнике участников
|
||||||
|
|
||||||
|
Добрый день!
|
||||||
|
|
||||||
|
Проводим автотестирование сервиса MOEX МОСТ (перевод M2M) с роботом в тестовом
|
||||||
|
контуре TEST3. Технически обмен настроен и работает: запрос доходит до сервиса,
|
||||||
|
НРД проверяет подпись и отвечает. Однако робот возвращает отказ:
|
||||||
|
|
||||||
|
StatusCode: ERROR
|
||||||
|
Код: M2M14
|
||||||
|
Текст: «Код ЭДО НРД отправителя сообщения отсутствует в справочнике
|
||||||
|
участников M2M»
|
||||||
|
|
||||||
|
Наши реквизиты:
|
||||||
|
- Депозитарный код (Код ЭДО НРД отправителя): MC0413600000
|
||||||
|
- Тестовый контур: TEST3, ГОСТ-криптография
|
||||||
|
- Период тестирования: 21.05.2026 — 01.09.2026
|
||||||
|
- GUID нашего запроса для поиска на вашей стороне:
|
||||||
|
b26440be-8a1e-4403-a35e-bc9df0da4a33
|
||||||
|
(ответ M2M14 приходит с нулевым GUID, поэтому указываем GUID запроса)
|
||||||
|
|
||||||
|
Во вложении — фактический обмен, чтобы предметно видеть, что мы отправляем и
|
||||||
|
что получаем в ответ:
|
||||||
|
1) 01_outgoing_M2MTransferRequest.xml — наш запрос роботу (MC0012500000),
|
||||||
|
SenderCode=MC0413600000, сценарий 2001;
|
||||||
|
2) 02_incoming_M2MTransferResponse.xml — ваш ответ с кодом M2M14.
|
||||||
|
|
||||||
|
Просим:
|
||||||
|
1) подтвердить, что отправителем M2M для нашего контура должен выступать
|
||||||
|
именно депозитарный код MC0413600000 (либо сообщить корректный код);
|
||||||
|
2) зарегистрировать наш код в справочнике участников M2M тестового контура,
|
||||||
|
чтобы робот распознавал отправителя и возвращал M2MTransferDecision
|
||||||
|
вместо ошибки M2M14.
|
||||||
|
|
||||||
|
Если для этого требуется уточнить/дополнить онлайн-заявку на участие в
|
||||||
|
тестировании систем НРД (система МОСТ, тип «M2M Автотестирование») — подскажите,
|
||||||
|
пожалуйста, что именно поправить.
|
||||||
|
|
||||||
|
С уважением,
|
||||||
|
ЗАПОЛНИТЬ_ФИО, ЗАПОЛНИТЬ_ДОЛЖНОСТЬ
|
||||||
|
ЗАПОЛНИТЬ_ОРГАНИЗАЦИЯ
|
||||||
|
ЗАПОЛНИТЬ_КОНТАКТ (телефон / e-mail)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bridge-and-Join-s — Crypto sidecar (Java + Валидата Клиент L)
|
||||||
|
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||||
|
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
|
||||||
Vendored
+62
@@ -0,0 +1,62 @@
|
|||||||
|
# Дистрибутив Интеграционного шлюза НРД (ИШ)
|
||||||
|
|
||||||
|
**Скачано с сайта НРД** (`https://www.nsd.ru/workflow/system/programs/web-service/`) 14.05.2026.
|
||||||
|
Через git не коммитим — файлы большие, ставятся отдельно.
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
|
||||||
|
| Файл | Размер | Описание |
|
||||||
|
|---|---:|---|
|
||||||
|
| `igate_100.0-765_amd64.deb` | 117 МБ | Дистрибутив ИШ для **Astra-Linux** (.deb пакет) |
|
||||||
|
| `igate_95.0-716_amd64.SGN` | 491 байт | Электронная подпись к дистрибутиву ИШ |
|
||||||
|
|
||||||
|
## Где скачать заново
|
||||||
|
|
||||||
|
- ИШ Linux: `https://old.nsd.ru/upload/docs/edo/po/igate_100.0-765_amd64.deb`
|
||||||
|
- ИШ Windows (рус): `https://old.nsd.ru/upload/docs/edo/po/igate-ru-100.0.0.764.zip`
|
||||||
|
- ИШ Windows (eng): `https://old.nsd.ru/upload/docs/edo/po/igate-en-100.0.0.764.zip`
|
||||||
|
- Все версии: `https://www.nsd.ru/workflow/system/programs/web-service/`
|
||||||
|
|
||||||
|
## Что ещё нужно (НЕ в этой папке)
|
||||||
|
|
||||||
|
### 1. СКЗИ «Валидата CSP» + АПК «Валидата Клиент L»
|
||||||
|
**Не выложено публично** — даётся НРД по запросу:
|
||||||
|
- Email НРД: `soed@nsd.ru`
|
||||||
|
- Email Московской Биржи: `pki@moex.com`
|
||||||
|
|
||||||
|
В письме указать: «Запрос дистрибутива СКЗИ Валидата CSP для Linux + временной лицензии для подключения к ЭДО НРД в рамках сервиса MOEX МОСТ M2M».
|
||||||
|
|
||||||
|
### 2. Сертификат подписи
|
||||||
|
Только от **УЦ Московской Биржи** (`https://ca.moex.com/`). Получает организация-депонент.
|
||||||
|
|
||||||
|
### 3. PostgreSQL
|
||||||
|
Если используется REST API ИШ — **обязательно** PostgreSQL (SQLite не подходит для API).
|
||||||
|
У нас PostgreSQL 16 уже работает в podman-контейнере → готово.
|
||||||
|
|
||||||
|
## Поддерживаемые ОС (из руководства по установке)
|
||||||
|
|
||||||
|
- **Astra Linux Special Edition x64** редакций 1.6, 1.7, исполнение 1 (РУСБ.10015-01/16)
|
||||||
|
- **Windows 10 / Server 2016/2019**
|
||||||
|
|
||||||
|
**РЕД ОС в списке не упомянута.** Варианты для нашей инфраструктуры:
|
||||||
|
1. Поднять отдельную Astra Linux ВМ для ИШ (рекомендуется)
|
||||||
|
2. Попробовать `dpkg -i` на РЕД ОС с `alien` (рискованно)
|
||||||
|
3. Использовать Debian/Ubuntu ВМ (близко к Astra, возможно сработает)
|
||||||
|
4. Контейнер с базовым образом `astralinux/astra-linux-edu:1.7.5` (если такой есть)
|
||||||
|
5. Запросить у НРД RPM-версию
|
||||||
|
|
||||||
|
## Контакты НРД
|
||||||
|
|
||||||
|
- Email по СЭД и дистрибутивам: `soed@nsd.ru`
|
||||||
|
- Email по форматам M2M: `M2MOST@nsd.ru`
|
||||||
|
- Сайт ИШ: `https://www.nsd.ru/workflow/system/programs/web-service/`
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
Все PDF лежат в `../../DOC/`:
|
||||||
|
- `ruk_install_ish_2025_11_10.pdf` — Руководство по установке ИШ (от 10.11.2025)
|
||||||
|
- `ruk_pol_ish.pdf` — Руководство пользователя ИШ
|
||||||
|
- `QA_ish.pdf` — Часто задаваемые вопросы
|
||||||
|
- `test-case_ish.pdf` — Тест-кейсы для проверки работоспособности ИШ
|
||||||
|
- `instr_int_sh_01072025.pdf` — Инструкция по созданию заявки на тестирование
|
||||||
|
- `web_service_nrd_standard_soap_rest.pdf` — Технические рекомендации Web-сервиса ONYX
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
module git.zetit.ru/zuevav/Bridge-and-Join-s
|
module git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgx/v5 v5.7.4
|
github.com/jackc/pgx/v5 v5.7.4
|
||||||
github.com/miekg/pkcs11 v1.1.2
|
golang.org/x/text v0.34.0
|
||||||
golang.org/x/text v0.22.0
|
google.golang.org/grpc v1.81.1
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/crypto v0.35.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -9,8 +21,6 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
|||||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/miekg/pkcs11 v1.1.2 h1:/VxmeAX5qU6Q3EwafypogwWbYryHFmF2RpkJmw3m4MQ=
|
|
||||||
github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -18,12 +28,36 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
+219
-273
@@ -1,351 +1,297 @@
|
|||||||
// Package cryptocli — Go-клиент к СКЗИ через PKCS#11 (КриптоПро CSP,
|
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
|
||||||
// Рутокен ЭЦП 2.0, ViPNet, Валидата). Загружает указанный .so модуль,
|
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
|
||||||
// открывает сессию, перечисляет токены, читает сертификаты и
|
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
|
||||||
// предоставляет операции Sign/Verify.
|
// Клиент L».
|
||||||
//
|
//
|
||||||
// На ВМ без установленного СКЗИ модуль не загрузится — клиент
|
// На дев-стендах без поднятого сайдкара (стандартный путь
|
||||||
// возвращает понятную ошибку и помечает себя как «провайдер
|
// /run/bj/crypto.sock не существует) клиент возвращает понятную
|
||||||
// недоступен». В этом случае lk-gateway переходит в режим stub:
|
// ошибку «провайдер недоступен» и lk-gateway работает в stub-режиме:
|
||||||
// XMLDSig-подписи проходят без реальной проверки (только для
|
// XMLDSig-подписи проходят без проверки (только для демо).
|
||||||
// дев-стендов и демо).
|
|
||||||
package cryptocli
|
package cryptocli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/asn1"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/miekg/pkcs11"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb"
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Provider — тип СКЗИ-провайдера.
|
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
|
||||||
|
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
|
||||||
type Provider string
|
type Provider string
|
||||||
|
|
||||||
// Известные провайдеры.
|
|
||||||
const (
|
const (
|
||||||
ProviderStub Provider = "stub"
|
ProviderStub Provider = "stub"
|
||||||
ProviderCryptoPro Provider = "cryptopro"
|
ProviderValidata Provider = "validata"
|
||||||
ProviderRutoken Provider = "rutoken"
|
|
||||||
ProviderValidata Provider = "validata"
|
|
||||||
ProviderVipNet Provider = "vipnet"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultModulePath возвращает дефолтный путь до PKCS#11 .so модуля
|
// DefaultModulePath сохранена для обратной совместимости с UI;
|
||||||
// для указанного провайдера. Используется в /admin/setup как placeholder.
|
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
|
||||||
|
// crypto-service. Возвращаемое значение информативное.
|
||||||
func DefaultModulePath(p Provider) string {
|
func DefaultModulePath(p Provider) string {
|
||||||
switch p {
|
if p == ProviderValidata {
|
||||||
case ProviderCryptoPro:
|
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
|
||||||
return "/opt/cprocsp/lib/amd64/libcppkcs11.so"
|
|
||||||
case ProviderRutoken:
|
|
||||||
return "/usr/lib64/librtpkcs11ecp.so"
|
|
||||||
case ProviderValidata:
|
|
||||||
return "/opt/validata/lib/libvalidata-pkcs11.so"
|
|
||||||
case ProviderVipNet:
|
|
||||||
return "/opt/itcs/lib/libvipnet-pkcs11.so"
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config — конфигурация клиента.
|
// Config — конфигурация клиента.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Provider Provider
|
// SocketPath — путь к UDS-сокету crypto-service.
|
||||||
ModulePath string // путь до PKCS#11 .so модуля (libcppkcs11.so и т.п.)
|
// Пустое значение = /run/bj/crypto.sock.
|
||||||
PIN string // PIN для сессии (логин на токен)
|
SocketPath string
|
||||||
SlotID uint // 0 = первый доступный
|
// Provider — желаемый провайдер; информативно (см. выше).
|
||||||
Timeout time.Duration
|
Provider Provider
|
||||||
|
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
|
||||||
|
ModulePath string
|
||||||
|
// Timeout — таймаут одной gRPC-операции.
|
||||||
|
Timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client — PKCS#11-клиент к СКЗИ.
|
// Client — gRPC-клиент к crypto-service.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ctx *pkcs11.Ctx
|
conn *grpc.ClientConn
|
||||||
opened bool
|
api cryptopb.CryptoServiceClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// New создаёт клиент. Сам Initialize() здесь не вызывается — это
|
// New создаёт клиент. Само соединение поднимается лениво при первом
|
||||||
// делает Connect или явный Ping (Health-check на admin-странице).
|
// вызове.
|
||||||
func New(cfg Config) *Client {
|
func New(cfg Config) *Client {
|
||||||
if cfg.Timeout == 0 {
|
if cfg.Timeout == 0 {
|
||||||
cfg.Timeout = 5 * time.Second
|
cfg.Timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
if cfg.SocketPath == "" {
|
||||||
|
cfg.SocketPath = "/run/bj/crypto.sock"
|
||||||
|
}
|
||||||
return &Client{cfg: cfg}
|
return &Client{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health — лёгкая проверка готовности. Шаги:
|
// Close закрывает gRPC-соединение.
|
||||||
// 1. Сам файл .so существует?
|
func (c *Client) Close() error {
|
||||||
// 2. Initialize модуля?
|
|
||||||
// 3. Есть ли хотя бы один доступный слот с токеном?
|
|
||||||
// 4. Информация о токене (label, manufacturer, serial).
|
|
||||||
func (c *Client) Health(_ context.Context) (HealthInfo, error) {
|
|
||||||
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
|
|
||||||
return HealthInfo{Provider: string(ProviderStub),
|
|
||||||
Message: "Провайдер stub — реальная криптография не подключена."}, nil
|
|
||||||
}
|
|
||||||
if c.cfg.ModulePath == "" {
|
|
||||||
return HealthInfo{}, errors.New("cryptocli: ModulePath не задан")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(c.cfg.ModulePath); err != nil {
|
|
||||||
return HealthInfo{}, fmt.Errorf("cryptocli: модуль %s не найден: %w", c.cfg.ModulePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if err := c.ensureInitLocked(); err != nil {
|
if c.conn != nil {
|
||||||
return HealthInfo{}, err
|
err := c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.api = nil
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
info, err := c.ctx.GetInfo()
|
|
||||||
if err != nil {
|
|
||||||
return HealthInfo{}, fmt.Errorf("cryptocli: GetInfo: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slots, err := c.ctx.GetSlotList(true) // только токены
|
|
||||||
if err != nil {
|
|
||||||
return HealthInfo{}, fmt.Errorf("cryptocli: GetSlotList: %w", err)
|
|
||||||
}
|
|
||||||
h := HealthInfo{
|
|
||||||
Provider: string(c.cfg.Provider),
|
|
||||||
ModulePath: c.cfg.ModulePath,
|
|
||||||
CryptokiVersion: fmt.Sprintf("%d.%d", info.CryptokiVersion.Major, info.CryptokiVersion.Minor),
|
|
||||||
ManufacturerID: info.ManufacturerID,
|
|
||||||
LibraryVersion: fmt.Sprintf("%d.%d", info.LibraryVersion.Major, info.LibraryVersion.Minor),
|
|
||||||
}
|
|
||||||
for _, slot := range slots {
|
|
||||||
tok, err := c.ctx.GetTokenInfo(slot)
|
|
||||||
if err != nil {
|
|
||||||
h.Tokens = append(h.Tokens, TokenInfo{SlotID: slot, Error: err.Error()})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.Tokens = append(h.Tokens, TokenInfo{
|
|
||||||
SlotID: slot,
|
|
||||||
Label: tok.Label,
|
|
||||||
Manufacturer: tok.ManufacturerID,
|
|
||||||
Model: tok.Model,
|
|
||||||
SerialNumber: tok.SerialNumber,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(h.Tokens) == 0 {
|
|
||||||
h.Message = "Модуль PKCS#11 загружен, но активных токенов не найдено. Подключите Рутокен или установите ключевой контейнер."
|
|
||||||
} else {
|
|
||||||
h.Message = fmt.Sprintf("Доступно токенов: %d. Криптография готова к работе.", len(h.Tokens))
|
|
||||||
}
|
|
||||||
return h, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Certificate — DER-сертификат с распарсенными атрибутами для UI.
|
// ensureConn устанавливает gRPC-канал к UDS-сокету при первом
|
||||||
|
// использовании. Используем встроенный в grpc-go резолвер unix:.
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.api != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := "unix:" + c.cfg.SocketPath
|
||||||
|
conn, err := grpc.NewClient(target,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cryptocli: dial %s: %w", c.cfg.SocketPath, err)
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.api = cryptopb.NewCryptoServiceClient(conn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health — gRPC Health-вызов. Если сокет недоступен (сайдкар не
|
||||||
|
// поднят) — вернёт «провайдер недоступен» с явной ошибкой.
|
||||||
|
func (c *Client) Health(ctx context.Context) (HealthInfo, error) {
|
||||||
|
if c.cfg.Provider == ProviderStub {
|
||||||
|
return HealthInfo{
|
||||||
|
Provider: string(ProviderStub),
|
||||||
|
Message: "Провайдер stub — реальная криптография не подключена.",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return HealthInfo{}, err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := c.api.Health(cctx, &cryptopb.HealthRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return HealthInfo{}, fmt.Errorf("cryptocli: Health: %w", err)
|
||||||
|
}
|
||||||
|
return HealthInfo{
|
||||||
|
Provider: resp.GetProvider(),
|
||||||
|
Message: resp.GetVersion(),
|
||||||
|
ModulePath: c.cfg.SocketPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificate — упрощённое описание сертификата (для совместимости с
|
||||||
|
// прежним UI). В gRPC-режиме crypto-service возвращает информацию о
|
||||||
|
// подписанте через VerifyResponse; полный список сертификатов
|
||||||
|
// (FindCertificates) пока не реализован — для UI возвращаем пустой
|
||||||
|
// список.
|
||||||
type Certificate struct {
|
type Certificate struct {
|
||||||
SlotID uint
|
SlotID uint
|
||||||
TokenLabel string
|
TokenLabel string
|
||||||
Label string // CKA_LABEL (объект на токене)
|
Label string
|
||||||
SubjectCN string
|
SubjectCN string
|
||||||
IssuerCN string
|
IssuerCN string
|
||||||
Serial string
|
Serial string
|
||||||
NotBefore time.Time
|
NotBefore time.Time
|
||||||
NotAfter time.Time
|
NotAfter time.Time
|
||||||
INN string // если есть в OID 1.2.643.3.131.1.1
|
INN string
|
||||||
DER []byte
|
DER []byte
|
||||||
HasPrivateKey bool // найден ли парный приватный ключ на токене
|
HasPrivateKey bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindCertificates перечисляет сертификаты на всех подключенных
|
// FindCertificates пока возвращает пустой список — список ключей
|
||||||
// токенах. Не требует Login для публичных сертификатов; для контейнеров
|
// управляется самой Валидатой через её собственный справочник (zcs),
|
||||||
// CryptoPro/Rutoken достаточно открыть сессию (CKU_USER не выполняется).
|
// а bj-server о конкретных сертификатах узнаёт по результатам
|
||||||
|
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
|
||||||
|
// gRPC-методом ListCertificates если потребуется.
|
||||||
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
|
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
|
||||||
if c.cfg.Provider == "" || c.cfg.Provider == ProviderStub {
|
return nil, nil
|
||||||
return nil, errors.New("cryptocli: провайдер stub — нет реальных сертификатов")
|
}
|
||||||
}
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
if err := c.ensureInitLocked(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
slots, err := c.ctx.GetSlotList(true)
|
// Shutdown — отправляет команду «выйти с exit-code 2» сайдкару.
|
||||||
|
// systemd с Restart=on-failure поднимет его обратно. Возвращает
|
||||||
|
// ошибку если соединение разорвалось (что нормально и означает что
|
||||||
|
// сайдкар уже завершается).
|
||||||
|
func (c *Client) Shutdown(ctx context.Context) error {
|
||||||
|
if c.cfg.Provider == ProviderStub {
|
||||||
|
return errors.New("provider=stub: некуда отправлять Shutdown")
|
||||||
|
}
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
_, err := c.api.Shutdown(cctx, &cryptopb.ShutdownRequest{})
|
||||||
|
// Закрываем соединение, чтобы не держать ссылку на падающий процесс.
|
||||||
|
_ = c.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateResult — результат переключения профиля Валидаты.
|
||||||
|
type ActivateResult struct {
|
||||||
|
OK bool
|
||||||
|
Provider string
|
||||||
|
Profile string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate переключает crypto-service на указанный профиль pki1.conf.
|
||||||
|
// Пустая строка = minimal mode (без профиля).
|
||||||
|
func (c *Client) Activate(ctx context.Context, profile string) (ActivateResult, error) {
|
||||||
|
if c.cfg.Provider == ProviderStub {
|
||||||
|
return ActivateResult{
|
||||||
|
OK: false,
|
||||||
|
Provider: string(ProviderStub),
|
||||||
|
Message: "Провайдер stub — переключение профиля недоступно.",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return ActivateResult{}, err
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := c.api.Activate(cctx, &cryptopb.ActivateRequest{Profile: profile})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cryptocli: GetSlotList: %w", err)
|
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %w", err)
|
||||||
}
|
}
|
||||||
|
return ActivateResult{
|
||||||
var out []Certificate
|
OK: resp.GetOk(),
|
||||||
for _, slot := range slots {
|
Provider: resp.GetProvider(),
|
||||||
tokInfo, _ := c.ctx.GetTokenInfo(slot)
|
Profile: resp.GetProfile(),
|
||||||
certs, err := c.listSlotCertificates(slot, tokInfo.Label)
|
Message: resp.GetMessage(),
|
||||||
if err != nil {
|
|
||||||
// продолжаем — возможно один слот занят, другие доступны
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, certs...)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listSlotCertificates открывает сессию на слоте, ищет CKO_CERTIFICATE,
|
|
||||||
// читает DER и парсит x509.
|
|
||||||
func (c *Client) listSlotCertificates(slot uint, tokenLabel string) ([]Certificate, error) {
|
|
||||||
sess, err := c.ctx.OpenSession(slot, pkcs11.CKF_SERIAL_SESSION)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("OpenSession: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = c.ctx.CloseSession(sess) }()
|
|
||||||
|
|
||||||
template := []*pkcs11.Attribute{
|
|
||||||
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_CERTIFICATE),
|
|
||||||
}
|
|
||||||
if err := c.ctx.FindObjectsInit(sess, template); err != nil {
|
|
||||||
return nil, fmt.Errorf("FindObjectsInit: %w", err)
|
|
||||||
}
|
|
||||||
handles, _, err := c.ctx.FindObjects(sess, 32)
|
|
||||||
_ = c.ctx.FindObjectsFinal(sess)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("FindObjects: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]Certificate, 0, len(handles))
|
|
||||||
for _, h := range handles {
|
|
||||||
attrs, err := c.ctx.GetAttributeValue(sess, h, []*pkcs11.Attribute{
|
|
||||||
pkcs11.NewAttribute(pkcs11.CKA_VALUE, nil),
|
|
||||||
pkcs11.NewAttribute(pkcs11.CKA_LABEL, nil),
|
|
||||||
pkcs11.NewAttribute(pkcs11.CKA_ID, nil),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
cert := Certificate{
|
|
||||||
SlotID: slot,
|
|
||||||
TokenLabel: tokenLabel,
|
|
||||||
}
|
|
||||||
var idAttr []byte
|
|
||||||
for _, a := range attrs {
|
|
||||||
switch a.Type {
|
|
||||||
case pkcs11.CKA_VALUE:
|
|
||||||
cert.DER = a.Value
|
|
||||||
case pkcs11.CKA_LABEL:
|
|
||||||
cert.Label = string(a.Value)
|
|
||||||
case pkcs11.CKA_ID:
|
|
||||||
idAttr = a.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Парсим X.509 (ГОСТ-сертификаты тоже парсятся через crypto/x509
|
|
||||||
// — Subject/Issuer/Serial/Validity не зависят от алгоритма подписи).
|
|
||||||
parsed, err := x509.ParseCertificate(cert.DER)
|
|
||||||
if err == nil {
|
|
||||||
cert.SubjectCN = parsed.Subject.CommonName
|
|
||||||
cert.IssuerCN = parsed.Issuer.CommonName
|
|
||||||
cert.Serial = parsed.SerialNumber.Text(16)
|
|
||||||
cert.NotBefore = parsed.NotBefore
|
|
||||||
cert.NotAfter = parsed.NotAfter
|
|
||||||
// ИНН в OID 1.2.643.3.131.1.1 — извлекаем из Subject.
|
|
||||||
cert.INN = extractINN(parsed)
|
|
||||||
}
|
|
||||||
// Проверим есть ли парный приватный ключ.
|
|
||||||
if len(idAttr) > 0 {
|
|
||||||
cert.HasPrivateKey = c.hasPrivateKey(sess, idAttr)
|
|
||||||
}
|
|
||||||
out = append(out, cert)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasPrivateKey ищет CKO_PRIVATE_KEY с тем же CKA_ID что и сертификат.
|
|
||||||
func (c *Client) hasPrivateKey(sess pkcs11.SessionHandle, id []byte) bool {
|
|
||||||
tmpl := []*pkcs11.Attribute{
|
|
||||||
pkcs11.NewAttribute(pkcs11.CKA_CLASS, pkcs11.CKO_PRIVATE_KEY),
|
|
||||||
pkcs11.NewAttribute(pkcs11.CKA_ID, id),
|
|
||||||
}
|
|
||||||
if err := c.ctx.FindObjectsInit(sess, tmpl); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer func() { _ = c.ctx.FindObjectsFinal(sess) }()
|
|
||||||
handles, _, err := c.ctx.FindObjects(sess, 1)
|
|
||||||
return err == nil && len(handles) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractINN ищет ИНН в Subject сертификата по OID НРД 1.2.643.3.131.1.1.
|
|
||||||
func extractINN(c *x509.Certificate) string {
|
|
||||||
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
|
||||||
for _, name := range c.Subject.Names {
|
|
||||||
if name.Type.Equal(innOID) {
|
|
||||||
if s, ok := name.Value.(string); ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyXMLDSig — заглушка для интерфейса m2mcore.CryptoVerifier.
|
|
||||||
// Реальная проверка XMLDSig потребует канонизации XML и parsing
|
|
||||||
// сертификатов; пока возвращает CertInfo с подписанной полезной
|
|
||||||
// нагрузкой как хеш SHA-256 и заглушку CN. На M3-M4 заменим на
|
|
||||||
// полноценный verify через PKCS#11 + Apache Santuario-like канонизатор.
|
|
||||||
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
|
|
||||||
if _, err := c.Health(ctx); err != nil {
|
|
||||||
return m2mcore.CertInfo{}, err
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256(payload)
|
|
||||||
return m2mcore.CertInfo{
|
|
||||||
SignerCN: "stub-verifier",
|
|
||||||
SignerINN: "",
|
|
||||||
Serial: hex.EncodeToString(sum[:8]),
|
|
||||||
NotBefore: time.Now().Add(-365 * 24 * time.Hour),
|
|
||||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close завершает работу PKCS#11 модуля.
|
// VerifyXMLDSig — проксирует в crypto-service.VerifyXMLDSig.
|
||||||
func (c *Client) Close() error {
|
// Реализует m2mcore.CryptoVerifier — поэтому возвращает CertInfo,
|
||||||
c.mu.Lock()
|
// заполненный из gRPC-ответа.
|
||||||
defer c.mu.Unlock()
|
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
|
||||||
if c.ctx == nil {
|
if c.cfg.Provider == ProviderStub {
|
||||||
return nil
|
return m2mcore.CertInfo{
|
||||||
|
SignerCN: "stub-verifier",
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
_ = c.ctx.Finalize()
|
if err := c.ensureConn(); err != nil {
|
||||||
c.ctx.Destroy()
|
return m2mcore.CertInfo{}, err
|
||||||
c.ctx = nil
|
}
|
||||||
c.opened = false
|
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||||
return nil
|
defer cancel()
|
||||||
|
resp, err := c.api.VerifyXMLDSig(cctx, &cryptopb.VerifyRequest{
|
||||||
|
Payload: payload,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return m2mcore.CertInfo{}, fmt.Errorf("cryptocli: VerifyXMLDSig: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.GetValid() {
|
||||||
|
var msg string
|
||||||
|
if errs := resp.GetErrors(); len(errs) > 0 {
|
||||||
|
msg = errs[0]
|
||||||
|
} else {
|
||||||
|
msg = "подпись недействительна"
|
||||||
|
}
|
||||||
|
return m2mcore.CertInfo{}, errors.New("cryptocli: " + msg)
|
||||||
|
}
|
||||||
|
return m2mcore.CertInfo{
|
||||||
|
SignerCN: resp.GetSignerCn(),
|
||||||
|
SignerINN: resp.GetSignerInn(),
|
||||||
|
Serial: resp.GetSerial(),
|
||||||
|
NotBefore: time.Unix(resp.GetNotBefore(), 0),
|
||||||
|
NotAfter: time.Unix(resp.GetNotAfter(), 0),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureInitLocked инициализирует PKCS#11 модуль если ещё не.
|
// SignXMLDSig — проксирует в crypto-service.SignXMLDSig. Возвращает
|
||||||
// Должен вызываться под c.mu.Lock.
|
// DER-байты CMS detached signature (готовы к включению в XMLDSig-обёртку
|
||||||
func (c *Client) ensureInitLocked() error {
|
// или к самостоятельной отправке как .p7s).
|
||||||
if c.opened {
|
//
|
||||||
return nil
|
// keyAlias — alias ключа из ПСП Валидаты (пустой = ключ по умолчанию
|
||||||
|
// активного профиля). profile — имя профиля в pki1.conf, пустой = тот
|
||||||
|
// что инициализирован.
|
||||||
|
func (c *Client) SignXMLDSig(ctx context.Context, payload []byte, keyAlias, profile string) ([]byte, error) {
|
||||||
|
if c.cfg.Provider == ProviderStub {
|
||||||
|
return nil, errors.New("provider=stub: подпись недоступна")
|
||||||
}
|
}
|
||||||
c.ctx = pkcs11.New(c.cfg.ModulePath)
|
if err := c.ensureConn(); err != nil {
|
||||||
if c.ctx == nil {
|
return nil, err
|
||||||
return fmt.Errorf("cryptocli: не получилось загрузить модуль %s", c.cfg.ModulePath)
|
|
||||||
}
|
}
|
||||||
if err := c.ctx.Initialize(); err != nil {
|
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||||
c.ctx.Destroy()
|
defer cancel()
|
||||||
c.ctx = nil
|
resp, err := c.api.SignXMLDSig(cctx, &cryptopb.SignRequest{
|
||||||
return fmt.Errorf("cryptocli: Initialize: %w", err)
|
Payload: payload,
|
||||||
|
KeyAlias: keyAlias,
|
||||||
|
Profile: profile,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cryptocli: SignXMLDSig: %w", err)
|
||||||
}
|
}
|
||||||
c.opened = true
|
return resp.GetSignedXml(), nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthInfo — что показывает /admin/setup и /admin/status.
|
// HealthInfo — что показывает /admin/setup → СКЗИ.
|
||||||
type HealthInfo struct {
|
type HealthInfo struct {
|
||||||
Provider string
|
Provider string
|
||||||
ModulePath string
|
ModulePath string // в gRPC-режиме — UDS-сокет
|
||||||
CryptokiVersion string
|
CryptokiVersion string // не используется
|
||||||
ManufacturerID string
|
ManufacturerID string // не используется
|
||||||
LibraryVersion string
|
LibraryVersion string // не используется
|
||||||
Tokens []TokenInfo
|
Tokens []TokenInfo
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenInfo — описание подключённого токена/контейнера.
|
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
|
||||||
type TokenInfo struct {
|
type TokenInfo struct {
|
||||||
SlotID uint
|
SlotID uint
|
||||||
Label string
|
Label string
|
||||||
|
|||||||
@@ -8,55 +8,45 @@ import (
|
|||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestStubProviderHealthOK — провайдер stub не лезет в gRPC,
|
||||||
|
// возвращает информативный Health без ошибки.
|
||||||
func TestStubProviderHealthOK(t *testing.T) {
|
func TestStubProviderHealthOK(t *testing.T) {
|
||||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
|
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
|
||||||
|
defer cli.Close()
|
||||||
h, err := cli.Health(context.Background())
|
h, err := cli.Health(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Health: %v", err)
|
t.Fatalf("Health: %v", err)
|
||||||
}
|
}
|
||||||
if h.Provider != string(cryptocli.ProviderStub) {
|
if h.Provider != string(cryptocli.ProviderStub) {
|
||||||
t.Errorf("Provider = %q", h.Provider)
|
t.Errorf("Provider = %q, ожидался stub", h.Provider)
|
||||||
}
|
}
|
||||||
if !strings.Contains(h.Message, "stub") {
|
if !strings.Contains(h.Message, "stub") {
|
||||||
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
|
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModulePathMissing(t *testing.T) {
|
// TestValidataProviderNoSocket — провайдер validata пытается дойти до
|
||||||
|
// сайдкара, но в тестах сокета нет. gRPC-клиент создаётся лениво
|
||||||
|
// (NewClient не возвращает ошибку), а ошибка приходит при первом RPC.
|
||||||
|
func TestValidataProviderNoSocket(t *testing.T) {
|
||||||
cli := cryptocli.New(cryptocli.Config{
|
cli := cryptocli.New(cryptocli.Config{
|
||||||
Provider: cryptocli.ProviderCryptoPro,
|
Provider: cryptocli.ProviderValidata,
|
||||||
ModulePath: "/nonexistent/libcppkcs11.so",
|
SocketPath: "/nonexistent/crypto.sock",
|
||||||
})
|
})
|
||||||
|
defer cli.Close()
|
||||||
_, err := cli.Health(context.Background())
|
_, err := cli.Health(context.Background())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ожидалась ошибка о ненайденном модуле")
|
t.Fatal("ожидалась ошибка о недоступном сокете")
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "не найден") {
|
|
||||||
t.Errorf("неинформативная ошибка: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEmptyModulePath(t *testing.T) {
|
|
||||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderCryptoPro})
|
|
||||||
_, err := cli.Health(context.Background())
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("ожидалась ошибка о пустом ModulePath")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultModulePath — информативный текст для UI.
|
||||||
func TestDefaultModulePath(t *testing.T) {
|
func TestDefaultModulePath(t *testing.T) {
|
||||||
cases := []struct {
|
if cryptocli.DefaultModulePath(cryptocli.ProviderStub) != "" {
|
||||||
p cryptocli.Provider
|
t.Error("DefaultModulePath(stub) должен быть пустым")
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{cryptocli.ProviderCryptoPro, "/opt/cprocsp/lib/amd64/libcppkcs11.so"},
|
|
||||||
{cryptocli.ProviderRutoken, "/usr/lib64/librtpkcs11ecp.so"},
|
|
||||||
{cryptocli.ProviderStub, ""},
|
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
v := cryptocli.DefaultModulePath(cryptocli.ProviderValidata)
|
||||||
got := cryptocli.DefaultModulePath(c.p)
|
if v == "" {
|
||||||
if got != c.want {
|
t.Error("DefaultModulePath(validata) не должен быть пустым")
|
||||||
t.Errorf("DefaultModulePath(%s) = %q, ожидалось %q", c.p, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,694 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc v3.12.4
|
||||||
|
// source: crypto.proto
|
||||||
|
|
||||||
|
package cryptopb
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivateRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Имя профиля в pki1.conf. Пустая строка = minimal mode.
|
||||||
|
Profile string `protobuf:"bytes,1,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateRequest) Reset() {
|
||||||
|
*x = ActivateRequest{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ActivateRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ActivateRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ActivateRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ActivateRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateRequest) GetProfile() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Profile
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivateResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// true если провайдер успешно (пере)инициализирован.
|
||||||
|
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||||
|
// Имя активного провайдера ("validata" / "stub").
|
||||||
|
Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||||
|
// Имя активного профиля (пусто для minimal).
|
||||||
|
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||||
|
// Сообщение о результате (для UI).
|
||||||
|
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) Reset() {
|
||||||
|
*x = ActivateResponse{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ActivateResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ActivateResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ActivateResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) GetOk() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) GetProvider() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Provider
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) GetProfile() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Profile
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActivateResponse) GetMessage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShutdownRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ShutdownRequest) Reset() {
|
||||||
|
*x = ShutdownRequest{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ShutdownRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ShutdownRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ShutdownRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ShutdownRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ShutdownRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShutdownResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// true означает «запрос принят, процесс завершится через ~500ms».
|
||||||
|
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ShutdownResponse) Reset() {
|
||||||
|
*x = ShutdownResponse{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ShutdownResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ShutdownResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ShutdownResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ShutdownResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ShutdownResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ShutdownResponse) GetOk() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Целиком подписанный XML.
|
||||||
|
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||||
|
// Профиль ключей и сертификатов: "guest-gost" | "test3-gost" |
|
||||||
|
// "prod-gost" | "guest-rsa" | ... — определяет хранилище и trust store.
|
||||||
|
Profile string `protobuf:"bytes,2,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyRequest) Reset() {
|
||||||
|
*x = VerifyRequest{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*VerifyRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *VerifyRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use VerifyRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*VerifyRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyRequest) GetPayload() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyRequest) GetProfile() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Profile
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Прошла ли проверка.
|
||||||
|
Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"`
|
||||||
|
// CN из сертификата подписанта.
|
||||||
|
SignerCn string `protobuf:"bytes,2,opt,name=signer_cn,json=signerCn,proto3" json:"signer_cn,omitempty"`
|
||||||
|
// ИНН из сертификата (если присутствует в OID 1.2.643.3.131.1.1).
|
||||||
|
SignerInn string `protobuf:"bytes,3,opt,name=signer_inn,json=signerInn,proto3" json:"signer_inn,omitempty"`
|
||||||
|
// Серийный номер сертификата (hex).
|
||||||
|
Serial string `protobuf:"bytes,4,opt,name=serial,proto3" json:"serial,omitempty"`
|
||||||
|
// Срок действия сертификата (unix epoch, секунды).
|
||||||
|
NotBefore int64 `protobuf:"varint,5,opt,name=not_before,json=notBefore,proto3" json:"not_before,omitempty"`
|
||||||
|
NotAfter int64 `protobuf:"varint,6,opt,name=not_after,json=notAfter,proto3" json:"not_after,omitempty"`
|
||||||
|
// Тексты ошибок проверки (если valid=false).
|
||||||
|
Errors []string `protobuf:"bytes,7,rep,name=errors,proto3" json:"errors,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) Reset() {
|
||||||
|
*x = VerifyResponse{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*VerifyResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use VerifyResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*VerifyResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetValid() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Valid
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetSignerCn() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SignerCn
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetSignerInn() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SignerInn
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetSerial() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Serial
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetNotBefore() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.NotBefore
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetNotAfter() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.NotAfter
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VerifyResponse) GetErrors() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Errors
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Канонизированный XML, который нужно подписать.
|
||||||
|
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||||
|
// Алиас ключа в JCP-keystore.
|
||||||
|
KeyAlias string `protobuf:"bytes,2,opt,name=key_alias,json=keyAlias,proto3" json:"key_alias,omitempty"`
|
||||||
|
// Профиль (тот же что у Verify).
|
||||||
|
Profile string `protobuf:"bytes,3,opt,name=profile,proto3" json:"profile,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignRequest) Reset() {
|
||||||
|
*x = SignRequest{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SignRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *SignRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use SignRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*SignRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignRequest) GetPayload() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignRequest) GetKeyAlias() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.KeyAlias
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignRequest) GetProfile() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Profile
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// Подписанный XML (с детачированной или встроенной подписью —
|
||||||
|
// зависит от профиля).
|
||||||
|
SignedXml []byte `protobuf:"bytes,1,opt,name=signed_xml,json=signedXml,proto3" json:"signed_xml,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignResponse) Reset() {
|
||||||
|
*x = SignResponse{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SignResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *SignResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[7]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use SignResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*SignResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SignResponse) GetSignedXml() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.SignedXml
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthRequest) Reset() {
|
||||||
|
*x = HealthRequest{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HealthRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *HealthRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use HealthRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*HealthRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthResponse struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||||
|
Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"`
|
||||||
|
// Активный провайдер криптографии: "cryptopro" | "validata" | "vipnet" | "stub".
|
||||||
|
Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthResponse) Reset() {
|
||||||
|
*x = HealthResponse{}
|
||||||
|
mi := &file_crypto_proto_msgTypes[9]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthResponse) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*HealthResponse) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *HealthResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_crypto_proto_msgTypes[9]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead.
|
||||||
|
func (*HealthResponse) Descriptor() ([]byte, []int) {
|
||||||
|
return file_crypto_proto_rawDescGZIP(), []int{9}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthResponse) GetOk() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ok
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthResponse) GetVersion() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Version
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *HealthResponse) GetProvider() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Provider
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_crypto_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_crypto_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\fcrypto.proto\x12\x1abridge_and_joins.crypto.v1\"+\n" +
|
||||||
|
"\x0fActivateRequest\x12\x18\n" +
|
||||||
|
"\aprofile\x18\x01 \x01(\tR\aprofile\"r\n" +
|
||||||
|
"\x10ActivateResponse\x12\x0e\n" +
|
||||||
|
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x1a\n" +
|
||||||
|
"\bprovider\x18\x02 \x01(\tR\bprovider\x12\x18\n" +
|
||||||
|
"\aprofile\x18\x03 \x01(\tR\aprofile\x12\x18\n" +
|
||||||
|
"\amessage\x18\x04 \x01(\tR\amessage\"\x11\n" +
|
||||||
|
"\x0fShutdownRequest\"\"\n" +
|
||||||
|
"\x10ShutdownResponse\x12\x0e\n" +
|
||||||
|
"\x02ok\x18\x01 \x01(\bR\x02ok\"C\n" +
|
||||||
|
"\rVerifyRequest\x12\x18\n" +
|
||||||
|
"\apayload\x18\x01 \x01(\fR\apayload\x12\x18\n" +
|
||||||
|
"\aprofile\x18\x02 \x01(\tR\aprofile\"\xce\x01\n" +
|
||||||
|
"\x0eVerifyResponse\x12\x14\n" +
|
||||||
|
"\x05valid\x18\x01 \x01(\bR\x05valid\x12\x1b\n" +
|
||||||
|
"\tsigner_cn\x18\x02 \x01(\tR\bsignerCn\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"signer_inn\x18\x03 \x01(\tR\tsignerInn\x12\x16\n" +
|
||||||
|
"\x06serial\x18\x04 \x01(\tR\x06serial\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"not_before\x18\x05 \x01(\x03R\tnotBefore\x12\x1b\n" +
|
||||||
|
"\tnot_after\x18\x06 \x01(\x03R\bnotAfter\x12\x16\n" +
|
||||||
|
"\x06errors\x18\a \x03(\tR\x06errors\"^\n" +
|
||||||
|
"\vSignRequest\x12\x18\n" +
|
||||||
|
"\apayload\x18\x01 \x01(\fR\apayload\x12\x1b\n" +
|
||||||
|
"\tkey_alias\x18\x02 \x01(\tR\bkeyAlias\x12\x18\n" +
|
||||||
|
"\aprofile\x18\x03 \x01(\tR\aprofile\"-\n" +
|
||||||
|
"\fSignResponse\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"signed_xml\x18\x01 \x01(\fR\tsignedXml\"\x0f\n" +
|
||||||
|
"\rHealthRequest\"V\n" +
|
||||||
|
"\x0eHealthResponse\x12\x0e\n" +
|
||||||
|
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x18\n" +
|
||||||
|
"\aversion\x18\x02 \x01(\tR\aversion\x12\x1a\n" +
|
||||||
|
"\bprovider\x18\x03 \x01(\tR\bprovider2\x88\x04\n" +
|
||||||
|
"\rCryptoService\x12f\n" +
|
||||||
|
"\rVerifyXMLDSig\x12).bridge_and_joins.crypto.v1.VerifyRequest\x1a*.bridge_and_joins.crypto.v1.VerifyResponse\x12`\n" +
|
||||||
|
"\vSignXMLDSig\x12'.bridge_and_joins.crypto.v1.SignRequest\x1a(.bridge_and_joins.crypto.v1.SignResponse\x12_\n" +
|
||||||
|
"\x06Health\x12).bridge_and_joins.crypto.v1.HealthRequest\x1a*.bridge_and_joins.crypto.v1.HealthResponse\x12e\n" +
|
||||||
|
"\bActivate\x12+.bridge_and_joins.crypto.v1.ActivateRequest\x1a,.bridge_and_joins.crypto.v1.ActivateResponse\x12e\n" +
|
||||||
|
"\bShutdown\x12+.bridge_and_joins.crypto.v1.ShutdownRequest\x1a,.bridge_and_joins.crypto.v1.ShutdownResponseBq\n" +
|
||||||
|
"!ru.zetit.bridgeandjoins.crypto.v1P\x01ZJgit.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli/cryptopb;cryptopbb\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_crypto_proto_rawDescOnce sync.Once
|
||||||
|
file_crypto_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_crypto_proto_rawDescGZIP() []byte {
|
||||||
|
file_crypto_proto_rawDescOnce.Do(func() {
|
||||||
|
file_crypto_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_crypto_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_crypto_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
||||||
|
var file_crypto_proto_goTypes = []any{
|
||||||
|
(*ActivateRequest)(nil), // 0: bridge_and_joins.crypto.v1.ActivateRequest
|
||||||
|
(*ActivateResponse)(nil), // 1: bridge_and_joins.crypto.v1.ActivateResponse
|
||||||
|
(*ShutdownRequest)(nil), // 2: bridge_and_joins.crypto.v1.ShutdownRequest
|
||||||
|
(*ShutdownResponse)(nil), // 3: bridge_and_joins.crypto.v1.ShutdownResponse
|
||||||
|
(*VerifyRequest)(nil), // 4: bridge_and_joins.crypto.v1.VerifyRequest
|
||||||
|
(*VerifyResponse)(nil), // 5: bridge_and_joins.crypto.v1.VerifyResponse
|
||||||
|
(*SignRequest)(nil), // 6: bridge_and_joins.crypto.v1.SignRequest
|
||||||
|
(*SignResponse)(nil), // 7: bridge_and_joins.crypto.v1.SignResponse
|
||||||
|
(*HealthRequest)(nil), // 8: bridge_and_joins.crypto.v1.HealthRequest
|
||||||
|
(*HealthResponse)(nil), // 9: bridge_and_joins.crypto.v1.HealthResponse
|
||||||
|
}
|
||||||
|
var file_crypto_proto_depIdxs = []int32{
|
||||||
|
4, // 0: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:input_type -> bridge_and_joins.crypto.v1.VerifyRequest
|
||||||
|
6, // 1: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:input_type -> bridge_and_joins.crypto.v1.SignRequest
|
||||||
|
8, // 2: bridge_and_joins.crypto.v1.CryptoService.Health:input_type -> bridge_and_joins.crypto.v1.HealthRequest
|
||||||
|
0, // 3: bridge_and_joins.crypto.v1.CryptoService.Activate:input_type -> bridge_and_joins.crypto.v1.ActivateRequest
|
||||||
|
2, // 4: bridge_and_joins.crypto.v1.CryptoService.Shutdown:input_type -> bridge_and_joins.crypto.v1.ShutdownRequest
|
||||||
|
5, // 5: bridge_and_joins.crypto.v1.CryptoService.VerifyXMLDSig:output_type -> bridge_and_joins.crypto.v1.VerifyResponse
|
||||||
|
7, // 6: bridge_and_joins.crypto.v1.CryptoService.SignXMLDSig:output_type -> bridge_and_joins.crypto.v1.SignResponse
|
||||||
|
9, // 7: bridge_and_joins.crypto.v1.CryptoService.Health:output_type -> bridge_and_joins.crypto.v1.HealthResponse
|
||||||
|
1, // 8: bridge_and_joins.crypto.v1.CryptoService.Activate:output_type -> bridge_and_joins.crypto.v1.ActivateResponse
|
||||||
|
3, // 9: bridge_and_joins.crypto.v1.CryptoService.Shutdown:output_type -> bridge_and_joins.crypto.v1.ShutdownResponse
|
||||||
|
5, // [5:10] is the sub-list for method output_type
|
||||||
|
0, // [0:5] is the sub-list for method input_type
|
||||||
|
0, // [0:0] is the sub-list for extension type_name
|
||||||
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
|
0, // [0:0] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_crypto_proto_init() }
|
||||||
|
func file_crypto_proto_init() {
|
||||||
|
if File_crypto_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_crypto_proto_rawDesc), len(file_crypto_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 10,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_crypto_proto_goTypes,
|
||||||
|
DependencyIndexes: file_crypto_proto_depIdxs,
|
||||||
|
MessageInfos: file_crypto_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_crypto_proto = out.File
|
||||||
|
file_crypto_proto_goTypes = nil
|
||||||
|
file_crypto_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
|
// - protoc v3.12.4
|
||||||
|
// source: crypto.proto
|
||||||
|
|
||||||
|
package cryptopb
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
CryptoService_VerifyXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/VerifyXMLDSig"
|
||||||
|
CryptoService_SignXMLDSig_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/SignXMLDSig"
|
||||||
|
CryptoService_Health_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Health"
|
||||||
|
CryptoService_Activate_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Activate"
|
||||||
|
CryptoService_Shutdown_FullMethodName = "/bridge_and_joins.crypto.v1.CryptoService/Shutdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CryptoServiceClient is the client API for CryptoService service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
|
||||||
|
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
|
||||||
|
type CryptoServiceClient interface {
|
||||||
|
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
|
||||||
|
// подписанте: CN, ИНН (если есть), срок действия сертификата.
|
||||||
|
VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error)
|
||||||
|
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
|
||||||
|
// серверной подписи действий оператора в admin-ui.
|
||||||
|
SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error)
|
||||||
|
// Health-check.
|
||||||
|
Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error)
|
||||||
|
// Activate — переинициализирует провайдер Валидаты на указанный
|
||||||
|
// профиль из pki1.conf. Если profile пуст — переходит в
|
||||||
|
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
|
||||||
|
// перезапуска сайдкара.
|
||||||
|
Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error)
|
||||||
|
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
|
||||||
|
// после отправки ответа). systemd с Restart=on-failure поднимет
|
||||||
|
// его снова через RestartSec секунд. Используется для UI-кнопки
|
||||||
|
// «Перезапустить crypto-service» без sudo.
|
||||||
|
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cryptoServiceClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCryptoServiceClient(cc grpc.ClientConnInterface) CryptoServiceClient {
|
||||||
|
return &cryptoServiceClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoServiceClient) VerifyXMLDSig(ctx context.Context, in *VerifyRequest, opts ...grpc.CallOption) (*VerifyResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(VerifyResponse)
|
||||||
|
err := c.cc.Invoke(ctx, CryptoService_VerifyXMLDSig_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoServiceClient) SignXMLDSig(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(SignResponse)
|
||||||
|
err := c.cc.Invoke(ctx, CryptoService_SignXMLDSig_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoServiceClient) Health(ctx context.Context, in *HealthRequest, opts ...grpc.CallOption) (*HealthResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(HealthResponse)
|
||||||
|
err := c.cc.Invoke(ctx, CryptoService_Health_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoServiceClient) Activate(ctx context.Context, in *ActivateRequest, opts ...grpc.CallOption) (*ActivateResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ActivateResponse)
|
||||||
|
err := c.cc.Invoke(ctx, CryptoService_Activate_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cryptoServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ShutdownResponse)
|
||||||
|
err := c.cc.Invoke(ctx, CryptoService_Shutdown_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CryptoServiceServer is the server API for CryptoService service.
|
||||||
|
// All implementations must embed UnimplementedCryptoServiceServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// CryptoService — серверная криптография по ГОСТ через КриптоПро JCP.
|
||||||
|
// Слушает на Unix Domain Socket (по умолчанию /run/bj/crypto.sock).
|
||||||
|
type CryptoServiceServer interface {
|
||||||
|
// Проверка XMLDSig-подписи (ГОСТ или RSA). Возвращает сведения о
|
||||||
|
// подписанте: CN, ИНН (если есть), срок действия сертификата.
|
||||||
|
VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error)
|
||||||
|
// Подпись XML по ГОСТ — для резервного канала WS ONYX и для
|
||||||
|
// серверной подписи действий оператора в admin-ui.
|
||||||
|
SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error)
|
||||||
|
// Health-check.
|
||||||
|
Health(context.Context, *HealthRequest) (*HealthResponse, error)
|
||||||
|
// Activate — переинициализирует провайдер Валидаты на указанный
|
||||||
|
// профиль из pki1.conf. Если profile пуст — переходит в
|
||||||
|
// VCERT_InitMinimal (без доступа к ПСП/ЛСП/ССС). Не требует
|
||||||
|
// перезапуска сайдкара.
|
||||||
|
Activate(context.Context, *ActivateRequest) (*ActivateResponse, error)
|
||||||
|
// Shutdown — корректно завершает процесс сайдкара (System.exit(2)
|
||||||
|
// после отправки ответа). systemd с Restart=on-failure поднимет
|
||||||
|
// его снова через RestartSec секунд. Используется для UI-кнопки
|
||||||
|
// «Перезапустить crypto-service» без sudo.
|
||||||
|
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
|
||||||
|
mustEmbedUnimplementedCryptoServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedCryptoServiceServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedCryptoServiceServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedCryptoServiceServer) VerifyXMLDSig(context.Context, *VerifyRequest) (*VerifyResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method VerifyXMLDSig not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCryptoServiceServer) SignXMLDSig(context.Context, *SignRequest) (*SignResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method SignXMLDSig not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCryptoServiceServer) Health(context.Context, *HealthRequest) (*HealthResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCryptoServiceServer) Activate(context.Context, *ActivateRequest) (*ActivateResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Activate not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCryptoServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCryptoServiceServer) mustEmbedUnimplementedCryptoServiceServer() {}
|
||||||
|
func (UnimplementedCryptoServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeCryptoServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to CryptoServiceServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeCryptoServiceServer interface {
|
||||||
|
mustEmbedUnimplementedCryptoServiceServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterCryptoServiceServer(s grpc.ServiceRegistrar, srv CryptoServiceServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedCryptoServiceServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&CryptoService_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CryptoService_VerifyXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(VerifyRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CryptoService_VerifyXMLDSig_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CryptoServiceServer).VerifyXMLDSig(ctx, req.(*VerifyRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CryptoService_SignXMLDSig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SignRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CryptoServiceServer).SignXMLDSig(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CryptoService_SignXMLDSig_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CryptoServiceServer).SignXMLDSig(ctx, req.(*SignRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CryptoService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(HealthRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CryptoServiceServer).Health(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CryptoService_Health_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CryptoServiceServer).Health(ctx, req.(*HealthRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CryptoService_Activate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ActivateRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CryptoServiceServer).Activate(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CryptoService_Activate_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CryptoServiceServer).Activate(ctx, req.(*ActivateRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CryptoService_Shutdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ShutdownRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CryptoServiceServer).Shutdown(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CryptoService_Shutdown_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CryptoServiceServer).Shutdown(ctx, req.(*ShutdownRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CryptoService_ServiceDesc is the grpc.ServiceDesc for CryptoService service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var CryptoService_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "bridge_and_joins.crypto.v1.CryptoService",
|
||||||
|
HandlerType: (*CryptoServiceServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "VerifyXMLDSig",
|
||||||
|
Handler: _CryptoService_VerifyXMLDSig_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SignXMLDSig",
|
||||||
|
Handler: _CryptoService_SignXMLDSig_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Health",
|
||||||
|
Handler: _CryptoService_Health_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Activate",
|
||||||
|
Handler: _CryptoService_Activate_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Shutdown",
|
||||||
|
Handler: _CryptoService_Shutdown_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "crypto.proto",
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Package license — формат лицензии Bridge-and-Join-s и её подпись Ed25519.
|
||||||
|
//
|
||||||
|
// Лицензия — самодостаточный подписанный токен (offline-проверяемый):
|
||||||
|
// клиент проверяет подпись зашитым публичным ключом и срок действия БЕЗ
|
||||||
|
// обращения к серверу. Это значит, что on-prem bj-server продолжает
|
||||||
|
// работать даже если license-сервер недоступен.
|
||||||
|
//
|
||||||
|
// Online-сервер (cmd/bj-license-server) нужен только для отзыва (revocation)
|
||||||
|
// и выдачи новых ключей. Базовая модель — годовой ключ: выпустили на год,
|
||||||
|
// клиент проверяет offline; перед обновлением bj-server гейтит установку
|
||||||
|
// валидной непросроченной лицензией.
|
||||||
|
//
|
||||||
|
// Издатель держит приватный ключ в секрете; публичный зашит в bj-server.
|
||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CurrentSchema = 1
|
||||||
|
|
||||||
|
// Plan — тариф лицензии.
|
||||||
|
type Plan string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlanFree Plan = "free"
|
||||||
|
PlanPro Plan = "pro"
|
||||||
|
PlanEnterprise Plan = "enterprise"
|
||||||
|
)
|
||||||
|
|
||||||
|
// License — содержимое лицензии (подписывается целиком).
|
||||||
|
type License struct {
|
||||||
|
Schema int `json:"schema"`
|
||||||
|
ID string `json:"id"` // UUID лицензии
|
||||||
|
Tenant string `json:"tenant"` // организация-клиент
|
||||||
|
Product string `json:"product"` // "bj-server"
|
||||||
|
Plan Plan `json:"plan"` // free|pro|enterprise
|
||||||
|
IssuedAt time.Time `json:"issued_at"` // дата выпуска
|
||||||
|
ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ)
|
||||||
|
Features []string `json:"features,omitempty"` // "updates","web-cabinet",...
|
||||||
|
MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита)
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка
|
||||||
|
// или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно.
|
||||||
|
type Token struct {
|
||||||
|
Payload string `json:"payload"` // base64(каноничный JSON License)
|
||||||
|
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
|
||||||
|
KeyID string `json:"key_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical сериализует лицензию детерминированно (для подписи/проверки).
|
||||||
|
func (l *License) Canonical() ([]byte, error) {
|
||||||
|
if l.Schema == 0 {
|
||||||
|
l.Schema = CurrentSchema
|
||||||
|
}
|
||||||
|
return json.Marshal(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid проверяет срок действия на момент now.
|
||||||
|
func (l *License) Valid(now time.Time) error {
|
||||||
|
if now.Before(l.IssuedAt.Add(-24 * time.Hour)) {
|
||||||
|
return errors.New("license: ещё не действует (issued_at в будущем)")
|
||||||
|
}
|
||||||
|
if now.After(l.ExpiresAt) {
|
||||||
|
return fmt.Errorf("license: истекла %s", l.ExpiresAt.Format("02.01.2006"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFeature — включена ли фича (или план enterprise — всё включено).
|
||||||
|
func (l *License) HasFeature(f string) bool {
|
||||||
|
if l.Plan == PlanEnterprise {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, x := range l.Features {
|
||||||
|
if x == f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowsUpdates — разрешены ли обновления по этой лицензии.
|
||||||
|
func (l *License) AllowsUpdates() bool { return l.HasFeature("updates") }
|
||||||
|
|
||||||
|
// DaysLeft — сколько дней до окончания (может быть отрицательным).
|
||||||
|
func (l *License) DaysLeft(now time.Time) int {
|
||||||
|
return int(l.ExpiresAt.Sub(now).Hours() / 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign подписывает лицензию и возвращает Token.
|
||||||
|
func Sign(l *License, priv ed25519.PrivateKey, keyID string) (*Token, error) {
|
||||||
|
payload, err := l.Canonical()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license: canonical: %w", err)
|
||||||
|
}
|
||||||
|
sig := ed25519.Sign(priv, payload)
|
||||||
|
return &Token{
|
||||||
|
Payload: base64.StdEncoding.EncodeToString(payload),
|
||||||
|
Signature: base64.StdEncoding.EncodeToString(sig),
|
||||||
|
KeyID: keyID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify проверяет подпись и возвращает License (срок проверяется отдельно
|
||||||
|
// через License.Valid — Verify только про подлинность).
|
||||||
|
func Verify(t *Token, pub ed25519.PublicKey) (*License, error) {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(t.Signature)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license: decode signature: %w", err)
|
||||||
|
}
|
||||||
|
payload, err := base64.StdEncoding.DecodeString(t.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license: decode payload: %w", err)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub, payload, sig) {
|
||||||
|
return nil, errors.New("license: подпись недействительна")
|
||||||
|
}
|
||||||
|
var l License
|
||||||
|
if err := json.Unmarshal(payload, &l); err != nil {
|
||||||
|
return nil, fmt.Errorf("license: unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
if l.Schema != CurrentSchema {
|
||||||
|
return nil, fmt.Errorf("license: неподдерживаемая схема %d", l.Schema)
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode сериализует Token в компактную строку payload.signature[.keyid]
|
||||||
|
// (то, что клиент вставляет в поле «лицензионный ключ»).
|
||||||
|
func (t *Token) Encode() string {
|
||||||
|
s := t.Payload + "." + t.Signature
|
||||||
|
if t.KeyID != "" {
|
||||||
|
s += "." + t.KeyID
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeToken разбирает компактную строку обратно в Token.
|
||||||
|
func DecodeToken(s string) (*Token, error) {
|
||||||
|
parts := strings.Split(strings.TrimSpace(s), ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, errors.New("license: неверный формат ключа (ожидается payload.signature)")
|
||||||
|
}
|
||||||
|
t := &Token{Payload: parts[0], Signature: parts[1]}
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
t.KeyID = parts[2]
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ключи (как в release) ---
|
||||||
|
|
||||||
|
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("license: decode seed: %w", err)
|
||||||
|
}
|
||||||
|
if len(seed) != ed25519.SeedSize {
|
||||||
|
return nil, fmt.Errorf("license: неверный размер seed %d", len(seed))
|
||||||
|
}
|
||||||
|
return ed25519.NewKeyFromSeed(seed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
|
||||||
|
pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pub) != ed25519.PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("license: неверный размер pubkey %d", len(pub))
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(pub), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mkLicense(plan Plan, expires time.Time, feats ...string) *License {
|
||||||
|
return &License{
|
||||||
|
ID: "test-id", Tenant: "ООО Тест", Product: "bj-server",
|
||||||
|
Plan: plan, IssuedAt: time.Now().UTC().Add(-time.Hour),
|
||||||
|
ExpiresAt: expires, Features: feats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerifyAndEncode(t *testing.T) {
|
||||||
|
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
l := mkLicense(PlanPro, time.Now().Add(365*24*time.Hour), "updates")
|
||||||
|
tok, err := Sign(l, priv, "main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// round-trip через компактную строку
|
||||||
|
dec, err := DecodeToken(tok.Encode())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Verify(dec, pub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
if got.Tenant != l.Tenant || got.Plan != PlanPro || !got.AllowsUpdates() {
|
||||||
|
t.Fatalf("mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpired(t *testing.T) {
|
||||||
|
l := mkLicense(PlanPro, time.Now().Add(-time.Hour), "updates")
|
||||||
|
if err := l.Valid(time.Now().UTC()); err == nil {
|
||||||
|
t.Fatal("истёкшая лицензия прошла Valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeaturesAndEnterprise(t *testing.T) {
|
||||||
|
pro := mkLicense(PlanPro, time.Now().Add(time.Hour), "updates")
|
||||||
|
if !pro.HasFeature("updates") || pro.HasFeature("web-cabinet") {
|
||||||
|
t.Fatal("pro features неверны")
|
||||||
|
}
|
||||||
|
ent := mkLicense(PlanEnterprise, time.Now().Add(time.Hour))
|
||||||
|
if !ent.HasFeature("anything") || !ent.AllowsUpdates() {
|
||||||
|
t.Fatal("enterprise должен включать всё")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsWrongKey(t *testing.T) {
|
||||||
|
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
other, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||||
|
l := mkLicense(PlanPro, time.Now().Add(time.Hour))
|
||||||
|
tok, _ := Sign(l, priv, "main")
|
||||||
|
if _, err := Verify(tok, other); err == nil {
|
||||||
|
t.Fatal("Verify принял подпись чужим ключом")
|
||||||
|
}
|
||||||
|
}
|
||||||
+129
-17
@@ -19,17 +19,29 @@ var templatesFS embed.FS
|
|||||||
// конкретный content-шаблон). Так html/template не путается с несколькими
|
// конкретный content-шаблон). Так html/template не путается с несколькими
|
||||||
// {{define "content"}} в разных файлах.
|
// {{define "content"}} в разных файлах.
|
||||||
type admin struct {
|
type admin struct {
|
||||||
home, claims, claim, status, setup *template.Template
|
home, claims, claim, status, setup *template.Template
|
||||||
help, helpDatabase, helpLK, helpCryptoPro, helpSystems *template.Template
|
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture *template.Template
|
||||||
|
wizard, news, keyWizard *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
|
||||||
// русификация статусов и других технических обозначений (см. требование
|
// русификация статусов и других технических обозначений (см. требование
|
||||||
// «всё UI на русском, кроме программных терминов»).
|
// «всё UI на русском, кроме программных терминов»).
|
||||||
var templateFuncs = template.FuncMap{
|
var templateFuncs = template.FuncMap{
|
||||||
"ru": russianText,
|
"ru": russianText,
|
||||||
"ruState": russianState,
|
"ruState": russianState,
|
||||||
"ruOutcome": russianOutcome,
|
"ruOutcome": russianOutcome,
|
||||||
|
"now": time.Now,
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"fallbackTpl": fallback,
|
||||||
|
"anyKeymedia": func(ds []flashDrive) bool {
|
||||||
|
for _, d := range ds {
|
||||||
|
if d.IsKeymedia {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// russianState переводит технический FSM-state в человекочитаемый
|
// russianState переводит технический FSM-state в человекочитаемый
|
||||||
@@ -113,27 +125,56 @@ func newAdmin() (*admin, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
|
return nil, fmt.Errorf("parse admin_help_lk: %w", err)
|
||||||
}
|
}
|
||||||
helpCP, err := parse("admin_help_cryptopro.html")
|
helpCrypto, err := parse("admin_help_crypto.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse admin_help_cryptopro: %w", err)
|
return nil, fmt.Errorf("parse admin_help_crypto: %w", err)
|
||||||
}
|
}
|
||||||
helpSys, err := parse("admin_help_systems.html")
|
helpSys, err := parse("admin_help_systems.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse admin_help_systems: %w", err)
|
return nil, fmt.Errorf("parse admin_help_systems: %w", err)
|
||||||
}
|
}
|
||||||
|
wizard, err := parse("admin_wizard.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin_wizard: %w", err)
|
||||||
|
}
|
||||||
|
news, err := parse("admin_news.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin_news: %w", err)
|
||||||
|
}
|
||||||
|
helpRobot, err := parse("admin_help_robot.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin_help_robot: %w", err)
|
||||||
|
}
|
||||||
|
helpArch, err := parse("admin_help_architecture.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin_help_architecture: %w", err)
|
||||||
|
}
|
||||||
|
keyWizard, err := parse("admin_keywizard.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse admin_keywizard: %w", err)
|
||||||
|
}
|
||||||
return &admin{
|
return &admin{
|
||||||
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
||||||
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
|
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
|
||||||
|
helpRobot: helpRobot, helpArchitecture: helpArch,
|
||||||
|
wizard: wizard, news: news, keyWizard: keyWizard,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// page — общий "конверт" данных для всех шаблонов.
|
// page — общий "конверт" данных для всех шаблонов.
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string
|
Title string
|
||||||
Active string
|
Active string
|
||||||
Now string
|
Now string
|
||||||
|
IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции
|
||||||
|
MockReason string // короткое описание почему mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
|
||||||
|
// Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через
|
||||||
|
// все renderXxx-функции, что шумно при широком фан-ауте.
|
||||||
|
var globalRC *RuntimeConfig
|
||||||
|
|
||||||
// homeData — данные дашборда.
|
// homeData — данные дашборда.
|
||||||
type homeData struct {
|
type homeData struct {
|
||||||
page
|
page
|
||||||
@@ -145,6 +186,12 @@ type homeData struct {
|
|||||||
Failed int
|
Failed int
|
||||||
}
|
}
|
||||||
Recent []ClaimView
|
Recent []ClaimView
|
||||||
|
News []NewsItem // top-3 активных или свежих новостей
|
||||||
|
|
||||||
|
// Сводка готовности системы для hero-блока дашборда.
|
||||||
|
AllReady bool
|
||||||
|
NotReadyCount int
|
||||||
|
TotalCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimsData — данные журнала.
|
// claimsData — данные журнала.
|
||||||
@@ -169,17 +216,18 @@ type statusData struct {
|
|||||||
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
|
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
|
||||||
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
|
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
|
||||||
// registerSetup для добавления вкладки «Настройка».
|
// registerSetup для добавления вкладки «Настройка».
|
||||||
func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) (*admin, error) {
|
func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) {
|
||||||
a, err := newAdmin()
|
a, err := newAdmin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
globalRC = rc
|
||||||
|
|
||||||
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := strings.TrimPrefix(r.URL.Path, "/admin/")
|
p := strings.TrimPrefix(r.URL.Path, "/admin/")
|
||||||
switch {
|
switch {
|
||||||
case p == "" || p == "index" || p == "home":
|
case p == "" || p == "index" || p == "home":
|
||||||
a.renderHome(w, r, svc, getOpts())
|
a.renderHome(w, r, svc, rc, getOpts())
|
||||||
case p == "claims":
|
case p == "claims":
|
||||||
a.renderClaims(w, r, svc)
|
a.renderClaims(w, r, svc)
|
||||||
case strings.HasPrefix(p, "claims/"):
|
case strings.HasPrefix(p, "claims/"):
|
||||||
@@ -193,10 +241,14 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
|
|||||||
render(w, a.helpDatabase, nowPage("База данных", "help"))
|
render(w, a.helpDatabase, nowPage("База данных", "help"))
|
||||||
case p == "help/lk-api":
|
case p == "help/lk-api":
|
||||||
render(w, a.helpLK, nowPage("API ЛК", "help"))
|
render(w, a.helpLK, nowPage("API ЛК", "help"))
|
||||||
case p == "help/cryptopro":
|
case p == "help/crypto":
|
||||||
render(w, a.helpCryptoPro, nowPage("КриптоПро", "help"))
|
render(w, a.helpCrypto, nowPage("Криптография", "help"))
|
||||||
case p == "help/systems":
|
case p == "help/systems":
|
||||||
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
|
render(w, a.helpSystems, nowPage("Внешние системы", "help"))
|
||||||
|
case p == "help/robot":
|
||||||
|
render(w, a.helpRobot, nowPage("Тестирование с роботом", "help"))
|
||||||
|
case p == "help/architecture":
|
||||||
|
render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help"))
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -207,7 +259,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
|
|||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) {
|
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, rc *RuntimeConfig, opts CheckOptions) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
status := CheckAll(ctx, opts)
|
status := CheckAll(ctx, opts)
|
||||||
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
|
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
|
||||||
@@ -219,7 +271,20 @@ func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service,
|
|||||||
page: nowPage("Дашборд", "home"),
|
page: nowPage("Дашборд", "home"),
|
||||||
Status: status,
|
Status: status,
|
||||||
Recent: recent.Items,
|
Recent: recent.Items,
|
||||||
|
News: topNews(rc.Snapshot().News.Items, 3),
|
||||||
}
|
}
|
||||||
|
// Готовность системы считаем ТОЛЬКО по обязательным компонентам.
|
||||||
|
// Опциональные (напр. callback в ЛК) не влияют на «готовность».
|
||||||
|
for _, c := range status.Checks {
|
||||||
|
if c.Optional {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data.TotalCount++
|
||||||
|
if !c.OK {
|
||||||
|
data.NotReadyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.AllReady = data.TotalCount > 0 && data.NotReadyCount == 0
|
||||||
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
|
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, c := range full.Items {
|
for _, c := range full.Items {
|
||||||
@@ -271,5 +336,52 @@ func render(w http.ResponseWriter, t *template.Template, data any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func nowPage(title, active string) page {
|
func nowPage(title, active string) page {
|
||||||
return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
|
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
|
||||||
|
if globalRC != nil {
|
||||||
|
s := globalRC.Snapshot()
|
||||||
|
switch {
|
||||||
|
case s.NSD.IGWBaseURL == "":
|
||||||
|
p.IsMockMode = true
|
||||||
|
p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)"
|
||||||
|
case s.Crypto.Provider == "" || s.Crypto.Provider == "stub":
|
||||||
|
p.IsMockMode = true
|
||||||
|
p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас
|
||||||
|
// (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо.
|
||||||
|
func topNews(items []NewsItem, n int) []NewsItem {
|
||||||
|
now := time.Now()
|
||||||
|
var active, rest []NewsItem
|
||||||
|
for _, it := range items {
|
||||||
|
if it.Dismissed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() &&
|
||||||
|
now.After(it.ValidFrom) && now.Before(it.ValidTo)
|
||||||
|
// «Будущие» окна с ValidFrom в будущем тоже считаем актуальными
|
||||||
|
// (предупредить заранее).
|
||||||
|
isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) &&
|
||||||
|
it.ValidFrom.Sub(now) < 7*24*time.Hour
|
||||||
|
if isActive || isUpcoming {
|
||||||
|
active = append(active, it)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := active
|
||||||
|
if len(out) < n {
|
||||||
|
need := n - len(out)
|
||||||
|
if need > len(rest) {
|
||||||
|
need = len(rest)
|
||||||
|
}
|
||||||
|
out = append(out, rest[:need]...)
|
||||||
|
}
|
||||||
|
if len(out) > n {
|
||||||
|
out = out[:n]
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// caCertsDir — куда складываются скачанные сертификаты УЦ.
|
||||||
|
const caCertsDir = "/var/lib/bj/ca-certs"
|
||||||
|
|
||||||
|
// defaultNSDCAURLs — список URL для авто-загрузки сертификатов УЦ НРД.
|
||||||
|
// Эти URL пользователь может скорректировать в /admin/setup → «Сертификаты
|
||||||
|
// УЦ» (раздел появляется после первого сохранения настроек). На сайте НРД
|
||||||
|
// (www.nsd.ru/workflow/system/cryptography/) сертификаты выложены в виде
|
||||||
|
// .cer файлов — нужно скопировать их прямые URL сюда.
|
||||||
|
//
|
||||||
|
// По умолчанию список пустой, потому что прямые URL у НРД меняются от
|
||||||
|
// релиза к релизу и должны быть проверены оператором перед использованием.
|
||||||
|
var defaultNSDCAURLs = []string{
|
||||||
|
// https://www.nsd.ru/workflow/system/cryptography/ — раскомментируйте
|
||||||
|
// нужные ссылки в UI после того, как уточните URL у НРД.
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCACertificates скачивает все URL из настроек, парсит .cer и
|
||||||
|
// сохраняет файл в /var/lib/bj/ca-certs/. Если передан rc — на каждое
|
||||||
|
// фактическое изменение сертификата (новый или изменился SHA-256)
|
||||||
|
// публикуется новость в ленту через rc.AddNews. На сертификаты,
|
||||||
|
// истекающие в ближайшие 14 дней — отдельная новость-предупреждение.
|
||||||
|
func FetchCACertificates(ctx context.Context, s CACertsSettings, rc *RuntimeConfig) (CACertsSettings, string) {
|
||||||
|
if len(s.URLs) == 0 {
|
||||||
|
return s, "Список URL пуст. Добавьте ссылки на .cer-файлы УЦ НРД в /admin/setup → «Сертификаты УЦ»."
|
||||||
|
}
|
||||||
|
var logBuf strings.Builder
|
||||||
|
now := time.Now()
|
||||||
|
newFetched := make([]FetchedCACert, 0, len(s.URLs))
|
||||||
|
|
||||||
|
for _, u := range s.URLs {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fc := FetchedCACert{URL: u, FetchedAt: now}
|
||||||
|
der, err := downloadAndParseCert(ctx, u)
|
||||||
|
if err != nil {
|
||||||
|
fc.Error = err.Error()
|
||||||
|
newFetched = append(newFetched, fc)
|
||||||
|
fmt.Fprintf(&logBuf, "%s — ОШИБКА: %s\n", u, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert, perr := x509.ParseCertificate(der)
|
||||||
|
if perr != nil {
|
||||||
|
fc.Error = "не удалось распарсить X.509: " + perr.Error()
|
||||||
|
newFetched = append(newFetched, fc)
|
||||||
|
fmt.Fprintf(&logBuf, "%s — не X.509: %s\n", u, perr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fc.SubjectCN = cert.Subject.CommonName
|
||||||
|
fc.IssuerCN = cert.Issuer.CommonName
|
||||||
|
fc.NotAfter = cert.NotAfter
|
||||||
|
fc.SHA256 = hex.EncodeToString(sha256Bytes(der))
|
||||||
|
// Корневые (Issuer == Subject) и промежуточные складываем рядом,
|
||||||
|
// в общую папку /var/lib/bj/ca-certs/.
|
||||||
|
kind := "intermediate"
|
||||||
|
if cert.Subject.CommonName == cert.Issuer.CommonName {
|
||||||
|
kind = "root"
|
||||||
|
}
|
||||||
|
fc.Store = kind
|
||||||
|
|
||||||
|
// Дедуп: если sha256 совпадает с уже импортированным — пропускаем
|
||||||
|
// сам импорт (но фиксируем что проверили).
|
||||||
|
alreadyImported := false
|
||||||
|
for _, old := range s.FetchedCerts {
|
||||||
|
if old.URL == u && old.SHA256 == fc.SHA256 && old.Error == "" {
|
||||||
|
alreadyImported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alreadyImported {
|
||||||
|
fmt.Fprintf(&logBuf, "%s — не изменился (sha256=%s...)\n", u, fc.SHA256[:12])
|
||||||
|
newFetched = append(newFetched, fc)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем DER на диск в /var/lib/bj/ca-certs/<sha>.cer.
|
||||||
|
isNew := true
|
||||||
|
for _, old := range s.FetchedCerts {
|
||||||
|
if old.URL == u && old.Error == "" {
|
||||||
|
isNew = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := saveCertToDir(der, fc.SHA256); err != nil {
|
||||||
|
fc.Error = "save: " + err.Error()
|
||||||
|
fmt.Fprintf(&logBuf, "%s — сохранить не удалось: %s\n", u, err)
|
||||||
|
if rc != nil {
|
||||||
|
_ = rc.AddNews(NewsItem{
|
||||||
|
ID: "ca-error-" + fc.SHA256[:12],
|
||||||
|
At: now,
|
||||||
|
Kind: "system",
|
||||||
|
Title: "Не удалось сохранить сертификат УЦ",
|
||||||
|
Body: "URL: " + u + "\nCN: " + fc.SubjectCN + "\nОшибка: " + err.Error(),
|
||||||
|
URL: u,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&logBuf, "%s — сохранён (%s, CN=%s, sha256=%s...)\n",
|
||||||
|
u, kind, fc.SubjectCN, fc.SHA256[:12])
|
||||||
|
if rc != nil {
|
||||||
|
kindTitle := "Обновлён сертификат УЦ"
|
||||||
|
if isNew {
|
||||||
|
kindTitle = "Установлен новый сертификат УЦ"
|
||||||
|
}
|
||||||
|
_ = rc.AddNews(NewsItem{
|
||||||
|
ID: "ca-update-" + fc.SHA256[:12],
|
||||||
|
At: now,
|
||||||
|
Kind: "feature",
|
||||||
|
Title: kindTitle + ": " + fc.SubjectCN,
|
||||||
|
Body: fmt.Sprintf("Тип: %s\nИздатель: %s\nДействителен до: %s\nSHA-256: %s…\nURL источника: %s",
|
||||||
|
kind, fc.IssuerCN, fc.NotAfter.Format("02.01.2006"), fc.SHA256[:16], u),
|
||||||
|
URL: u,
|
||||||
|
ValidTo: fc.NotAfter,
|
||||||
|
})
|
||||||
|
// Предупреждение если истекает в ближайшие 14 дней.
|
||||||
|
if !fc.NotAfter.IsZero() && time.Until(fc.NotAfter) < 14*24*time.Hour {
|
||||||
|
_ = rc.AddNews(NewsItem{
|
||||||
|
ID: "ca-expiring-" + fc.SHA256[:12],
|
||||||
|
At: now,
|
||||||
|
Kind: "system",
|
||||||
|
Title: "⚠ Сертификат УЦ скоро истечёт: " + fc.SubjectCN,
|
||||||
|
Body: fmt.Sprintf("Срок действия — %s (через %d дней). Получите новую версию у УЦ и обновите URL в /admin/setup → «Сертификаты УЦ».",
|
||||||
|
fc.NotAfter.Format("02.01.2006"),
|
||||||
|
int(time.Until(fc.NotAfter)/(24*time.Hour))),
|
||||||
|
URL: u,
|
||||||
|
ValidTo: fc.NotAfter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newFetched = append(newFetched, fc)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.LastFetch = now
|
||||||
|
s.LastFetchLog = logBuf.String()
|
||||||
|
s.FetchedCerts = newFetched
|
||||||
|
return s, logBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha256Bytes(b []byte) []byte {
|
||||||
|
h := sha256.Sum256(b)
|
||||||
|
return h[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadAndParseCert качает URL и возвращает DER-байты сертификата.
|
||||||
|
// Поддерживает PEM (-----BEGIN CERTIFICATE-----) и сырой DER.
|
||||||
|
func downloadAndParseCert(ctx context.Context, rawURL string) ([]byte, error) {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("не URL: %w", err)
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return nil, fmt.Errorf("поддерживаются только http/https, получено %q", u.Scheme)
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||||
|
// noProxyClient определён в news.go — игнорирует HTTPS_PROXY (zetit).
|
||||||
|
resp, err := noProxyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("сеть: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Пробуем PEM.
|
||||||
|
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
||||||
|
return block.Bytes, nil
|
||||||
|
}
|
||||||
|
// Иначе считаем что DER.
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCertToDir сохраняет DER-байты в /var/lib/bj/ca-certs/<sha>.cer.
|
||||||
|
func saveCertToDir(der []byte, sha256hex string) error {
|
||||||
|
if err := os.MkdirAll(caCertsDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst := filepath.Join(caCertsDir, sha256hex+".cer")
|
||||||
|
return os.WriteFile(dst, der, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCACertsAutoUpdater запускает горутину, которая раз в сутки
|
||||||
|
// перекачивает сертификаты УЦ и переустанавливает изменённые. Возвращает
|
||||||
|
// функцию остановки. Если AutoUpdate=false — фон не запускается.
|
||||||
|
func StartCACertsAutoUpdater(rc *RuntimeConfig) func() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
// При старте — небольшой запас, чтобы не лезть в сеть в ту же
|
||||||
|
// секунду запуска bj-server.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
s := rc.Snapshot().CACerts
|
||||||
|
if s.AutoUpdate && len(s.URLs) > 0 {
|
||||||
|
updated, _ := FetchCACertificates(ctx, s, rc)
|
||||||
|
if err := rc.UpdateCACerts(updated); err != nil {
|
||||||
|
log.Printf("ca-certs auto-update: save failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("ca-certs auto-update: %d url'ов проверено", len(s.URLs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveCACerts — POST /admin/setup/cacerts.
|
||||||
|
// Принимает форму с textarea (одна URL на строку) и чекбоксом auto_update.
|
||||||
|
func (h *setupHandlers) saveCACerts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw := r.FormValue("urls")
|
||||||
|
auto := r.FormValue("auto_update") == "on"
|
||||||
|
urls := []string{}
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" && !strings.HasPrefix(line, "#") {
|
||||||
|
urls = append(urls, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cur := h.rc.Snapshot().CACerts
|
||||||
|
cur.URLs = urls
|
||||||
|
cur.AutoUpdate = auto
|
||||||
|
if err := h.rc.UpdateCACerts(cur); err != nil {
|
||||||
|
setupFlash(w, r, "Сертификаты УЦ: не получилось сохранить: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupFlash(w, r, fmt.Sprintf("Сертификаты УЦ: сохранено %d URL'ов, авто-обновление: %v", len(urls), auto))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchCACertsNow — POST /admin/setup/cacerts/fetch.
|
||||||
|
// Ручной триггер «скачать сейчас», вызывает FetchCACertificates сразу.
|
||||||
|
func (h *setupHandlers) fetchCACertsNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cur := h.rc.Snapshot().CACerts
|
||||||
|
updated, summary := FetchCACertificates(ctx, cur, h.rc)
|
||||||
|
if err := h.rc.UpdateCACerts(updated); err != nil {
|
||||||
|
setupFlash(w, r, "Сертификаты УЦ: ошибка сохранения: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary == "" {
|
||||||
|
summary = "готово"
|
||||||
|
}
|
||||||
|
// Обрезаем длинный лог в flash-сообщении.
|
||||||
|
if len(summary) > 800 {
|
||||||
|
summary = summary[:800] + "…"
|
||||||
|
}
|
||||||
|
setupFlash(w, r, "Сертификаты УЦ обновлены: "+strings.TrimSpace(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// caCertsTemplateString — компактный URL для отображения в UI.
|
||||||
|
func caCertsTemplateString(s CACertsSettings) string {
|
||||||
|
return strings.Join(s.URLs, "\n")
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -15,6 +17,9 @@ type Status struct {
|
|||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
|
// Optional — компонент не обязателен для работы с НРД. Его «не-OK»
|
||||||
|
// не делает систему «не готовой» (напр. callback в ЛК).
|
||||||
|
Optional bool `json:"optional,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemStatus — все проверки.
|
// SystemStatus — все проверки.
|
||||||
@@ -55,17 +60,30 @@ func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPostgres(_ context.Context, o CheckOptions) Status {
|
func checkPostgres(ctx context.Context, o CheckOptions) Status {
|
||||||
s := Status{Name: "postgres"}
|
s := Status{Name: "База данных PostgreSQL"}
|
||||||
if o.PostgresDSN == "" {
|
if o.PostgresDSN == "" {
|
||||||
s.OK = true
|
s.OK = true
|
||||||
s.Message = "in-memory (PostgresDSN не задан, репозиторий — m2mcore.MemoryRepository)"
|
s.Optional = true
|
||||||
|
s.Message = "in-memory — данные не сохраняются между перезапусками"
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
// На M2 здесь будет sql.Open + Ping. На текущем шаге — заглушка.
|
pctx, cancel := context.WithTimeout(ctx, o.Timeout)
|
||||||
s.OK = false
|
defer cancel()
|
||||||
s.Message = "PostgreSQL Repository не подключён (требуется pgx, M2-шаг-3)"
|
pool, err := pgxpool.New(pctx, o.PostgresDSN)
|
||||||
s.Detail = "DSN: " + o.PostgresDSN
|
if err != nil {
|
||||||
|
s.OK = false
|
||||||
|
s.Message = "ошибка подключения: " + err.Error()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
if err := pool.Ping(pctx); err != nil {
|
||||||
|
s.OK = false
|
||||||
|
s.Message = "не отвечает: " + err.Error()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.OK = true
|
||||||
|
s.Message = "подключена, репозиторий m2m_core.deals"
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,20 +129,27 @@ func checkCryptoSocket(o CheckOptions) Status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
|
func checkNSDAdapter(ctx context.Context, o CheckOptions) Status {
|
||||||
s := Status{Name: "nsd-adapter (REST к ИШ)"}
|
s := Status{Name: "Интеграционный шлюз НРД"}
|
||||||
if o.NSDAdapterURL == "" {
|
if o.NSDAdapterURL == "" {
|
||||||
s.OK = true
|
s.OK = true
|
||||||
s.Message = "BJ_NSD_ADAPTER_URL не задан — используется mock NSDSender"
|
s.Optional = true
|
||||||
|
s.Message = "не подключён — режим эмуляции (mock)"
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return httpHealth(ctx, o.NSDAdapterURL+"/healthz", o.Timeout, s)
|
// У ИШ нет /healthz — проверяем рабочий эндпоинт Web API (engine/state
|
||||||
|
// отвечает 200 «Running», когда движок поднят).
|
||||||
|
st := httpHealth(ctx, o.NSDAdapterURL+"/api/admin/engine/state", o.Timeout, s)
|
||||||
|
if st.OK {
|
||||||
|
st.Message = "подключён, движок работает"
|
||||||
|
}
|
||||||
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLKCallback(ctx context.Context, o CheckOptions) Status {
|
func checkLKCallback(ctx context.Context, o CheckOptions) Status {
|
||||||
s := Status{Name: "lk-emulator (callback)"}
|
s := Status{Name: "Callback в личный кабинет", Optional: true}
|
||||||
if o.LKCallbackURL == "" {
|
if o.LKCallbackURL == "" {
|
||||||
s.OK = false
|
s.OK = false
|
||||||
s.Message = "BJ_LK_CALLBACK_URL не задан — callback'и в ЛК отключены"
|
s.Message = "не настроен — уведомления в ЛК отключены (необязательно для работы с НРД)"
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
|
return httpHealth(ctx, o.LKCallbackURL+"/healthz", o.Timeout, s)
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||||
|
)
|
||||||
|
|
||||||
|
// urlQ — экранирование строки для query-параметра flash.
|
||||||
|
func urlQ(s string) string { return url.QueryEscape(s) }
|
||||||
|
|
||||||
|
// Пошаговый мастер установки ключа Валидаты на съёмный носитель (USB keymedia
|
||||||
|
// ИШ). Реализует то, что просил пользователь: загрузил архив + пароль →
|
||||||
|
// распаковка → запись на флешку → формирование справочника сертификатов →
|
||||||
|
// проверка Валидаты → «Готово» → можно слать тестовый документ.
|
||||||
|
//
|
||||||
|
// Привилегированные операции с флешкой (бэкап, remount rw, запись, перезапуск
|
||||||
|
// VDCrySvc) делает помощник /usr/local/sbin/bj-keymedia-install через узкий
|
||||||
|
// sudoers (bj-server работает под непривилегированным bj).
|
||||||
|
|
||||||
|
|
||||||
|
// keyWizardStep — один шаг мастера.
|
||||||
|
type keyWizardStep struct {
|
||||||
|
Title string
|
||||||
|
Status string // pending | active | ok | error
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyWizardState — состояние одного прогона мастера (в памяти, один активный).
|
||||||
|
type keyWizardState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
StagingID string // id распаковки в /var/lib/bj/media/iso/<id>
|
||||||
|
VDK string // имя файла ключа
|
||||||
|
Profile string // имя установленного профиля на носителе
|
||||||
|
Backup string // путь бэкапа
|
||||||
|
Steps []keyWizardStep // 1..5
|
||||||
|
Done bool // все шаги пройдены
|
||||||
|
Flash string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKeyWizardState() *keyWizardState {
|
||||||
|
return &keyWizardState{Steps: defaultKeySteps()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset обнуляет поля прогона, НЕ трогая мьютекс (вызывать под Lock).
|
||||||
|
func (s *keyWizardState) reset() {
|
||||||
|
s.StagingID = ""
|
||||||
|
s.VDK = ""
|
||||||
|
s.Profile = ""
|
||||||
|
s.Backup = ""
|
||||||
|
s.Steps = defaultKeySteps()
|
||||||
|
s.Done = false
|
||||||
|
s.Flash = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultKeySteps() []keyWizardStep {
|
||||||
|
return []keyWizardStep{
|
||||||
|
{Title: "Загрузка архива и распаковка", Status: "pending"},
|
||||||
|
{Title: "Запись ключа на выбранную флешку (с бэкапом)", Status: "pending"},
|
||||||
|
{Title: "Формирование справочника сертификатов (CRL)", Status: "pending"},
|
||||||
|
{Title: "Перезапуск и проверка ИШ", Status: "pending"},
|
||||||
|
{Title: "Готово — можно отправлять тестовый документ", Status: "pending"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *keyWizardState) set(i int, status, detail string) {
|
||||||
|
if i >= 0 && i < len(s.Steps) {
|
||||||
|
s.Steps[i].Status = status
|
||||||
|
if detail != "" {
|
||||||
|
s.Steps[i].Detail = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flashDrive — съёмный носитель (USB), обнаруженный в системе.
|
||||||
|
type flashDrive struct {
|
||||||
|
Device string // /dev/sdb1
|
||||||
|
Size string // 1,9G
|
||||||
|
Label string
|
||||||
|
FSType string
|
||||||
|
Mountpoint string // пусто если не смонтирован
|
||||||
|
Model string // USB2FlashStorage
|
||||||
|
IsKeymedia bool // смонтирован как текущий ключевой носитель ИШ
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyWizardData — данные шаблона admin_keywizard.html.
|
||||||
|
type keyWizardData struct {
|
||||||
|
page
|
||||||
|
State *keyWizardState
|
||||||
|
Drives []flashDrive
|
||||||
|
}
|
||||||
|
|
||||||
|
const keymediaMount = "/var/lib/igate/keymedia"
|
||||||
|
|
||||||
|
// listFlashDrives перечисляет съёмные (removable/hotplug) USB-носители с ФС —
|
||||||
|
// чтобы пользователь выбрал, на какую флешку писать ключ.
|
||||||
|
func listFlashDrives() []flashDrive {
|
||||||
|
out, err := exec.Command("lsblk", "-J", "-b", "-o",
|
||||||
|
"NAME,SIZE,LABEL,MOUNTPOINT,RM,HOTPLUG,TYPE,MODEL,FSTYPE,PATH").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Blockdevices []json.RawMessage `json:"blockdevices"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(out, &parsed) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var drives []flashDrive
|
||||||
|
var walk func(raw []byte, parentRemovable bool, parentModel string)
|
||||||
|
walk = func(raw []byte, parentRemovable bool, parentModel string) {
|
||||||
|
var d struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
RM bool `json:"rm"`
|
||||||
|
Hotplug bool `json:"hotplug"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
FSType string `json:"fstype"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Children []json.RawMessage `json:"children"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &d) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
removable := d.RM || d.Hotplug || parentRemovable
|
||||||
|
model := strings.TrimSpace(d.Model)
|
||||||
|
if model == "" {
|
||||||
|
model = parentModel
|
||||||
|
}
|
||||||
|
// Носитель с ФС — кандидат на запись.
|
||||||
|
if removable && d.Type == "part" && d.FSType != "" {
|
||||||
|
drives = append(drives, flashDrive{
|
||||||
|
Device: d.Path,
|
||||||
|
Size: humanSize(d.Size),
|
||||||
|
Label: d.Label,
|
||||||
|
FSType: d.FSType,
|
||||||
|
Mountpoint: d.Mountpoint,
|
||||||
|
Model: model,
|
||||||
|
IsKeymedia: d.Mountpoint == keymediaMount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, c := range d.Children {
|
||||||
|
walk(c, removable, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range parsed.Blockdevices {
|
||||||
|
walk(b, false, "")
|
||||||
|
}
|
||||||
|
return drives
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanSize(b int64) string {
|
||||||
|
switch {
|
||||||
|
case b >= 1<<30:
|
||||||
|
return fmt.Sprintf("%.1f ГБ", float64(b)/(1<<30))
|
||||||
|
case b >= 1<<20:
|
||||||
|
return fmt.Sprintf("%.0f МБ", float64(b)/(1<<20))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d Б", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerKeyWizard вешает маршруты мастера установки ключа.
|
||||||
|
func (h *setupHandlers) registerKeyWizard(mux *http.ServeMux) {
|
||||||
|
if h.keyWiz == nil {
|
||||||
|
h.keyWiz = newKeyWizardState()
|
||||||
|
}
|
||||||
|
mux.HandleFunc("/admin/setup/keywizard", h.renderKeyWizard)
|
||||||
|
mux.HandleFunc("/admin/setup/keywizard/upload", h.keyWizardUpload)
|
||||||
|
mux.HandleFunc("/admin/setup/keywizard/install", h.keyWizardInstall)
|
||||||
|
mux.HandleFunc("/admin/setup/keywizard/reset", h.keyWizardReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *setupHandlers) renderKeyWizard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.keyWiz.mu.Lock()
|
||||||
|
h.keyWiz.Flash = r.URL.Query().Get("flash")
|
||||||
|
st := h.keyWiz
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
// Список флешек нужен на шаге выбора носителя (когда архив уже загружен).
|
||||||
|
var drives []flashDrive
|
||||||
|
if st.StagingID != "" && !st.Done {
|
||||||
|
drives = listFlashDrives()
|
||||||
|
}
|
||||||
|
render(w, h.tpl.a.keyWizard, keyWizardData{page: nowPage("Установка ключа", "setup"), State: st, Drives: drives})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *setupHandlers) keyWizardReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.keyWiz.mu.Lock()
|
||||||
|
h.keyWiz.reset()
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyWizardUpload — шаг 1: приём .7z + пароль, распаковка, инспекция.
|
||||||
|
func (h *setupHandlers) keyWizardUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(500 << 20); err != nil {
|
||||||
|
h.keyWizFlash(w, r, "Ошибка чтения формы: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("archive")
|
||||||
|
if err != nil {
|
||||||
|
h.keyWizFlash(w, r, "Выберите файл архива (.7z/.zip)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
lower := strings.ToLower(header.Filename)
|
||||||
|
if !(strings.HasSuffix(lower, ".7z") || strings.HasSuffix(lower, ".zip")) {
|
||||||
|
h.keyWizFlash(w, r, "Архив должен быть .7z или .zip")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
isoDir := "/var/lib/bj/iso"
|
||||||
|
if err := os.MkdirAll(isoDir, 0o755); err != nil {
|
||||||
|
h.keyWizFlash(w, r, "Не удалось создать "+isoDir+": "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := filepath.Join(isoDir, time.Now().UTC().Format("20060102-150405-")+filepath.Base(header.Filename))
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
|
out.Close()
|
||||||
|
_ = os.Remove(dst)
|
||||||
|
h.keyWizFlash(w, r, "Запись архива: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
m, err := ExtractISO(ctx, dst, password)
|
||||||
|
if err != nil {
|
||||||
|
h.keyWizFlash(w, r, "Распаковка не удалась (проверьте пароль): "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.keyWiz.mu.Lock()
|
||||||
|
h.keyWiz.reset()
|
||||||
|
h.keyWiz.StagingID = m.ID
|
||||||
|
// Инспекция через помощник (читает staging, находит .vdk/gdbm/pse).
|
||||||
|
staging := filepath.Join("/var/lib/bj/media/iso", m.ID)
|
||||||
|
insp, ierr := runKeymediaHelper(ctx, "inspect", staging, "", "", "")
|
||||||
|
if ierr == nil && insp["ok"] == true {
|
||||||
|
if v, ok := insp["vdk"].(string); ok {
|
||||||
|
h.keyWiz.VDK = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("Ключ: %s · справочник сертификатов: %s",
|
||||||
|
fallback(h.keyWiz.VDK, "—"), yesNo(insp["has_gdbm"]))
|
||||||
|
h.keyWiz.set(0, "ok", detail)
|
||||||
|
h.keyWiz.set(1, "active", "")
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/setup/keywizard", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyWizardInstall — шаги 2-5: запись на флешку, справочник, проверка, готово.
|
||||||
|
func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.keyWiz.mu.Lock()
|
||||||
|
id := h.keyWiz.StagingID
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
if id == "" {
|
||||||
|
h.keyWizFlash(w, r, "Сначала загрузите архив (шаг 1)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
staging := filepath.Join("/var/lib/bj/media/iso", id)
|
||||||
|
|
||||||
|
// Выбор флешки и имя профиля из формы.
|
||||||
|
profileName := strings.TrimSpace(r.FormValue("profile_name"))
|
||||||
|
targetDev := strings.TrimSpace(r.FormValue("target_device"))
|
||||||
|
targetMnt := ""
|
||||||
|
if targetDev != "" {
|
||||||
|
for _, d := range listFlashDrives() {
|
||||||
|
if d.Device == targetDev {
|
||||||
|
targetMnt = d.Mountpoint
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Шаг 2-3: запись ключа на флешку + справочник + CRL (привилегированный воркер).
|
||||||
|
res, err := runKeymediaHelper(ctx, "install", staging, profileName, targetDev, targetMnt)
|
||||||
|
h.keyWiz.mu.Lock()
|
||||||
|
if err != nil || res["ok"] != true {
|
||||||
|
msg := errStr(err)
|
||||||
|
if e, ok := res["error"].(string); ok {
|
||||||
|
msg = e
|
||||||
|
}
|
||||||
|
h.keyWiz.set(1, "error", "Запись на флешку не удалась: "+msg)
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
h.keyWizFlash(w, r, "Установка прервана: "+msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p, ok := res["profile"].(string); ok {
|
||||||
|
h.keyWiz.Profile = p
|
||||||
|
}
|
||||||
|
if b, ok := res["backup"].(string); ok {
|
||||||
|
h.keyWiz.Backup = b
|
||||||
|
}
|
||||||
|
tgt, _ := res["target"].(string)
|
||||||
|
spr, _ := res["spr"].(string)
|
||||||
|
h.keyWiz.set(1, "ok", fmt.Sprintf("Профиль «%s» записан на %s. Бэкап: %s", h.keyWiz.Profile, fallback(tgt, "носитель"), h.keyWiz.Backup))
|
||||||
|
crl, _ := res["crl"].(string)
|
||||||
|
h.keyWiz.set(2, "ok", fmt.Sprintf("Справочник «%s» сформирован. CRL: %s", fallback(spr, "—"), crlRu(crl)))
|
||||||
|
h.keyWiz.set(3, "active", "")
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
|
||||||
|
// Шаг 4: перезапуск и проверка ИШ. Перезапуск VDCrySvc мог сбросить
|
||||||
|
// активацию серверного профиля bj-crypto — восстанавливаем. Затем
|
||||||
|
// перезапускаем ИШ и проверяем engine/state: ИШ поднялся с новым ключом.
|
||||||
|
h.reactivateCryptoProfile(ctx)
|
||||||
|
ishOK, ishMsg := h.restartAndVerifyISH(ctx)
|
||||||
|
h.keyWiz.mu.Lock()
|
||||||
|
if !ishOK {
|
||||||
|
h.keyWiz.set(3, "error", "ИШ не подтвердил готовность: "+ishMsg)
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
h.keyWizFlash(w, r, "Ключ записан, но ИШ не готов: "+ishMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.keyWiz.set(3, "ok", "ИШ перезапущен и работает: "+ishMsg)
|
||||||
|
h.keyWiz.set(4, "ok", "Теперь подпишите новым ключом — отправьте тестовый документ роботу НРД ниже")
|
||||||
|
h.keyWiz.Done = true
|
||||||
|
h.keyWiz.mu.Unlock()
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ("Готово! Ключ на флешке, справочник сформирован, ИШ перезапущен и работает. Финальная проверка — тестовым документом роботу."), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartAndVerifyISH перезапускает Интеграционный шлюз и проверяет, что он
|
||||||
|
// поднялся (engine/state). Возвращает (ok, сообщение).
|
||||||
|
func (h *setupHandlers) restartAndVerifyISH(ctx context.Context) (bool, string) {
|
||||||
|
// Перезапуск igate через привилегированный воркер (bj не sudoer).
|
||||||
|
res, err := runKeymediaHelper(ctx, "restart-ish", "/var/lib/bj/media/iso", "", "", "")
|
||||||
|
if err != nil || res["ok"] != true {
|
||||||
|
// restart-ish может быть не поддержан — не критично, проверим состояние.
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
// Проверяем состояние ИШ через nsd-адаптер (engine/state).
|
||||||
|
deadline := time.Now().Add(40 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if st := h.ishEngineState(ctx); st != "" {
|
||||||
|
return true, "engine "+st
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, "таймаут"
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, "ИШ не ответил на /api/admin/engine/state за 40 сек"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ishEngineState запрашивает состояние движка ИШ; пусто если недоступен.
|
||||||
|
func (h *setupHandlers) ishEngineState(ctx context.Context) string {
|
||||||
|
s := h.rc.Snapshot()
|
||||||
|
base := s.NSD.IGWBaseURL
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(base, "/")+"/api/admin/engine/state", nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cl := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := cl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func crlRu(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "updated":
|
||||||
|
return "обновлены из точек распространения"
|
||||||
|
case "failed":
|
||||||
|
return "не удалось обновить (проверьте сеть/CDP)"
|
||||||
|
case "skip":
|
||||||
|
return "пропущено"
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reactivateCryptoProfile повторно активирует текущий серверный профиль
|
||||||
|
// bj-crypto (после перезапуска VDCrySvc активация в сайдкаре сбрасывается).
|
||||||
|
// Best-effort: возвращает true при успехе.
|
||||||
|
func (h *setupHandlers) reactivateCryptoProfile(ctx context.Context) bool {
|
||||||
|
s := h.rc.Snapshot()
|
||||||
|
if s.Crypto.Profile == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cli := cryptocli.New(cryptocli.Config{
|
||||||
|
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||||||
|
SocketPath: s.Crypto.SocketPath,
|
||||||
|
})
|
||||||
|
defer cli.Close()
|
||||||
|
res, err := cli.Activate(ctx, s.Crypto.Profile)
|
||||||
|
return err == nil && res.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
// vdcrysvcActive проверяет, что демон Валидаты (vdmkdev) запущен.
|
||||||
|
func vdcrysvcActive() bool {
|
||||||
|
out, _ := exec.Command("systemctl", "is-active", "vdmkdev.service").Output()
|
||||||
|
return strings.TrimSpace(string(out)) == "active"
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolRu(b bool, yes, no string) string {
|
||||||
|
if b {
|
||||||
|
return yes
|
||||||
|
}
|
||||||
|
return no
|
||||||
|
}
|
||||||
|
|
||||||
|
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
|
||||||
|
|
||||||
|
// runKeymediaHelper передаёт запрос привилегированному воркеру через файловый
|
||||||
|
// обмен: bj-server (в песочнице) пишет <id>.req, root-сервис bj-keymedia
|
||||||
|
// (host namespace, триггерится bj-keymedia.path) выполняет операцию с флешкой
|
||||||
|
// и пишет <id>.res. bj-server опрашивает результат. Так привилегированная
|
||||||
|
// работа идёт вне mount-namespace песочницы, где доступно перемонтирование USB.
|
||||||
|
func runKeymediaHelper(ctx context.Context, action, staging, profile, targetDev, targetMnt string) (map[string]any, error) {
|
||||||
|
id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
|
||||||
|
reqPath := filepath.Join(keymediaReqDir, id+".req")
|
||||||
|
resPath := filepath.Join(keymediaReqDir, id+".res")
|
||||||
|
defer os.Remove(resPath)
|
||||||
|
|
||||||
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
|
"action": action, "staging": staging, "profile": profile,
|
||||||
|
"target_dev": targetDev, "target_mnt": targetMnt,
|
||||||
|
})
|
||||||
|
// Пишем атомарно (tmp → rename), чтобы .path не подхватил полупустой файл.
|
||||||
|
tmp := reqPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
|
||||||
|
return nil, fmt.Errorf("запись запроса: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, reqPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("публикация запроса: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Опрос результата.
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("таймаут ожидания воркера установки ключа")
|
||||||
|
case <-ticker.C:
|
||||||
|
b, err := os.ReadFile(resPath)
|
||||||
|
if err != nil {
|
||||||
|
continue // ещё не готово
|
||||||
|
}
|
||||||
|
res := map[string]any{}
|
||||||
|
if jerr := json.Unmarshal(b, &res); jerr != nil {
|
||||||
|
return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
if res["ok"] != true {
|
||||||
|
msg, _ := res["error"].(string)
|
||||||
|
return res, fmt.Errorf("воркер: %s", fallback(msg, "ошибка"))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *setupHandlers) keyWizFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||||||
|
http.Redirect(w, r, "/admin/setup/keywizard?flash="+urlQ(msg), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallback(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func yesNo(v any) string {
|
||||||
|
if b, ok := v.(bool); ok && b {
|
||||||
|
return "да"
|
||||||
|
}
|
||||||
|
return "нет"
|
||||||
|
}
|
||||||
|
|
||||||
|
func errStr(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "неизвестная ошибка"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLicensePublicKey — публичный ключ лицензий, зашитый в релиз.
|
||||||
|
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
||||||
|
// настройках (LicenseSettings.PublicKey) — приоритет у настроек.
|
||||||
|
var DefaultLicensePublicKey = ""
|
||||||
|
|
||||||
|
// LicenseStatus — сводка состояния лицензии для UI и гейтов.
|
||||||
|
type LicenseStatus struct {
|
||||||
|
Present bool // ключ введён
|
||||||
|
Valid bool // подпись верна и срок не истёк
|
||||||
|
Tenant string
|
||||||
|
Plan string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
DaysLeft int
|
||||||
|
AllowsUpdates bool
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// licensingEnabled — включено ли лицензирование (есть публичный ключ для
|
||||||
|
// проверки). Если ключа нет вовсе — продукт в открытом режиме, гейты не
|
||||||
|
// действуют (удобно для разработки и бесплатной редакции).
|
||||||
|
func licensingEnabled(rc *RuntimeConfig) bool {
|
||||||
|
return rc.Snapshot().License.PublicKey != "" || DefaultLicensePublicKey != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// licenseStatus разбирает и проверяет лицензию из настроек.
|
||||||
|
func licenseStatus(rc *RuntimeConfig) LicenseStatus {
|
||||||
|
s := rc.Snapshot().License
|
||||||
|
st := LicenseStatus{}
|
||||||
|
if s.Key == "" {
|
||||||
|
st.Message = "лицензионный ключ не введён"
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
st.Present = true
|
||||||
|
|
||||||
|
pubB64 := s.PublicKey
|
||||||
|
if pubB64 == "" {
|
||||||
|
pubB64 = DefaultLicensePublicKey
|
||||||
|
}
|
||||||
|
if pubB64 == "" {
|
||||||
|
st.Message = "нет публичного ключа для проверки лицензии"
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
pub, err := license.ParsePublicKey(pubB64)
|
||||||
|
if err != nil {
|
||||||
|
st.Message = "неверный публичный ключ: " + err.Error()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
tok, err := license.DecodeToken(s.Key)
|
||||||
|
if err != nil {
|
||||||
|
st.Message = "неверный формат ключа: " + err.Error()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
lic, err := license.Verify(tok, pub)
|
||||||
|
if err != nil {
|
||||||
|
st.Message = "подпись лицензии недействительна"
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
st.Tenant = lic.Tenant
|
||||||
|
st.Plan = string(lic.Plan)
|
||||||
|
st.ExpiresAt = lic.ExpiresAt
|
||||||
|
st.DaysLeft = lic.DaysLeft(now)
|
||||||
|
st.AllowsUpdates = lic.AllowsUpdates()
|
||||||
|
if err := lic.Valid(now); err != nil {
|
||||||
|
st.Message = err.Error()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
st.Valid = true
|
||||||
|
st.Message = "активна до " + lic.ExpiresAt.Format("02.01.2006")
|
||||||
|
return st
|
||||||
|
}
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/asn1"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
|
||||||
|
// импортированные ключевые контейнеры. На прод-машине пользователь bj
|
||||||
|
// должен быть владельцем этой директории (создаётся install.sh).
|
||||||
|
const (
|
||||||
|
mediaRoot = "/var/lib/bj/media"
|
||||||
|
mediaISODir = "/var/lib/bj/media/iso"
|
||||||
|
containersDir = "/var/lib/bj/containers"
|
||||||
|
profilesDir = "/var/lib/bj/profiles"
|
||||||
|
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
|
||||||
|
)
|
||||||
|
|
||||||
|
// Medium — один носитель: USB-флешка или распакованная ISO.
|
||||||
|
type Medium struct {
|
||||||
|
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
|
||||||
|
// для ISO — sha256-prefix от исходного файла).
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Kind — "usb" или "iso".
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
// Mountpoint — корень, по которому сейчас доступен носитель.
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
// Source — для ISO: путь до исходного .iso на сервере.
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
|
||||||
|
Profile *ValidataProfile `json:"profile,omitempty"`
|
||||||
|
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
|
||||||
|
Containers []KeyContainer `json:"containers"`
|
||||||
|
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
|
||||||
|
Certificates []CertFile `json:"certificates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
|
||||||
|
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
|
||||||
|
type ValidataProfile struct {
|
||||||
|
Root string `json:"root"` // mountpoint, где найден профиль
|
||||||
|
PSEFiles []string `json:"pse_files"` // относительные пути до .pse
|
||||||
|
GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm
|
||||||
|
KeyFiles []string `json:"key_files"` // относительные пути до .vdk
|
||||||
|
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
|
||||||
|
type KeyContainer struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"` // имя последней компоненты пути
|
||||||
|
Files []string `json:"files"` // имена файлов в контейнере
|
||||||
|
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertFile — публичный или PKCS#12 сертификат.
|
||||||
|
type CertFile struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Format string `json:"format"` // "cer" | "pem" | "pfx"
|
||||||
|
SubjectCN string `json:"subject_cn"`
|
||||||
|
IssuerCN string `json:"issuer_cn"`
|
||||||
|
Serial string `json:"serial"`
|
||||||
|
NotBefore time.Time `json:"not_before"`
|
||||||
|
NotAfter time.Time `json:"not_after"`
|
||||||
|
INN string `json:"inn,omitempty"`
|
||||||
|
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
|
||||||
|
ParseError string `json:"parse_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanMedia собирает список всех видимых носителей: USB + распакованные
|
||||||
|
// ISO. Безопасна для частых вызовов — IO ограничен директориями верхнего
|
||||||
|
// уровня в типичных mount-точках.
|
||||||
|
func ScanMedia() []Medium {
|
||||||
|
var out []Medium
|
||||||
|
out = append(out, scanUSB()...)
|
||||||
|
out = append(out, listExtractedISOs()...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanUSB ищет USB-монтирования в /run/media/$USER, /media/$USER, /media, /mnt.
|
||||||
|
func scanUSB() []Medium {
|
||||||
|
u, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
roots := []string{
|
||||||
|
filepath.Join("/run/media", u.Username),
|
||||||
|
filepath.Join("/media", u.Username),
|
||||||
|
"/media",
|
||||||
|
"/mnt",
|
||||||
|
}
|
||||||
|
var out []Medium
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, root := range roots {
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mountpoint := filepath.Join(root, e.Name())
|
||||||
|
// Не лезем в наши собственные /var/lib/bj/media/iso/*.
|
||||||
|
if strings.HasPrefix(mountpoint, mediaISODir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[mountpoint] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mountpoint] = true
|
||||||
|
out = append(out, scanMountpoint("usb", mountpoint, ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// listExtractedISOs возвращает все ранее распакованные ISO в /var/lib/bj/media/iso/.
|
||||||
|
func listExtractedISOs() []Medium {
|
||||||
|
entries, err := os.ReadDir(mediaISODir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []Medium
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := e.Name()
|
||||||
|
mountpoint := filepath.Join(mediaISODir, id)
|
||||||
|
source := readISOSource(id)
|
||||||
|
m := scanMountpoint("iso", mountpoint, source)
|
||||||
|
m.ID = id
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
|
||||||
|
func scanMountpoint(kind, mountpoint, source string) Medium {
|
||||||
|
m := Medium{
|
||||||
|
ID: sha1Path(mountpoint),
|
||||||
|
Kind: kind,
|
||||||
|
Mountpoint: mountpoint,
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
containers, certs, profile := walkForArtifacts(mountpoint)
|
||||||
|
m.Containers = containers
|
||||||
|
m.Certificates = certs
|
||||||
|
m.Profile = profile
|
||||||
|
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
|
||||||
|
for i := range m.Containers {
|
||||||
|
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
|
||||||
|
m.Containers[i].Imported = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
|
||||||
|
// есть директория с тем же именем (имя берётся от носителя).
|
||||||
|
if m.Profile != nil {
|
||||||
|
name := filepath.Base(mountpoint)
|
||||||
|
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
|
||||||
|
m.Profile.Imported = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
|
||||||
|
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
|
||||||
|
// - отдельные сертификаты (.cer/.pfx/...);
|
||||||
|
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
|
||||||
|
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
|
||||||
|
var containers []KeyContainer
|
||||||
|
var certs []CertFile
|
||||||
|
prof := &ValidataProfile{Root: root}
|
||||||
|
|
||||||
|
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(root, p)
|
||||||
|
depth := strings.Count(rel, string(filepath.Separator))
|
||||||
|
if depth > 4 {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if p != root {
|
||||||
|
if c, ok := classifyContainer(p); ok {
|
||||||
|
containers = append(containers, c)
|
||||||
|
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
|
||||||
|
// .vdk-файлы для определения профиля Валидаты.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(info.Name())
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lower, ".pse"):
|
||||||
|
prof.PSEFiles = append(prof.PSEFiles, rel)
|
||||||
|
case strings.HasSuffix(lower, ".gdbm"):
|
||||||
|
prof.GDBMFiles = append(prof.GDBMFiles, rel)
|
||||||
|
case strings.HasSuffix(lower, ".vdk"):
|
||||||
|
prof.KeyFiles = append(prof.KeyFiles, rel)
|
||||||
|
default:
|
||||||
|
if cert := classifyCertFile(p); cert != nil {
|
||||||
|
certs = append(certs, *cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Профилем считаем носитель если есть и pse, и vdk (gdbm
|
||||||
|
// опционален — но обычно тоже присутствует).
|
||||||
|
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
|
||||||
|
prof = nil
|
||||||
|
}
|
||||||
|
return containers, certs, prof
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyContainer — директория является ключевым контейнером, если:
|
||||||
|
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
|
||||||
|
// - в ней >=1 файл *.vdk (Валидата Linux).
|
||||||
|
func classifyContainer(dir string) (KeyContainer, bool) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return KeyContainer{}, false
|
||||||
|
}
|
||||||
|
var keyFiles, vdkFiles, allFiles []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
allFiles = append(allFiles, name)
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lower, ".vdk"):
|
||||||
|
vdkFiles = append(vdkFiles, name)
|
||||||
|
case strings.HasSuffix(lower, ".key"):
|
||||||
|
keyFiles = append(keyFiles, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
|
||||||
|
return KeyContainer{}, false
|
||||||
|
}
|
||||||
|
return KeyContainer{
|
||||||
|
Path: dir,
|
||||||
|
Name: filepath.Base(dir),
|
||||||
|
Files: allFiles,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyCertFile парсит один файл — возвращает CertFile если это
|
||||||
|
// похоже на сертификат.
|
||||||
|
func classifyCertFile(path string) *CertFile {
|
||||||
|
lower := strings.ToLower(path)
|
||||||
|
var format string
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
|
||||||
|
format = "cer"
|
||||||
|
case strings.HasSuffix(lower, ".pem"):
|
||||||
|
format = "pem"
|
||||||
|
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
|
||||||
|
format = "pfx"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cf := &CertFile{
|
||||||
|
Path: path,
|
||||||
|
Name: filepath.Base(path),
|
||||||
|
Format: format,
|
||||||
|
}
|
||||||
|
if format == "pfx" {
|
||||||
|
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
|
||||||
|
cf.HasPrivateKey = true
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
cf.ParseError = "read: " + err.Error()
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
if len(data) > 32*1024 {
|
||||||
|
// Странно большой файл для сертификата — режем.
|
||||||
|
data = data[:32*1024]
|
||||||
|
}
|
||||||
|
der := data
|
||||||
|
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
||||||
|
der = block.Bytes
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
cf.ParseError = "x509: " + err.Error()
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
cf.SubjectCN = cert.Subject.CommonName
|
||||||
|
cf.IssuerCN = cert.Issuer.CommonName
|
||||||
|
cf.Serial = cert.SerialNumber.Text(16)
|
||||||
|
cf.NotBefore = cert.NotBefore
|
||||||
|
cf.NotAfter = cert.NotAfter
|
||||||
|
cf.INN = extractCertINN(cert)
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
|
||||||
|
func extractCertINN(c *x509.Certificate) string {
|
||||||
|
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
||||||
|
for _, name := range c.Subject.Names {
|
||||||
|
if name.Type.Equal(innOID) {
|
||||||
|
if s, ok := name.Value.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
|
||||||
|
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
|
||||||
|
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
|
||||||
|
// пути. Возвращает Medium или ошибку.
|
||||||
|
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
|
||||||
|
abs, err := filepath.Abs(isoPath)
|
||||||
|
if err != nil {
|
||||||
|
return Medium{}, fmt.Errorf("ISO путь: %w", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := isoID(abs)
|
||||||
|
dst := filepath.Join(mediaISODir, id)
|
||||||
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||||
|
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
if isEmpty, _ := dirEmpty(dst); !isEmpty {
|
||||||
|
// Уже распакован раньше — просто пересканируем.
|
||||||
|
writeISOSource(id, abs)
|
||||||
|
m := scanMountpoint("iso", dst, abs)
|
||||||
|
m.ID = id
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
// 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
|
||||||
|
args := []string{"x", "-y", "-o" + dst}
|
||||||
|
if password != "" {
|
||||||
|
// 7z требует пароль через -p без пробела.
|
||||||
|
args = append(args, "-p"+password)
|
||||||
|
} else {
|
||||||
|
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
|
||||||
|
args = append(args, "-p-")
|
||||||
|
}
|
||||||
|
args = append(args, abs)
|
||||||
|
cmd := exec.CommandContext(cctx, "7z", args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(dst)
|
||||||
|
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
writeISOSource(id, abs)
|
||||||
|
|
||||||
|
m := scanMountpoint("iso", dst, abs)
|
||||||
|
m.ID = id
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmountISO удаляет всё, что относится к загруженному образу:
|
||||||
|
// - распакованную директорию /var/lib/bj/media/iso/<id>/;
|
||||||
|
// - .src-meta файл с записанным источником;
|
||||||
|
// - сам исходный .img/.iso в /var/lib/bj/iso/, если он находится
|
||||||
|
// в наших границах (защита: путь должен начинаться с /var/lib/bj/iso/).
|
||||||
|
//
|
||||||
|
// Безопасно только для тех id, что лежат в нашем mediaISODir.
|
||||||
|
func UnmountISO(id string) error {
|
||||||
|
if strings.ContainsAny(id, "/.") {
|
||||||
|
return errors.New("неверный id")
|
||||||
|
}
|
||||||
|
dst := filepath.Join(mediaISODir, id)
|
||||||
|
if !strings.HasPrefix(dst, mediaISODir+"/") {
|
||||||
|
return errors.New("путь вне media-root")
|
||||||
|
}
|
||||||
|
// Сначала забираем путь исходника, потом удаляем .src.
|
||||||
|
src := readISOSource(id)
|
||||||
|
if err := os.RemoveAll(dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Remove(filepath.Join(mediaISODir, id+".src"))
|
||||||
|
// Если запись об источнике существовала и путь — внутри /var/lib/bj/iso/,
|
||||||
|
// удаляем и сам файл .img/.iso.
|
||||||
|
if src != "" {
|
||||||
|
abs, _ := filepath.Abs(src)
|
||||||
|
if strings.HasPrefix(abs, "/var/lib/bj/iso/") {
|
||||||
|
_ = os.Remove(abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportKeyContainer копирует контейнер в /var/lib/bj/containers/<name>/.
|
||||||
|
// Возвращает целевой путь.
|
||||||
|
func ImportKeyContainer(src string) (string, error) {
|
||||||
|
info, err := os.Stat(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("источник: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "", errors.New("источник не директория")
|
||||||
|
}
|
||||||
|
if _, ok := classifyContainer(src); !ok {
|
||||||
|
return "", errors.New("в директории не найдено >=2 файлов *.key — не похоже на контейнер")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(containersDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("создать %s: %w", containersDir, err)
|
||||||
|
}
|
||||||
|
name := filepath.Base(src)
|
||||||
|
dst := filepath.Join(containersDir, name)
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
return "", fmt.Errorf("контейнер %q уже импортирован", name)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dst, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("создать %s: %w", dst, err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sp := filepath.Join(src, e.Name())
|
||||||
|
dp := filepath.Join(dst, e.Name())
|
||||||
|
data, err := os.ReadFile(sp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("чтение %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dp, data, 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("запись %s: %w", e.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportProfileResult — результат импорта профиля Валидаты.
|
||||||
|
type ImportProfileResult struct {
|
||||||
|
Path string // /var/lib/bj/profiles/<name>/
|
||||||
|
Pki1ConfSection string // готовая секция для pki1.conf
|
||||||
|
ConfWritten bool // удалось ли дописать в /opt/Validata/VDCSP/etc/pki1.conf
|
||||||
|
ConfWriteError string // если не удалось — причина
|
||||||
|
}
|
||||||
|
|
||||||
|
const validataPki1Conf = "/opt/Validata/VDCSP/etc/pki1.conf"
|
||||||
|
|
||||||
|
// ImportProfile копирует профиль Валидаты (pse/gdbm/vdkeys) в
|
||||||
|
// /var/lib/bj/profiles/<name>/, генерирует секцию для pki1.conf и
|
||||||
|
// пробует дописать её в системный конфиг Валидаты. Имя берётся от
|
||||||
|
// носителя, если name пуст. Возвращает деталь — что получилось.
|
||||||
|
func ImportProfile(root, name string) (ImportProfileResult, error) {
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(root)
|
||||||
|
}
|
||||||
|
if !validProfileName(name) {
|
||||||
|
return ImportProfileResult{}, errors.New("имя профиля: допустимы латинские буквы, цифры, '-' и '_'")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
|
||||||
|
return ImportProfileResult{}, fmt.Errorf("создать %s: %w", profilesDir, err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(profilesDir, name)
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
return ImportProfileResult{}, fmt.Errorf("профиль %q уже импортирован", name)
|
||||||
|
}
|
||||||
|
if err := copyTree(root, dst); err != nil {
|
||||||
|
_ = os.RemoveAll(dst)
|
||||||
|
return ImportProfileResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем фактический pse и gdbm внутри импортированной папки —
|
||||||
|
// обычно spr*/local.pse + spr*/local.gdbm.
|
||||||
|
psePath, gdbmPath := findProfileFiles(dst)
|
||||||
|
if psePath == "" {
|
||||||
|
return ImportProfileResult{}, errors.New("после копирования не найден .pse — формат профиля нестандартный")
|
||||||
|
}
|
||||||
|
|
||||||
|
section := buildPki1ConfSection(name, psePath, gdbmPath)
|
||||||
|
// Сохраняем секцию рядом с профилем — чтобы оператор мог
|
||||||
|
// посмотреть/перечитать.
|
||||||
|
_ = os.WriteFile(filepath.Join(dst, "pki1.conf-section.txt"),
|
||||||
|
[]byte(section), 0o644)
|
||||||
|
|
||||||
|
res := ImportProfileResult{
|
||||||
|
Path: dst,
|
||||||
|
Pki1ConfSection: section,
|
||||||
|
}
|
||||||
|
// Пробуем дописать в pki1.conf — если файл доступен на запись.
|
||||||
|
if err := appendToPki1Conf(name, section); err != nil {
|
||||||
|
res.ConfWriteError = err.Error()
|
||||||
|
} else {
|
||||||
|
res.ConfWritten = true
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validProfileName(s string) bool {
|
||||||
|
if s == "" || len(s) > 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == '-' || r == '_'
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// findProfileFiles ищет .pse и .gdbm внутри директории профиля.
|
||||||
|
// Возвращает абсолютные пути или пустые строки.
|
||||||
|
func findProfileFiles(dir string) (psePath, gdbmPath string) {
|
||||||
|
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(info.Name())
|
||||||
|
if psePath == "" && strings.HasSuffix(lower, ".pse") {
|
||||||
|
psePath = p
|
||||||
|
}
|
||||||
|
if gdbmPath == "" && strings.HasSuffix(lower, ".gdbm") {
|
||||||
|
gdbmPath = p
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPki1ConfSection формирует блок pki1.conf для нашего профиля.
|
||||||
|
func buildPki1ConfSection(name, psePath, gdbmPath string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n# --- bj-server: профиль " + name + " ---\n")
|
||||||
|
b.WriteString("local: " + name + "\n")
|
||||||
|
b.WriteString("pse: pse://signed/" + psePath + "\n")
|
||||||
|
if gdbmPath != "" {
|
||||||
|
b.WriteString("localstore: file://" + gdbmPath + "\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendToPki1Conf пишет секцию в системный pki1.conf, если процесс
|
||||||
|
// имеет права. Возвращает ошибку при отсутствии прав или I/O-сбое.
|
||||||
|
// Дедуп — если в файле уже есть блок с тем же `local: <name>`, не
|
||||||
|
// пишем повторно.
|
||||||
|
func appendToPki1Conf(name, section string) error {
|
||||||
|
existing, err := os.ReadFile(validataPki1Conf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s: %w", validataPki1Conf, err)
|
||||||
|
}
|
||||||
|
marker := "local: " + name
|
||||||
|
if strings.Contains(string(existing), marker) {
|
||||||
|
return fmt.Errorf("в pki1.conf уже есть секция %q — пропускаем", name)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(validataPki1Conf, os.O_WRONLY|os.O_APPEND, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", validataPki1Conf, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(section); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", validataPki1Conf, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyTree рекурсивно копирует src в dst, сохраняя структуру директорий.
|
||||||
|
// Права на новые директории — 0700, на файлы — 0600 (приватные ключи).
|
||||||
|
func copyTree(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o700)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(target, data, 0o600)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteImportedContainer сносит /var/lib/bj/containers/<name>/.
|
||||||
|
func DeleteImportedContainer(name string) error {
|
||||||
|
if !validProfileName(name) {
|
||||||
|
return errors.New("неверное имя контейнера")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(containersDir, name)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
return fmt.Errorf("контейнер не найден: %w", err)
|
||||||
|
}
|
||||||
|
return os.RemoveAll(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteImportedProfile сносит и директорию профиля
|
||||||
|
// /var/lib/bj/profiles/<name>/, и связанную секцию из pki1.conf
|
||||||
|
// (между маркерами «# --- bj-server: профиль <name> ---» и следующим
|
||||||
|
// «# --- bj-server: ...» или концом файла).
|
||||||
|
func DeleteImportedProfile(name string) error {
|
||||||
|
if !validProfileName(name) {
|
||||||
|
return errors.New("неверное имя профиля")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(profilesDir, name)
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
return fmt.Errorf("профиль не найден: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("удалить %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
// Чистим секцию в pki1.conf — best effort, если файл недоступен на
|
||||||
|
// запись, профиль всё равно удалён, но в конфиге останется огрызок.
|
||||||
|
if err := removeFromPki1Conf(name); err != nil {
|
||||||
|
return fmt.Errorf("директория удалена, но pki1.conf не почистился: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeFromPki1Conf удаляет блок профиля из pki1.conf.
|
||||||
|
// Блок начинается с «# --- bj-server: профиль <name> ---» и кончается
|
||||||
|
// перед следующим таким маркером или до конца файла. Если блок не
|
||||||
|
// найден — успех (idempotent).
|
||||||
|
func removeFromPki1Conf(name string) error {
|
||||||
|
data, err := os.ReadFile(validataPki1Conf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
startMarker := "# --- bj-server: профиль " + name + " ---"
|
||||||
|
startIdx := strings.Index(string(data), startMarker)
|
||||||
|
if startIdx < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Найдём конец блока: следующий маркер «# --- bj-server: профиль» или EOF.
|
||||||
|
rest := string(data)[startIdx+len(startMarker):]
|
||||||
|
endRel := strings.Index(rest, "# --- bj-server: профиль ")
|
||||||
|
var newContent string
|
||||||
|
if endRel < 0 {
|
||||||
|
newContent = string(data)[:startIdx]
|
||||||
|
} else {
|
||||||
|
newContent = string(data)[:startIdx] + rest[endRel:]
|
||||||
|
}
|
||||||
|
// Убираем хвостовые пустые строки от секции.
|
||||||
|
newContent = strings.TrimRight(newContent, "\n") + "\n"
|
||||||
|
return os.WriteFile(validataPki1Conf, []byte(newContent), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImportedProfiles возвращает имена директорий в /var/lib/bj/profiles/.
|
||||||
|
func ListImportedProfiles() []string {
|
||||||
|
entries, err := os.ReadDir(profilesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImportedContainers возвращает уже импортированные контейнеры.
|
||||||
|
func ListImportedContainers() []KeyContainer {
|
||||||
|
entries, err := os.ReadDir(containersDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []KeyContainer
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(containersDir, e.Name())
|
||||||
|
if c, ok := classifyContainer(dir); ok {
|
||||||
|
c.Imported = true
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isoID(absPath string) string {
|
||||||
|
h := sha256.Sum256([]byte(absPath))
|
||||||
|
return hex.EncodeToString(h[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
func sha1Path(s string) string {
|
||||||
|
h := sha256.Sum256([]byte(s))
|
||||||
|
return hex.EncodeToString(h[:6])
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirEmpty(path string) (bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
names, err := f.Readdirnames(1)
|
||||||
|
if errors.Is(err, os.ErrInvalid) || err != nil {
|
||||||
|
return len(names) == 0, nil
|
||||||
|
}
|
||||||
|
return len(names) == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readISOSource(id string) string {
|
||||||
|
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeISOSource(id, src string) {
|
||||||
|
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultDocSources — стартовый набор страниц НРД, которые doc-watcher
|
||||||
|
// будет проверять раз в сутки. Пользователь может добавить/удалить через UI.
|
||||||
|
var defaultDocSources = []DocSource{
|
||||||
|
{
|
||||||
|
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
|
||||||
|
Name: "Сервис MOEX МОСТ для M2M",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
URL: "https://www.nsd.ru/workflow/system/programs/",
|
||||||
|
Name: "ПО для участников ЭДО (ИШ, ФШ)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
URL: "https://www.nsd.ru/workflow/system/programs/cryptoservice/",
|
||||||
|
Name: "Криптосервис",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDocSources гарантирует что defaultDocSources прописаны в конфиге.
|
||||||
|
// Вызывается при старте bj-server.
|
||||||
|
func EnsureDocSources(rc *RuntimeConfig) {
|
||||||
|
s := rc.Snapshot().News
|
||||||
|
if len(s.DocSources) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.DocSources = append([]DocSource(nil), defaultDocSources...)
|
||||||
|
if err := rc.UpdateNews(s); err != nil {
|
||||||
|
log.Printf("news: не получилось сохранить default DocSources: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfHrefRe — ищет в HTML href'ы, заканчивающиеся на .pdf (case-insensitive).
|
||||||
|
var pdfHrefRe = regexp.MustCompile(`(?i)href="([^"]+\.pdf)"`)
|
||||||
|
|
||||||
|
// noProxyClient — HTTP-клиент, который игнорирует переменные окружения
|
||||||
|
// HTTPS_PROXY / HTTP_PROXY. Корпоративный прокси zetit блокирует
|
||||||
|
// nsd.ru — поэтому doc-watcher ходит на внешние сайты НРД напрямую.
|
||||||
|
// Transport.Proxy = nil отключает любую проксификацию (включая
|
||||||
|
// автодетект через env).
|
||||||
|
var noProxyClient = &http.Client{
|
||||||
|
Timeout: 90 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDocSources обходит все DocSource из настроек, парсит HTML, ищет
|
||||||
|
// новые PDF и скачивает их в DOC/. На каждое нововведение эмитирует
|
||||||
|
// NewsItem типа "doc-update". Возвращает суммарную строку для лога.
|
||||||
|
func CheckDocSources(ctx context.Context, rc *RuntimeConfig) string {
|
||||||
|
s := rc.Snapshot().News
|
||||||
|
if len(s.DocSources) == 0 {
|
||||||
|
s.DocSources = append([]DocSource(nil), defaultDocSources...)
|
||||||
|
}
|
||||||
|
var summary strings.Builder
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i, src := range s.DocSources {
|
||||||
|
fmt.Fprintf(&summary, "→ %s\n", src.URL)
|
||||||
|
pdfs, err := fetchPDFLinks(ctx, src.URL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(&summary, " ошибка: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if src.KnownPDFs == nil {
|
||||||
|
s.DocSources[i].KnownPDFs = map[string]string{}
|
||||||
|
}
|
||||||
|
known := s.DocSources[i].KnownPDFs
|
||||||
|
fmt.Fprintf(&summary, " найдено %d ссылок на PDF\n", len(pdfs))
|
||||||
|
newlyAdded := 0
|
||||||
|
for _, pdfURL := range pdfs {
|
||||||
|
hash, changed := checkPDF(ctx, pdfURL, known)
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
known[pdfURL] = hash
|
||||||
|
newlyAdded++
|
||||||
|
localPath, err := downloadPDFToDOC(ctx, pdfURL)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(&summary, " ✗ %s: %v\n", pdfURL, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&summary, " ✓ %s → %s\n", pdfURL, localPath)
|
||||||
|
// Новость в ленту.
|
||||||
|
_ = rc.AddNews(NewsItem{
|
||||||
|
ID: "doc-" + hash[:12],
|
||||||
|
At: now,
|
||||||
|
Kind: "doc-update",
|
||||||
|
Title: "Обновлена документация: " + filepath.Base(localPath),
|
||||||
|
Body: "Источник: " + src.Name + "\nURL: " + pdfURL +
|
||||||
|
"\nЛокально: " + localPath + "\nSHA-256: " + hash[:16] + "…",
|
||||||
|
URL: pdfURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.DocSources[i].LastChecked = now
|
||||||
|
if newlyAdded > 0 {
|
||||||
|
fmt.Fprintf(&summary, " добавлено новых: %d\n", newlyAdded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.LastDocCheck = now
|
||||||
|
s.DocCheckResult = summary.String()
|
||||||
|
if err := rc.UpdateNews(s); err != nil {
|
||||||
|
log.Printf("news: save failed: %v", err)
|
||||||
|
}
|
||||||
|
return summary.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchPDFLinks качает HTML-страницу и извлекает все href'ы, заканчивающиеся
|
||||||
|
// на .pdf. Относительные URL разворачиваются в абсолютные.
|
||||||
|
func fetchPDFLinks(ctx context.Context, pageURL string) ([]string, error) {
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
|
||||||
|
resp, err := noProxyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base, err := url.Parse(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
matches := pdfHrefRe.FindAllStringSubmatch(string(body), -1)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, m := range matches {
|
||||||
|
ref, err := url.Parse(m[1])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
abs := base.ResolveReference(ref).String()
|
||||||
|
// Игнорируем «системные» PDF (политика конфиденциальности и т.п.).
|
||||||
|
low := strings.ToLower(abs)
|
||||||
|
if strings.Contains(low, "personal_information") ||
|
||||||
|
strings.Contains(low, "personal-information") ||
|
||||||
|
strings.Contains(low, "razmeschenie-logotipa") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[abs] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[abs] = true
|
||||||
|
out = append(out, abs)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPDF делает HEAD-запрос (или GET если HEAD не сработал) и сравнивает
|
||||||
|
// sha256 PDF с известным значением. Возвращает (новый_hash, изменился).
|
||||||
|
// HEAD у НРД редко возвращает Content-MD5/ETag — реальная проверка =
|
||||||
|
// скачать и посчитать sha256.
|
||||||
|
func checkPDF(ctx context.Context, pdfURL string, known map[string]string) (string, bool) {
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
|
||||||
|
resp, err := noProxyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, io.LimitReader(resp.Body, 32<<20)); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
hash := hex.EncodeToString(h.Sum(nil))
|
||||||
|
if old, ok := known[pdfURL]; ok && old == hash {
|
||||||
|
return hash, false
|
||||||
|
}
|
||||||
|
return hash, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadPDFToDOC скачивает PDF в DOC/. Если файл с таким именем уже
|
||||||
|
// есть — переименовывает старый в name.old-YYYYMMDD.pdf, чтобы оставить
|
||||||
|
// аудит. Возвращает путь до нового файла.
|
||||||
|
func downloadPDFToDOC(ctx context.Context, pdfURL string) (string, error) {
|
||||||
|
u, err := url.Parse(pdfURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := filepath.Base(u.Path)
|
||||||
|
if name == "" || !strings.HasSuffix(strings.ToLower(name), ".pdf") {
|
||||||
|
return "", errors.New("странное имя файла")
|
||||||
|
}
|
||||||
|
docDir := "DOC"
|
||||||
|
if _, err := os.Stat(docDir); err != nil {
|
||||||
|
return "", fmt.Errorf("DOC/ не доступен: %w", err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(docDir, name)
|
||||||
|
// Если файл уже есть — переименуем как backup.
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
old := filepath.Join(docDir,
|
||||||
|
strings.TrimSuffix(name, ".pdf")+
|
||||||
|
"."+time.Now().Format("2006-01-02")+".pdf.bak")
|
||||||
|
_ = os.Rename(dst, old)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pdfURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36")
|
||||||
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8")
|
||||||
|
resp, err := noProxyClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := io.Copy(f, io.LimitReader(resp.Body, 64<<20)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDocWatcher запускает горутину, которая раз в сутки проверяет
|
||||||
|
// DocSources и эмитирует новости. Стартует через 60 сек после Run().
|
||||||
|
func StartDocWatcher(rc *RuntimeConfig) func() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(60 * time.Second):
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
summary := CheckDocSources(ctx, rc)
|
||||||
|
log.Printf("doc-watcher: проверка завершена\n%s", summary)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
// addManualNews — POST /admin/news/add.
|
||||||
|
func (h *setupHandlers) addManualNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(r.FormValue("title"))
|
||||||
|
body := strings.TrimSpace(r.FormValue("body"))
|
||||||
|
kind := r.FormValue("kind")
|
||||||
|
if kind == "" {
|
||||||
|
kind = "manual"
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
setupFlash(w, r, "Новости: укажите заголовок")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := NewsItem{
|
||||||
|
At: time.Now(),
|
||||||
|
Kind: kind,
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
if vf := r.FormValue("valid_from"); vf != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", vf); err == nil {
|
||||||
|
item.ValidFrom = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if vt := r.FormValue("valid_to"); vt != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", vt); err == nil {
|
||||||
|
item.ValidTo = t.Add(24*time.Hour - time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.rc.AddNews(item); err != nil {
|
||||||
|
setupFlash(w, r, "Новости: ошибка сохранения: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupFlash(w, r, "Новость «"+title+"» добавлена в ленту")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dismissNews — POST /admin/news/dismiss?id=...
|
||||||
|
func (h *setupHandlers) dismissNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.FormValue("id")
|
||||||
|
if id == "" {
|
||||||
|
setupFlash(w, r, "Новости: id обязателен")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.rc.DismissNews(id)
|
||||||
|
setupFlash(w, r, "Новость скрыта")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDocsNow — POST /admin/news/check-docs.
|
||||||
|
func (h *setupHandlers) checkDocsNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
summary := CheckDocSources(ctx, h.rc)
|
||||||
|
if len(summary) > 600 {
|
||||||
|
summary = summary[:600] + "…"
|
||||||
|
}
|
||||||
|
setupFlash(w, r, "Проверка обновлений документации завершена. "+strings.TrimSpace(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedDefaultNews добавляет в ленту известные на момент запуска события
|
||||||
|
// (окно техработ TEST3 в мае 2026 и появление робота-автотестирования).
|
||||||
|
// Вызывается из server.go при старте — дедуп по ID гарантирован AddNews.
|
||||||
|
func SeedDefaultNews(rc *RuntimeConfig) {
|
||||||
|
defaults := []NewsItem{
|
||||||
|
{
|
||||||
|
ID: "test3-maintenance-2026-05",
|
||||||
|
At: time.Date(2026, 5, 13, 0, 0, 0, 0, time.UTC),
|
||||||
|
Kind: "maintenance",
|
||||||
|
Title: "TEST3 недоступен 18.05.2026 — 22.05.2026 (техработы)",
|
||||||
|
Body: "НРД проводит техработы на тестовом контуре TEST3. На gost-t3.nsd.ru / rsa-t3.nsd.ru интеграционные прогоны в этот период не пойдут. При необходимости — переключитесь на GUEST (gost-gt.nsd.ru) или mock-режим. Источник: НРД письмо НРД-И-2026-8452 от 13.05.2026.",
|
||||||
|
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
|
||||||
|
ValidFrom: time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC),
|
||||||
|
ValidTo: time.Date(2026, 5, 22, 23, 59, 59, 0, time.UTC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "robot-autotest-2026-05-12",
|
||||||
|
At: time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC),
|
||||||
|
Kind: "feature",
|
||||||
|
Title: "Доступно автотестирование MOEX МОСТ с роботом на TEST3",
|
||||||
|
Body: "С 12.05.2026 клиенты, подключившиеся к автотестированию, могут гонять обмен сообщениями с роботом-контрагентом на TEST3. Не нужно ждать живого второго депозитария. Контакт: M2MOST@nsd.ru. Опубликованы новые инструкции: «Инструкция по тестированию с роботом» и «Инструкция для обмена при self-transfer» — обе в DOC/.",
|
||||||
|
URL: "https://www.nsd.ru/services/novye-servisy/moex-most-dlya-m2m/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, item := range defaults {
|
||||||
|
_ = rc.AddNews(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pollerStateFile — постоянная память id применённых входящих пакетов ИШ.
|
||||||
|
// Без неё перезапуск bj-server заново вычитывал бы старые ответы НРД и
|
||||||
|
// повторно применял их к заявкам (для ответов с нулевым GUID это приводило
|
||||||
|
// к ложным «отклонениям» по FIFO). Файл переживает перезапуски.
|
||||||
|
const pollerStateFile = "/var/lib/bj/.bj/poller-processed.json"
|
||||||
|
|
||||||
|
// loadProcessed читает множество обработанных id; второй результат false,
|
||||||
|
// если файла нет (самый первый запуск).
|
||||||
|
func loadProcessed() (map[int]bool, bool) {
|
||||||
|
m := make(map[int]bool)
|
||||||
|
b, err := os.ReadFile(pollerStateFile)
|
||||||
|
if err != nil {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
var ids []int
|
||||||
|
if json.Unmarshal(b, &ids) != nil {
|
||||||
|
return m, false
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
m[id] = true
|
||||||
|
}
|
||||||
|
return m, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveProcessed атомарно сохраняет множество обработанных id.
|
||||||
|
func saveProcessed(m map[int]bool) {
|
||||||
|
ids := make([]int, 0, len(m))
|
||||||
|
for id := range m {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(ids)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := pollerStateFile + ".tmp"
|
||||||
|
if os.WriteFile(tmp, b, 0o640) == nil {
|
||||||
|
_ = os.Rename(tmp, pollerStateFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollIncoming периодически опрашивает ИШ на входящие пакеты от НРД
|
||||||
|
// (M2MTransferDecision / Response) и применяет их через svc.ApplyDecision.
|
||||||
|
// Замыкает цикл: bj-server отправил заявку → ИШ → НРД → робот ответил →
|
||||||
|
// ИШ забрал ответ во входящие → этот поллер применяет Decision (статус
|
||||||
|
// заявки переходит в confirmed/rejected, срабатывает callback в ЛК).
|
||||||
|
//
|
||||||
|
// Дедупликация по id обработанных пакетов: ИШ возвращает их повторно,
|
||||||
|
// пока мы не подтвердим, поэтому держим множество уже обработанных.
|
||||||
|
func (s *Server) pollIncoming(ctx context.Context) {
|
||||||
|
const interval = 30 * time.Second
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
processed, existed := loadProcessed()
|
||||||
|
if !existed {
|
||||||
|
// Первый запуск: помечаем все уже лежащие во входящих пакеты как
|
||||||
|
// обработанные, чтобы не применять исторический backlog (старые ответы
|
||||||
|
// НРД) к текущим заявкам. Реальные новые ответы придут позже.
|
||||||
|
s.seedProcessed(ctx, processed)
|
||||||
|
saveProcessed(processed)
|
||||||
|
}
|
||||||
|
log.Printf("lk-gateway: поллер входящих ИШ запущен (канал %s, интервал %s, обработанных в памяти %d)", s.igwChannel, interval, len(processed))
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.fetchAndApply(ctx, processed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAndApply — один проход поллера: список входящих → для каждого нового
|
||||||
|
// забираем тело, распаковываем, парсим Decision, применяем.
|
||||||
|
func (s *Server) fetchAndApply(ctx context.Context, processed map[int]bool) {
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Тип не указываем — ИШ вернёт оба (M2MTD + M2MER). Date=сегодня.
|
||||||
|
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{
|
||||||
|
Channel: s.igwChannel,
|
||||||
|
Date: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("lk-gateway: поллер ListIncoming: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if processed[p.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.applyIncoming(cctx, p); err != nil {
|
||||||
|
log.Printf("lk-gateway: поллер пакет id=%d (%s): %v", p.ID, p.Type, err)
|
||||||
|
continue // не помечаем обработанным — повторим в след. раз
|
||||||
|
}
|
||||||
|
processed[p.ID] = true
|
||||||
|
saveProcessed(processed) // переживает перезапуск
|
||||||
|
log.Printf("lk-gateway: поллер применил входящий пакет id=%d тип=%s", p.ID, p.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedProcessed помечает все текущие входящие пакеты как обработанные
|
||||||
|
// (используется при самом первом запуске поллера).
|
||||||
|
func (s *Server) seedProcessed(ctx context.Context, processed map[int]bool) {
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
pkgs, err := s.igwClient.ListIncoming(cctx, igw.ListFilter{Channel: s.igwChannel, Date: time.Now()})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("lk-gateway: поллер seed: ListIncoming: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, p := range pkgs {
|
||||||
|
processed[p.ID] = true
|
||||||
|
}
|
||||||
|
log.Printf("lk-gateway: поллер первый запуск — засеяно %d существующих пакетов как обработанные", len(pkgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyIncoming забирает тело пакета и применяет M2M-ответ к сделке.
|
||||||
|
// Среди входящих от НРД много служебных пакетов (квитанции ЭДО типа C/Z,
|
||||||
|
// конверты) — они не M2M-документы и пропускаются. Реальные ответы —
|
||||||
|
// M2MTransferDecision (решение принимающей стороны) и M2MTransferResponse
|
||||||
|
// (ответ сервиса МОСТ, в т.ч. ошибки M2Mxx).
|
||||||
|
func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
||||||
|
zipBytes, err := s.igwClient.GetPackage(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err // сетевая ошибка — повторим в след. раз
|
||||||
|
}
|
||||||
|
unpacked, err := igw.UnpackPackage(zipBytes)
|
||||||
|
if err != nil {
|
||||||
|
// Нет основного .xml — служебный пакет (квитанция/конверт ЭДО).
|
||||||
|
// Не ошибка: помечаем обработанным, чтобы не повторять.
|
||||||
|
log.Printf("lk-gateway: поллер пакет id=%d (%s) — служебный (квитанция/конверт), пропуск", p.ID, p.Type)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
doc := string(unpacked.DocXML)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(doc, "M2MTransferDecision"):
|
||||||
|
decision, err := igw.ParseDecision(unpacked.DocXML)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("lk-gateway: поллер Decision id=%d: разбор: %v", p.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.svc.ApplyDecision(ctx, decision)
|
||||||
|
case strings.Contains(doc, "M2MTransferResponse"):
|
||||||
|
resp, err := igw.ParseResponse(unpacked.DocXML)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("lk-gateway: поллер Response id=%d: разбор: %v", p.ID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Ответ сервиса МОСТ: статус + код (M2Mxx). Применяем к сделке:
|
||||||
|
// INFO — приём в обработку (статус не меняется), ERROR — отказ сервиса
|
||||||
|
// (напр. M2M14 — отправитель не в справочнике), сделка → Отклонена.
|
||||||
|
// Ответ сохраняется в сделке и виден в карточке заявки.
|
||||||
|
var codes string
|
||||||
|
for _, rr := range resp.Responses {
|
||||||
|
codes += string(rr.Code) + " "
|
||||||
|
}
|
||||||
|
log.Printf("lk-gateway: поллер M2MTransferResponse id=%d: статус=%s коды=[%s] GUID=%s",
|
||||||
|
p.ID, resp.StatusCode, strings.TrimSpace(codes), resp.GUID)
|
||||||
|
if err := s.svc.ApplyServiceResponse(ctx, resp, unpacked.DocXML); err != nil {
|
||||||
|
// Сделка может быть не найдена (ответ на чужой/старый GUID) —
|
||||||
|
// логируем, но помечаем обработанным, чтобы не зациклиться.
|
||||||
|
log.Printf("lk-gateway: поллер ApplyServiceResponse id=%d GUID=%s: %v", p.ID, resp.GUID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
log.Printf("lk-gateway: поллер пакет id=%d (%s) — неизвестный M2M-документ, пропуск", p.ID, p.Type)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,21 +24,98 @@ type Settings struct {
|
|||||||
Crypto CryptoSettings `json:"crypto"`
|
Crypto CryptoSettings `json:"crypto"`
|
||||||
NSD NSDSettings `json:"nsd"`
|
NSD NSDSettings `json:"nsd"`
|
||||||
LK LKSettings `json:"lk"`
|
LK LKSettings `json:"lk"`
|
||||||
|
CACerts CACertsSettings `json:"ca_certs"`
|
||||||
|
News NewsSettings `json:"news"`
|
||||||
|
Update UpdateSettings `json:"update"`
|
||||||
|
License LicenseSettings `json:"license"`
|
||||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LicenseSettings — лицензионный ключ (подписанный токен).
|
||||||
|
type LicenseSettings struct {
|
||||||
|
Key string `json:"key"` // компактный токен payload.signature.keyid
|
||||||
|
PublicKey string `json:"public_key"` // base64 (если не зашит в бинарь)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettings — авто-обновления из артефактории (#18/#20).
|
||||||
|
type UpdateSettings struct {
|
||||||
|
BaseURL string `json:"base_url"` // https://updates.example.com
|
||||||
|
Channel string `json:"channel"` // "stable" | "beta"
|
||||||
|
PublicKey string `json:"public_key"` // base64 Ed25519 (если не зашит в бинарь)
|
||||||
|
AutoCheck bool `json:"auto_check"` // проверять автоматически
|
||||||
|
LastCheck time.Time `json:"last_check"` // когда последний раз проверяли
|
||||||
|
LastResult string `json:"last_result"` // текст результата проверки
|
||||||
|
Available string `json:"available_version"` // доступная версия (если новее)
|
||||||
|
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsSettings — лента новостей (события системы, окна техработ, обновления
|
||||||
|
// документации НРД). События добавляются вручную через UI или автоматически
|
||||||
|
// doc-watcher'ом и cron-задачами. Каждое событие может быть скрыто (Dismissed)
|
||||||
|
// оператором, но не удалено — лента служит «журналом» для аудита.
|
||||||
|
type NewsSettings struct {
|
||||||
|
Items []NewsItem `json:"items"`
|
||||||
|
DocSources []DocSource `json:"doc_sources"` // URL'ы для авто-проверки (NSD pages)
|
||||||
|
LastDocCheck time.Time `json:"last_doc_check"`
|
||||||
|
DocCheckResult string `json:"doc_check_result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewsItem — одно событие в ленте.
|
||||||
|
type NewsItem struct {
|
||||||
|
ID string `json:"id"` // уникальный идентификатор для dismiss
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Kind string `json:"kind"` // "maintenance" | "feature" | "doc-update" | "manual" | "system"
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
URL string `json:"url,omitempty"` // ссылка на источник
|
||||||
|
ValidFrom time.Time `json:"valid_from,omitempty"` // для maintenance окон
|
||||||
|
ValidTo time.Time `json:"valid_to,omitempty"`
|
||||||
|
Dismissed bool `json:"dismissed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocSource — страница НРД, которую doc-watcher периодически проверяет.
|
||||||
|
type DocSource struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Name string `json:"name"` // человекочитаемое имя
|
||||||
|
LastChecked time.Time `json:"last_checked"`
|
||||||
|
KnownPDFs map[string]string `json:"known_pdfs"` // url → sha256
|
||||||
|
}
|
||||||
|
|
||||||
|
// CACertsSettings — URL'ы для авто-загрузки сертификатов УЦ НРД и нашего
|
||||||
|
// УЦ. Список редактируется пользователем; раз в сутки фоновая горутина
|
||||||
|
// перекачивает каждый URL и сохраняет сертификат, если он поменялся.
|
||||||
|
type CACertsSettings struct {
|
||||||
|
URLs []string `json:"urls"`
|
||||||
|
AutoUpdate bool `json:"auto_update"`
|
||||||
|
LastFetch time.Time `json:"last_fetch"`
|
||||||
|
LastFetchLog string `json:"last_fetch_log"`
|
||||||
|
FetchedCerts []FetchedCACert `json:"fetched_certs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchedCACert — информация о последнем удачно скачанном сертификате.
|
||||||
|
type FetchedCACert struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
SubjectCN string `json:"subject_cn"`
|
||||||
|
IssuerCN string `json:"issuer_cn"`
|
||||||
|
NotAfter time.Time `json:"not_after"`
|
||||||
|
Store string `json:"store"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
|
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
|
||||||
type PostgresSettings struct {
|
type PostgresSettings struct {
|
||||||
DSN string `json:"dsn"`
|
DSN string `json:"dsn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CryptoSettings — путь к JCP, провайдер, лицензионный ключ.
|
// CryptoSettings — путь к PKCS#11 модулю и тип провайдера.
|
||||||
type CryptoSettings struct {
|
type CryptoSettings struct {
|
||||||
Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet"
|
Provider string `json:"provider"` // "stub" | "validata"
|
||||||
SocketPath string `json:"socket_path"` // UDS crypto-service
|
SocketPath string `json:"socket_path"` // UDS crypto-service
|
||||||
JCPPath string `json:"jcp_path"` // путь до jcp.jar
|
ModulePath string `json:"module_path"` // путь до .so модуля PKCS#11
|
||||||
LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро
|
Profile string `json:"profile"` // активный профиль Валидаты (имя из pki1.conf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NSDSettings — профиль и подключение к ИШ НРД.
|
// NSDSettings — профиль и подключение к ИШ НРД.
|
||||||
@@ -46,6 +123,12 @@ type NSDSettings struct {
|
|||||||
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
|
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
|
||||||
IGWBaseURL string `json:"igw_base_url"` // http://host:port
|
IGWBaseURL string `json:"igw_base_url"` // http://host:port
|
||||||
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
|
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
|
||||||
|
|
||||||
|
// Депозитарные реквизиты клиента — откуда списываются бумаги
|
||||||
|
// (SettlementLocation в M2MTransferRequest). Из договора/письма НРД.
|
||||||
|
DeponentCode string `json:"deponent_code"` // депкод, напр. MC0413600000
|
||||||
|
AccountID string `json:"account_id"` // депозитарный счёт
|
||||||
|
SectionID string `json:"section_id"` // раздел депозитарного счёта
|
||||||
}
|
}
|
||||||
|
|
||||||
// LKSettings — настройки callback в ЛК клиента.
|
// LKSettings — настройки callback в ЛК клиента.
|
||||||
@@ -107,6 +190,24 @@ func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
|
|||||||
return r.save()
|
return r.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveLicense сохраняет лицензионные настройки.
|
||||||
|
func (r *RuntimeConfig) SaveLicense(s LicenseSettings) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.data.License = s
|
||||||
|
r.data.UpdatedAt = time.Now().UTC()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveUpdateSettings сохраняет настройки авто-обновлений.
|
||||||
|
func (r *RuntimeConfig) SaveUpdateSettings(s UpdateSettings) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.data.Update = s
|
||||||
|
r.data.UpdatedAt = time.Now().UTC()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r.save()
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateCrypto сохраняет crypto-настройки.
|
// UpdateCrypto сохраняет crypto-настройки.
|
||||||
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
|
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -126,6 +227,64 @@ func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateLK сохраняет LK callback URL.
|
// UpdateLK сохраняет LK callback URL.
|
||||||
|
// UpdateCACerts сохраняет настройки авто-загрузки сертификатов УЦ.
|
||||||
|
func (r *RuntimeConfig) UpdateCACerts(s CACertsSettings) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.data.CACerts = s
|
||||||
|
r.data.UpdatedAt = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNews заменяет всю ленту новостей.
|
||||||
|
func (r *RuntimeConfig) UpdateNews(s NewsSettings) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.data.News = s
|
||||||
|
r.data.UpdatedAt = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNews добавляет новость в начало ленты (newest first). Если в ленте уже
|
||||||
|
// есть новость с таким же ID — она обновляется (вместо дубликата).
|
||||||
|
func (r *RuntimeConfig) AddNews(item NewsItem) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
if item.ID == "" {
|
||||||
|
item.ID = item.At.Format("20060102-150405") + "-" + item.Kind
|
||||||
|
}
|
||||||
|
if item.At.IsZero() {
|
||||||
|
item.At = time.Now()
|
||||||
|
}
|
||||||
|
// Дедуп по ID.
|
||||||
|
replaced := false
|
||||||
|
for i, ex := range r.data.News.Items {
|
||||||
|
if ex.ID == item.ID {
|
||||||
|
r.data.News.Items[i] = item
|
||||||
|
replaced = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !replaced {
|
||||||
|
r.data.News.Items = append([]NewsItem{item}, r.data.News.Items...)
|
||||||
|
}
|
||||||
|
r.data.UpdatedAt = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissNews помечает новость скрытой по ID (не удаляет — для аудита).
|
||||||
|
func (r *RuntimeConfig) DismissNews(id string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
for i := range r.data.News.Items {
|
||||||
|
if r.data.News.Items[i].ID == id {
|
||||||
|
r.data.News.Items[i].Dismissed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.data.UpdatedAt = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r.save()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.data.LK = s
|
r.data.LK = s
|
||||||
@@ -191,7 +350,7 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "crypto-service",
|
Name: "crypto-service",
|
||||||
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "",
|
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub",
|
||||||
Ready: false,
|
Ready: false,
|
||||||
Message: cryptoMsg(s.Crypto),
|
Message: cryptoMsg(s.Crypto),
|
||||||
},
|
},
|
||||||
@@ -220,15 +379,12 @@ func posMsg(dsn string) string {
|
|||||||
|
|
||||||
func cryptoMsg(c CryptoSettings) string {
|
func cryptoMsg(c CryptoSettings) string {
|
||||||
if c.Provider == "" || c.Provider == "stub" {
|
if c.Provider == "" || c.Provider == "stub" {
|
||||||
return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён."
|
return "Криптография не настроена (provider=stub) — реальная подпись недоступна."
|
||||||
}
|
}
|
||||||
if c.JCPPath == "" {
|
if c.ModulePath == "" {
|
||||||
return "Провайдер " + c.Provider + ", но путь к JCP не задан."
|
return "Провайдер " + c.Provider + ", путь к PKCS#11 модулю не задан."
|
||||||
}
|
}
|
||||||
if c.LicenseKey == "" {
|
return "Провайдер " + c.Provider + ", PKCS#11 модуль: " + c.ModulePath
|
||||||
return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена."
|
|
||||||
}
|
|
||||||
return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func nsdMsg(n NSDSettings) string {
|
func nsdMsg(n NSDSettings) string {
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ func NewSeedStore() *SeedStore {
|
|||||||
"DP789456", "31MC0021900000F01", "P001", "7702070139")
|
"DP789456", "31MC0021900000F01", "P001", "7702070139")
|
||||||
s.addAccount("11111111-1111-1111-1111-111111111111",
|
s.addAccount("11111111-1111-1111-1111-111111111111",
|
||||||
"AA789451", "33MC0021900000F02", "F002", "7802031669")
|
"AA789451", "33MC0021900000F02", "F002", "7802031669")
|
||||||
|
// Тест-инвестор робота: место расчётов = наш реальный тестовый счёт в НРД
|
||||||
|
// (депкод MC0413600000, счёт HL171004001C, раздел 36MC0413600000F00,
|
||||||
|
// расчётный депозитарий НРД ИНН 7702165310). Иначе НРД отвечает M2M19
|
||||||
|
// «недопустимое место расчетов».
|
||||||
s.addAccount("22222222-2222-2222-2222-222222222222",
|
s.addAccount("22222222-2222-2222-2222-222222222222",
|
||||||
"DP100200", "31MC0010000000A01", "A001", "7702070139")
|
"MC0413600000", "HL171004001C", "36MC0413600000F00", "7702165310")
|
||||||
s.addAccount("33333333-3333-3333-3333-333333333333",
|
s.addAccount("33333333-3333-3333-3333-333333333333",
|
||||||
"DP300400", "31MC0030000000B01", "B001", "0702345678")
|
"DP300400", "31MC0030000000B01", "B001", "0702345678")
|
||||||
s.addAccount("55555555-5555-5555-5555-555555555555",
|
s.addAccount("55555555-5555-5555-5555-555555555555",
|
||||||
|
|||||||
@@ -9,9 +9,23 @@ import (
|
|||||||
|
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/mock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// igwClientAdapter адаптирует igw.Client под узкий nsdadapter.IGWClient:
|
||||||
|
// разворачивает (channel, since, type) в igw.ListFilter.
|
||||||
|
type igwClientAdapter struct{ c *igw.Client }
|
||||||
|
|
||||||
|
func (a igwClientAdapter) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) {
|
||||||
|
return a.c.SendPackage(ctx, channel, packageType, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a igwClientAdapter) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]igw.Package, error) {
|
||||||
|
return a.c.ListIncoming(ctx, igw.ListFilter{Channel: channel, Date: since, Type: packageType})
|
||||||
|
}
|
||||||
|
|
||||||
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
|
// ServerConfig — конфигурация HTTP-сервера lk-gateway.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Addr string
|
Addr string
|
||||||
@@ -31,6 +45,12 @@ type Server struct {
|
|||||||
rc *RuntimeConfig
|
rc *RuntimeConfig
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
server *http.Server
|
server *http.Server
|
||||||
|
|
||||||
|
// igwClient/igwChannel заполнены только в реальном режиме (ИШ настроен).
|
||||||
|
// На них работает поллер входящих pollIncoming — забирает ответы НРД
|
||||||
|
// (M2MTransferDecision/Response) и применяет через svc.ApplyDecision.
|
||||||
|
igwClient *igw.Client
|
||||||
|
igwChannel string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
|
// NewServer собирает Server с репозиторием, mock NSDSender, SeedStore
|
||||||
@@ -45,13 +65,44 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
if cfg.MockDecisionDelay > 0 {
|
if cfg.MockDecisionDelay > 0 {
|
||||||
mockCfg.DecisionDelay = cfg.MockDecisionDelay
|
mockCfg.DecisionDelay = cfg.MockDecisionDelay
|
||||||
}
|
}
|
||||||
sender := mock.NewSender(mockCfg)
|
|
||||||
|
|
||||||
rc, err := NewRuntimeConfig(cfg.SetupPath)
|
rc, err := NewRuntimeConfig(cfg.SetupPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Выбор NSD-сендера: если в runtime-конфиге задан профиль ИШ и URL —
|
||||||
|
// используем реальный nsdadapter поверх REST ИШ; иначе mock-эмуляция.
|
||||||
|
// mockSender остаётся не-nil только в mock-режиме — на нём висит
|
||||||
|
// consumeDecisions (реальные Decision приходят поллером входящих ИШ).
|
||||||
|
var sender m2mcore.NSDSender
|
||||||
|
var mockSender *mock.Sender
|
||||||
|
var igwClient *igw.Client
|
||||||
|
var igwChannel string
|
||||||
|
{
|
||||||
|
s := rc.Snapshot()
|
||||||
|
if s.NSD.IGWBaseURL != "" && s.NSD.Profile != "" {
|
||||||
|
prof, perr := nsdadapter.LookupProfile(s.NSD.Profile)
|
||||||
|
if perr != nil {
|
||||||
|
log.Printf("lk-gateway: профиль ИШ %q неизвестен (%v) — fallback mock", s.NSD.Profile, perr)
|
||||||
|
} else {
|
||||||
|
prof.IGWBaseURL = s.NSD.IGWBaseURL // override URL из setup.json
|
||||||
|
cl := igw.NewClient(s.NSD.IGWBaseURL)
|
||||||
|
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
|
||||||
|
igwClient = cl
|
||||||
|
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
|
||||||
|
igwChannel = prof.Channel + string(cfg.DefaultSender)
|
||||||
|
log.Printf("lk-gateway: реальный ИШ-адаптер — профиль %s, канал %s, ИШ %s",
|
||||||
|
prof.Name, igwChannel, s.NSD.IGWBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sender == nil {
|
||||||
|
mockSender = mock.NewSender(mockCfg)
|
||||||
|
sender = mockSender
|
||||||
|
log.Printf("lk-gateway: NSD mock-режим (Decision через эмуляцию)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Repository: pgx если DSN указан, иначе in-memory.
|
// Repository: pgx если DSN указан, иначе in-memory.
|
||||||
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
|
var repo m2mcore.Repository = m2mcore.NewMemoryRepository()
|
||||||
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
|
if dsn := rc.Snapshot().Postgres.DSN; dsn != "" {
|
||||||
@@ -105,7 +156,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
checkOpts = cfg.CheckOptions
|
checkOpts = cfg.CheckOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
adminTpl, err := RegisterAdmin(mux, svc, checkOpts)
|
adminTpl, err := RegisterAdmin(mux, svc, rc, checkOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -115,12 +166,14 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
|||||||
registerSeedListing(mux, store)
|
registerSeedListing(mux, store)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
svc: svc,
|
svc: svc,
|
||||||
mock: sender,
|
mock: mockSender,
|
||||||
store: store,
|
store: store,
|
||||||
rc: rc,
|
rc: rc,
|
||||||
mux: mux,
|
mux: mux,
|
||||||
|
igwClient: igwClient,
|
||||||
|
igwChannel: igwChannel,
|
||||||
server: &http.Server{
|
server: &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
@@ -159,6 +212,27 @@ func (s *Server) Mux() http.Handler { return s.mux }
|
|||||||
func (s *Server) Run(ctx context.Context) error {
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
go s.consumeDecisions(ctx)
|
go s.consumeDecisions(ctx)
|
||||||
|
|
||||||
|
// Поллер входящих от НРД (только в реальном режиме ИШ): забирает
|
||||||
|
// ответы робота/контрагента и применяет их через ApplyDecision.
|
||||||
|
if s.igwClient != nil && s.igwChannel != "" {
|
||||||
|
go s.pollIncoming(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фоновая авто-проверка обновлений из артефактории (если включена).
|
||||||
|
go NewUpdater(s.rc).updateLoop(ctx)
|
||||||
|
|
||||||
|
// Авто-обновление сертификатов УЦ раз в сутки (если оператор включил).
|
||||||
|
stopCACerts := StartCACertsAutoUpdater(s.rc)
|
||||||
|
defer stopCACerts()
|
||||||
|
|
||||||
|
// Doc-watcher: раз в сутки проверяет сайт НРД на новые PDF и
|
||||||
|
// эмитирует новости в ленту. Дефолтные источники + дефолтные
|
||||||
|
// новости (окно техработ TEST3, появление робота) сеются один раз.
|
||||||
|
EnsureDocSources(s.rc)
|
||||||
|
SeedDefaultNews(s.rc)
|
||||||
|
stopDocWatcher := StartDocWatcher(s.rc)
|
||||||
|
defer stopDocWatcher()
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
log.Printf("lk-gateway: listen %s", s.cfg.Addr)
|
||||||
@@ -181,6 +255,12 @@ func (s *Server) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
|
// consumeDecisions слушает Decisions от mock и обновляет соответствующие сделки.
|
||||||
func (s *Server) consumeDecisions(ctx context.Context) {
|
func (s *Server) consumeDecisions(ctx context.Context) {
|
||||||
|
if s.mock == nil {
|
||||||
|
// Реальный ИШ-режим: Decision приходят не из mock-канала, а через
|
||||||
|
// поллер входящих пакетов ИШ (отдельный механизм). Здесь нечего слушать.
|
||||||
|
<-ctx.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|||||||
@@ -104,11 +104,16 @@ func TestAdminHome(t *testing.T) {
|
|||||||
t.Fatalf("admin home code=%d", w.Code)
|
t.Fatalf("admin home code=%d", w.Code)
|
||||||
}
|
}
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
if !strings.Contains(body, "lk-gateway") {
|
// Редизайн #26: оператор-дашборд в стиле Apple — бренд Bridge&Join,
|
||||||
t.Errorf("в дашборде нет заголовка lk-gateway")
|
// приветствие-hero и крупные плитки задач.
|
||||||
|
if !strings.Contains(body, "Bridge") {
|
||||||
|
t.Errorf("в дашборде нет бренда Bridge&Join")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "Состояние системы") {
|
if !strings.Contains(body, "Добрый день") {
|
||||||
t.Errorf("в дашборде нет блока статуса")
|
t.Errorf("в дашборде нет hero-приветствия")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Диагностика") {
|
||||||
|
t.Errorf("в дашборде нет плитки задач")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +125,8 @@ func TestAdminStatus(t *testing.T) {
|
|||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("status code=%d", w.Code)
|
t.Fatalf("status code=%d", w.Code)
|
||||||
}
|
}
|
||||||
if !strings.Contains(w.Body.String(), "postgres") {
|
if !strings.Contains(w.Body.String(), "PostgreSQL") {
|
||||||
t.Errorf("в статусе нет проверки postgres")
|
t.Errorf("в статусе нет проверки PostgreSQL")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
|
// Service — бизнес-логика lk-gateway: преобразует DTO в доменные сущности
|
||||||
@@ -67,9 +68,13 @@ func (s *Service) CreateClaim(ctx context.Context, in CreateClaimRequest) (Creat
|
|||||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
|
return CreateClaimResponse{}, fmt.Errorf("lkgateway: dtoToClaim: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
receiver := s.defaultReceiver
|
||||||
|
if in.ReceiverCodeOverride != "" {
|
||||||
|
receiver = m2m.DeponentCode(in.ReceiverCodeOverride)
|
||||||
|
}
|
||||||
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
|
req, err := m2mcore.EnrichRequest(ctx, s.store, domainClaim, m2mcore.SenderReceiver{
|
||||||
SenderCode: s.defaultSender,
|
SenderCode: s.defaultSender,
|
||||||
ReceiverCode: s.defaultReceiver,
|
ReceiverCode: receiver,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
|
return CreateClaimResponse{}, fmt.Errorf("lkgateway: EnrichRequest: %w", err)
|
||||||
@@ -163,6 +168,80 @@ func (s *Service) ApplyDecision(ctx context.Context, decision *m2m.M2MTransferDe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyServiceResponse применяет M2MTransferResponse (ответ сервиса МОСТ) к
|
||||||
|
// сделке: сохраняет ответ, при ERROR переводит сделку в Rejected и шлёт
|
||||||
|
// callback в ЛК. Сделку ищем по GUID ответа.
|
||||||
|
func (s *Service) ApplyServiceResponse(ctx context.Context, resp *m2m.M2MTransferResponse, raw []byte) error {
|
||||||
|
if resp == nil {
|
||||||
|
return errors.New("lkgateway: ApplyServiceResponse: resp=nil")
|
||||||
|
}
|
||||||
|
deal, err := s.findDealForResponse(ctx, resp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lkgateway: поиск сделки для ответа: %w", err)
|
||||||
|
}
|
||||||
|
prev := deal.State
|
||||||
|
if err := deal.ReceiveServiceResponse(ctx, resp, raw); err != nil {
|
||||||
|
return fmt.Errorf("lkgateway: ReceiveServiceResponse: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.repo.Update(ctx, deal); err != nil {
|
||||||
|
return fmt.Errorf("lkgateway: repo.Update: %w", err)
|
||||||
|
}
|
||||||
|
// Состояние сменилось (ERROR → Rejected) — учитываем в метриках и шлём
|
||||||
|
// callback. На INFO состояние не меняется — callback не нужен.
|
||||||
|
if deal.State != prev {
|
||||||
|
s.recorder.IncDeal(deal.State)
|
||||||
|
if s.callbackURL != "" {
|
||||||
|
s.sendCallback(ctx, deal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zeroGUID — нулевой UUID, который НРД присылает в сервисных ошибках
|
||||||
|
// (напр. M2M14), когда не идентифицирует исходный запрос.
|
||||||
|
const zeroGUID = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
// findDealForResponse сопоставляет ответ МОСТ со сделкой. Сначала по GUID;
|
||||||
|
// если GUID нулевой/пустой или сделка по нему не найдена (реальное поведение
|
||||||
|
// НРД при M2M14 — ответ без нашего GUID и без ReferenceID), применяем
|
||||||
|
// эвристику: берём самую раннюю ожидающую решение заявку без ответа. Для
|
||||||
|
// тестового сценария «одна заявка в полёте» это однозначно; при множестве
|
||||||
|
// заявок in-flight такие сервисные ошибки в принципе неразличимы на стороне
|
||||||
|
// НРД, и FIFO — наилучшее доступное приближение.
|
||||||
|
func (s *Service) findDealForResponse(ctx context.Context, resp *m2m.M2MTransferResponse) (*m2mcore.Deal, error) {
|
||||||
|
guid := string(resp.GUID)
|
||||||
|
if guid != "" && guid != zeroGUID {
|
||||||
|
deal, err := s.repo.GetByGUID(ctx, resp.GUID)
|
||||||
|
if err == nil {
|
||||||
|
return deal, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, m2mcore.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: ответ без идентифицируемого GUID.
|
||||||
|
st := m2mcore.StateAwaitingDecision
|
||||||
|
deals, err := s.repo.List(ctx, m2mcore.Filter{State: &st, Limit: 200})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cand *m2mcore.Deal
|
||||||
|
for _, d := range deals {
|
||||||
|
if d.Response != nil {
|
||||||
|
continue // этой заявке ответ уже сопоставлен
|
||||||
|
}
|
||||||
|
if cand == nil || d.CreatedAt.Before(cand.CreatedAt) {
|
||||||
|
cand = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cand == nil {
|
||||||
|
return nil, m2mcore.ErrNotFound
|
||||||
|
}
|
||||||
|
log.Printf("lkgateway: ответ МОСТ с GUID=%s сопоставлен по эвристике (FIFO) заявке id=%s status=%s",
|
||||||
|
guid, cand.ID, resp.StatusCode)
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
|
||||||
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
|
// sendCallback отправляет PATCH в ЛК с обновлением статуса.
|
||||||
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
|
func (s *Service) sendCallback(ctx context.Context, deal *m2mcore.Deal) {
|
||||||
cb := callbackForDeal(deal)
|
cb := callbackForDeal(deal)
|
||||||
@@ -185,6 +264,14 @@ func dtoToClaim(in CreateClaimRequest) (m2mcore.ClaimInput, error) {
|
|||||||
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
|
TransferringDepositoryINN: m2m.OrganizationINN(in.TransferringDepositoryINN),
|
||||||
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
|
ReceivingDepositoryINN: m2m.OrganizationINN(in.ReceivingDepositoryINN),
|
||||||
}
|
}
|
||||||
|
// Переопределение документа инвестора (тест с роботом: серия = сценарий).
|
||||||
|
if d := in.InvestorDocumentOverride; d != nil {
|
||||||
|
out.InvestorDocument = &m2mcore.ClientDocument{
|
||||||
|
DocumentType: m2m.IdentityDocumentCode(d.DocumentType),
|
||||||
|
Series: d.Series,
|
||||||
|
Number: d.Number,
|
||||||
|
}
|
||||||
|
}
|
||||||
// CostInfo
|
// CostInfo
|
||||||
if in.CostInfo.Yes != nil {
|
if in.CostInfo.Yes != nil {
|
||||||
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
|
out.CostInfo = m2m.CostInfo{Yes: &m2m.CostInfoYes{Code: m2m.DeponentCode(in.CostInfo.Yes.Code)}}
|
||||||
@@ -304,6 +391,10 @@ func dealToView(d *m2mcore.Deal) ClaimView {
|
|||||||
}
|
}
|
||||||
if d.Response != nil {
|
if d.Response != nil {
|
||||||
out.M2MResponse = responseToView(d.Response)
|
out.M2MResponse = responseToView(d.Response)
|
||||||
|
if len(d.RawResponse) > 0 {
|
||||||
|
// Ответ НРД в windows-1251 — декодируем в UTF-8 для показа.
|
||||||
|
out.M2MResponse.RawXML = string(nsdxml.DecodeWindows1251(d.RawResponse))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if d.Decision != nil {
|
if d.Decision != nil {
|
||||||
out.M2MDecision = decisionToView(d.Decision)
|
out.M2MDecision = decisionToView(d.Decision)
|
||||||
|
|||||||
+796
-220
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,13 @@ type CreateClaimRequest struct {
|
|||||||
Securities []ClaimSec `json:"securities"`
|
Securities []ClaimSec `json:"securities"`
|
||||||
SignedDocument string `json:"signed_document"`
|
SignedDocument string `json:"signed_document"`
|
||||||
SignatureFormat string `json:"signature_format"`
|
SignatureFormat string `json:"signature_format"`
|
||||||
|
// ReceiverCodeOverride — если задан, переопределяет код получателя
|
||||||
|
// (Header.ReceiverCode). Используется для тестовых пакетов роботу НРД
|
||||||
|
// (MC0012500000). Пусто = берётся defaultReceiver.
|
||||||
|
ReceiverCodeOverride string `json:"receiver_code_override,omitempty"`
|
||||||
|
// InvestorDocumentOverride — если задан, переопределяет документ инвестора
|
||||||
|
// из анкеты. Используется тестом с роботом НРД (серия ДУЛ = код сценария).
|
||||||
|
InvestorDocumentOverride *Document `json:"investor_document_override,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Investor — анкета.
|
// Investor — анкета.
|
||||||
@@ -159,6 +166,9 @@ type NSDResponseView struct {
|
|||||||
GUID string `json:"guid"`
|
GUID string `json:"guid"`
|
||||||
StatusCode string `json:"status_code"`
|
StatusCode string `json:"status_code"`
|
||||||
Responses []NSDResponseEntry `json:"responses"`
|
Responses []NSDResponseEntry `json:"responses"`
|
||||||
|
// RawXML — точные байты ответа МОСТ от НРД (декодированные в UTF-8 для
|
||||||
|
// показа). Для дословной пересылки в техподдержку НРД.
|
||||||
|
RawXML string `json:"raw_xml,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NSDResponseEntry — одна запись Response.
|
// NSDResponseEntry — одна запись Response.
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package lkgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildVersion — версия bj-server. Переопределяется при сборке:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X .../lkgateway.BuildVersion=1.2.0"
|
||||||
|
var BuildVersion = "0.1.0"
|
||||||
|
|
||||||
|
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
|
||||||
|
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
||||||
|
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
|
||||||
|
var DefaultUpdatePublicKey = ""
|
||||||
|
|
||||||
|
// installPaths — куда устанавливать артефакты по логическому имени.
|
||||||
|
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
|
||||||
|
// обновляются отдельно, не на лету).
|
||||||
|
var installPaths = map[string]string{
|
||||||
|
"bj-server": "/opt/bj/bj-server",
|
||||||
|
"crypto-service.jar": "/opt/bj/crypto-service.jar",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
|
||||||
|
type Updater struct{ rc *RuntimeConfig }
|
||||||
|
|
||||||
|
// NewUpdater создаёт Updater на текущем runtime-конфиге.
|
||||||
|
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
|
||||||
|
|
||||||
|
// UpdateStatus — сводка для UI/handler.
|
||||||
|
type UpdateStatus struct {
|
||||||
|
Configured bool
|
||||||
|
CurrentVersion string
|
||||||
|
Available string
|
||||||
|
HasUpdate bool
|
||||||
|
Channel string
|
||||||
|
Notes string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Updater) updateClient() (*release.Client, error) {
|
||||||
|
cfg := u.rc.Snapshot().Update
|
||||||
|
pub := cfg.PublicKey
|
||||||
|
if pub == "" {
|
||||||
|
pub = DefaultUpdatePublicKey
|
||||||
|
}
|
||||||
|
if cfg.BaseURL == "" || pub == "" {
|
||||||
|
return nil, fmt.Errorf("обновления не настроены (нужны URL артефактории и публичный ключ)")
|
||||||
|
}
|
||||||
|
channel := cfg.Channel
|
||||||
|
if channel == "" {
|
||||||
|
channel = "stable"
|
||||||
|
}
|
||||||
|
return release.NewClient(cfg.BaseURL, channel, pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
|
||||||
|
// сохраняет результат в настройки. Возвращает сводку.
|
||||||
|
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||||
|
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
|
||||||
|
cl, err := u.updateClient()
|
||||||
|
if err != nil {
|
||||||
|
st.Message = err.Error()
|
||||||
|
return st, nil // не настроено — не ошибка
|
||||||
|
}
|
||||||
|
st.Configured = true
|
||||||
|
|
||||||
|
m, err := cl.FetchManifest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
st.Message = "проверка не удалась: " + err.Error()
|
||||||
|
u.saveCheckResult(st)
|
||||||
|
return st, err
|
||||||
|
}
|
||||||
|
st.Available = m.Version
|
||||||
|
st.Notes = m.Notes
|
||||||
|
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
|
||||||
|
if st.HasUpdate {
|
||||||
|
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
|
||||||
|
} else {
|
||||||
|
st.Message = "установлена актуальная версия " + BuildVersion
|
||||||
|
}
|
||||||
|
u.saveCheckResult(st)
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Updater) saveCheckResult(st UpdateStatus) {
|
||||||
|
cfg := u.rc.Snapshot().Update
|
||||||
|
cfg.LastCheck = time.Now().UTC()
|
||||||
|
cfg.LastResult = st.Message
|
||||||
|
cfg.Available = st.Available
|
||||||
|
cfg.Notes = st.Notes
|
||||||
|
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
|
||||||
|
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
|
||||||
|
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
|
||||||
|
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
|
||||||
|
func (u *Updater) ApplyUpdate(ctx context.Context) error {
|
||||||
|
// Гейт лицензией: если лицензирование включено — требуется валидная
|
||||||
|
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
|
||||||
|
if licensingEnabled(u.rc) {
|
||||||
|
ls := licenseStatus(u.rc)
|
||||||
|
if !ls.Valid {
|
||||||
|
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
|
||||||
|
}
|
||||||
|
if !ls.AllowsUpdates {
|
||||||
|
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cl, err := u.updateClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := cl.FetchManifest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("манифест: %w", err)
|
||||||
|
}
|
||||||
|
if !release.IsNewer(m.Version, BuildVersion) {
|
||||||
|
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := 0
|
||||||
|
for _, a := range m.Artifacts {
|
||||||
|
dst, ok := installPaths[a.Name]
|
||||||
|
if !ok {
|
||||||
|
continue // скрипты/SQL не обновляем на лету
|
||||||
|
}
|
||||||
|
dir := dirOf(dst)
|
||||||
|
path, err := cl.DownloadArtifact(ctx, a, dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("скачивание %s: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
|
||||||
|
// иное — переименуем атомарно.
|
||||||
|
if path != dst {
|
||||||
|
if err := os.Rename(path, dst); err != nil {
|
||||||
|
return fmt.Errorf("установка %s: %w", a.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("lk-gateway: обновлён %s → %s (%s)", a.Name, dst, m.Version)
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
if updated == 0 {
|
||||||
|
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("lk-gateway: обновление до %s применено (%d файлов), перезапуск через systemd…", m.Version, updated)
|
||||||
|
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
|
||||||
|
// новый бинарь. Даём пару секунд на флаш логов/ответа.
|
||||||
|
go func() {
|
||||||
|
time.Sleep(800 * time.Millisecond)
|
||||||
|
os.Exit(42)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLoop — фоновая авто-проверка обновлений (если включена).
|
||||||
|
func (u *Updater) updateLoop(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(6 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
check := func() {
|
||||||
|
if !u.rc.Snapshot().Update.AutoCheck {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
|
||||||
|
log.Printf("lk-gateway: доступно обновление %s (текущая %s)", st.Available, st.CurrentVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// первая проверка через минуту после старта
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(time.Minute):
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirOf(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '/' {
|
||||||
|
return path[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "."
|
||||||
|
}
|
||||||
@@ -51,16 +51,51 @@
|
|||||||
|
|
||||||
{{if .Claim.M2MResponse}}
|
{{if .Claim.M2MResponse}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Ответ НРД (M2MTransferResponse)</h2>
|
<h2>Ответ сервиса МОСТ (M2MTransferResponse)</h2>
|
||||||
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code> · Status <code>{{.Claim.M2MResponse.StatusCode}}</code></p>
|
<p>
|
||||||
|
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
|
||||||
|
<span class="badge err">● ERROR — заявка отклонена сервисом НРД</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge ok">● {{.Claim.M2MResponse.StatusCode}} — принято в обработку</span>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
<p class="muted">GUID <code>{{.Claim.M2MResponse.GUID}}</code></p>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст</th></tr></thead>
|
<thead><tr><th>ReferenceID</th><th>Код</th><th>Текст ответа НРД</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Claim.M2MResponse.Responses}}
|
{{range .Claim.M2MResponse.Responses}}
|
||||||
<tr><td><code>{{.ReferenceID}}</code></td><td>{{.Code}}</td><td>{{.Text}}</td></tr>
|
<tr><td><code>{{.ReferenceID}}</code></td><td><code>{{.Code}}</code></td><td>{{.Text}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{if eq .Claim.M2MResponse.StatusCode "ERROR"}}
|
||||||
|
<p class="muted" style="margin-top:10px">
|
||||||
|
Это отказ на сервисном уровне — запрос не дошёл до контрагента. Решение
|
||||||
|
(M2MTransferDecision) по такой заявке не придёт. Устраните причину по коду
|
||||||
|
выше и отправьте новую заявку.
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
{{if .Claim.M2MResponse.RawXML}}
|
||||||
|
<details style="margin-top:12px">
|
||||||
|
<summary style="cursor:pointer;font-weight:600">
|
||||||
|
Сырой ответ НРД (для техподдержки M2MOST@nsd.ru)
|
||||||
|
</summary>
|
||||||
|
<p class="muted" style="margin:8px 0">
|
||||||
|
Точные байты ответа сервиса МОСТ. Можно дословно переслать в поддержку НРД.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn" onclick="copyRaw(this)">Скопировать</button>
|
||||||
|
<pre id="raw-response" style="white-space:pre-wrap;word-break:break-all;background:var(--surface-2,#f5f5f7);padding:12px;border-radius:8px;font-size:12px;overflow:auto;max-height:340px">{{.Claim.M2MResponse.RawXML}}</pre>
|
||||||
|
</details>
|
||||||
|
<script>
|
||||||
|
function copyRaw(btn){
|
||||||
|
var t=document.getElementById('raw-response').innerText;
|
||||||
|
navigator.clipboard.writeText(t).then(function(){
|
||||||
|
var o=btn.textContent; btn.textContent='Скопировано ✓';
|
||||||
|
setTimeout(function(){btn.textContent=o;},1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
|
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/help/cryptopro" style="text-decoration:none">
|
<a href="/admin/help/crypto" style="text-decoration:none">
|
||||||
<div class="card" style="height:100%">
|
<div class="card" style="height:100%">
|
||||||
<h2 style="color:var(--accent)">КриптоПро и Рутокен →</h2>
|
<h2 style="color:var(--accent)">Криптография (Валидата) →</h2>
|
||||||
<p class="muted">Установка КриптоПро CSP на РЕД ОС / Ubuntu, ввод серийного номера, PKCS#11 модуль, серверная подпись и подпись оператора через Рутокен ЭЦП 2.0, тестирование.</p>
|
<p class="muted">Установка АПК «Валидата Клиент L» на Astra Linux SE, подключение через PKCS#11, тестирование подписи и проверки квитанций НРД.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/help/systems" style="text-decoration:none">
|
<a href="/admin/help/systems" style="text-decoration:none">
|
||||||
@@ -29,5 +29,17 @@
|
|||||||
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
|
<p class="muted">ИШ НРД (профили GUEST/TEST3/PROD), команда Fansy (ETL в staging), уведомления (e-mail, Yandex Messenger, Telegram), порядок согласования.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/help/robot" style="text-decoration:none">
|
||||||
|
<div class="card" style="height:100%">
|
||||||
|
<h2 style="color:var(--accent)">Тестирование с роботом MOEX МОСТ →</h2>
|
||||||
|
<p class="muted">Робот НРД на TEST3 (код <code>MC0012500000</code>), 4 тестовых сценария (отказ / принять все / частично / встречный перевод), управление через DocumentSeries и DocumentNumber, тестовые наборы депозитариев и кодов ошибок.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/help/architecture" style="text-decoration:none">
|
||||||
|
<div class="card" style="height:100%">
|
||||||
|
<h2 style="color:var(--accent)">Архитектура обмена с НРД →</h2>
|
||||||
|
<p class="muted">Полная схема: bj-server → ИШ (на Astra Linux ВМ) → ONYX (НРД) → робот-автотест. Кто на чьей стороне, какое СКЗИ, какие сертификаты, FAQ.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Архитектура: как устроен обмен с НРД</h2>
|
||||||
|
<p class="muted">Документ-источник: <code>DOC/ruk_install_ish_2025_11_10.pdf</code> (Руководство по установке ИШ от 10.11.2025), <code>DOC/instr-ish-rest-api.pdf</code> (REST API ИШ).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Схема обмена (полная)</h2>
|
||||||
|
<pre style="font-size:12px;line-height:1.4;background:var(--bg);padding:16px;border-radius:6px;overflow:auto">
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ НАША СТОРОНА │
|
||||||
|
│ │
|
||||||
|
│ Linux ВМ (РЕД ОС 8) Astra Linux ВМ │
|
||||||
|
│ ────────────────── ────────────────── │
|
||||||
|
│ ┌──────────────────┐ REST API ┌──────────────────┐ │
|
||||||
|
│ │ bj-server │ ────POST/GET──> │ ИШ (igate) │ │
|
||||||
|
│ │ (наше ПО) │ <───────────── │ (получаем у НРД)│ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ • стейт-машина │ │ Делает САМ: │ │
|
||||||
|
│ │ • PostgreSQL │ │ • подпись │ │
|
||||||
|
│ │ • админка :8080 │ │ • упаковку ЭДО │ │
|
||||||
|
│ │ • lk-emulator │ │ • проверку │ │
|
||||||
|
│ │ │ │ подписей НРД │ │
|
||||||
|
│ └──────────────────┘ │ • БД PostgreSQL │ │
|
||||||
|
│ │ (history) │ │
|
||||||
|
│ └──────┬───────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ АПК «Валидата Клиент L» │ АПК «Валидата │
|
||||||
|
│ (PKCS#11) — общий для всех │ Клиент L» │
|
||||||
|
│ компонентов │ │
|
||||||
|
└──────────────────────────────────────────────┼─────────────────┘
|
||||||
|
│
|
||||||
|
SOAP/REST/HTTPS │ Web-сервис ONYX
|
||||||
|
▼
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ СТОРОНА НРД │
|
||||||
|
│ │
|
||||||
|
│ GUEST: gost-gt.nsd.ru │
|
||||||
|
│ TEST3: gost.nsd.ru │
|
||||||
|
│ PROM: edog.nsd.ru │
|
||||||
|
│ │
|
||||||
|
│ /onyxgs/WslService │
|
||||||
|
│ /onyxt3/WslService │
|
||||||
|
│ /onyxpr/WslService │
|
||||||
|
│ │
|
||||||
|
│ ↓ внутрь НРД │
|
||||||
|
│ • робот-автотест │
|
||||||
|
│ MC0012500000 │
|
||||||
|
│ • реальные депозитарии │
|
||||||
|
│ • биржевые системы │
|
||||||
|
└────────────────────────────┘
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Компоненты — кто на чьей стороне</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Компонент</th><th>Сторона</th><th>ОС</th><th>СКЗИ</th><th>Назначение</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>bj-server</strong></td>
|
||||||
|
<td>наша</td>
|
||||||
|
<td>Astra Linux SE 1.7 / Linux</td>
|
||||||
|
<td>АПК «Валидата Клиент L» (PKCS#11)</td>
|
||||||
|
<td>Стейт-машина, журнал в БД, веб-админка, lk-emulator</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>ИШ (igate)</strong></td>
|
||||||
|
<td>наша <span class="muted">(но дистрибутив даёт НРД)</span></td>
|
||||||
|
<td>Astra Linux SE 1.6/1.7</td>
|
||||||
|
<td>АПК «Валидата Клиент L»</td>
|
||||||
|
<td>Подписывает наш XML сертификатом УЦ МБ, упаковывает в пакет ЭДО, отправляет в НРД</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>ONYX (WSL)</strong></td>
|
||||||
|
<td>НРД</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Web-сервис НРД — принимает пакеты от ИШ всех клиентов</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Робот-автотест</strong></td>
|
||||||
|
<td>НРД</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
|
<td>Контрагент-эмулятор внутри НРД. Адресуется кодом <code>MC0012500000</code> в TEST3</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Часто задаваемые вопросы</h2>
|
||||||
|
|
||||||
|
<h3>Q: ИШ — это сервер НРД, к которому мы подключаемся?</h3>
|
||||||
|
<p>Нет. <strong>ИШ — это наша программа, поставленная у нас.</strong> НРД даёт дистрибутив (<code>igate_100.0-765_amd64.deb</code>, 117 МБ), но ставим у себя. ИШ — это «персональный почтовый клиент к НРД» с подписью.</p>
|
||||||
|
|
||||||
|
<h3>Q: ИШ можно поставить на ту же ВМ, что и bj-server?</h3>
|
||||||
|
<p>Да, если ВМ — Astra Linux. И bj-server, и ИШ работают на Astra Linux SE 1.6/1.7 и используют одно и то же СКЗИ — АПК «Валидата Клиент L». Можно собрать всё на одной ВМ или разнести по отдельным.</p>
|
||||||
|
|
||||||
|
<h3>Q: Мы перекладываем файлы между bj-server и ИШ?</h3>
|
||||||
|
<p>Нет. Мы используем <strong>REST API</strong> ИШ (раздел 2.5 инструкции). bj-server делает HTTP-запросы: <code>POST /api/package/{channel}/file</code> с ZIP в теле. Никаких разделяемых папок. (Альтернативный режим «обменные папки» в ИШ есть — мы его не используем.)</p>
|
||||||
|
|
||||||
|
<h3>Q: Где брать Валидату?</h3>
|
||||||
|
<p>Дистрибутив для Astra Linux SE опубликован на сайте Московской Биржи: <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. На Linux отдельной лицензии и регистрационных данных не требует — пакеты <code>zpki</code>/<code>zsdk</code> ставятся через <code>dpkg -i</code> и работают сразу.</p>
|
||||||
|
|
||||||
|
<h3>Q: Какой сертификат нужен?</h3>
|
||||||
|
<p>Только от <strong>УЦ Московской Биржи</strong> (<code>ca.moex.com</code>). Сертификаты других УЦ ИШ не примет. УЦ МБ выпускает сертификаты только для организаций, подключённых к ЭДО НРД (по договору).</p>
|
||||||
|
|
||||||
|
<h3>Q: Что делать, чтобы протестировать на роботе НРД на TEST3?</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Получить сертификат УЦ МБ для нашей организации.</li>
|
||||||
|
<li>Подать <a href="https://www.nsd.ru/workflow/zayavka-na-testirovanie/" target="_blank">заявку на тестирование</a> в НРД (инструкция в <code>DOC/instr_int_sh_01072025.pdf</code>).</li>
|
||||||
|
<li>Получить от НРД код депонента-тестера и доступ к TEST3.</li>
|
||||||
|
<li>Поднять Astra Linux ВМ, поставить ИШ + Валидату, импортировать сертификат.</li>
|
||||||
|
<li>В нашем <a href="/admin/setup">/admin/setup</a> → «Интеграционный шлюз НРД» указать URL ИШ (например <code>http://10.10.10.23:8080</code>) и имя канала из ИШ.</li>
|
||||||
|
<li>Отправить тестовую заявку с <code>ReceiverCode = MC0012500000</code> и <code>DocumentSeries = 2001</code> — робот ответит «Принять все бумаги».</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Q: Сколько времени нужно от старта подключения до прогона на TEST3?</h3>
|
||||||
|
<p>Оптимистично — <strong>1-2 недели</strong> (если все ответы НРД быстрые и УЦ МБ не задерживает). Реалистично — <strong>3-4 недели</strong>. На нашей стороне всё уже готово; задержка только во внешних шагах.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Что у нас уже готово</h2>
|
||||||
|
<ul>
|
||||||
|
<li>✅ <strong>REST-клиент ИШ</strong> в <code>internal/nsdadapter/igw/</code> — все 4 endpoint'а по спецификации, упаковщик/распаковщик ZIP, 10 тестов PASS</li>
|
||||||
|
<li>✅ <strong>Робот-эмулятор</strong> в <code>internal/nsdadapter/mock/</code> — позволяет проверить нашу логику до получения реального ИШ</li>
|
||||||
|
<li>✅ <strong>Конфигурация в админке</strong> — поля <code>igw_base_url</code> и <code>channel</code> в /admin/setup; авто-определение профилей GUEST/TEST3/PROD</li>
|
||||||
|
<li>✅ <strong>Подбор URL контуров</strong> — при выборе профиля URL ONYX заполняется автоматически</li>
|
||||||
|
<li>✅ <strong>Полная документация ИШ</strong> в <code>DOC/</code> и дистрибутив в <code>dist/ish/</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Криптография (АПК «Валидата Клиент L»)</h2>
|
||||||
|
<p class="muted">bj-server общается с СКЗИ «Валидата Клиент L» через сайдкар <code>bj-crypto</code> по UDS <code>/run/bj/crypto.sock</code>. Чтобы подпись и проверка квитанций НРД заработали, нужен <strong>ключевой профиль</strong> — папка с тремя сущностями: <code>local.pse</code> (зашифрованный контейнер), <code>local.gdbm</code> (база сертификатов) и <code>vdkeys/*.vdk</code> (сам ключ).</p>
|
||||||
|
<p class="muted"><strong>Архив от MOEX/НРД содержит «резервную копию», а не готовый профиль.</strong> На Linux рабочий <code>local.gdbm</code> нельзя восстановить headless — Валидата Linux требует GUI-операции «Восстановить справочники из резервной копии». Поэтому профиль готовится один раз на Windows и переносится на сервер через USB.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="border-left:4px solid var(--accent)">
|
||||||
|
<h2>Почему профиль готовится на Windows, а не на сервере</h2>
|
||||||
|
<p>Боевой Astra Linux SE-сервер с ГОСТ-криптографией <strong>обязан быть headless</strong>: чем меньше пакетов и поверхности атаки, тем проще сертификация ФСТЭК и тем меньше нарушений требований к контуру ЭДО НРД. Установка GUI (X-сервер, GTK, шрифты, VNC/RDP) тянет 50+ пакетов, расширяет surface attack и усложняет аудит — поэтому отказались.</p>
|
||||||
|
<p>Это <strong>стандартная практика</strong> в фин-секторе: на admin-станции (под Windows или отдельной защищённой ВМ) генерируются и обновляются профили; на боевые серверы они доставляются готовыми через выделенный USB или защищённый канал. Все инструкции MOEX/НРД написаны именно под Windows — этот путь поддерживается официально.</p>
|
||||||
|
<p class="muted">Альтернативный путь — Linux GUI через X11-forwarding или VNC на дев-стенды — допустим только в песочнице, не в проде. На боевых серверах <code>zcs</code>/<code>vdcsp_cfg</code> не должны запускаться.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="border-left:4px solid var(--ok)">
|
||||||
|
<h2>✅ Подготовка профиля (Windows → USB → bj-server)</h2>
|
||||||
|
|
||||||
|
<h3 style="margin-top:14px">Шаг A — на компьютере под Windows</h3>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Установите СКЗИ Валидата CSP для Windows</strong>.<br>
|
||||||
|
Скачайте дистрибутив с <a href="https://www.moex.com/s1292" target="_blank" rel="noopener">moex.com/s1292</a> (раздел «СКЗИ для Windows», файл «Валидата CSP v.6.0.482.0 64bit»). Внутри архива есть <code>Readme.txt</code> с регистрационными данными — введите их во время установки.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><strong>Распакуйте архив-профиль от MOEX/НРД</strong>.<br>
|
||||||
|
Например <code>PrUser985.7z</code> с паролем <code>11</code> в папку <code>C:\moex-src\</code>. Получится структура:
|
||||||
|
<pre style="font-size:12px">C:\moex-src\
|
||||||
|
spr985\
|
||||||
|
local.pse
|
||||||
|
local.gdbm ← это «резервная копия», на Linux не работает напрямую
|
||||||
|
vdkeys\
|
||||||
|
XXXXXXXXXXXXXXXX.vdk
|
||||||
|
key.reg</pre>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><strong>Зарегистрируйте ключ в системе Windows</strong>.<br>
|
||||||
|
Двойной клик по <code>key.reg</code> → «Да» на запрос о записи в реестр. Это нужно, чтобы Валидата увидела ключ при восстановлении справочников.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><strong>Откройте «Справочник сертификатов x64»</strong> из меню «Пуск» → «АПК Валидата Клиент».</li>
|
||||||
|
|
||||||
|
<li><strong>Создайте профиль на флешке</strong>:
|
||||||
|
<ul>
|
||||||
|
<li>Вставьте чистую USB-флешку, запомните её букву (например <code>E:</code>).</li>
|
||||||
|
<li>В Справочнике: меню <em>Профили</em> → <em>Настройка профилей</em> → <em>Добавить</em>.</li>
|
||||||
|
<li>Имя профиля: например <code>moex</code>.</li>
|
||||||
|
<li><strong>Каталог профиля</strong>: создайте новую пустую папку <strong>на флешке</strong>, например <code>E:\moex\</code>. Это путь, куда Валидата положит рабочую копию.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><strong>Восстановите справочники из резервной копии</strong>:<br>
|
||||||
|
Меню <em>Сервис</em> → <em>Восстановить справочники из резервной копии</em>. В диалоге укажите папку <code>C:\moex-src\spr985\</code>. Дождитесь сообщения «Справочники восстановлены».<br>
|
||||||
|
После этого в <code>E:\moex\</code> появятся <code>local.pse</code> и <strong>рабочий</strong> <code>local.gdbm</code> (отличается от исходной резервной копии).
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><strong>Скопируйте папку <code>vdkeys</code> на корень флешки</strong>.<br>
|
||||||
|
Скопируйте папку <code>C:\moex-src\vdkeys\</code> в корень флешки. Итоговая структура:
|
||||||
|
<pre style="font-size:12px">E:\
|
||||||
|
moex\ ← рабочий профиль, созданный Валидатой
|
||||||
|
local.pse
|
||||||
|
local.gdbm ← теперь правильный
|
||||||
|
vdkeys\
|
||||||
|
XXXXXXXXXXXXXXXX.vdk</pre>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li><strong>Безопасно извлеките флешку</strong> через значок в системном трее Windows.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 style="margin-top:18px">Шаг B — на сервере (этот веб-интерфейс)</h3>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Вставьте флешку в сервер</strong> (физический USB-порт или прокинутая через гипервизор виртуальная флешка).</li>
|
||||||
|
|
||||||
|
<li>Откройте <a href="/admin/setup">/admin/setup</a>. Через 2-3 секунды (автодетект монтирования) в блоке <strong>«Носители ключей»</strong> появится строка <code>🔌 USB /run/media/...</code>. Внутри неё — сабблок <strong>«Профиль Валидаты»</strong> с тремя строками: <code>local.pse</code> / <code>local.gdbm</code> / <code>*.vdk</code>.</li>
|
||||||
|
|
||||||
|
<li>В поле <strong>«Имя профиля»</strong> введите осмысленное имя (например <code>moex</code>) и нажмите <strong>«Импортировать профиль в Валидату»</strong>.<br>
|
||||||
|
Сервер скопирует файлы в <code>/var/lib/bj/profiles/<имя>/</code>, допишет секцию в <code>/opt/Validata/VDCSP/etc/pki1.conf</code>. Toast подтвердит: «Секция дописана в pki1.conf».</li>
|
||||||
|
|
||||||
|
<li>В таблице <strong>«Импортированные профили Валидаты»</strong> ниже — нажмите <strong>«Активировать»</strong> в строке вашего профиля.<br>
|
||||||
|
Toast: «Валидата: контекст с профилем <имя> инициализирован» → готово.</li>
|
||||||
|
|
||||||
|
<li>Можно извлекать флешку — все нужные файлы уже скопированы в <code>/var/lib/bj/profiles/</code>.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 style="margin-top:18px">Проверка</h3>
|
||||||
|
<ol>
|
||||||
|
<li>В блоке «СКЗИ» нажмите зелёную кнопку <strong>«✓ Проверить подключение СКЗИ»</strong>.</li>
|
||||||
|
<li>Toast должен показать что-то вроде: <code>СКЗИ validata: 0.1.0 (Валидата: контекст с профилем «moex» инициализирован)</code>.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Что делать если профиль на флешке не виден</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>USB не монтируется автоматически в Astra Linux SE.</strong> Подключите вручную: посмотрите <code>lsblk</code>, потом <code>sudo mount /dev/sdb1 /mnt</code>. Через секунду «Носители ключей» подхватит точку монтирования.</li>
|
||||||
|
<li><strong>Файлы лежат не в корне флешки.</strong> Сканер ищет в глубину 4 уровня — если поместили в <code>E:\very\deep\folder\moex\</code>, должно тоже найтись.</li>
|
||||||
|
<li><strong>На флешке нет <code>vdkeys\</code>.</strong> Без неё профиль не работает — ключ <code>.vdk</code> обязателен.</li>
|
||||||
|
<li><strong>«Ни контейнеров, ни сертификатов, ни профиля Валидаты не найдено».</strong> Это значит на носителе нет <em>одновременно</em> <code>.pse</code> и <code>.vdk</code> файлов. Перепроверьте Шаг 6-7 на Windows.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Альтернатива: загрузка как ZIP-архив</h2>
|
||||||
|
<p>Если USB-доступ к серверу неудобен — можно собрать содержимое флешки в обычный <code>.zip</code> на Windows и загрузить через web-форму.</p>
|
||||||
|
<ol>
|
||||||
|
<li>После шага A.7 (когда на флешке готовая структура <code>moex\</code> + <code>vdkeys\</code>) — выделите обе папки, правый клик → <em>Отправить</em> → <em>Сжатая ZIP-папка</em>.</li>
|
||||||
|
<li>На сервере: <a href="/admin/setup">/admin/setup</a> → «Носители ключей» → форма «Загрузить образ или архив» → выберите ZIP, поле «Пароль» оставьте пустым.</li>
|
||||||
|
<li>Дальше как в Шаге B со 2-го пункта.</li>
|
||||||
|
</ol>
|
||||||
|
<p class="muted">Под капотом сервер распаковывает архив через <code>7z</code> в <code>/var/lib/bj/media/iso/</code>, сканирует на профиль Валидаты — далее всё то же самое, что с USB.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Справочные команды (диагностика)</h2>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>systemctl status bj-crypto</code></td><td>Состояние Java-сайдкара (UDS-сокет, провайдер).</td></tr>
|
||||||
|
<tr><td><code>sudo journalctl -u bj-crypto -n 50</code></td><td>Последние строки лога сайдкара.</td></tr>
|
||||||
|
<tr><td><code>cat /opt/Validata/VDCSP/etc/pki1.conf</code></td><td>Список профилей, которые видит Валидата (наши секции помечены <code># --- bj-server: профиль ...</code>).</td></tr>
|
||||||
|
<tr><td><code>sudo ls -la /var/lib/bj/profiles/</code></td><td>Импортированные профили на сервере.</td></tr>
|
||||||
|
<tr><td><code>/opt/Validata/VDCSP/bin/amd64/testcsp -silent</code></td><td>Базовая проверка провайдера CSP.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Установка Валидаты на сервер (если её ещё нет)</h2>
|
||||||
|
<p class="muted">Если этот раздел вам не показывает «✓ ready» — повторите установку:</p>
|
||||||
|
<pre>curl -fsSL https://fs.moex.com/cdp/po/ClientL_ALSE.zip -o ClientL_ALSE.zip
|
||||||
|
unzip ClientL_ALSE.zip
|
||||||
|
sudo apt-get install -y libccid pcscd execstack
|
||||||
|
sudo dpkg -i ClientL_ALSE/zpki-*.deb ClientL_ALSE/zsdk-*.deb
|
||||||
|
sudo apt-get -f install -y
|
||||||
|
sudo execstack -c /opt/Validata/VDCSP/lib/amd64/libvdcsp.so
|
||||||
|
sudo systemctl enable --now pcscd</pre>
|
||||||
|
<p class="muted">Дистрибутив для Astra Linux SE — <a href="https://fs.moex.com/cdp/po/ClientL_ALSE.zip" target="_blank" rel="noopener">fs.moex.com/cdp/po/ClientL_ALSE.zip</a>. Linux-версия отдельной лицензии не требует.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
{{define "content"}}
|
|
||||||
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>КриптоПро и Рутокен</h2>
|
|
||||||
<p class="muted">Bridge-and-Join-s использует ГОСТ Р 34.10-2012 для подписи и проверки XMLDSig. Серверная криптография — КриптоПро CSP. Подпись оператора в admin-ui — Рутокен ЭЦП 2.0 (опционально). Оба продукта говорят со стандартным интерфейсом PKCS#11, поэтому Go-клиент общается с ними одинаково.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>1. Что и зачем нужно</h2>
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Сценарий</th><th>СКЗИ</th><th>Цена (ориентир)</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>Проверка XMLDSig входящих от НРД и брокеров</td><td>КриптоПро CSP «Сервер»</td><td>~30-50к ₽ (один раз)</td></tr>
|
|
||||||
<tr><td>Подпись пакетов в НРД (резервный канал WS ONYX)</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
|
|
||||||
<tr><td>Подпись действий оператора в admin-ui</td><td>Рутокен ЭЦП 2.0 + лицензия CSP «Рабочее место»</td><td>~3-5к ₽ железо + ~2-3к ₽ лицензия</td></tr>
|
|
||||||
<tr><td>Проверка XMLDSig заявлений от ЛК</td><td>КриптоПро CSP «Сервер»</td><td>включено</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="muted">Если используется Интеграционный шлюз НРД (ИШ), он сам подписывает пакеты — наша серверная подпись нужна только для резервного канала ONYX и подписи действий оператора. Можно начать с минимума: только Рутокен оператора и отложить серверную лицензию.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>2. Установка КриптоПро CSP на РЕД ОС (проверено)</h2>
|
|
||||||
<p><strong>Способ 1 — через веб-интерфейс (рекомендуется):</strong> <a href="/admin/setup">/admin/setup</a> → «СКЗИ» → «Установка КриптоПро CSP» → выбрать <code>linux-amd64.tar</code> с cryptopro.ru → «Загрузить и установить».</p>
|
|
||||||
<p><strong>Способ 2 — вручную из терминала.</strong> Скачать <code>linux-amd64.tgz</code> с <code>www.cryptopro.ru/products/csp/downloads</code> (доступ через личный кабинет), распаковать на ВМ и установить минимальный набор:</p>
|
|
||||||
<pre>tar -xzf linux-amd64.tgz
|
|
||||||
cd linux-amd64
|
|
||||||
sudo rpm -Uvh --replacepkgs --nodeps \
|
|
||||||
lsb-cprocsp-base-5.0.*.noarch.rpm \
|
|
||||||
lsb-cprocsp-ca-certs-5.0.*.noarch.rpm \
|
|
||||||
lsb-cprocsp-rdr-64-5.0.*.x86_64.rpm \
|
|
||||||
lsb-cprocsp-capilite-64-5.0.*.x86_64.rpm \
|
|
||||||
lsb-cprocsp-kc1-64-5.0.*.x86_64.rpm \
|
|
||||||
lsb-cprocsp-pkcs11-64-5.0.*.x86_64.rpm \
|
|
||||||
cprocsp-curl-64-5.0.*.x86_64.rpm \
|
|
||||||
cprocsp-rdr-gui-gtk-64-5.0.*.x86_64.rpm</pre>
|
|
||||||
<p>Ключевые пакеты:</p>
|
|
||||||
<ul>
|
|
||||||
<li><code>lsb-cprocsp-base</code> + <code>lsb-cprocsp-rdr-64</code> — базовая инфраструктура</li>
|
|
||||||
<li><code>lsb-cprocsp-capilite-64</code> — CAPILite (<code>libcapi20.so.4</code>, <code>libcpext.so.4</code>) — иначе libcppkcs11.so не загрузится</li>
|
|
||||||
<li><code>lsb-cprocsp-kc1-64</code> — CSP класса КС1 (без него Initialize упадёт с CKR_FUNCTION_FAILED)</li>
|
|
||||||
<li><code>lsb-cprocsp-pkcs11-64</code> — собственно <code>libcppkcs11.so</code></li>
|
|
||||||
</ul>
|
|
||||||
<p>Демо-лицензия на 3 месяца встроена в дистрибутив, отдельная активация не требуется. Проверка:</p>
|
|
||||||
<pre>/opt/cprocsp/sbin/amd64/cpconfig -license -view
|
|
||||||
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
|
|
||||||
<p><strong>Важно — LD_LIBRARY_PATH.</strong> КриптоПро CSP кладёт .so в <code>/opt/cprocsp/lib/amd64</code> без записи в <code>/etc/ld.so.conf.d</code>. Bj-server при запуске должен иметь:</p>
|
|
||||||
<pre>Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64</pre>
|
|
||||||
<p>В systemd-юните это уже прописано (<code>deploy/systemd/bj-server.service</code>). При ручном запуске из shell — <code>LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64 ./bin/bj-server</code>.</p>
|
|
||||||
<p><strong>Активация коммерческой лицензии.</strong> После того как демо истечёт, серийник вводится через UI на <a href="/admin/setup">/admin/setup</a> → «Активация лицензии», или вручную:</p>
|
|
||||||
<pre>sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>3. Установка на Ubuntu / Debian</h2>
|
|
||||||
<pre>sudo dpkg -i cprocsp-rdr-gui-gtk-64_5.0.*_amd64.deb \
|
|
||||||
cprocsp-rdr-64_5.0.*_amd64.deb \
|
|
||||||
lsb-cprocsp-base_5.0.*_all.deb \
|
|
||||||
lsb-cprocsp-rdr-64_5.0.*_amd64.deb
|
|
||||||
sudo apt-get install -f
|
|
||||||
sudo /opt/cprocsp/sbin/amd64/cpconfig -license -set XXXX-XXXXX-XXXXX-XXXXX-XXXXX</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>4. PKCS#11 модуль</h2>
|
|
||||||
<p>Путь к библиотеке после установки:</p>
|
|
||||||
<pre>/opt/cprocsp/lib/amd64/libcppkcs11.so</pre>
|
|
||||||
<p>Эта же библиотека работает и с CSP-ключами (контейнеры на диске или в реестре), и с Рутокен ЭЦП 2.0 (подключённым по USB или в виде smart-card reader).</p>
|
|
||||||
<p>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» укажите:</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Провайдер</strong>: <code>cryptopro</code></li>
|
|
||||||
<li><strong>UDS-сокет</strong>: <code>/run/bj/crypto.sock</code> (для legacy crypto-service на Java — на M2+ переходим на Go-клиент напрямую через PKCS#11)</li>
|
|
||||||
<li><strong>Путь к jcp.jar / PKCS#11</strong>: <code>/opt/cprocsp/lib/amd64/libcppkcs11.so</code></li>
|
|
||||||
<li><strong>Лицензионный ключ</strong>: серийный номер CSP</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>5. Подключение Рутокен ЭЦП 2.0</h2>
|
|
||||||
<p>Подключите Рутокен в USB. Драйверы КриптоПро CSP уже включают поддержку Рутокен:</p>
|
|
||||||
<pre># увидеть подключённые токены
|
|
||||||
/opt/cprocsp/bin/amd64/csptest -card -enum
|
|
||||||
|
|
||||||
# увидеть ключевые контейнеры на токене
|
|
||||||
/opt/cprocsp/bin/amd64/csptest -keyset -enum -unique</pre>
|
|
||||||
<p>Для подписи действий оператора в admin-ui:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Запросить сертификат на физлицо у УЦ (через личный кабинет КриптоПро или через АРМ оператора УЦ).</li>
|
|
||||||
<li>Записать сертификат и контейнер на Рутокен.</li>
|
|
||||||
<li>На <a href="/admin/setup">странице «Настройка»</a> в карточке «Криптография» выбрать провайдер <code>cryptopro</code> и указать слот Рутокен.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>6. Импорт сертификата</h2>
|
|
||||||
<pre># сертификат корневого УЦ (если ещё нет в системе)
|
|
||||||
/opt/cprocsp/bin/amd64/certmgr -inst -store mroot -file /path/to/root-ca.cer
|
|
||||||
|
|
||||||
# сертификат подписанта (контейнер на токене)
|
|
||||||
/opt/cprocsp/bin/amd64/certmgr -inst -store uMy -cont '\\.\HDIMAGE\my-keys' \
|
|
||||||
-file /path/to/operator.cer
|
|
||||||
|
|
||||||
# проверить установленные сертификаты
|
|
||||||
/opt/cprocsp/bin/amd64/certmgr -list -store uMy</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>7. Тестирование подписи</h2>
|
|
||||||
<p>Через CLI КриптоПро (быстрая проверка что криптография работает):</p>
|
|
||||||
<pre># подписать произвольный файл
|
|
||||||
/opt/cprocsp/bin/amd64/cryptcp -signf -dn 'CN=Иванов И.И.' \
|
|
||||||
-det -strict /tmp/test.txt
|
|
||||||
|
|
||||||
# проверить подпись
|
|
||||||
/opt/cprocsp/bin/amd64/cryptcp -vsignf -det /tmp/test.txt /tmp/test.txt.sgn</pre>
|
|
||||||
<p>Через нашу систему — раздел <a href="/admin/setup">Настройка</a> → кнопка «Запустить тестовую заявку». На странице «Заявка» появится результат и расшифровка проверки подписи.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>8. Поддержка</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Документация КриптоПро: <code>www.cryptopro.ru/products/csp</code></li>
|
|
||||||
<li>Установка на РЕД ОС: <code>www.cryptopro.ru/forum2/default.aspx?g=topics&f=43</code></li>
|
|
||||||
<li>Технические вопросы: <code>support@cryptopro.ru</code></li>
|
|
||||||
<li>Рутокен: <code>dev.rutoken.ru/display/PUB/Rutoken+EDS</code></li>
|
|
||||||
</ul>
|
|
||||||
<p class="muted">При проблемах с лицензией сначала проверьте <code>cpconfig -license -view</code> — лицензия должна быть валидна и не просрочена. Срок действия КриптоПро лицензии — обычно 1 год.</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>8. Подписание заявления</h2>
|
<h2>8. Подписание заявления</h2>
|
||||||
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/cryptopro">инструкцию по КриптоПро</a>.</p>
|
<p>ЛК должен подписать заявление XMLDSig (ГОСТ или RSA) и положить в поле <code>signed_document</code> (base64). Мы проверяем подпись через crypto-service — см. <a href="/admin/help/crypto">инструкцию по криптографии</a>.</p>
|
||||||
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
|
<p class="muted">На M2 проверка подписи отключена (stub). На M3-M4 включится после подключения СКЗИ.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<p style="margin-bottom:16px"><a href="/admin/help">← все инструкции</a></p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Тестирование с роботом MOEX МОСТ</h2>
|
||||||
|
<p class="muted">Источник: <code>DOC/instruktsiya-po-testirovaniyu-s-robotom.pdf</code> (опубликована 12.05.2026). Демо-ролик: <a href="https://disk.yandex.ru/i/F1SL2CVY5GphwQ" target="_blank">disk.yandex.ru/i/F1SL2CVY5GphwQ</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>1. Что это</h2>
|
||||||
|
<p>НРД разработан специальный «робот» для тестирования интеграции информационных систем клиента и сервиса переводов M2M. Робот работает <strong>в круглосуточном режиме</strong> и эмулирует действия второй стороны при обмене сообщениями в сервисе M2M.</p>
|
||||||
|
<p>Робот может выступать как принимающей стороной (по умолчанию), так и передающей. Он может формировать как успешные сообщения, так и сообщения о нештатных ситуациях.</p>
|
||||||
|
<p>Доступен на тестовом контуре <strong>TEST3</strong> (<code>gost-t3.nsd.ru</code>). Подключение к роботу не требует отдельной регистрации — достаточно быть подключённым к ЭДО НРД на TEST3.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>2. Адресация робота</h2>
|
||||||
|
<p><strong>КОД РОБОТА: <code>MC0012500000</code></strong></p>
|
||||||
|
<p>Чтобы робот получил сообщение, его код должен быть указан в получателях — <code>Header.ReceiverCode</code>.</p>
|
||||||
|
<p class="muted">В <code>bj-server</code> mock-сендер (<code>internal/nsdadapter/mock</code>) уже понимает этот код: если <code>ReceiverCode == MC0012500000</code> и в заявке указан DocumentSeries из таблицы ниже — внутренний робот-эмулятор сформирует ответ по выбранному сценарию. То же поведение будет на реальном TEST3, когда подключим ИШ.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>3. Тестовые сценарии</h2>
|
||||||
|
<p>Выбор сценария — через поле <code>Data.InvestorInformation.IdentityDocument.DocumentSeries</code> в M2MTransferRequest.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Код</th><th>Сценарий</th><th>Управляющий параметр</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>1111</code></td>
|
||||||
|
<td><strong>Ответ с отказом</strong> — все бумаги отвергаются с выбранным кодом ошибки</td>
|
||||||
|
<td>Последние 2 символа <code>DocumentNumber</code> = ключ ошибки (<code>01</code>..<code>09</code>) → код <code>M2M01</code>..<code>M2M09</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>2001</code></td>
|
||||||
|
<td><strong>Принять все бумаги</strong></td>
|
||||||
|
<td><code>DocumentNumber</code>: i-я цифра = номер депозитария-получателя для i-й секции (<code>1</code> или <code>2</code>). По умолчанию <code>1</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>2002</code></td>
|
||||||
|
<td><strong>Принять бумаги частично</strong></td>
|
||||||
|
<td><code>DocumentNumber</code>: i-я цифра = номер депозитария (<code>1</code>/<code>2</code>) или <code>0</code> (отклонить с <code>M2M05</code>).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>3333</code></td>
|
||||||
|
<td><strong>Выступить принимающей стороной</strong> — робот отвергает оригинал и формирует встречный M2MTransferRequest</td>
|
||||||
|
<td>Первые 2 цифры <code>DocumentNumber</code> = реквизиты двух депозитариев для нового перевода</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="margin-top:8px">Пример: для сценария <code>1111</code> с <code>DocumentNumber=111102</code> робот вернёт код ошибки <code>M2M02</code>. Для сценария <code>2001</code> с 4 секциями ЦБ и <code>DocumentNumber=111200</code> — секции 1,2,3 принимаются депозитарием 1, секция 4 — депозитарием 2.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>4. Тестовые данные депозитариев</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Ключ</th><th>ИНН (SettlementRequisites)</th><th>SettlementDepositoryLocation</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>1</code></td>
|
||||||
|
<td><code>7702165310</code></td>
|
||||||
|
<td>ИНН <code>7722061076</code> · depcode <code>MC0012500000</code> · счёт <code>HL2603250011</code> · раздел <code>31MC0012500000F00</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>2</code></td>
|
||||||
|
<td><code>7702165310</code></td>
|
||||||
|
<td>ИНН <code>7722061076</code> · depcode <code>MC0012500000</code> · счёт <code>HL2603250011</code> · раздел <code>36MC0012500000F00</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>3</code></td>
|
||||||
|
<td><code>7831000034</code></td>
|
||||||
|
<td class="muted">остальные поля — заглушки</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>5. Коды ошибок (для сценария 1111)</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Ключ</th><th>Код ошибки</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>01</code></td><td><code>M2M01</code></td></tr>
|
||||||
|
<tr><td><code>02</code></td><td><code>M2M02</code></td></tr>
|
||||||
|
<tr><td><code>03</code></td><td><code>M2M03</code></td></tr>
|
||||||
|
<tr><td><code>04</code></td><td><code>M2M04</code></td></tr>
|
||||||
|
<tr><td><code>05</code></td><td><code>M2M05</code></td></tr>
|
||||||
|
<tr><td><code>06</code></td><td><code>M2M06</code></td></tr>
|
||||||
|
<tr><td><code>07</code></td><td><code>M2M07</code></td></tr>
|
||||||
|
<tr><td><code>08</code></td><td><code>M2M08</code></td></tr>
|
||||||
|
<tr><td><code>09</code></td><td><code>M2M09</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>6. Как запустить</h2>
|
||||||
|
<p><strong>Сейчас, без реального ИШ:</strong> используется внутренний робот-эмулятор в bj-server. Отправь заявку с ReceiverCode = <code>MC0012500000</code> и DocumentSeries по таблице — Decision придёт через 3 секунды по правилам робота.</p>
|
||||||
|
<p><strong>На реальном TEST3 НРД:</strong> установи ИШ НРД (см. <a href="/admin/help/systems">/admin/help/systems</a>), укажи в <a href="/admin/setup">/admin/setup</a> → ИШ профиль <code>test3-gost</code>, URL <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code>. Дальше отправляй те же заявки — НРД направит их роботу, ответ будет идентичный.</p>
|
||||||
|
<p class="muted">Сценарий 3333 («выступить принимающей стороной») в нашем внутреннем эмуляторе пока реализован частично — отдаёт только первое сообщение (отказ M2M05). Встречный M2MTransferRequest от робота требует доработки приёмной стороны bj-server.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -43,26 +43,20 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
|
<li>Профиль (например, <code>test3-gost</code>) — при выборе URL и контейнер заполняются автоматически</li>
|
||||||
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
|
<li>URL ONYX — например <code>https://gost-t3.nsd.ru/onyx-ms/OnyxEdoWSService/OnyxEdo</code></li>
|
||||||
<li>Ключевой контейнер — имя контейнера КриптоПро с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
|
<li>Ключевой контейнер — имя контейнера Валидаты с ключами ЭДО НРД (выдаются УЦ НРД, см. ниже)</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
|
<p class="muted">Без настроенного ИШ система работает в <strong>mock-режиме</strong>: bj-server эмитирует синтетический Decision через 3 секунды для каждой заявки. Это удобно для дев-демо и не требует подключения к НРД.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
|
<h2>1а. Сертификаты УЦ НРД (для проверки квитанций)</h2>
|
||||||
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно импортировать корневые сертификаты УЦ НРД в хранилище <code>mroot</code> (доверенные корневые).</p>
|
<p>НРД подписывает все исходящие пакеты ЭДО ГОСТ-подписью своего УЦ. Для проверки этих подписей на нашей стороне нужно загрузить корневые и промежуточные сертификаты УЦ НРД.</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
|
<li>Скачать сертификаты с сайта УЦ НРД: <code>www.nsd.ru/workflow/system/cryptography/</code> (или из дистрибутива ИШ).</li>
|
||||||
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Импорт сертификата» → выбрать файл <code>.cer</code>, тип хранилища <code>mroot — корневой УЦ</code>, нажать «Импортировать». Под капотом выполняется <code>certmgr -inst -file root.cer -store mroot</code>.</li>
|
<li>В <a href="/admin/setup">/admin/setup</a> → раздел «Сертификаты УЦ» добавить прямые URL <code>.cer</code>-файлов и нажать «Скачать и импортировать сейчас». Файлы сохраняются в <code>/var/lib/bj/ca-certs/</code> (по SHA-256). Включите «Авто-обновление раз в сутки» — система перепроверит и обновит.</li>
|
||||||
<li>Промежуточные сертификаты УЦ — в хранилище <code>uRoot</code>.</li>
|
<li>Загруженные через Валидату ключи и сертификаты управляются её собственным справочником (<code>zcs</code>/<code>vdcsp_cfg</code>).</li>
|
||||||
<li>Для проверки подписей самой системы НРД (квитанции ЭДО) — импортировать сертификат подписи НРД в <code>uMy</code> (как корреспондента), либо оставить в <code>mroot</code>, если он самоподписной.</li>
|
|
||||||
</ol>
|
|
||||||
<p><strong>Наши сертификаты для отправки в НРД</strong> (получаются из другого УЦ — нашей организации):</p>
|
|
||||||
<ol>
|
|
||||||
<li>Сертификат подписи нашей организации (с приватным ключом в виде <code>.pfx</code>/<code>.p12</code> или на Рутокен) — импортировать в <code>uMy</code> с PIN.</li>
|
|
||||||
<li>Цепочка сертификатов вашего УЦ — в <code>mroot</code> (корневой) и <code>uRoot</code> (промежуточные).</li>
|
|
||||||
<li>После импорта проверить: <code>certmgr -list -store uMy</code> и <code>cpverify</code>.</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
|
<p><strong>Наши сертификаты для отправки в НРД</strong> загружаются в профиль Валидаты её утилитой <code>zcs</code> (импорт ключевого контейнера и сертификата подписи).</p>
|
||||||
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
|
<p class="muted">Полный цикл обмена сертификатами с НРД описан в <code>DOC/Инструкция M2M.pdf</code> и <code>DOC/Презентация MOEX MOST.pdf</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
|
<p><strong>Документация по подключению</strong>: <code>DOC/instr_podkl_stend_v3.pdf</code>, <code>DOC/Ссылки для доступа в тестовые контуры.pdf</code>.</p>
|
||||||
@@ -105,7 +99,6 @@
|
|||||||
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
|
<tr><td>НРД (Национальный расчётный депозитарий)</td><td>Тестовые сертификаты GUEST/TEST3, дистрибутив ИШ, доступ к личному кабинету УЦ НРД</td></tr>
|
||||||
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
|
<tr><td>Команда ЛК (ESIA Finance)</td><td>Базовый URL ЛК, Basic-auth учётные данные, очерёдность подключения (сначала эмулятор, потом реальный ЛК)</td></tr>
|
||||||
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
|
<tr><td>Команда Fansy</td><td>Контракт <code>docs/fansy-contract/v1/</code>, SLA, окна обслуживания, IP-allowlist</td></tr>
|
||||||
<tr><td>КриптоПро</td><td>Серийный номер лицензии CSP, актуальный дистрибутив, поддержка <code>support@cryptopro.ru</code></td></tr>
|
|
||||||
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
|
<tr><td>Брокеры-контрагенты MOST</td><td>БКС (ИНН 5406121446), Ренессанс (7709258228), Альфа-Банк (7728168971) — уже в seed</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,42 +1,75 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
{{/* ===== Оператор-дашборд (Apple-стиль): приветствие → статус → плитки задач → сводка ===== */}}
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1 class="hero-greeting">Добрый день</h1>
|
||||||
|
{{if .AllReady}}
|
||||||
|
<span class="hero-status ok">● Система готова к работе</span>
|
||||||
|
{{else}}
|
||||||
|
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
|
||||||
|
<span class="hero-status warn">● Требуется настройка — {{.NotReadyCount}} из {{.TotalCount}} компонентов</span>
|
||||||
|
<a href="/admin/wizard" class="btn">Открыть мастер настройки →</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ===== Крупные плитки задач ===== */}}
|
||||||
|
<div class="tiles">
|
||||||
|
<a href="/admin/claims?new=1" class="tile brand">
|
||||||
|
<span class="ico">+</span>
|
||||||
|
<span class="t-title">Новый перевод</span>
|
||||||
|
<span class="t-sub">Заявка на перевод ценных бумаг M2M</span>
|
||||||
|
<span class="t-arrow">→</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/claims" class="tile">
|
||||||
|
<span class="ico">📋</span>
|
||||||
|
<span class="t-title">Переводы</span>
|
||||||
|
<span class="t-sub">{{.Counts.Total}} всего · {{.Counts.InProgress}} в работе</span>
|
||||||
|
<span class="t-arrow">→</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/status" class="tile">
|
||||||
|
<span class="ico">🔍</span>
|
||||||
|
<span class="t-title">Диагностика</span>
|
||||||
|
<span class="t-sub">Состояние СКЗИ, ИШ и базы</span>
|
||||||
|
<span class="t-arrow">→</span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/setup" class="tile">
|
||||||
|
<span class="ico">⚙️</span>
|
||||||
|
<span class="t-title">Настройка</span>
|
||||||
|
<span class="t-sub">Криптография, НРД, подключения</span>
|
||||||
|
<span class="t-arrow">→</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ===== Сводка по переводам ===== */}}
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Всего сделок</div>
|
<div class="stat-label">Всего переводов</div>
|
||||||
<div class="stat-value">{{.Counts.Total}}</div>
|
<div class="stat-value">{{.Counts.Total}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Подтверждено</div>
|
<div class="stat-label">Подтверждено</div>
|
||||||
<div class="stat-value" style="color: var(--ok)">{{.Counts.Confirmed}}</div>
|
<div class="stat-value" style="color:var(--ok)">{{.Counts.Confirmed}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">В ожидании</div>
|
<div class="stat-label">В ожидании</div>
|
||||||
<div class="stat-value" style="color: var(--warn)">{{.Counts.InProgress}}</div>
|
<div class="stat-value" style="color:var(--warn)">{{.Counts.InProgress}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Отказы / таймауты</div>
|
<div class="stat-label">Отказы / таймауты</div>
|
||||||
<div class="stat-value" style="color: var(--err)">{{.Counts.Failed}}</div>
|
<div class="stat-value" style="color:var(--err)">{{.Counts.Failed}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
{{/* ===== Последние переводы ===== */}}
|
||||||
<h2>Состояние системы</h2>
|
<div class="section-head">
|
||||||
{{range .Status.Checks}}
|
<h2>Последние переводы</h2>
|
||||||
<div style="padding: 6px 0">
|
<a href="/admin/claims">все →</a>
|
||||||
<span class="dot {{if .OK}}ok{{else}}err{{end}}"></span>
|
|
||||||
<strong>{{.Name}}</strong> — {{.Message}}
|
|
||||||
{{if .Detail}}<span class="muted"> · <code>{{.Detail}}</code></span>{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
<div class="muted" style="margin-top: 12px">
|
|
||||||
Профиль: <code>{{.Status.Profile}}</code> · Crypto-провайдер: <code>{{.Status.Provider}}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Последние заявки</h2>
|
|
||||||
{{if .Recent}}
|
{{if .Recent}}
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Создана</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
<thead><tr><th>Время</th><th>ID</th><th>Инвестор</th><th>ЦБ</th><th>Статус</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Recent}}
|
{{range .Recent}}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -51,7 +84,25 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">Заявок ещё нет. Подайте первую через lk-emulator или POST /api/v1/back_office/claims/.</p>
|
<p class="muted" style="margin:0">Переводов ещё нет. Нажмите «Новый перевод», чтобы создать первый.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ===== События (компактно, если есть) ===== */}}
|
||||||
|
{{if .News}}
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>События</h2>
|
||||||
|
<a href="/admin/news">все →</a>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
{{range .News}}
|
||||||
|
<div style="padding:9px 0;border-bottom:1px solid var(--border)">
|
||||||
|
<div style="font-weight:600;font-size:13.5px">
|
||||||
|
{{if eq .Kind "maintenance"}}🔧 {{end}}{{if eq .Kind "feature"}}✨ {{end}}{{if eq .Kind "system"}}⚠️ {{end}}{{if eq .Kind "doc-update"}}📄 {{end}}{{.Title}}
|
||||||
|
</div>
|
||||||
|
{{if .Body}}<div class="muted" style="font-size:12px;margin-top:3px">{{.Body}}</div>{{end}}
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{/* Пошаговый мастер установки ключа Валидаты на флешку. */}}
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1 class="hero-greeting">Установка ключа на флешку</h1>
|
||||||
|
<span class="hero-status">Загрузите архив НРД → запись на носитель → справочник сертификатов → проверка → готово</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{$s := .State}}
|
||||||
|
|
||||||
|
{{/* ===== Лента шагов ===== */}}
|
||||||
|
<div class="card">
|
||||||
|
<ol style="list-style:none;padding:0;margin:0;display:grid;gap:12px">
|
||||||
|
{{range $i, $step := $s.Steps}}
|
||||||
|
<li style="display:flex;gap:12px;align-items:flex-start">
|
||||||
|
<span style="flex:0 0 28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;
|
||||||
|
{{if eq $step.Status "ok"}}background:var(--ok-weak);color:var(--ok)
|
||||||
|
{{else if eq $step.Status "error"}}background:var(--err-weak);color:var(--err)
|
||||||
|
{{else if eq $step.Status "active"}}background:var(--accent-weak);color:var(--accent)
|
||||||
|
{{else}}background:var(--surface-2,#eee);color:var(--muted,#999){{end}}">
|
||||||
|
{{if eq $step.Status "ok"}}✓{{else if eq $step.Status "error"}}✕{{else}}{{add $i 1}}{{end}}
|
||||||
|
</span>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div style="font-weight:600">{{$step.Title}}</div>
|
||||||
|
{{if $step.Detail}}<div class="muted" style="font-size:13px;margin-top:2px">{{$step.Detail}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{/* ===== Действие в зависимости от состояния ===== */}}
|
||||||
|
{{if $s.Done}}
|
||||||
|
<div class="card" style="border-left:3px solid var(--ok)">
|
||||||
|
<h2>✓ Готово</h2>
|
||||||
|
<p>Ключ установлен на флешку, справочник сертификатов сформирован, Валидата проверена.</p>
|
||||||
|
{{if $s.Backup}}<p class="muted">Бэкап прежнего носителя: <code>{{$s.Backup}}</code></p>{{end}}
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||||||
|
<form method="post" action="/admin/setup/test-nsd" style="margin:0">
|
||||||
|
<input type="hidden" name="scenario" value="2001">
|
||||||
|
<button type="submit" class="btn btn-ok">→ Отправить тестовый документ роботу</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/setup/keywizard/reset" style="margin:0">
|
||||||
|
<button type="submit" class="btn btn-secondary">Установить ещё один ключ</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if $s.StagingID}}
|
||||||
|
{{/* Архив загружен — выбор флешки + запись */}}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Шаг 2 — выбор флешки и запись</h2>
|
||||||
|
<p class="muted">Архив распакован. Ключ: <code>{{fallbackTpl $s.VDK "—"}}</code>.
|
||||||
|
Выберите носитель — запись сделает бэкап, запишет ключ и справочник
|
||||||
|
сертификатов, дотянет CRL и перезапустит ИШ.</p>
|
||||||
|
<form method="post" action="/admin/setup/keywizard/install" style="margin-top:12px;display:grid;gap:12px;max-width:640px"
|
||||||
|
onsubmit="this.querySelector('button[type=submit]').disabled=true;this.querySelector('button[type=submit]').textContent='Устанавливаю…';">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="font-weight:600;display:block;margin-bottom:6px">Целевая флешка</label>
|
||||||
|
{{if .Drives}}
|
||||||
|
{{range $i, $d := .Drives}}
|
||||||
|
<label style="display:flex;gap:10px;align-items:flex-start;padding:10px;border:1px solid var(--border,#ddd);border-radius:8px;margin-bottom:8px;cursor:pointer">
|
||||||
|
<input type="radio" name="target_device" value="{{$d.Device}}" {{if $d.IsKeymedia}}checked{{else if and (eq $i 0) (not (anyKeymedia $.Drives))}}checked{{end}} style="margin-top:3px">
|
||||||
|
<span>
|
||||||
|
<b>{{fallbackTpl $d.Model "USB-носитель"}}</b> · {{$d.Size}} · {{$d.FSType}}
|
||||||
|
{{if $d.Label}}· метка «{{$d.Label}}»{{end}}<br>
|
||||||
|
<span class="muted" style="font-size:12px">{{$d.Device}}{{if $d.Mountpoint}} · {{$d.Mountpoint}}{{end}}
|
||||||
|
{{if $d.IsKeymedia}}<b style="color:var(--accent)">← текущий ключевой носитель ИШ (рекомендуется)</b>{{end}}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">Съёмные носители не обнаружены — будет использован текущий ключевой носитель ИШ по умолчанию.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="font-weight:600;display:block;margin-bottom:6px">Имя профиля в справочнике (необязательно)</label>
|
||||||
|
<input type="text" name="profile_name" placeholder="Авто из архива (напр. PrUser1046)" autocomplete="off"
|
||||||
|
pattern="[A-Za-z0-9_-]*" style="width:100%">
|
||||||
|
<span class="muted" style="font-size:12px">Пусто = имя берётся из архива автоматически.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-ok">Записать на флешку, сформировать справочник и проверить ИШ</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/setup/keywizard/reset" style="margin-top:8px">
|
||||||
|
<button type="submit" class="btn btn-secondary">Отмена / загрузить другой архив</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
{{/* Начало — форма загрузки */}}
|
||||||
|
<div class="card">
|
||||||
|
<h2>Шаг 1 — загрузка архива</h2>
|
||||||
|
<p class="muted">Выберите .7z-архив с ключом от НРД и введите пароль архива.</p>
|
||||||
|
<form method="post" action="/admin/setup/keywizard/upload" enctype="multipart/form-data"
|
||||||
|
style="margin-top:12px;display:grid;gap:10px;max-width:560px">
|
||||||
|
<input type="file" name="archive" accept=".7z,.zip" required>
|
||||||
|
<input type="password" name="password" placeholder="Пароль архива (например 11)" autocomplete="off">
|
||||||
|
<button type="submit" class="btn btn-ok">Загрузить и распаковать</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<p style="margin-top:16px"><a href="/admin/setup" class="muted">← Назад к настройкам</a></p>
|
||||||
|
|
||||||
|
{{end}}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user