Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 586ffb3a31 | |||
| a694f475a8 | |||
| 9737c787f9 | |||
| 6e503433d4 | |||
| bac55cbdfd | |||
| 7a7aa0cf6c | |||
| de41aea00c | |||
| 5fa6ea6ab1 | |||
| 1ffe62133c | |||
| 19a2b6dda4 | |||
| 93f3ec240c | |||
| f1e05c0ca3 | |||
| 2142c4f586 | |||
| cb0f7efd4c | |||
| 0ef75e05e8 | |||
| 3e34995e69 | |||
| 82b3186b95 | |||
| 660d71e21a | |||
| 9216eafb7f | |||
| 2e09e21ad6 | |||
| 67e81e5d7f | |||
| 978777ff6a | |||
| ee642e5eaa | |||
| 958d777751 | |||
| c5695bf0b6 | |||
| e2720c09f7 | |||
| 1cf069b55b | |||
| a8cdeeb838 | |||
| 9e6e95f431 | |||
| a040f8b07d | |||
| 93bcbca12c |
+10
@@ -1,6 +1,7 @@
|
||||
# Сборки
|
||||
/bin/
|
||||
/dist/
|
||||
!/dist/ish/README.md
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
@@ -58,3 +59,12 @@ test-results/
|
||||
|
||||
# macOS
|
||||
.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.
@@ -19,11 +19,8 @@ help:
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
$(GO) build -o bin/lk-gateway ./cmd/lk-gateway
|
||||
$(GO) build -o bin/m2m-core ./cmd/m2m-core
|
||||
$(GO) build -o bin/nsd-adapter ./cmd/nsd-adapter
|
||||
$(GO) build -o bin/lk-emulator ./cmd/lk-emulator
|
||||
$(GO) build -o bin/notify ./cmd/notify
|
||||
$(GO) build -o bin/bj-server ./cmd/bj-server
|
||||
$(GO) build -o bin/lk-emulator ./cmd/lk-emulator
|
||||
|
||||
test:
|
||||
$(GO) test ./... -race -count=1
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package main — единый сервис bj-server.
|
||||
//
|
||||
// Объединяет в одном процессе: lk-gateway (REST API ЛК + admin web UI),
|
||||
// m2m-core (FSM сделки, репозиторий, эмиссия и потребление Decision),
|
||||
// nsd-adapter (REST к ИШ НРД и опрос входящих, когда профиль настроен),
|
||||
// notify (заглушка отправки уведомлений). lk-emulator живёт отдельным
|
||||
// бинарником как QA-инструмент.
|
||||
//
|
||||
// Архитектура подсказана объёмом 100-1000 сделок/день: для такого
|
||||
// потока избыточно держать 5 отдельных процессов и микросервисную
|
||||
// шину. Один Go-бинарник проще деплоить, проще наблюдать и
|
||||
// масштабировать вертикально, а компоненты внутри по-прежнему
|
||||
// разделены пакетами internal/<...>.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
)
|
||||
|
||||
const serviceName = "bj-server"
|
||||
|
||||
func main() {
|
||||
addr := getenv("BJ_HTTP_ADDR", ":8080")
|
||||
defaultSender := m2m.DeponentCode(getenv("BJ_M2M_SENDER", "MC0079200000"))
|
||||
defaultReceiver := m2m.DeponentCode(getenv("BJ_M2M_RECEIVER", "MC0010300000"))
|
||||
setupPath := os.Getenv("BJ_SETUP_PATH")
|
||||
|
||||
cfg := lkgateway.ServerConfig{
|
||||
Addr: addr,
|
||||
DefaultSender: defaultSender,
|
||||
DefaultReceiver: defaultReceiver,
|
||||
SetupPath: setupPath,
|
||||
// CheckOptions не задаём — server.go использует свой снапшот-based
|
||||
// вариант, который читает актуальные значения из setup.json
|
||||
// (DSN, crypto-сокет, URL ИШ, профиль), а не из ENV. Так проверки
|
||||
// статуса совпадают с тем, что реально настроено в UI.
|
||||
}
|
||||
|
||||
srv, err := lkgateway.NewServer(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("%s: NewServer: %v", serviceName, err)
|
||||
}
|
||||
if cb := os.Getenv("BJ_LK_CALLBACK_URL"); cb != "" {
|
||||
srv.SetCallbackURL(cb)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Опционально — поллер входящих пакетов ИШ НРД. Запускается если
|
||||
// BJ_NSD_PROFILE задан (после установки реального ИШ через UI этот
|
||||
// блок будет тянуть Decisions из настоящего НРД и применять их через
|
||||
// lkgateway.Service.ApplyDecision).
|
||||
if profileName := os.Getenv("BJ_NSD_PROFILE"); profileName != "" {
|
||||
go runNSDPoller(ctx, profileName)
|
||||
}
|
||||
|
||||
// notify-демон: пока заглушка, в M3-M4 будет рассылать события
|
||||
// (e-mail, Yandex Messenger, Telegram, WS-push в admin-ui).
|
||||
go runNotifyWorker(ctx)
|
||||
|
||||
log.Printf("%s: запуск, HTTP %s", serviceName, addr)
|
||||
runErr := srv.Run(ctx)
|
||||
stop()
|
||||
if runErr != nil {
|
||||
log.Printf("%s: %v", serviceName, runErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runNSDPoller — фоновый поллер входящих пакетов ИШ НРД.
|
||||
func runNSDPoller(ctx context.Context, profileName string) {
|
||||
profile, err := nsdadapter.LookupProfile(profileName)
|
||||
if err != nil {
|
||||
log.Printf("%s: NSD poller: %v (доступные профили: %v)", serviceName, err, nsdadapter.AvailableProfiles())
|
||||
return
|
||||
}
|
||||
interval := 30 * time.Second
|
||||
if v := os.Getenv("BJ_NSD_POLL_INTERVAL"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
interval = d
|
||||
}
|
||||
}
|
||||
client := igw.NewClient(profile.IGWBaseURL, igw.WithRetry(profile.RetryMax, profile.RetryBackoff))
|
||||
log.Printf("%s: NSD poller: профиль %s, канал %s, ИШ %s, интервал %s",
|
||||
serviceName, profile.Name, profile.Channel, profile.IGWBaseURL, interval)
|
||||
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
since := time.Now().UTC().Add(-time.Hour)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
for _, kind := range nsdadapter.IncomingPackageKinds() {
|
||||
pkgs, err := client.ListIncoming(ctx, igw.ListFilter{
|
||||
Channel: profile.Channel,
|
||||
Date: since,
|
||||
Type: string(kind),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err)
|
||||
continue
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
log.Printf("%s: NSD входящий пакет id=%d (%s) типа %s, канал %s, state %s",
|
||||
serviceName, p.ID, p.Name, p.Type, p.Channel, p.State)
|
||||
// TODO(M3): GetPackage(p.ID) → unpack ZIP → парсить XML →
|
||||
// передавать в lkgateway.Service.ApplyDecision
|
||||
}
|
||||
}
|
||||
since = time.Now().UTC()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runNotifyWorker — заглушка демона уведомлений.
|
||||
func runNotifyWorker(ctx context.Context) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
// На M3-M4 здесь будет: вытащить очередь событий из БД,
|
||||
// разослать по настроенным каналам (e-mail, мессенджер).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
+39
-11
@@ -1,20 +1,48 @@
|
||||
// Package main — сервис lk-emulator. Эмулятор ЛК клиента (ESIA Finance API V1)
|
||||
// на время, пока реальный ЛК не готов. Позволяет «как будто загрузить»
|
||||
// заявление через веб-форму и запустить полный путь обработки документа.
|
||||
// Package main — сервис lk-emulator. Имитация ЛК клиента (ESIA Finance
|
||||
// API V1) на время, пока реальный ЛК не готов. Веб-форма «новая заявка»,
|
||||
// журнал моих заявок, приёмник callback'ов от lk-gateway.
|
||||
//
|
||||
// Когда реальный ЛК подключится — эмулятор остаётся как тестовый инструмент
|
||||
// в QA-окружении.
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
// Когда реальный ЛК подключится, эмулятор остаётся как тестовый
|
||||
// инструмент в QA-окружении: даёт сквозной сценарий без зависимости от
|
||||
// внешней стороны.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkemulator"
|
||||
)
|
||||
|
||||
const serviceName = "lk-emulator"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
addr := getenv("BJ_HTTP_ADDR", ":8083")
|
||||
gw := getenv("BJ_GATEWAY_URL", "http://127.0.0.1:8080")
|
||||
self := getenv("BJ_EMULATOR_PUBLIC_URL", "http://127.0.0.1:8083")
|
||||
|
||||
srv, err := lkemulator.NewServer(lkemulator.ServerConfig{
|
||||
Addr: addr,
|
||||
GatewayURL: gw,
|
||||
SelfPublicURL: self,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("lk-emulator: NewServer: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
runErr := srv.Run(ctx)
|
||||
stop()
|
||||
if runErr != nil {
|
||||
log.Printf("lk-emulator: %v", runErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(k, def string) string {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Package main — сервис lk-gateway. Принимает заявления от ЛК клиента
|
||||
// (платформа ESIA Finance, /api/v1/back_office/...), валидирует их подпись,
|
||||
// передаёт в m2m-core, отдаёт callback-статусы обратно в ЛК.
|
||||
//
|
||||
// На этапе M1 — заглушка. Реализация контракта — M2.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "lk-gateway"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Package main — сервис m2m-core. Бизнес-логика и FSM сделки M2M-перевода:
|
||||
// идемпотентность по GUID, валидация по XSD, метрики SLA, ветка ручного
|
||||
// согласования и таймаут-отказа MOST.
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "m2m-core"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Package main — сервис notify. Отправка уведомлений по нескольким каналам:
|
||||
// e-mail (SMTP), Yandex Messenger (Yandex 360), WebSocket-push в admin-ui,
|
||||
// плюс расширяемая модель провайдеров-плагинов (smtp, yandex360, telegram,
|
||||
// mattermost, webhook) под единый интерфейс Notifier — для тиражирования
|
||||
// продукта другим компаниям.
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "notify"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// Package main — сервис nsd-adapter. Транспорт к НРД:
|
||||
// - Интеграционный шлюз через REST API (основной канал, ИШ сам подписывает);
|
||||
// - Web-сервис ONYX напрямую (резерв);
|
||||
// - Файловый шлюз / обменные папки ИШ (fallback).
|
||||
//
|
||||
// Сериализация и парсинг XML по схемам M2MSchemas в windows-1251,
|
||||
// маршрутизация по типам пакетов (#M2MTR / #M2MTD / #M2MER / SUBBR / SUBER /
|
||||
// SUB16 / Справки / квитанции ЭДО).
|
||||
//
|
||||
// На этапе M1 — заглушка.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
const serviceName = "nsd-adapter"
|
||||
|
||||
func main() {
|
||||
fmt.Fprintf(os.Stdout, "%s: запуск (заглушка M1)\n", serviceName)
|
||||
}
|
||||
@@ -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
|
||||
@@ -7,7 +7,7 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: docker.io/library/postgres:16
|
||||
# В проде заменить на postgrespro/std-16 или registry.postgrespro.ru/pgpro/...
|
||||
container_name: bj-postgres
|
||||
environment:
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- bj-postgres-data:/var/lib/postgresql/data
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: docker.io/minio/minio:latest
|
||||
container_name: bj-minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
@@ -32,6 +32,20 @@ services:
|
||||
volumes:
|
||||
- bj-minio-data:/data
|
||||
|
||||
crypto-service:
|
||||
build:
|
||||
context: ../../services/crypto-service
|
||||
dockerfile: Dockerfile
|
||||
container_name: bj-crypto-service
|
||||
environment:
|
||||
BJ_CRYPTO_SOCKET: /run/bj/crypto.sock
|
||||
BJ_CRYPTO_PROVIDER: stub
|
||||
volumes:
|
||||
# UDS-сокет наружу как named volume, чтобы Go-сервисы
|
||||
# (m2m-core, lk-gateway, nsd-adapter) могли его mount'ить.
|
||||
- bj-crypto-sock:/run/bj
|
||||
|
||||
volumes:
|
||||
bj-postgres-data:
|
||||
bj-minio-data:
|
||||
bj-crypto-sock:
|
||||
|
||||
@@ -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,33 @@
|
||||
# deploy/systemd — юниты для деплоя
|
||||
|
||||
Минимальный production-деплой Bridge-and-Join-s — два бинарника + два
|
||||
systemd-юнита.
|
||||
|
||||
## Состав
|
||||
|
||||
- `bj-server.service` — основной сервис: lk-gateway BFF + admin UI +
|
||||
m2m-core FSM + nsd-adapter поллер + notify. HTTP `:8080`.
|
||||
- `bj-emulator.service` — имитация ЛК (QA-инструмент). HTTP `:8083`.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
sudo useradd --system --no-create-home --shell /usr/sbin/nologin bj
|
||||
sudo mkdir -p /opt/bj /var/lib/bj /var/log/bj /run/bj
|
||||
sudo chown bj:bj /var/lib/bj /var/log/bj /run/bj
|
||||
|
||||
# собрать бинарники на dev-ВМ и положить в /opt/bj/
|
||||
sudo cp bin/bj-server bin/lk-emulator /opt/bj/
|
||||
|
||||
# юниты
|
||||
sudo cp deploy/systemd/bj-server.service deploy/systemd/bj-emulator.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now bj-server bj-emulator
|
||||
|
||||
# проверка
|
||||
systemctl status bj-server bj-emulator
|
||||
journalctl -u bj-server -f
|
||||
```
|
||||
|
||||
Веб-интерфейс: `http://<host>:8080/admin/setup` — настройка PostgreSQL,
|
||||
КриптоПро CSP, ИШ НРД, callback ЛК.
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
Description=Bridge-and-Join-s — эмулятор ЛК ESIA Finance (QA)
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target bj-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=/opt/bj
|
||||
ExecStart=/opt/bj/lk-emulator
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Environment=BJ_HTTP_ADDR=:8083
|
||||
Environment=BJ_GATEWAY_URL=http://127.0.0.1:8080
|
||||
Environment=BJ_EMULATOR_PUBLIC_URL=http://127.0.0.1:8083
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
|
||||
LimitNOFILE=65536
|
||||
TasksMax=128
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,46 @@
|
||||
[Unit]
|
||||
Description=Bridge-and-Join-s — единый сервис M2M-переводов
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
WorkingDirectory=/opt/bj
|
||||
ExecStart=/opt/bj/bj-server
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Конфигурация — через ENV или ~/.bj/setup.json (UI /admin/setup).
|
||||
Environment=BJ_HTTP_ADDR=:8080
|
||||
Environment=BJ_SETUP_PATH=/var/lib/bj/setup.json
|
||||
Environment=BJ_M2M_SENDER=MC0079200000
|
||||
Environment=BJ_M2M_RECEIVER=MC0010300000
|
||||
|
||||
# КриптоПро CSP кладёт .so в /opt/cprocsp/lib/amd64 без записи в
|
||||
# /etc/ld.so.conf.d. Чтобы Go-PKCS#11 клиент (cryptocli) нашёл
|
||||
# libcppkcs11.so и его зависимости (libcapi20, libcpext, liburlretrieve),
|
||||
# подмешиваем путь через LD_LIBRARY_PATH. Без этого Initialize() падает
|
||||
# с CKR_FUNCTION_FAILED или 'cannot open shared object file'.
|
||||
Environment=LD_LIBRARY_PATH=/opt/cprocsp/lib/amd64
|
||||
|
||||
# Безопасность.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/bj /var/log/bj /run/bj
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
|
||||
# Лимиты.
|
||||
LimitNOFILE=65536
|
||||
TasksMax=512
|
||||
|
||||
[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,33 +1,56 @@
|
||||
# docs/fansy-contract/v1 — контракт данных с командой Fansy
|
||||
|
||||
ETL Fansy → принимающая БД (`fansy-store`) реализует **другая команда
|
||||
разработки**. С нашей стороны:
|
||||
разработки**. С нашей стороны зафиксирован контракт: схема таблиц,
|
||||
индексы, миграции, требования к выгрузке и тестовые данные.
|
||||
|
||||
1. Спроектировать таблицы по требованиям документации НРД к данным M2M.
|
||||
2. Передать команде Fansy DDL и контракт данных.
|
||||
3. Согласовать тип load (UPSERT в staging), окна обновления, SLA на
|
||||
свежесть данных.
|
||||
4. Не давать ETL-роли DDL-прав в принимающей схеме.
|
||||
## Состав каталога
|
||||
|
||||
Состав каталога (создаём в M1, отправляем в начале M2):
|
||||
- **`ddl/`** — SQL-миграции PostgreSQL:
|
||||
- `000__roles.sql` — роли `fansy_etl` (ETL Fansy), `bj_reader`
|
||||
(наши сервисы), `bj_migrator` (миграции).
|
||||
- `001__schemas.sql` — две схемы: `fansy_staging` (куда пишет ETL) и
|
||||
`fansy` (рабочая, для нашего чтения). Гранты по ролям.
|
||||
- `002__working.sql` — рабочие таблицы: `participants`, `securities`,
|
||||
`clients`, `client_documents`, `iia_contracts`,
|
||||
`settlement_requisites`, `depo_accounts`, `portfolios`,
|
||||
`etl_errors`.
|
||||
- `003__staging.sql` — staging-зеркало рабочих таблиц с полем
|
||||
`loaded_at` и сниженными ограничениями.
|
||||
- `004__seed_participants.sql` — предзаполнение справочника
|
||||
участников: НРД, БКС (5406121446), Ренессанс (7709258228),
|
||||
Альфа-Банк (7728168971).
|
||||
- **`data-dictionary.md`** — семантика каждого поля.
|
||||
- **`etl-requirements.md`** — требования к процессу выгрузки от
|
||||
команды Fansy: подключение, тип load (UPSERT в staging),
|
||||
SLA свежести по таблицам, обработка ошибок, окна простоя, ПДн.
|
||||
- **`examples/`**:
|
||||
- `example-claim.md` — какие данные `m2m-core` тянет из БД для
|
||||
одной типовой M2M-заявки (с конкретными SQL).
|
||||
- `seed-data.sql` — 5 тестовых клиентов, портфели, договоры —
|
||||
основа для приёмочного теста.
|
||||
|
||||
- `ddl/` — `*.sql` миграции PostgreSQL для всех таблиц.
|
||||
- `data-dictionary.md` — семантика каждого поля (источник в Fansy,
|
||||
nullable, единицы, примеры).
|
||||
- `etl-requirements.md` — требования к процессу выгрузки: тип load,
|
||||
расписание, способ записи, окна простоя, обработка ошибок,
|
||||
конфиденциальность.
|
||||
- `examples/` — пример заявки M2M «end-to-end», 5–10 тестовых клиентов
|
||||
и заявок для совместного приёмочного теста.
|
||||
## Рабочие копии миграций
|
||||
|
||||
Минимальный набор таблиц (см. план):
|
||||
Те же файлы лежат в `migrations/fansy-store/` — оттуда они
|
||||
применяются при инициализации БД сервиса.
|
||||
|
||||
- Депоненты / клиенты.
|
||||
- Документы инвестора (`IdentityDocumentCodeEnum`).
|
||||
- ИИС-договоры (`IIAContractTypeEnum ∈ {T12, T03}`).
|
||||
- Депо-счета и разделы (`AccountId`, `SectionId`, `DeponentCode`).
|
||||
- Реквизиты расчётов (ИНН депозитария).
|
||||
- Портфели и остатки (Whole / Fractional, `IsolationStatus = SGDN`).
|
||||
- Справочник ЦБ (`SecurityCode`, `ISIN`, `Classification`, `Category`).
|
||||
- Контрагенты-участники сервиса MOST (Справочник пользователей).
|
||||
- Audit / staging-таблицы для каждой основной.
|
||||
## Порядок согласования
|
||||
|
||||
1. Передать команде Fansy ссылку на эту папку (тег `fansy-contract-v1`).
|
||||
2. Обсудить с ними SLA, окна простоя, тип load.
|
||||
3. По согласовании — дать им учётку с ролью `fansy_etl` и подсеть для
|
||||
доступа.
|
||||
4. Запустить совместный приёмочный тест на `seed-data.sql`.
|
||||
5. Изменения контракта — через новую папку `v2/` с changelog'ом, без
|
||||
правки `v1/`.
|
||||
|
||||
## Принципы
|
||||
|
||||
- Имена таблиц/колонок — `snake_case` английский.
|
||||
- Комментарии к таблицам и важным колонкам — на русском
|
||||
через `COMMENT ON ... IS '...'`.
|
||||
- Все timestamp — `timestamptz` в UTC.
|
||||
- DDL-права только у `bj_migrator`, у `fansy_etl` нет.
|
||||
- ETL пишет ТОЛЬКО в `fansy_staging.*`. Перелив в `fansy.*` — на нашей
|
||||
стороне после валидации.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Data Dictionary — fansy-store v1
|
||||
|
||||
Семантика полей рабочей схемы `fansy`. Структура staging-схемы
|
||||
`fansy_staging` повторяет её один-к-одному, плюс поле `loaded_at` и
|
||||
отсутствие части ограничений (валидация — при переливе).
|
||||
|
||||
Обозначения: `?` — nullable; `!` — обязательное.
|
||||
|
||||
## participants — справочник контрагентов M2M
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| inn | varchar(10) | ! | ИНН юрлица, PK | `client_master.inn` | `7702165310` |
|
||||
| ogrn | varchar(15) | ? | ОГРН | `client_master.ogrn` | `1027739132563` |
|
||||
| full_name_rus | text | ! | Полное наименование на русском | `client_master.full_name` | `НКО АО НРД` |
|
||||
| short_name_rus | text | ? | Короткое наименование | `client_master.short_name` | `НРД` |
|
||||
| display_name_rus | text | ! | Отображаемое имя для UI | `client_master.display_name` | `НРД` |
|
||||
| full_name_eng | text | ? | Полное наименование на английском | `client_master.full_name_en` | `National Settlement Depository` |
|
||||
| short_name_eng | text | ? | Короткое английское | `client_master.short_name_en` | `NSD` |
|
||||
| display_name_eng | text | ? | Английское display | `client_master.display_name_en` | `NSD` |
|
||||
| depository_participant_code | varchar(12) | ? | Код участника M2M (депозитарий) | `m2m_codes.dep_code` | `MC0010300000` |
|
||||
| broker_participant_code | varchar(12) | ? | Код участника M2M (брокер) | `m2m_codes.brk_code` | `MC0079200001` |
|
||||
| is_available_for_m2m | boolean | ! | Готовность к приёму M2M | `m2m_codes.is_active` | `true` |
|
||||
| comment | text | ? | Свободный комментарий | — | — |
|
||||
| created_at, updated_at | timestamptz | ! | Авто | — | — |
|
||||
|
||||
## securities — справочник ЦБ
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| security_code | char(12) | ! | Идентификатор ЦБ в системе НРД, PK | `security_master.nsd_code` | `MM0766162534` |
|
||||
| isin | char(12) | ? | ISIN | `security_master.isin` | `RU0007661625` |
|
||||
| classification | varchar(4) | ? | `BOND` (облигация), `SHAR` (акция), `MFUN` (ПИФ) | `security_master.type_code` | `SHAR` |
|
||||
| category | varchar(4) | ? | `ORDN`/`PREF`/`UKWN` | `security_master.category` | `ORDN` |
|
||||
| security_type | varchar(256) | ? | Текстовое описание типа | `security_master.type_text` | `Акция обыкновенная` |
|
||||
| security_series | text | ? | Серия выпуска (для облигаций) | `security_master.series` | `01` |
|
||||
| reg_number | varchar(256) | ? | Регистрационный номер выпуска / правил ДУ ПИФ | `security_master.reg_number` | `1-01-00010-A` |
|
||||
| fund_class | varchar(120) | ? | Класс паёв ПИФ | `security_master.fund_class` | `A` |
|
||||
| display_name | text | ! | Отображаемое имя для UI | `security_master.display` | `Сбербанк ао` |
|
||||
|
||||
## clients — депоненты-физлица
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK, генерируется БД | `customer.uuid` | — |
|
||||
| inn | varchar(12) | ? | ИНН (10 цифр юрлицо, 12 цифр физлицо) | `customer.inn` | `771234567890` |
|
||||
| last_name | varchar(50) | ! | Фамилия | `customer.last_name` | `Иванов` |
|
||||
| first_name | varchar(50) | ! | Имя | `customer.first_name` | `Иван` |
|
||||
| middle_name | varchar(50) | ? | Отчество | `customer.middle_name` | `Иванович` |
|
||||
| birth_date | date | ? | Дата рождения | `customer.birth_date` | `1980-01-15` |
|
||||
|
||||
## client_documents — документы инвестора
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | `customer_doc.customer_uuid` | — |
|
||||
| document_type | varchar(2) | ! | Код документа по справочнику НРД (01..91) | `customer_doc.type_code` | `21` |
|
||||
| series | text | ? | Серия (без пробелов) | `customer_doc.series` | `4512` |
|
||||
| number | text | ! | Номер (без пробелов) | `customer_doc.number` | `654321` |
|
||||
| issued_at | date | ? | Дата выдачи | `customer_doc.issued_at` | `2010-05-12` |
|
||||
| issuer | text | ? | Кем выдан | `customer_doc.issuer` | `ОУФМС России` |
|
||||
|
||||
## iia_contracts — договоры ИИС
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | — | — |
|
||||
| agreement_type | varchar(3) | ! | `T12` (ИИС-1/ИИС-2) или `T03` (ИИС-3) | `iia.type` | `T03` |
|
||||
| agreement_number | varchar(128) | ! | Номер договора | `iia.number` | `ИИС78/2024` |
|
||||
| agreement_date | date | ! | Дата заключения | `iia.signed_at` | `2026-01-15` |
|
||||
| broker_inn | varchar(10) | ! | ИНН брокера, ведущего ИИС | `iia.broker_inn` | `0707083893` |
|
||||
|
||||
## settlement_requisites — реквизиты депозитариев
|
||||
|
||||
| Поле | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| id | uuid | ! | PK |
|
||||
| inn | varchar(10) | ! | ИНН депозитария, UNIQUE |
|
||||
| display_name | text | ! | Отображаемое имя |
|
||||
|
||||
## depo_accounts — счета депо
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | — | — |
|
||||
| deponent_code | varchar(50) | ! | Код депонента у депозитария | `depo.deponent_code` | `DP789456` |
|
||||
| account_id | varchar(50) | ! | Номер счёта депо | `depo.account_id` | `31MC0021900000F01` |
|
||||
| section_id | varchar(50) | ! | Номер раздела счёта | `depo.section_id` | `P001` |
|
||||
| depository_inn | varchar(10) | ! | ИНН депозитария | `depo.depository_inn` | `7702070139` |
|
||||
| is_active | boolean | ! | Активен ли счёт | `depo.is_active` | `true` |
|
||||
| is_trading | boolean | ! | Торговый раздел | `depo.is_trading` | `true` |
|
||||
|
||||
Уникальность по тройке `(deponent_code, account_id, section_id)`.
|
||||
|
||||
## portfolios — портфели и остатки ЦБ
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Источник Fansy | Пример |
|
||||
|---|---|---|---|---|---|
|
||||
| id | uuid | ! | PK | — | — |
|
||||
| client_id | uuid | ! | FK на `clients.id` | — | — |
|
||||
| depo_account_id | uuid | ! | FK на `depo_accounts.id` | — | — |
|
||||
| security_code | char(12) | ! | FK на `securities.security_code` | — | `MM0766162534` |
|
||||
| isin | char(12) | ? | Кэш ISIN из securities | — | `RU0007661625` |
|
||||
| quantity_whole | numeric(38,0) | ? | Целое количество (для акций/облигаций) | `position.qty_whole` | `1500` |
|
||||
| quantity_fractional | numeric(38,16) | ? | Дробное (для паёв) | `position.qty_fract` | `2500.7500000000000000` |
|
||||
| isolation_status | varchar(4) | ! | Всегда `SGDN` | — | `SGDN` |
|
||||
| valued_at | timestamptz | ! | На какой момент актуально | `position.valued_at` | `2026-03-02T11:30:00Z` |
|
||||
|
||||
Должно быть заполнено ровно одно из (`quantity_whole`, `quantity_fractional`).
|
||||
|
||||
## etl_errors — журнал ошибок ETL
|
||||
|
||||
| Поле | Тип | Обяз. | Описание |
|
||||
|---|---|---|---|
|
||||
| id | bigserial | ! | PK |
|
||||
| source_table | text | ! | Таблица в Fansy |
|
||||
| source_pk | text | ? | PK записи в Fansy |
|
||||
| payload | jsonb | ? | Сама запись для ретрая |
|
||||
| error_message | text | ! | Сообщение об ошибке |
|
||||
| created_at | timestamptz | ! | Когда зафиксирована |
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 000__roles.sql
|
||||
-- Роли для принимающей БД fansy-store.
|
||||
-- Запускать первым, отдельно от структурных миграций.
|
||||
-- Пароли проставляются администратором БД через ALTER ROLE.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fansy_etl') THEN
|
||||
CREATE ROLE fansy_etl LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
|
||||
COMMENT ON ROLE fansy_etl IS
|
||||
'Роль команды Fansy для ETL: INSERT/UPDATE/SELECT в схему fansy_staging. DDL-прав нет.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_reader') THEN
|
||||
CREATE ROLE bj_reader LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
|
||||
COMMENT ON ROLE bj_reader IS
|
||||
'Роль сервисов Bridge-and-Join-s (m2m-core, lk-gateway) для чтения схемы fansy.';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bj_migrator') THEN
|
||||
CREATE ROLE bj_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE INHERIT;
|
||||
COMMENT ON ROLE bj_migrator IS
|
||||
'Роль с DDL-правами для миграций. Только эта роль может CREATE/ALTER/DROP.';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 001__schemas.sql
|
||||
-- Две схемы: fansy_staging (куда пишет ETL Fansy) и fansy (рабочая,
|
||||
-- куда переливаются данные после валидации).
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS fansy_staging AUTHORIZATION bj_migrator;
|
||||
COMMENT ON SCHEMA fansy_staging IS
|
||||
'Staging-схема. ETL Fansy делает UPSERT в эти таблицы. Сюда же пишутся ошибки выгрузки.';
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS fansy AUTHORIZATION bj_migrator;
|
||||
COMMENT ON SCHEMA fansy IS
|
||||
'Рабочая схема. Сюда переливаются актуальные данные триггерами или процедурами после валидации staging.';
|
||||
|
||||
-- Права по ролям. DDL-права остаются только у владельца bj_migrator.
|
||||
GRANT USAGE ON SCHEMA fansy_staging TO fansy_etl;
|
||||
GRANT USAGE ON SCHEMA fansy TO bj_reader;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging
|
||||
GRANT SELECT, INSERT, UPDATE ON TABLES TO fansy_etl;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy_staging
|
||||
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO fansy_etl;
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA fansy
|
||||
GRANT SELECT ON TABLES TO bj_reader;
|
||||
@@ -0,0 +1,231 @@
|
||||
-- 002__working.sql
|
||||
-- Рабочая схема fansy. Данные сюда переливаются из fansy_staging после
|
||||
-- валидации. Сервисы Bridge-and-Join-s читают только эту схему.
|
||||
|
||||
SET search_path TO fansy, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- participants — справочник участников сервиса MOST (контрагенты M2M)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
inn varchar(10) PRIMARY KEY,
|
||||
ogrn varchar(15),
|
||||
full_name_rus text NOT NULL,
|
||||
short_name_rus text,
|
||||
display_name_rus text NOT NULL,
|
||||
full_name_eng text,
|
||||
short_name_eng text,
|
||||
display_name_eng text,
|
||||
depository_participant_code varchar(12),
|
||||
broker_participant_code varchar(12),
|
||||
is_available_for_m2m boolean NOT NULL DEFAULT false,
|
||||
comment text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (inn ~ '^[0-9]{10}$'),
|
||||
CHECK (depository_participant_code IS NULL OR depository_participant_code ~ '^[A-Z0-9]+$'),
|
||||
CHECK (broker_participant_code IS NULL OR broker_participant_code ~ '^[A-Z0-9]+$')
|
||||
);
|
||||
COMMENT ON TABLE participants IS 'Справочник участников сервиса MOST: депозитарии и брокеры, между которыми идут M2M-переводы.';
|
||||
COMMENT ON COLUMN participants.inn IS 'ИНН юрлица (10 цифр), первичный ключ.';
|
||||
COMMENT ON COLUMN participants.depository_participant_code IS 'Код участника M2M на стороне депозитария (для DepositoryPlace в M2MTransferHandbook).';
|
||||
COMMENT ON COLUMN participants.broker_participant_code IS 'Код участника M2M на стороне брокера (для BrokerPlace).';
|
||||
COMMENT ON COLUMN participants.is_available_for_m2m IS 'Готовность участника принимать/отправлять M2M-сообщения (включается после подписания НРД-договора).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_dep_code ON participants(depository_participant_code) WHERE depository_participant_code IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_brk_code ON participants(broker_participant_code) WHERE broker_participant_code IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- securities — справочник ценных бумаг
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS securities (
|
||||
security_code char(12) PRIMARY KEY,
|
||||
isin char(12),
|
||||
classification varchar(4),
|
||||
category varchar(4),
|
||||
security_type varchar(256),
|
||||
security_series text,
|
||||
reg_number varchar(256),
|
||||
fund_class varchar(120),
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (security_code ~ '^[0-9A-Z_/-]+$'),
|
||||
CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'),
|
||||
CHECK (classification IS NULL OR classification IN ('BOND', 'SHAR', 'MFUN')),
|
||||
CHECK (category IS NULL OR category IN ('ORDN', 'PREF', 'UKWN'))
|
||||
);
|
||||
COMMENT ON TABLE securities IS 'Справочник ценных бумаг с их идентификаторами и классификацией.';
|
||||
COMMENT ON COLUMN securities.security_code IS 'Идентификатор ценной бумаги в системе НРД (XSD SecurityCodeType).';
|
||||
COMMENT ON COLUMN securities.classification IS 'Тип ценной бумаги: BOND (облигация), SHAR (акция), MFUN (ПИФ).';
|
||||
COMMENT ON COLUMN securities.category IS 'Категория акций: ORDN (обыкновенные), PREF (привилегированные), UKWN (неизвестно).';
|
||||
COMMENT ON COLUMN securities.reg_number IS 'Регистрационный номер выпуска (для акций и облигаций) или регномер правил доверительного управления ПИФ.';
|
||||
COMMENT ON COLUMN securities.fund_class IS 'Класс паёв ПИФа (если применимо).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_securities_isin ON securities(isin) WHERE isin IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- clients — депоненты / инвесторы
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inn varchar(12),
|
||||
last_name varchar(50) NOT NULL,
|
||||
first_name varchar(50) NOT NULL,
|
||||
middle_name varchar(50),
|
||||
birth_date date,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (inn IS NULL OR inn ~ '^[0-9]{10,12}$')
|
||||
);
|
||||
COMMENT ON TABLE clients IS 'Депоненты-физлица. Привязка к документам и счетам — через FK из дочерних таблиц.';
|
||||
COMMENT ON COLUMN clients.inn IS 'ИНН физлица (12 цифр) или организации (10 цифр), опционально.';
|
||||
COMMENT ON COLUMN clients.last_name IS 'Фамилия (XSD String50, обязательно).';
|
||||
COMMENT ON COLUMN clients.first_name IS 'Имя (XSD String50, обязательно).';
|
||||
COMMENT ON COLUMN clients.middle_name IS 'Отчество (XSD String50, опционально).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_inn ON clients(inn) WHERE inn IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_lastname ON clients(last_name, first_name);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- client_documents — документы инвестора
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_documents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
document_type varchar(2) NOT NULL,
|
||||
series text,
|
||||
number text NOT NULL,
|
||||
issued_at date,
|
||||
issuer text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (document_type IN (
|
||||
'01','02','03','04','05','06','07','09','10','11','12','13','14',
|
||||
'21','22','23','26','27','91'
|
||||
)),
|
||||
CHECK (series IS NULL OR series ~ '^\S+$'),
|
||||
CHECK (number ~ '^\S+$')
|
||||
);
|
||||
COMMENT ON TABLE client_documents IS 'Документы, удостоверяющие личность инвестора. Коды по справочнику НРД (XSD IdentityDocumentCodeEnum).';
|
||||
COMMENT ON COLUMN client_documents.document_type IS 'Код вида документа (01..91, см. XSD НРД).';
|
||||
COMMENT ON COLUMN client_documents.series IS 'Серия документа (без пробелов).';
|
||||
COMMENT ON COLUMN client_documents.number IS 'Номер документа (без пробелов, обязательно).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_client_documents_client ON client_documents(client_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- iia_contracts — договоры ИИС
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iia_contracts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
agreement_type varchar(3) NOT NULL,
|
||||
agreement_number varchar(128) NOT NULL,
|
||||
agreement_date date NOT NULL,
|
||||
broker_inn varchar(10) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (agreement_type IN ('T12', 'T03')),
|
||||
CHECK (broker_inn ~ '^[0-9]{10}$')
|
||||
);
|
||||
COMMENT ON TABLE iia_contracts IS 'Договоры на ведение ИИС инвестора.';
|
||||
COMMENT ON COLUMN iia_contracts.agreement_type IS 'Тип договора: T12 — ИИС-1/ИИС-2 (старый формат); T03 — ИИС-3 (новый).';
|
||||
COMMENT ON COLUMN iia_contracts.broker_inn IS 'ИНН брокера, с которым заключён договор ИИС.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_iia_contracts_client ON iia_contracts(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_iia_contracts_broker ON iia_contracts(broker_inn);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- settlement_requisites — реквизиты расчётов (депозитарии)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_requisites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inn varchar(10) NOT NULL UNIQUE,
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (inn ~ '^[0-9]{10}$')
|
||||
);
|
||||
COMMENT ON TABLE settlement_requisites IS 'Реквизиты передающего и принимающего депозитариев (XSD SettlementRequisitesType — содержит только ИНН).';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- depo_accounts — депо-счета и разделы
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS depo_accounts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
|
||||
deponent_code varchar(50) NOT NULL,
|
||||
account_id varchar(50) NOT NULL,
|
||||
section_id varchar(50) NOT NULL,
|
||||
depository_inn varchar(10) NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
is_trading boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (depository_inn ~ '^[0-9]{10}$'),
|
||||
UNIQUE (deponent_code, account_id, section_id)
|
||||
);
|
||||
COMMENT ON TABLE depo_accounts IS 'Счета депо инвестора и их разделы у различных депозитариев.';
|
||||
COMMENT ON COLUMN depo_accounts.deponent_code IS 'Код депонента у конкретного депозитария (XSD SettlementDepositoryLocationType.DeponentCode).';
|
||||
COMMENT ON COLUMN depo_accounts.account_id IS 'Номер счёта депо (XSD AccountIdType).';
|
||||
COMMENT ON COLUMN depo_accounts.section_id IS 'Номер раздела счёта депо.';
|
||||
COMMENT ON COLUMN depo_accounts.is_trading IS 'Признак торгового раздела (для отделения от изолированных).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_depo_accounts_client ON depo_accounts(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_depo_accounts_deponent ON depo_accounts(deponent_code);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- portfolios — портфели и остатки ЦБ
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
depo_account_id uuid NOT NULL REFERENCES depo_accounts(id) ON DELETE CASCADE,
|
||||
security_code char(12) NOT NULL REFERENCES securities(security_code),
|
||||
isin char(12),
|
||||
quantity_whole numeric(38, 0),
|
||||
quantity_fractional numeric(38, 16),
|
||||
isolation_status varchar(4) NOT NULL DEFAULT 'SGDN',
|
||||
valued_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (isolation_status IN ('SGDN')),
|
||||
CHECK ((quantity_whole IS NOT NULL) OR (quantity_fractional IS NOT NULL)),
|
||||
CHECK (isin IS NULL OR isin ~ '^[A-Z]{2}[A-Z0-9]{9}[0-9]$')
|
||||
);
|
||||
COMMENT ON TABLE portfolios IS 'Остатки ценных бумаг на счетах депо. Whole/Fractional — choice по XSD QuantityType (заполняется ровно одно).';
|
||||
COMMENT ON COLUMN portfolios.quantity_whole IS 'Целое количество (для акций, облигаций).';
|
||||
COMMENT ON COLUMN portfolios.quantity_fractional IS 'Дробное количество (для паёв ПИФ, до 16 знаков после точки).';
|
||||
COMMENT ON COLUMN portfolios.isolation_status IS 'Статус обособления по XSD НРД, всегда SGDN.';
|
||||
COMMENT ON COLUMN portfolios.valued_at IS 'Дата/время оценки (на какой момент актуален остаток).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_client ON portfolios(client_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_depo ON portfolios(depo_account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_security ON portfolios(security_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolios_valued_at ON portfolios(valued_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- etl_errors — ошибки выгрузки Fansy
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etl_errors (
|
||||
id bigserial PRIMARY KEY,
|
||||
source_table text NOT NULL,
|
||||
source_pk text,
|
||||
payload jsonb,
|
||||
error_message text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE etl_errors IS 'Журнал ошибок выгрузки Fansy: что не смогли записать в staging и почему.';
|
||||
COMMENT ON COLUMN etl_errors.source_table IS 'Название таблицы в источнике (Fansy).';
|
||||
COMMENT ON COLUMN etl_errors.source_pk IS 'Первичный ключ записи в источнике (для повторной попытки).';
|
||||
COMMENT ON COLUMN etl_errors.payload IS 'Сама запись, которую не удалось загрузить (для диагностики).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_etl_errors_created ON etl_errors(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_etl_errors_table ON etl_errors(source_table);
|
||||
@@ -0,0 +1,109 @@
|
||||
-- 003__staging.sql
|
||||
-- Staging-схема. Структура повторяет fansy.*, плюс loaded_at и
|
||||
-- допущения на промежуточные NULL'ы (валидация будет в процессе
|
||||
-- перелива в fansy.*).
|
||||
|
||||
SET search_path TO fansy_staging, public;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
inn varchar(10) PRIMARY KEY,
|
||||
ogrn varchar(15),
|
||||
full_name_rus text,
|
||||
short_name_rus text,
|
||||
display_name_rus text,
|
||||
full_name_eng text,
|
||||
short_name_eng text,
|
||||
display_name_eng text,
|
||||
depository_participant_code varchar(12),
|
||||
broker_participant_code varchar(12),
|
||||
is_available_for_m2m boolean,
|
||||
comment text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE participants IS 'Staging для справочника участников. Перезаливка целиком, не чаще раза в сутки.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS securities (
|
||||
security_code char(12) PRIMARY KEY,
|
||||
isin char(12),
|
||||
classification varchar(4),
|
||||
category varchar(4),
|
||||
security_type varchar(256),
|
||||
security_series text,
|
||||
reg_number varchar(256),
|
||||
fund_class varchar(120),
|
||||
display_name text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE securities IS 'Staging для справочника ЦБ. Перезаливка целиком.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id uuid PRIMARY KEY,
|
||||
inn varchar(12),
|
||||
last_name varchar(50),
|
||||
first_name varchar(50),
|
||||
middle_name varchar(50),
|
||||
birth_date date,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE clients IS 'Staging для клиентов. Инкрементный UPSERT по id.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_documents (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
document_type varchar(2),
|
||||
series text,
|
||||
number text,
|
||||
issued_at date,
|
||||
issuer text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE client_documents IS 'Staging для документов клиента. UPSERT по id.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iia_contracts (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
agreement_type varchar(3),
|
||||
agreement_number varchar(128),
|
||||
agreement_date date,
|
||||
broker_inn varchar(10),
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE iia_contracts IS 'Staging для договоров ИИС.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlement_requisites (
|
||||
id uuid PRIMARY KEY,
|
||||
inn varchar(10) NOT NULL,
|
||||
display_name text,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE settlement_requisites IS 'Staging для реквизитов расчётов.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS depo_accounts (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
deponent_code varchar(50),
|
||||
account_id varchar(50),
|
||||
section_id varchar(50),
|
||||
depository_inn varchar(10),
|
||||
is_active boolean,
|
||||
is_trading boolean,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE depo_accounts IS 'Staging для депо-счетов.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portfolios (
|
||||
id uuid PRIMARY KEY,
|
||||
client_id uuid NOT NULL,
|
||||
depo_account_id uuid NOT NULL,
|
||||
security_code char(12) NOT NULL,
|
||||
isin char(12),
|
||||
quantity_whole numeric(38, 0),
|
||||
quantity_fractional numeric(38, 16),
|
||||
isolation_status varchar(4),
|
||||
valued_at timestamptz,
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE portfolios IS 'Staging для портфелей. UPSERT по id; SLA свежести — 1 мин.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stg_portfolios_loaded ON portfolios(loaded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_stg_clients_loaded ON clients(loaded_at DESC);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- 004__seed_participants.sql
|
||||
-- Предзаполнение справочника участников по DOC/Справочник пользователей.pdf
|
||||
-- НРД и тестовые контрагенты Регламента M2M.
|
||||
|
||||
SET search_path TO fansy, public;
|
||||
|
||||
INSERT INTO participants (
|
||||
inn, ogrn, full_name_rus, short_name_rus, display_name_rus,
|
||||
full_name_eng, short_name_eng, display_name_eng,
|
||||
depository_participant_code, broker_participant_code,
|
||||
is_available_for_m2m, comment
|
||||
) VALUES
|
||||
(
|
||||
'7702165310', '1027739132563',
|
||||
'Небанковская кредитная организация акционерное общество "Национальный расчётный депозитарий"',
|
||||
'НКО АО НРД', 'НРД',
|
||||
'National Settlement Depository', 'NSD', 'NSD',
|
||||
'MC0010300000', NULL, true,
|
||||
'Центральный депозитарий, держатель реестра M2M-сделок.'
|
||||
),
|
||||
(
|
||||
'5406121446', '1025402459334',
|
||||
'Общество с ограниченной ответственностью "Компания БКС"',
|
||||
'ООО "Компания БКС"', 'БКС',
|
||||
'BCS Company Ltd', 'BCS', 'BCS',
|
||||
NULL, 'MC0079200001', true,
|
||||
'Брокер БКС, контрагент M2M.'
|
||||
),
|
||||
(
|
||||
'7709258228', '1027739675260',
|
||||
'Общество с ограниченной ответственностью "Ренессанс Брокер"',
|
||||
'ООО "Ренессанс Брокер"', 'Ренессанс Брокер',
|
||||
'Renaissance Broker Ltd', 'Renaissance', 'Renaissance',
|
||||
NULL, 'MC0010300032', true,
|
||||
'Брокер Ренессанс, контрагент M2M.'
|
||||
),
|
||||
(
|
||||
'7728168971', '1027700067328',
|
||||
'Акционерное общество "Альфа-Банк"',
|
||||
'АО "Альфа-Банк"', 'Альфа-Банк',
|
||||
'Alfa-Bank JSC', 'Alfa-Bank', 'Alfa-Bank',
|
||||
NULL, 'MC0079200033', true,
|
||||
'Брокер Альфа-Банк, контрагент M2M.'
|
||||
)
|
||||
ON CONFLICT (inn) DO UPDATE SET
|
||||
full_name_rus = EXCLUDED.full_name_rus,
|
||||
short_name_rus = EXCLUDED.short_name_rus,
|
||||
display_name_rus = EXCLUDED.display_name_rus,
|
||||
full_name_eng = EXCLUDED.full_name_eng,
|
||||
short_name_eng = EXCLUDED.short_name_eng,
|
||||
display_name_eng = EXCLUDED.display_name_eng,
|
||||
depository_participant_code = EXCLUDED.depository_participant_code,
|
||||
broker_participant_code = EXCLUDED.broker_participant_code,
|
||||
is_available_for_m2m = EXCLUDED.is_available_for_m2m,
|
||||
comment = EXCLUDED.comment,
|
||||
updated_at = now();
|
||||
@@ -0,0 +1,87 @@
|
||||
# Требования к ETL Fansy → fansy-store v1
|
||||
|
||||
## Подключение
|
||||
|
||||
- СУБД: PostgreSQL 16 / PostgreSQL Pro Certified (по согласованию).
|
||||
- Хост, порт, имя БД, IP-allowlist — выдаются администратором ВМ
|
||||
Bridge-and-Join-s отдельно.
|
||||
- Учётная запись: роль **`fansy_etl`** (создаётся миграцией
|
||||
`000__roles.sql`). Пароль выдаётся через защищённый канал, не в
|
||||
репозиторий.
|
||||
- TLS: обязательно (`sslmode=verify-full` со стороны клиента ETL).
|
||||
|
||||
## Куда писать
|
||||
|
||||
- Только в схему `fansy_staging`. Прав на DDL нет, на схему `fansy`
|
||||
тоже нет. INSERT/UPDATE/SELECT на таблицы staging.
|
||||
- Запись в `fansy.*` происходит на нашей стороне после валидации.
|
||||
|
||||
## Тип load
|
||||
|
||||
- **Инкрементный UPSERT** в staging по PK (`id`):
|
||||
```sql
|
||||
INSERT INTO fansy_staging.clients (id, ...) VALUES (...)
|
||||
ON CONFLICT (id) DO UPDATE SET ..., loaded_at = now();
|
||||
```
|
||||
- Справочники с относительно небольшим размером и редкой сменой
|
||||
(`securities`, `participants`) разрешена **полная перезаливка** не
|
||||
чаще одного раза в сутки. Полная перезаливка реализуется через
|
||||
транзакцию: `TRUNCATE` + `COPY` + `COMMIT`.
|
||||
|
||||
## SLA на свежесть данных
|
||||
|
||||
| Таблица | SLA свежести |
|
||||
|---|---|
|
||||
| `portfolios` | ≤ 1 минута после фактического изменения в Fansy |
|
||||
| `clients`, `depo_accounts`, `client_documents`, `iia_contracts` | ≤ 5 минут |
|
||||
| `securities`, `participants`, `settlement_requisites` | ≤ 24 часа (по событию или по расписанию) |
|
||||
|
||||
## Форматы и кодировки
|
||||
|
||||
- Все timestamp — `timestamptz` в **UTC** (явная зона `+00`).
|
||||
- Все строковые поля — UTF-8.
|
||||
- ИНН, коды депонентов, ISIN, SecurityCode — в верхнем регистре.
|
||||
- Числа с дробной частью (`numeric(38,16)`) — точка как разделитель,
|
||||
без разделителей тысяч.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
При нарушении CHECK-ограничений, FK или типов команда Fansy:
|
||||
|
||||
1. Пишет запись в `fansy_staging.etl_errors`:
|
||||
```sql
|
||||
INSERT INTO fansy_staging.etl_errors (source_table, source_pk, payload, error_message)
|
||||
VALUES ('fansy.position', '<pk>', '<json>', '<text>');
|
||||
```
|
||||
2. Логирует у себя и продолжает работу.
|
||||
3. Не блокирует загрузку остальных записей.
|
||||
|
||||
Мы (Bridge-and-Join-s) еженедельно просматриваем `etl_errors`,
|
||||
поднимаем инциденты с командой Fansy.
|
||||
|
||||
## Окна и расписание
|
||||
|
||||
- Регламентное окно простоя — **с 23:00 до 23:30 МСК**, по средам.
|
||||
В это время ETL может приостанавливаться для обновлений.
|
||||
- Внеплановые работы — анонсируются за 2 часа в общем чате.
|
||||
|
||||
## Конфиденциальность
|
||||
|
||||
- ПДн (ФИО, документ, дата рождения) — только по нужным таблицам.
|
||||
- Журналирование SQL-запросов ETL **не должно** включать значения ПДн.
|
||||
- Соединения только с IP-allowlist'а.
|
||||
|
||||
## Контроль и наблюдаемость
|
||||
|
||||
Мы предоставим команде Fansy `read-only` доступ к двум представлениям:
|
||||
|
||||
- `fansy_staging.v_load_lag` — задержка свежести по таблицам.
|
||||
- `fansy_staging.v_load_stats` — счётчики INSERT/UPDATE за сутки.
|
||||
|
||||
(Создаются в более позднем PR — `M3`.)
|
||||
|
||||
## Точка контакта
|
||||
|
||||
- Технический контакт со стороны Bridge-and-Join-s — указан в
|
||||
`docs/architecture/plan.md`, раздел «Контакты».
|
||||
- Эскалация — в общий канал интеграции, тред «fansy-store ETL».
|
||||
@@ -0,0 +1,118 @@
|
||||
# Пример заявки M2M end-to-end
|
||||
|
||||
Типовой сценарий: инвестор Иванов И.И. подаёт через ЛК заявку на
|
||||
перевод 3 ценных бумаг с депо-счёта у БКС в депо-счёт у Ренессанс
|
||||
Брокера. Один из переводов — паи ПИФ с дробным количеством. ИИС
|
||||
тип T03.
|
||||
|
||||
## Какие данные нужны m2m-core для формирования M2MTransferRequest
|
||||
|
||||
Сервис `m2m-core` достаёт следующее из `fansy-store` (рабочая схема
|
||||
`fansy`) по идентификатору клиента и набору ЦБ:
|
||||
|
||||
### 1. Анкета клиента (для `InvestorInformation`)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
c.last_name,
|
||||
c.first_name,
|
||||
c.middle_name,
|
||||
d.document_type,
|
||||
d.series AS document_series,
|
||||
d.number AS document_number
|
||||
FROM fansy.clients c
|
||||
JOIN fansy.client_documents d ON d.client_id = c.id
|
||||
WHERE c.id = :client_id
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### 2. ИИС-договор (для `IIAAgreementDetails`)
|
||||
|
||||
```sql
|
||||
SELECT agreement_type, agreement_number, agreement_date, broker_inn
|
||||
FROM fansy.iia_contracts
|
||||
WHERE client_id = :client_id
|
||||
ORDER BY agreement_date DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
### 3. Реквизиты передающего/принимающего депозитариев
|
||||
|
||||
```sql
|
||||
SELECT inn
|
||||
FROM fansy.settlement_requisites
|
||||
WHERE inn IN (:transferring_inn, :receiving_inn);
|
||||
```
|
||||
|
||||
### 4. Депо-счета и разделы инвестора (для `SettlementAccount`)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
da.deponent_code,
|
||||
da.account_id,
|
||||
da.section_id,
|
||||
da.depository_inn
|
||||
FROM fansy.depo_accounts da
|
||||
WHERE da.client_id = :client_id
|
||||
AND da.depository_inn = :depository_inn
|
||||
AND da.is_active = true;
|
||||
```
|
||||
|
||||
### 5. Информация о ценных бумагах и их остатках
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.security_code,
|
||||
s.isin,
|
||||
s.classification,
|
||||
s.category,
|
||||
s.security_type,
|
||||
s.reg_number,
|
||||
s.fund_class,
|
||||
p.quantity_whole,
|
||||
p.quantity_fractional,
|
||||
p.isolation_status
|
||||
FROM fansy.portfolios p
|
||||
JOIN fansy.securities s USING (security_code)
|
||||
WHERE p.client_id = :client_id
|
||||
AND p.security_code = ANY(:requested_codes)
|
||||
AND p.valued_at >= now() - interval '5 minutes';
|
||||
```
|
||||
|
||||
### 6. Проверка достаточности остатков
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
p.security_code,
|
||||
COALESCE(p.quantity_whole, 0) + COALESCE(p.quantity_fractional, 0) AS available
|
||||
FROM fansy.portfolios p
|
||||
WHERE p.client_id = :client_id
|
||||
AND p.security_code = ANY(:requested_codes);
|
||||
```
|
||||
|
||||
Сравниваем `available` с запрошенным количеством. Если меньше — отказ
|
||||
от формирования M2MTransferRequest, ошибка в ЛК.
|
||||
|
||||
## Какие данные команда Fansy обязана положить в staging
|
||||
|
||||
Из примера выше:
|
||||
|
||||
- `clients`: запись на инвестора Иванова И.И.
|
||||
- `client_documents`: документ с DocumentType `21`.
|
||||
- `iia_contracts`: договор T03 с брокером (БКС, ИНН 5406121446).
|
||||
- `depo_accounts`: счёт у БКС с разделом для перевода и счёт у
|
||||
Ренессанс Брокера.
|
||||
- `securities`: 3 записи (SHAR/ORDN, SHAR/PREF, MFUN/UKWN с
|
||||
fund_class='A').
|
||||
- `portfolios`: остатки по этим 3 ЦБ на 1500 / 300 / 2500.75
|
||||
соответственно.
|
||||
- `participants`: НРД, БКС (5406121446), Ренессанс (7709258228) — из
|
||||
начального seed.
|
||||
|
||||
## Результат
|
||||
|
||||
`m2m-core` собирает данные → формирует `M2MTransferRequest` →
|
||||
валидирует → подписывает (через `crypto-service`) → отправляет в НРД
|
||||
через `nsd-adapter`. Получает `M2MTransferDecision` от принимающей
|
||||
стороны, обновляет статус сделки и шлёт callback в ЛК.
|
||||
@@ -0,0 +1,90 @@
|
||||
-- seed-data.sql
|
||||
-- Тестовые данные для совместного приёмочного тестирования
|
||||
-- Bridge-and-Join-s ↔ команда Fansy. Запускать поверх 002__working.sql.
|
||||
|
||||
SET search_path TO fansy, public;
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Реквизиты депозитариев
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO settlement_requisites (id, inn, display_name) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', '7702070139', 'Депозитарий Сбербанк'),
|
||||
('00000000-0000-0000-0000-000000000002', '7802031669', 'Депозитарий СПб Банк'),
|
||||
('00000000-0000-0000-0000-000000000003', '0702345678', 'Депозитарий БКС'),
|
||||
('00000000-0000-0000-0000-000000000004', '0710987654', 'Депозитарий Ренессанс')
|
||||
ON CONFLICT (inn) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Справочник ЦБ (минимальный)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO securities (security_code, isin, classification, category, security_type, reg_number, display_name) VALUES
|
||||
('MM0766162534', 'RU0007661625', 'SHAR', 'ORDN', 'Акция обыкновенная', '1-01-00077-A', 'Газпром ао'),
|
||||
('MM0907654321', 'RU0009029540', 'SHAR', 'PREF', 'Акция привилегированная', '2-02-00009-A', 'Сбербанк ап'),
|
||||
('MM2300100100', NULL, 'MFUN', 'UKWN', 'Пай ПИФ', '23-001', 'ПИФ Альфа Капитал')
|
||||
ON CONFLICT (security_code) DO NOTHING;
|
||||
|
||||
UPDATE securities SET fund_class = 'A' WHERE security_code = 'MM2300100100';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5 тестовых клиентов
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO clients (id, last_name, first_name, middle_name, birth_date) VALUES
|
||||
('11111111-1111-1111-1111-111111111111', 'Иванов', 'Иван', 'Иванович', '1980-01-15'),
|
||||
('22222222-2222-2222-2222-222222222222', 'Петров', 'Пётр', 'Петрович', '1985-06-20'),
|
||||
('33333333-3333-3333-3333-333333333333', 'Сидоров', 'Сидор', 'Сидорович', '1990-11-30'),
|
||||
('44444444-4444-4444-4444-444444444444', 'Кузнецов','Сергей','Михайлович','1975-03-10'),
|
||||
('55555555-5555-5555-5555-555555555555', 'Соколова','Анна', 'Викторовна','1988-09-25')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Документы клиентов
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO client_documents (id, client_id, document_type, series, number, issued_at, issuer) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '21', '4512', '654321', '2010-05-12', 'ОУФМС России по Москве'),
|
||||
('a0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', '21', '4513', '654322', '2011-06-13', 'ОУФМС России по Москве'),
|
||||
('a0000000-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', '21', '4514', '654323', '2012-07-14', 'ОУФМС России по СПб'),
|
||||
('a0000000-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', '03', '111', '222333', '1995-08-15', 'Свидетельство о рождении'),
|
||||
('a0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', '21', '4516', '654325', '2014-09-16', 'ОУФМС России по СПб')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- ИИС-договоры (для 3 клиентов)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO iia_contracts (id, client_id, agreement_type, agreement_number, agreement_date, broker_inn) VALUES
|
||||
('b0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'T03', 'ИИС78/2024', '2026-01-15', '5406121446'),
|
||||
('b0000000-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'T12', 'ИИС79/2023', '2025-12-01', '7709258228'),
|
||||
('b0000000-0000-0000-0000-000000000003', '55555555-5555-5555-5555-555555555555', 'T03', 'ИИС80/2024', '2026-02-10', '7728168971')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Депо-счета
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO depo_accounts (id, client_id, deponent_code, account_id, section_id, depository_inn, is_active, is_trading) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'DP789456', '31MC0021900000F01', 'P001', '7702070139', true, true),
|
||||
('c0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'AA789451', '33MC0021900000F02', 'F002', '7802031669', true, true),
|
||||
('c0000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'DP100200', '31MC0010000000A01', 'A001', '7702070139', true, true),
|
||||
('c0000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'DP300400', '31MC0030000000B01', 'B001', '0702345678', true, true),
|
||||
('c0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'DP500600', '31MC0050000000C01', 'C001', '0710987654', true, true)
|
||||
ON CONFLICT (deponent_code, account_id, section_id) DO NOTHING;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Портфели (остатки ЦБ)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO portfolios (id, client_id, depo_account_id, security_code, isin, quantity_whole, quantity_fractional, valued_at) VALUES
|
||||
('d0000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0766162534', 'RU0007661625', 1500, NULL, now()),
|
||||
('d0000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM0907654321', 'RU0009029540', 300, NULL, now()),
|
||||
('d0000000-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'c0000000-0000-0000-0000-000000000001', 'MM2300100100', NULL, NULL, 2500.75, now()),
|
||||
('d0000000-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'c0000000-0000-0000-0000-000000000003', 'MM0766162534', 'RU0007661625', 5000, NULL, now()),
|
||||
('d0000000-0000-0000-0000-000000000005', '55555555-5555-5555-5555-555555555555', 'c0000000-0000-0000-0000-000000000005', 'MM2300100100', NULL, NULL, 100.00, now())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,18 +1,58 @@
|
||||
# docs/lk-contract/v1 — контракт с ЛК клиента (ESIA Finance)
|
||||
# docs/lk-contract/v1 — контракт с ЛК клиента (ESIA Finance API V1)
|
||||
|
||||
ЛК клиента работает на платформе **ESIA Finance**, контракт описан
|
||||
в `DOC/API ЛК ЕСИА.pdf` (`/api/v1/back_office/...`, Basic HTTP, JSON,
|
||||
ЛК клиента работает на платформе **ESIA Finance**, контракт описан в
|
||||
`DOC/API ЛК ЕСИА.pdf` (`/api/v1/back_office/...`, Basic HTTP, JSON,
|
||||
UTF-8).
|
||||
|
||||
На этапе M1 в `lk-emulator` мы воспроизводим этот контракт для запуска
|
||||
сквозного потока. Реальный ЛК подключится по тому же контракту, без
|
||||
правок на нашей стороне.
|
||||
На этапе M1 в `lk-emulator` (отдельный PR) мы реализуем этот контракт
|
||||
как «как-будто-ЛК» для запуска сквозного потока. Реальный ЛК
|
||||
подключится по тому же контракту без правок на нашей стороне.
|
||||
|
||||
В этом каталоге будут:
|
||||
## Состав каталога
|
||||
|
||||
- `openapi.yaml` — наш OpenAPI-контракт `lk-gateway`, согласованный
|
||||
с командой ЛК.
|
||||
- `examples/` — примеры заявлений и ответов.
|
||||
- `changelog.md` — версионирование контракта.
|
||||
- **`openapi.yaml`** — OpenAPI 3.0 контракт lk-gateway. Описывает
|
||||
четыре операции: создание, чтение, callback статуса и список заявок.
|
||||
Модель `Claim` включает все поля, нужные m2m-core для формирования
|
||||
`M2MTransferRequest`.
|
||||
- **`examples/`**:
|
||||
- `claim-request.json` — пример заявки на перевод (3 ЦБ, ИИС T03).
|
||||
- `claim-response.json` — пример ответа на создание.
|
||||
- `callback-confirmed.json` — callback подтверждения (status_code
|
||||
INFO, 3 коды 01).
|
||||
- `callback-rejected.json` — callback отказа (status_code ERROR).
|
||||
- `error-422.json` — ошибка валидации подписи.
|
||||
- **`changelog.md`** — версионирование контракта.
|
||||
|
||||
Реализация — задача M1.
|
||||
## Что входит в модель заявки
|
||||
|
||||
- Идентификация инвестора (UUID в ЛК, ФИО, документ).
|
||||
- Реквизиты передающего и принимающего депозитариев (ИНН).
|
||||
- Информация об учёте стоимости (`cost_info: yes | no`).
|
||||
- Опциональный блок ИИС (тип T12/T03, номер договора, дата, ИНН брокера).
|
||||
- Массив ценных бумаг (1..N), каждая с:
|
||||
- `security_code` (НРД-код, 12 символов),
|
||||
- идентификацией (`isin` или развёрнутый `security_info`),
|
||||
- количеством (целое `whole` или дробное `fractional` до 16 знаков),
|
||||
- списком счетов депо (`settlement_accounts[]`).
|
||||
- Подписанный XML заявления (base64) и формат подписи
|
||||
(XMLDSig-GOST или XMLDSig-RSA).
|
||||
|
||||
## Что входит в callback статуса
|
||||
|
||||
- `claim_id`, `new_status`, `updated_at`.
|
||||
- Для `rejected`/`timed_out`: код и текст причины из ответа НРД.
|
||||
- Полное `nsd_response` (опц., для аудита).
|
||||
|
||||
## Порядок согласования
|
||||
|
||||
1. Передать команде ЛК ссылку на эту папку (тег `lk-contract-v1`).
|
||||
2. Обсудить базовый URL, авторизацию (Basic, через VPN), окна.
|
||||
3. Запустить `lk-emulator` на нашей стороне как опорную реализацию.
|
||||
4. После приёмки — поднимать реальную интеграцию.
|
||||
|
||||
## Принципы
|
||||
|
||||
- OpenAPI 3.0, валидный по spectral / openapi-cli.
|
||||
- Operation IDs в snake_case.
|
||||
- Описания на русском, имена полей на английском.
|
||||
- Enum'ы значений M2M — буквально как в XSD НРД (T12/T03, BOND/SHAR/MFUN, ...).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Changelog контракта lk-gateway
|
||||
|
||||
## v1.0.0 (2026-05-14)
|
||||
|
||||
Первая опубликованная версия контракта. Соответствует ESIA Finance
|
||||
API V1 (`DOC/API ЛК ЕСИА.pdf`).
|
||||
|
||||
Поддерживаемые операции:
|
||||
|
||||
- `POST /api/v1/back_office/claims/` — создание заявки.
|
||||
- `GET /api/v1/back_office/claims` — список с фильтрами.
|
||||
- `GET /api/v1/back_office/claims/{id}` — деталь.
|
||||
- `PATCH /api/v1/back_office/claims/{id}` — callback статуса.
|
||||
|
||||
Модели:
|
||||
|
||||
- `Claim` — заявка с массивом `securities[]` (1..N ЦБ).
|
||||
- `CreateClaimRequest` — входное тело создания.
|
||||
- `StatusCallback` — обновление статуса с `nsd_response` для аудита.
|
||||
- `ErrorResponse` — формат идентичен ESIA Finance V1.
|
||||
|
||||
Совместимость:
|
||||
|
||||
- HTTP Basic-auth.
|
||||
- UTF-8, JSON.
|
||||
- Поля enum — буквально как в XSD M2M (T12/T03, BOND/SHAR/MFUN,
|
||||
ORDN/PREF/UKWN, INFO/ERROR).
|
||||
|
||||
## Принципы версионирования
|
||||
|
||||
- Несовместимые изменения — `v2/`, `v3/` (новая папка, отдельный
|
||||
changelog).
|
||||
- Совместимые добавления — minor-версия в этом файле.
|
||||
- Документация исправлений — patch-версия в этом файле.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"new_status": "confirmed",
|
||||
"updated_at": "2026-03-02T14:38:12Z",
|
||||
"nsd_response": {
|
||||
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status_code": "INFO",
|
||||
"responses": [
|
||||
{
|
||||
"reference_id": "M2M2026030200001",
|
||||
"code": "01",
|
||||
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
|
||||
},
|
||||
{
|
||||
"reference_id": "M2M2026030200002",
|
||||
"code": "01",
|
||||
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
|
||||
},
|
||||
{
|
||||
"reference_id": "M2M2026030200003",
|
||||
"code": "01",
|
||||
"text": "Запрос на перевод принят и подтверждён принимающей стороной."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"claim_id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"new_status": "rejected",
|
||||
"reason_code": "07",
|
||||
"reason_text": "Не найдена сделка с таким GUID на стороне принимающего депозитария.",
|
||||
"updated_at": "2026-03-02T14:40:00Z",
|
||||
"nsd_response": {
|
||||
"guid": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status_code": "ERROR",
|
||||
"responses": [
|
||||
{
|
||||
"code": "07",
|
||||
"text": "Не найдена сделка с таким GUID."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"investor": {
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"last_name": "Иванов",
|
||||
"first_name": "Иван",
|
||||
"middle_name": "Иванович",
|
||||
"document": {
|
||||
"document_type": "21",
|
||||
"series": "4512",
|
||||
"number": "654321"
|
||||
}
|
||||
},
|
||||
"transferring_depository_inn": "0702345678",
|
||||
"receiving_depository_inn": "0710987654",
|
||||
"cost_info": {
|
||||
"yes": {
|
||||
"code": "MC0010300032"
|
||||
}
|
||||
},
|
||||
"iia_agreement": {
|
||||
"agreement_type": "T03",
|
||||
"agreement_number": "ИИС78/2024",
|
||||
"agreement_date": "2026-01-15",
|
||||
"broker_inn": "0707083893"
|
||||
},
|
||||
"securities": [
|
||||
{
|
||||
"security_code": "MM0766162534",
|
||||
"security_details": {
|
||||
"isin": "RU0007661625"
|
||||
},
|
||||
"quantity": {
|
||||
"whole": 1500
|
||||
},
|
||||
"settlement_accounts": [
|
||||
{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"settlement_requisites_inn": "7802031669",
|
||||
"settlement_location": {
|
||||
"deponent_code": "AA789451",
|
||||
"account_id": "33MC0021900000F02",
|
||||
"section_id": "F002"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"security_code": "MM0907654321",
|
||||
"security_details": {
|
||||
"isin": "RU0009029540"
|
||||
},
|
||||
"quantity": {
|
||||
"whole": 300
|
||||
},
|
||||
"settlement_accounts": [
|
||||
{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"security_code": "MM2300100100",
|
||||
"security_details": {
|
||||
"security_info": {
|
||||
"classification": "MFUN",
|
||||
"category": "UKWN",
|
||||
"identification_details": {
|
||||
"fund_shares": {
|
||||
"reg_number": "23-001",
|
||||
"class": "A"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quantity": {
|
||||
"fractional": "2500.75"
|
||||
},
|
||||
"settlement_accounts": [
|
||||
{
|
||||
"settlement_requisites_inn": "7702070139",
|
||||
"settlement_location": {
|
||||
"deponent_code": "DP789456",
|
||||
"account_id": "31MC0021900000F01",
|
||||
"section_id": "P001"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"signed_document": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0id2luZG93cy0xMjUxIj8+...base64-XML...",
|
||||
"signature_format": "XMLDSig-GOST"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "c02a1d5e-c2af-4799-bab4-953f133c5133",
|
||||
"status": "submitted",
|
||||
"created_at": "2026-03-02T14:30:45Z",
|
||||
"success": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"error": true,
|
||||
"status": 422,
|
||||
"code": "invalid_signature",
|
||||
"title": "Подпись заявления не прошла проверку",
|
||||
"meta": {
|
||||
"message": "Сертификат подписанта недействителен или цепочка доверия не построена.",
|
||||
"errors": [
|
||||
{
|
||||
"field": "signed_document",
|
||||
"message": "XMLDSig: certificate chain not trusted (signer CN = ИВАНОВ И.И.)."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: lk-gateway API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
REST-контракт между сервисом `lk-gateway` (Bridge-and-Join-s) и ЛК
|
||||
инвестора на платформе ESIA Finance. Версия V1 совместима с
|
||||
официальным API ESIA Finance (`DOC/API ЛК ЕСИА.pdf`).
|
||||
|
||||
Контракт обслуживает жизненный цикл заявки M2M-перевода: создание,
|
||||
получение, обновление статуса и список заявок.
|
||||
|
||||
Аутентификация — HTTP Basic. Кодировка — UTF-8. Тело запросов и
|
||||
ответов — JSON.
|
||||
servers:
|
||||
- url: https://lk-gateway.bridge-and-joins.local
|
||||
description: Production lk-gateway
|
||||
- url: http://localhost:8080
|
||||
description: Локальный эмулятор (lk-emulator)
|
||||
security:
|
||||
- basicAuth: []
|
||||
|
||||
paths:
|
||||
/api/v1/back_office/claims/:
|
||||
post:
|
||||
operationId: create_claim
|
||||
summary: Создать заявку на M2M-перевод
|
||||
description: |
|
||||
Принимает подписанное (XMLDSig) заявление инвестора. Сервис
|
||||
проверяет подпись через crypto-service, валидирует данные,
|
||||
создаёт сделку и инициирует отправку в НРД.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateClaimRequest'
|
||||
examples:
|
||||
full_claim:
|
||||
summary: Заявка с тремя ЦБ, ИИС T03
|
||||
externalValue: ./examples/claim-request.json
|
||||
responses:
|
||||
'201':
|
||||
description: Заявка создана
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateClaimResponse'
|
||||
'400':
|
||||
description: Невалидные входные данные
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Не авторизован
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'422':
|
||||
description: Подпись неверна или данные не прошли бизнес-валидацию
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/api/v1/back_office/claims:
|
||||
get:
|
||||
operationId: list_claims
|
||||
summary: Список заявок
|
||||
description: Возвращает список заявок с фильтрацией по статусу, периоду и инвестору.
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
description: Фильтр по статусу.
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
- name: investor_id
|
||||
in: query
|
||||
description: UUID инвестора в ЛК.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: created_from
|
||||
in: query
|
||||
description: Нижняя граница периода создания (ISO 8601, UTC).
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: created_to
|
||||
in: query
|
||||
description: Верхняя граница периода создания (ISO 8601, UTC).
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
responses:
|
||||
'200':
|
||||
description: Страница списка заявок
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ClaimsPage'
|
||||
|
||||
/api/v1/back_office/claims/{id}:
|
||||
get:
|
||||
operationId: get_claim
|
||||
summary: Получить заявку и её статус
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: UUID заявки.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Заявка
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Claim'
|
||||
'404':
|
||||
description: Заявка не найдена
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
patch:
|
||||
operationId: update_claim_status
|
||||
summary: Callback обновления статуса (от lk-gateway к ЛК)
|
||||
description: |
|
||||
Используется лгатвей-ом для уведомления ЛК о смене статуса
|
||||
сделки на стороне НРД. Подтверждение, отказ или таймаут.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: UUID заявки.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/StatusCallback'
|
||||
examples:
|
||||
confirmed:
|
||||
summary: Подтверждение
|
||||
externalValue: ./examples/callback-confirmed.json
|
||||
rejected:
|
||||
summary: Отказ
|
||||
externalValue: ./examples/callback-rejected.json
|
||||
responses:
|
||||
'200':
|
||||
description: Callback принят
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CallbackResponse'
|
||||
'404':
|
||||
description: Заявка не найдена
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
basicAuth:
|
||||
type: http
|
||||
scheme: basic
|
||||
|
||||
schemas:
|
||||
ClaimStatus:
|
||||
type: string
|
||||
description: |
|
||||
Жизненный цикл заявки на M2M-перевод.
|
||||
- `draft` — черновик, ещё не подписан.
|
||||
- `signed` — подписан, но не отправлен.
|
||||
- `submitted` — отправлен в НРД.
|
||||
- `in_progress` — НРД принял, ждём решение от принимающей стороны.
|
||||
- `confirmed` — подтверждён, перевод исполнен.
|
||||
- `rejected` — отклонён.
|
||||
- `timed_out` — превышен SLA, ручной разбор.
|
||||
enum:
|
||||
- draft
|
||||
- signed
|
||||
- submitted
|
||||
- in_progress
|
||||
- confirmed
|
||||
- rejected
|
||||
- timed_out
|
||||
|
||||
SignatureFormat:
|
||||
type: string
|
||||
description: Тип цифровой подписи заявления.
|
||||
enum:
|
||||
- XMLDSig-GOST
|
||||
- XMLDSig-RSA
|
||||
|
||||
AgreementType:
|
||||
type: string
|
||||
description: |
|
||||
Тип договора ИИС.
|
||||
- `T12` — ИИС-1 или ИИС-2 (старый формат).
|
||||
- `T03` — ИИС-3 (новый).
|
||||
enum:
|
||||
- T12
|
||||
- T03
|
||||
|
||||
SecurityClassification:
|
||||
type: string
|
||||
description: Тип ценной бумаги.
|
||||
enum:
|
||||
- BOND
|
||||
- SHAR
|
||||
- MFUN
|
||||
|
||||
SecurityCategory:
|
||||
type: string
|
||||
description: Категория акций.
|
||||
enum:
|
||||
- ORDN
|
||||
- PREF
|
||||
- UKWN
|
||||
|
||||
Investor:
|
||||
type: object
|
||||
description: Анкета инвестора.
|
||||
required:
|
||||
- last_name
|
||||
- first_name
|
||||
- document
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: UUID инвестора в ЛК (если уже известен).
|
||||
last_name:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: Иванов
|
||||
first_name:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: Иван
|
||||
middle_name:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: Иванович
|
||||
document:
|
||||
$ref: '#/components/schemas/IdentityDocument'
|
||||
|
||||
IdentityDocument:
|
||||
type: object
|
||||
description: Документ, удостоверяющий личность.
|
||||
required:
|
||||
- document_type
|
||||
- number
|
||||
properties:
|
||||
document_type:
|
||||
type: string
|
||||
pattern: '^(0[1-7]|09|1[0-4]|2[1-37]|26|91)$'
|
||||
description: Код документа по справочнику НРД.
|
||||
example: '21'
|
||||
series:
|
||||
type: string
|
||||
pattern: '^\S+$'
|
||||
example: '4512'
|
||||
number:
|
||||
type: string
|
||||
pattern: '^\S+$'
|
||||
example: '654321'
|
||||
|
||||
Quantity:
|
||||
type: object
|
||||
description: Количество ценных бумаг — choice (ровно одно поле).
|
||||
properties:
|
||||
whole:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
example: 1500
|
||||
fractional:
|
||||
type: string
|
||||
description: Десятичная строка с не более 16 знаками после точки.
|
||||
pattern: '^[0-9]+(\.[0-9]{1,16})?$'
|
||||
example: '2500.75'
|
||||
|
||||
FundShares:
|
||||
type: object
|
||||
required:
|
||||
- reg_number
|
||||
properties:
|
||||
reg_number:
|
||||
type: string
|
||||
maxLength: 256
|
||||
example: '23-001'
|
||||
class:
|
||||
type: string
|
||||
maxLength: 120
|
||||
example: A
|
||||
|
||||
IdentificationDetails:
|
||||
type: object
|
||||
description: Идентификация ЦБ — choice (ровно одно поле).
|
||||
properties:
|
||||
reg_number:
|
||||
type: string
|
||||
maxLength: 20
|
||||
fund_shares:
|
||||
$ref: '#/components/schemas/FundShares'
|
||||
|
||||
SecurityInfo:
|
||||
type: object
|
||||
description: Описание ЦБ при отсутствии ISIN.
|
||||
required:
|
||||
- classification
|
||||
- category
|
||||
- identification_details
|
||||
properties:
|
||||
classification:
|
||||
$ref: '#/components/schemas/SecurityClassification'
|
||||
category:
|
||||
$ref: '#/components/schemas/SecurityCategory'
|
||||
security_type:
|
||||
type: string
|
||||
maxLength: 256
|
||||
security_series:
|
||||
type: string
|
||||
identification_details:
|
||||
$ref: '#/components/schemas/IdentificationDetails'
|
||||
|
||||
SecurityDetails:
|
||||
type: object
|
||||
description: Идентификация ЦБ — choice (ровно одно поле).
|
||||
properties:
|
||||
isin:
|
||||
type: string
|
||||
pattern: '^[A-Z]{2}[A-Z0-9]{9}[0-9]$'
|
||||
example: RU0007661625
|
||||
security_info:
|
||||
$ref: '#/components/schemas/SecurityInfo'
|
||||
|
||||
SettlementLocation:
|
||||
type: object
|
||||
required:
|
||||
- deponent_code
|
||||
- account_id
|
||||
- section_id
|
||||
properties:
|
||||
deponent_code:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: DP789456
|
||||
account_id:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: 31MC0021900000F01
|
||||
section_id:
|
||||
type: string
|
||||
maxLength: 50
|
||||
example: P001
|
||||
|
||||
SettlementAccount:
|
||||
type: object
|
||||
required:
|
||||
- settlement_requisites_inn
|
||||
- settlement_location
|
||||
properties:
|
||||
settlement_requisites_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
example: '7702070139'
|
||||
settlement_location:
|
||||
$ref: '#/components/schemas/SettlementLocation'
|
||||
|
||||
ClaimSecurity:
|
||||
type: object
|
||||
required:
|
||||
- security_code
|
||||
- security_details
|
||||
- quantity
|
||||
- settlement_accounts
|
||||
properties:
|
||||
security_code:
|
||||
type: string
|
||||
pattern: '^[0-9A-Z_/-]{12}$'
|
||||
example: MM0766162534
|
||||
security_details:
|
||||
$ref: '#/components/schemas/SecurityDetails'
|
||||
quantity:
|
||||
$ref: '#/components/schemas/Quantity'
|
||||
settlement_accounts:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/SettlementAccount'
|
||||
|
||||
CostInfo:
|
||||
type: object
|
||||
description: |
|
||||
Информация об учёте стоимости приобретения. Choice: либо
|
||||
`yes` (с кодом депонента-источника), либо `no` (учёт не ведётся).
|
||||
properties:
|
||||
yes:
|
||||
type: object
|
||||
required: [code]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
pattern: '^[A-Z0-9]+$'
|
||||
maxLength: 12
|
||||
example: MC0010300032
|
||||
no:
|
||||
type: object
|
||||
description: Пустой объект — учёт не ведётся.
|
||||
|
||||
IIAAgreement:
|
||||
type: object
|
||||
description: Реквизиты договора ИИС (нужно, если перевод идёт по ИИС).
|
||||
required:
|
||||
- agreement_type
|
||||
- agreement_number
|
||||
- agreement_date
|
||||
- broker_inn
|
||||
properties:
|
||||
agreement_type:
|
||||
$ref: '#/components/schemas/AgreementType'
|
||||
agreement_number:
|
||||
type: string
|
||||
maxLength: 128
|
||||
example: ИИС78/2024
|
||||
agreement_date:
|
||||
type: string
|
||||
format: date
|
||||
example: '2026-01-15'
|
||||
broker_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
example: '0707083893'
|
||||
|
||||
CreateClaimRequest:
|
||||
type: object
|
||||
required:
|
||||
- investor
|
||||
- transferring_depository_inn
|
||||
- receiving_depository_inn
|
||||
- cost_info
|
||||
- securities
|
||||
- signed_document
|
||||
- signature_format
|
||||
properties:
|
||||
investor:
|
||||
$ref: '#/components/schemas/Investor'
|
||||
transferring_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
receiving_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
cost_info:
|
||||
$ref: '#/components/schemas/CostInfo'
|
||||
iia_agreement:
|
||||
$ref: '#/components/schemas/IIAAgreement'
|
||||
securities:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/ClaimSecurity'
|
||||
signed_document:
|
||||
type: string
|
||||
format: byte
|
||||
description: Подписанный XML заявления в base64.
|
||||
signature_format:
|
||||
$ref: '#/components/schemas/SignatureFormat'
|
||||
|
||||
CreateClaimResponse:
|
||||
type: object
|
||||
required: [id, status, created_at, success]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
Claim:
|
||||
type: object
|
||||
description: Полная сущность заявки.
|
||||
required:
|
||||
- id
|
||||
- status
|
||||
- created_at
|
||||
- updated_at
|
||||
- investor
|
||||
- transferring_depository_inn
|
||||
- receiving_depository_inn
|
||||
- securities
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
status:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
investor:
|
||||
$ref: '#/components/schemas/Investor'
|
||||
transferring_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
receiving_depository_inn:
|
||||
type: string
|
||||
pattern: '^[0-9]{10}$'
|
||||
cost_info:
|
||||
$ref: '#/components/schemas/CostInfo'
|
||||
iia_agreement:
|
||||
$ref: '#/components/schemas/IIAAgreement'
|
||||
securities:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ClaimSecurity'
|
||||
last_callback:
|
||||
$ref: '#/components/schemas/StatusCallback'
|
||||
|
||||
ClaimsPage:
|
||||
type: object
|
||||
required: [items, total, limit, offset]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Claim'
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
|
||||
StatusCallback:
|
||||
type: object
|
||||
description: Callback обновления статуса от lk-gateway к ЛК.
|
||||
required:
|
||||
- claim_id
|
||||
- new_status
|
||||
- updated_at
|
||||
properties:
|
||||
claim_id:
|
||||
type: string
|
||||
format: uuid
|
||||
new_status:
|
||||
$ref: '#/components/schemas/ClaimStatus'
|
||||
reason_code:
|
||||
type: string
|
||||
maxLength: 6
|
||||
description: Код причины (для rejected/timed_out) из M2MTransferResponse или M2MTransferDecision.
|
||||
example: '01'
|
||||
reason_text:
|
||||
type: string
|
||||
maxLength: 1024
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nsd_response:
|
||||
type: object
|
||||
description: Оригинал ответа НРД (необязательно, для аудита).
|
||||
properties:
|
||||
guid:
|
||||
type: string
|
||||
format: uuid
|
||||
status_code:
|
||||
type: string
|
||||
enum: [INFO, ERROR]
|
||||
responses:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
reference_id:
|
||||
type: string
|
||||
pattern: '^M2M[A-Z0-9]{13}$'
|
||||
code:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
|
||||
CallbackResponse:
|
||||
type: object
|
||||
required: [success]
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
description: Формат ошибки, идентичный API ESIA Finance V1.
|
||||
required: [error, status]
|
||||
properties:
|
||||
error:
|
||||
type: boolean
|
||||
example: true
|
||||
status:
|
||||
type: integer
|
||||
example: 422
|
||||
code:
|
||||
type: string
|
||||
example: invalid_signature
|
||||
title:
|
||||
type: string
|
||||
example: Подпись не прошла проверку
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
@@ -12,11 +12,12 @@ PR-1 → PR-N. Каждая задача — самостоятельный ос
|
||||
| PR | Файл | Статус | Зависит от |
|
||||
|----|------|--------|-----------|
|
||||
| PR-1 | `PR-1-go-models-m2m.md` | выполнено | — |
|
||||
| PR-2 | `PR-2-fansy-ddl.md` | готово к запуску | — (параллельно с PR-1) |
|
||||
| PR-3 | `PR-3-lk-openapi.md` | готово к запуску | — (параллельно с PR-1) |
|
||||
| PR-4 | `PR-4-m2m-core-skeleton.md` | готово к запуску | PR-1 |
|
||||
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | ждёт ИШ НРД и сертификаты | PR-1, PR-4 |
|
||||
| PR-6 | `PR-6-crypto-service-skeleton.md` | ждёт КриптоПро JCP | PR-1 |
|
||||
| PR-2 | `PR-2-fansy-ddl.md` | выполнено | — (параллельно с PR-1) |
|
||||
| PR-3 | `PR-3-lk-openapi.md` | выполнено | — (параллельно с PR-1) |
|
||||
| PR-4 | `PR-4-m2m-core-skeleton.md` | выполнено | PR-1 |
|
||||
| PR-5 | `PR-5-nsd-adapter-skeleton.md` | выполнено (каркас) | PR-1, PR-4 |
|
||||
| PR-6 | `PR-6-crypto-service-skeleton.md` | выполнено (скелет) | PR-1 |
|
||||
| M2-шаг-1 | сквозной поток: lk-gateway BFF + admin web + lk-emulator + mock NSD | выполнено | PR-1, PR-3, PR-4 |
|
||||
|
||||
## Как запустить задачу
|
||||
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
module git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
|
||||
go 1.23
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.7.4
|
||||
golang.org/x/text v0.34.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.48.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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/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/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,29 @@
|
||||
# internal/cryptocli — Go-клиент crypto-service
|
||||
|
||||
Реализация `m2mcore.CryptoVerifier` поверх gRPC по Unix Domain Socket.
|
||||
|
||||
## Состояние
|
||||
|
||||
На M1 — заглушка. Подключается к UDS-сокету crypto-service, проверяет
|
||||
доступность и возвращает `ErrNotImplemented`. Этого достаточно, чтобы:
|
||||
|
||||
- m2m-core и другие сервисы могли инжектить клиент без условных веток;
|
||||
- логи различали «сокета нет» (например, контейнер crypto-service не
|
||||
запущен) от «сокет есть, но криптография не подключена» (нет лицензии
|
||||
КриптоПро JCP).
|
||||
|
||||
## Когда станет полноценным
|
||||
|
||||
После генерации gRPC-стабов из `services/crypto-service/proto/crypto.proto`
|
||||
(требует `protoc` + плагины), что в свою очередь требует доступа к
|
||||
Maven Central / Go module proxy через прокси zetit.
|
||||
|
||||
## API
|
||||
|
||||
```go
|
||||
cli := cryptocli.NewClient("/run/bj/crypto.sock")
|
||||
info, err := cli.VerifyXMLDSig(ctx, signedXML)
|
||||
if errors.Is(err, cryptocli.ErrNotImplemented) {
|
||||
// M1: запасной путь (ручная проверка / откладывание).
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package cryptocli — gRPC-клиент к crypto-service по Unix Domain
|
||||
// Socket. Сам Go-процесс не выполняет криптографию — всё делает
|
||||
// Java-сайдкар (services/crypto-service) поверх АПК «Валидата
|
||||
// Клиент L».
|
||||
//
|
||||
// На дев-стендах без поднятого сайдкара (стандартный путь
|
||||
// /run/bj/crypto.sock не существует) клиент возвращает понятную
|
||||
// ошибку «провайдер недоступен» и lk-gateway работает в stub-режиме:
|
||||
// XMLDSig-подписи проходят без проверки (только для демо).
|
||||
package cryptocli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Provider — тип СКЗИ-провайдера (информативный — реальный выбор
|
||||
// делает crypto-service через переменную BJ_CRYPTO_PROVIDER).
|
||||
type Provider string
|
||||
|
||||
const (
|
||||
ProviderStub Provider = "stub"
|
||||
ProviderValidata Provider = "validata"
|
||||
)
|
||||
|
||||
// DefaultModulePath сохранена для обратной совместимости с UI;
|
||||
// текущий путь интеграции — не PKCS#11-модуль, а UDS-сокет
|
||||
// crypto-service. Возвращаемое значение информативное.
|
||||
func DefaultModulePath(p Provider) string {
|
||||
if p == ProviderValidata {
|
||||
return "/opt/Validata/VDCSP/lib/amd64 (через сайдкар, не PKCS#11)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Config — конфигурация клиента.
|
||||
type Config struct {
|
||||
// SocketPath — путь к UDS-сокету crypto-service.
|
||||
// Пустое значение = /run/bj/crypto.sock.
|
||||
SocketPath string
|
||||
// Provider — желаемый провайдер; информативно (см. выше).
|
||||
Provider Provider
|
||||
// ModulePath — сохраняется для UI; в gRPC-режиме не используется.
|
||||
ModulePath string
|
||||
// Timeout — таймаут одной gRPC-операции.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client — gRPC-клиент к crypto-service.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
mu sync.Mutex
|
||||
conn *grpc.ClientConn
|
||||
api cryptopb.CryptoServiceClient
|
||||
}
|
||||
|
||||
// New создаёт клиент. Само соединение поднимается лениво при первом
|
||||
// вызове.
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
if cfg.SocketPath == "" {
|
||||
cfg.SocketPath = "/run/bj/crypto.sock"
|
||||
}
|
||||
return &Client{cfg: cfg}
|
||||
}
|
||||
|
||||
// Close закрывает gRPC-соединение.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
c.api = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
SlotID uint
|
||||
TokenLabel string
|
||||
Label string
|
||||
SubjectCN string
|
||||
IssuerCN string
|
||||
Serial string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
INN string
|
||||
DER []byte
|
||||
HasPrivateKey bool
|
||||
}
|
||||
|
||||
// FindCertificates пока возвращает пустой список — список ключей
|
||||
// управляется самой Валидатой через её собственный справочник (zcs),
|
||||
// а bj-server о конкретных сертификатах узнаёт по результатам
|
||||
// Verify/Sign-операций. Эту функцию переопределим позже отдельным
|
||||
// gRPC-методом ListCertificates если потребуется.
|
||||
func (c *Client) FindCertificates(_ context.Context) ([]Certificate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return ActivateResult{}, fmt.Errorf("cryptocli: Activate: %w", err)
|
||||
}
|
||||
return ActivateResult{
|
||||
OK: resp.GetOk(),
|
||||
Provider: resp.GetProvider(),
|
||||
Profile: resp.GetProfile(),
|
||||
Message: resp.GetMessage(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyXMLDSig — проксирует в crypto-service.VerifyXMLDSig.
|
||||
// Реализует m2mcore.CryptoVerifier — поэтому возвращает CertInfo,
|
||||
// заполненный из gRPC-ответа.
|
||||
func (c *Client) VerifyXMLDSig(ctx context.Context, payload []byte) (m2mcore.CertInfo, error) {
|
||||
if c.cfg.Provider == ProviderStub {
|
||||
return m2mcore.CertInfo{
|
||||
SignerCN: "stub-verifier",
|
||||
}, nil
|
||||
}
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return m2mcore.CertInfo{}, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
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
|
||||
}
|
||||
|
||||
// SignXMLDSig — проксирует в crypto-service.SignXMLDSig. Возвращает
|
||||
// DER-байты CMS detached signature (готовы к включению в XMLDSig-обёртку
|
||||
// или к самостоятельной отправке как .p7s).
|
||||
//
|
||||
// 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: подпись недоступна")
|
||||
}
|
||||
if err := c.ensureConn(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
defer cancel()
|
||||
resp, err := c.api.SignXMLDSig(cctx, &cryptopb.SignRequest{
|
||||
Payload: payload,
|
||||
KeyAlias: keyAlias,
|
||||
Profile: profile,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cryptocli: SignXMLDSig: %w", err)
|
||||
}
|
||||
return resp.GetSignedXml(), nil
|
||||
}
|
||||
|
||||
// HealthInfo — что показывает /admin/setup → СКЗИ.
|
||||
type HealthInfo struct {
|
||||
Provider string
|
||||
ModulePath string // в gRPC-режиме — UDS-сокет
|
||||
CryptokiVersion string // не используется
|
||||
ManufacturerID string // не используется
|
||||
LibraryVersion string // не используется
|
||||
Tokens []TokenInfo
|
||||
Message string
|
||||
}
|
||||
|
||||
// TokenInfo — для совместимости с UI; в gRPC-режиме пустой.
|
||||
type TokenInfo struct {
|
||||
SlotID uint
|
||||
Label string
|
||||
Manufacturer string
|
||||
Model string
|
||||
SerialNumber string
|
||||
Error string
|
||||
}
|
||||
|
||||
// Ensure Client реализует m2mcore.CryptoVerifier.
|
||||
var _ m2mcore.CryptoVerifier = (*Client)(nil)
|
||||
@@ -0,0 +1,52 @@
|
||||
package cryptocli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||||
)
|
||||
|
||||
// TestStubProviderHealthOK — провайдер stub не лезет в gRPC,
|
||||
// возвращает информативный Health без ошибки.
|
||||
func TestStubProviderHealthOK(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{Provider: cryptocli.ProviderStub})
|
||||
defer cli.Close()
|
||||
h, err := cli.Health(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Health: %v", err)
|
||||
}
|
||||
if h.Provider != string(cryptocli.ProviderStub) {
|
||||
t.Errorf("Provider = %q, ожидался stub", h.Provider)
|
||||
}
|
||||
if !strings.Contains(h.Message, "stub") {
|
||||
t.Errorf("сообщение не содержит 'stub': %q", h.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidataProviderNoSocket — провайдер validata пытается дойти до
|
||||
// сайдкара, но в тестах сокета нет. gRPC-клиент создаётся лениво
|
||||
// (NewClient не возвращает ошибку), а ошибка приходит при первом RPC.
|
||||
func TestValidataProviderNoSocket(t *testing.T) {
|
||||
cli := cryptocli.New(cryptocli.Config{
|
||||
Provider: cryptocli.ProviderValidata,
|
||||
SocketPath: "/nonexistent/crypto.sock",
|
||||
})
|
||||
defer cli.Close()
|
||||
_, err := cli.Health(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о недоступном сокете")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultModulePath — информативный текст для UI.
|
||||
func TestDefaultModulePath(t *testing.T) {
|
||||
if cryptocli.DefaultModulePath(cryptocli.ProviderStub) != "" {
|
||||
t.Error("DefaultModulePath(stub) должен быть пустым")
|
||||
}
|
||||
v := cryptocli.DefaultModulePath(cryptocli.ProviderValidata)
|
||||
if v == "" {
|
||||
t.Error("DefaultModulePath(validata) не должен быть пустым")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user