@@ -268,22 +268,27 @@ function rosDelete(
} )
}
/** GET бинарного содержимого (файлы RouterOS): без utf8-декодирования, JSON- ответ = ошибка . */
function rosDownload (
/** Запрос к RouterOS с сырым (не обязательно JSON) ответом — для содержимого файлов . */
function rosRawRequest (
params : MikrotikConnectParams ,
path : string ,
timeoutMs : number ,
opts : { method : "GET" | "POST" ; path : string ; body? : Record < string , string | string [ ] > ; timeoutMs : number } ,
) : Promise < Buffer > {
return new Promise ( ( resolve , reject ) = > {
const basePath = params . apiPath ? ? "/rest"
const authHeader = "Basic " + Buffer . from ( ` ${ params . username } : ${ params . password } ` ) . toString ( "base64" )
const payload = opts . body ? JSON . stringify ( opts . body ) : undefined
const options : https.RequestOptions = {
hostname : params.host ,
port : params.port ,
path : basePath + path ,
method : "GET" ,
headers : { Authorization : authHeader } ,
path : basePath + opts . path ,
method : opts.method ,
headers : {
Authorization : authHeader ,
. . . ( payload
? { "Content-Type" : "application/json" , "Content-Length" : Buffer . byteLength ( payload ) }
: { } ) ,
} ,
rejectUnauthorized : params.useSsl ? params.verifySsl : undefined ,
}
@@ -295,12 +300,7 @@ function rosDownload(
res . on ( "end" , ( ) = > {
const buf = Buffer . concat ( chunks )
if ( ! res . statusCode || res . statusCode < 200 || res . statusCode >= 300 ) {
reject ( new MikrotikError ( res . statusCode ? ? 0 , path , buf . toString ( "utf8" ) . slice ( 0 , 200 ) ) )
return
}
const contentType = String ( res . headers [ "content-type" ] ? ? "" )
if ( contentType . includes ( "application/json" ) ) {
reject ( new Error ( ` RouterOS вернул метаданные вместо содержимого файла ${ path } ` ) )
reject ( new MikrotikError ( res . statusCode ? ? 0 , opts . path , buf . toString ( "utf8" ) . slice ( 0 , 200 ) ) )
return
}
resolve ( buf )
@@ -308,13 +308,14 @@ function rosDownload(
} )
const timer = setTimeout ( ( ) = > {
req . destroy ( new Error ( ` Connection to ${ params . host } : ${ params . port } timed out after ${ timeoutMs / 1000 } s ` ) )
} , timeoutMs )
req . destroy ( new Error ( ` Connection to ${ params . host } : ${ params . port } timed out after ${ opts . timeoutMs / 1000 } s ` ) )
} , opts . timeoutMs )
req . on ( "close" , ( ) = > clearTimeout ( timer ) )
req . on ( "error" , ( err ) = > {
clearTimeout ( timer )
reject ( err )
} )
if ( payload ) req . write ( payload )
req . end ( )
} )
}
@@ -401,6 +402,102 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName . endsWith ( ` / ${ base } ` )
}
/** Лимит команды `/file/get`: выше 61439 байт RouterOS молча отдаёт 0 байт. */
const MAX_FILE_GET_BYTES = 61439
const RET_MARKER = Buffer . from ( '"ret":' , "latin1" )
const CONTENTS_MARKER = Buffer . from ( '"contents":' , "latin1" )
function assertRosFileReadable ( entry : { name : string ; size : number } ) : void {
if ( entry . size >= MAX_FILE_GET_BYTES ) {
throw new Error (
` Файл ${ routerFileBasename ( entry . name ) } больше ${ MAX_FILE_GET_BYTES / 1024 } КБ — `
+ "RouterOS REST отдаёт содержимое только до 60 КБ (используйте SCP/FTP)" ,
)
}
}
/** Превью ответа RouterOS для диагностики: непечатаемые байты → `\uXXXX`. */
function rosResponsePreview ( raw : Buffer , limit = 200 ) : string {
const slice = raw . subarray ( 0 , limit )
let out = ""
for ( const byte of slice ) {
if ( byte >= 0x20 && byte < 0x7f ) out += String . fromCharCode ( byte )
else out += ` \\ u ${ byte . toString ( 16 ) . padStart ( 4 , "0" ) } `
}
return out
}
/**
* RouterOS REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
* (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486).
* Значение извлекаем напрямую из сырых байтов, без парсинга всего ответа.
* Команда `get` кладёт результат в поле `ret` (`!done=ret=...`), команда `print` — в `contents`.
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
*/
export function extractRosContentsField ( raw : Buffer ) : Buffer {
const start = findRosStringField ( raw , RET_MARKER ) ? ? findRosStringField ( raw , CONTENTS_MARKER )
if ( start == null ) {
throw new Error ( ` ответ не содержит поле ret/contents ( ${ rosResponsePreview ( raw ) } ) ` )
}
const out : number [ ] = [ ]
let i = start
while ( i < raw . length ) {
const byte = raw [ i ]
if ( byte === 0x22 ) return Buffer . from ( out ) // закрывающая кавычка
if ( byte !== 0x5c ) { // обычный байт
out . push ( byte )
i += 1
continue
}
const esc = raw [ i + 1 ]
if ( esc === undefined ) break
i += 2
switch ( esc ) {
case 0x22 : out.push ( 0x22 ) ; break // \"
case 0x5c : out.push ( 0x5c ) ; break // \\
case 0x2f : out.push ( 0x2f ) ; break // \/
case 0x62 : out.push ( 0x08 ) ; break // \b
case 0x66 : out.push ( 0x0c ) ; break // \f
case 0x6e : out.push ( 0x0a ) ; break // \n
case 0x72 : out.push ( 0x0d ) ; break // \r
case 0x74 : out.push ( 0x09 ) ; break // \t
case 0x75 : { // \uXXXX
const hex = raw . subarray ( i , i + 4 ) . toString ( "latin1" )
if ( ! /^[0-9a-fA-F]{4}$/ . test ( hex ) ) throw new Error ( "некорректный \\u-escape в содержимом файла" )
i += 4
for ( const b of Buffer . from ( String . fromCharCode ( parseInt ( hex , 16 ) ) , "utf8" ) ) out . push ( b )
break
}
default : out . push ( esc )
}
}
throw new Error ( "строка содержимого файла не закрыта" )
}
/**
* Ищет ключ JSON и возвращает индекс первого байта строкового значения (после открывающей кавычки).
* Возвращает null, если ключа нет или за ним не строка.
*/
function findRosStringField ( raw : Buffer , marker : Buffer ) : number | null {
let from = 0
for ( ; ; ) {
const at = raw . indexOf ( marker , from )
if ( at < 0 ) return null
let i = at + marker . length
while ( i < raw . length && isRosJsonSpace ( raw [ i ] ) ) i += 1
if ( raw [ i ] === 0x22 ) return i + 1
from = at + marker . length
}
}
function isRosJsonSpace ( byte : number ) : boolean {
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d
}
export function firewallRestPath (
family : FirewallFamily ,
table : FirewallTable | "address-list" ,
@@ -625,13 +722,27 @@ export class MikrotikClient {
return raw . filter ( ( row ) : row is Record < string , string | undefined > = > row != null && typeof row === "object" )
}
async listFiles ( ) : Promise < Array < { name : string } > > {
async listFiles ( ) : Promise < Array < { id : string ; name : string ; size : number } > > {
const raw = await this . get < unknown > ( "/file" )
if ( ! Array . isArray ( raw ) ) return [ ]
return raw
. filter ( ( row ) : row is Record < string , unknown > = > row != null && typeof row === "object" )
. map ( ( row ) = > ( { name : String ( row . name ? ? "" ) } ) )
. filter ( ( row ) = > row . name . length > 0 )
. map ( ( row ) = > ( {
id : String ( row [ ".id" ] ? ? "" ) ,
name : String ( row . name ? ? "" ) ,
size : Number ( row . size ? ? 0 ) ,
} ) )
. filter ( ( row ) = > row . name . length > 0 && row . id . length > 0 )
}
/** Поиск файла в `/file` по полному имени, базовому имени или `flash/<base>`. */
private async findFileEntry ( name : string ) : Promise < { id : string ; name : string ; size : number } | undefined > {
const base = routerFileBasename ( name )
const files = await this . listFiles ( )
return files . find ( ( file ) = > file . name === name )
? ? files . find ( ( file ) = > file . name === base )
? ? files . find ( ( file ) = > file . name === ` flash/ ${ base } ` )
? ? files . find ( ( file ) = > routerFileBasename ( file . name ) === base )
}
private async resolveUploadedFileName ( requested : string ) : Promise < string > {
@@ -705,21 +816,76 @@ export class MikrotikClient {
}
}
/** Скачивание содержимого файла RouterOS (GET /rest/file/<name>, бинарно). */
async downloadFile ( fileName : string , timeoutMs = 30 _000 ) : Promise < Buffer > {
const normalized = routerFileBasename ( fileName )
const candidates = [ normalized , ` flash/ ${ normalized } ` ]
let lastError : unknown
for ( const name of candidates ) {
try {
return await rosDownload ( this . params , ` /file/ ${ encodeURIComponent ( name ) } ` , timeoutMs )
} catch ( error ) {
lastError = error
/**
* Чтение содержимого файла RouterOS. Путь `GET /file/<name>` не существует — RouterOS
* отвечает `no such command prefix`; используются команды `get`/`print`.
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
*/
private async readFileContents (
target : { id? : string ; name? : string } ,
timeoutMs = 30 _000 ,
) : Promise < Buffer > {
const readOnce = async ( id? : string , name? : string ) : Promise < Buffer > = > {
const strategies : Array < { path : string ; body : Record < string , string | string [ ] > } > = [ ]
if ( id ) {
strategies . push ( { path : "/file/get" , body : { ".id" : id , "value-name" : "contents" } } )
strategies . push ( { path : "/file/get" , body : { ".id" : id , ".proplist" : "contents" } } )
}
if ( name ) {
strategies . push ( { path : "/file/print" , body : { ".proplist" : "contents" , ".query" : [ ` name= ${ name } ` ] } } )
}
if ( strategies . length === 0 ) throw new Error ( "не задан файл для чтения" )
let lastError : unknown
for ( const strategy of strategies ) {
try {
const raw = await rosRawRequest ( this . params , {
method : "POST" ,
path : strategy.path ,
body : strategy.body ,
timeoutMs ,
} )
const content = extractRosContentsField ( raw )
if ( content . length > 0 ) return content
lastError = new Error ( "RouterOS вернул пустое содержимое файла" )
} catch ( error ) {
lastError = error
}
}
throw lastError instanceof Error ? lastError : new Error ( "не удалось прочитать содержимое файла" )
}
throw lastError instanceof Error
? lastError
: new Error ( ` Не удалось скачать файл ${ normalized } с RouterOS ` )
try {
return await readOnce ( target . id , target . name )
} catch ( error ) {
// `.id` мог устареть (файл пересоздан) — пробуем один раз найти его заново по имени.
if ( ! target . name ) throw error
const fresh = await this . findFileEntry ( target . name )
if ( ! fresh || fresh . id === target . id ) throw error
return readOnce ( fresh . id , fresh . name )
}
}
/** Скачивание содержимого файла RouterOS: `/file/get` + побайтовый разбор (лимит 60 КБ). */
async downloadFile (
target : { name : string ; id? : string ; size? : number } ,
timeoutMs = 30 _000 ,
) : Promise < Buffer > {
let entry = target . id
? { id : target.id , name : target.name , size : target.size ? ? 0 }
: await this . findFileEntry ( target . name )
if ( ! entry ) throw new Error ( ` Файл ${ routerFileBasename ( target . name ) } не найден на RouterOS ` )
assertRosFileReadable ( entry )
return this . readFileContents ( { id : entry.id , name : entry.name } , timeoutMs )
}
/** Удаление файла RouterOS по `.id` или имени (для временных артефактов экспорта). */
async removeFile ( idOrName : string , timeoutMs = 10 _000 ) : Promise < void > {
const id = idOrName . startsWith ( "*" )
? idOrName
: ( await this . findFileEntry ( idOrName ) ) ? . id
if ( ! id ) return
await this . delete ( ` /file/ ${ encodeURIComponent ( id ) } ` , timeoutMs )
}
/**
@@ -771,12 +937,12 @@ export class MikrotikClient {
}
}
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает имя созданного файла . */
/** Экспорт сертификата в файл на роутере (pkcs12/pem); возвращает созданный файл. */
async exportCertificate ( params : {
name : string
type : "pkcs12" | "pem"
passphrase? : string
} , timeoutMs = 60 _000 ) : Promise < string > {
} , timeoutMs = 60 _000 ) : Promise < { fileName : string ; fileId : string ; size : number } > {
const body : Record < string , string > = { name : params.name , type : params . type }
if ( params . passphrase ? . trim ( ) ) body [ "export-passphrase" ] = params . passphrase . trim ( )
let raw : unknown
@@ -796,29 +962,35 @@ export class MikrotikClient {
const hit = files . find ( ( f ) = > wanted . includes ( f . name ) )
? ? files . find ( ( f ) = > f . name . endsWith ( ` . ${ ext } ` ) && f . name . includes ( params . name ) )
if ( ! hit ) throw new Error ( ` Файл экспорта ${ params . name } . ${ ext } не найден на RouterOS ` )
return hit . name
return { fileName : hit.name , fileId : hit.id , size : hit.size }
}
/** Скачивание .p12 (сертификат + ключ + цепочка) как бинарный Buffer. */
async exportCertificatePkcs12 ( params : { name : string ; passphrase? : string } ) : Promise < { fileName : string ; content : Buffer ; passphrase : string } > {
let passphrase = params . passphrase ? . trim ( ) || generateExportPassphrase ( )
let fileName : string
let exported : { fileName : string ; fileId : string ; size : number }
try {
fileName = await this . exportCertificate ( { name : params.name , type : "pkcs12" , passphrase } )
exported = await this . exportCertificate ( { name : params.name , type : "pkcs12" , passphrase } )
} catch ( error ) {
if ( ! isPassphraseTooShortError ( error ) ) throw error
// RouterOS мог отклонить пароль пользователя — повторяем со сгенерированным и отдаём его в бандл.
const forced = generateExportPassphrase ( )
try {
fileName = await this . exportCertificate ( { name : params.name , type : "pkcs12" , passphrase : forced } )
exported = await this . exportCertificate ( { name : params.name , type : "pkcs12" , passphrase : forced } )
passphrase = forced
} catch ( retryError ) {
if ( isPassphraseTooShortError ( retryError ) ) throw new Error ( EXPORT_PASSPHRASE_HINT )
throw retryError
}
}
const content = await this . downloadFile ( fileName )
return { fileName , content , passphrase }
const content = await this . downloadFile ( {
name : exported.fileName ,
id : exported.fileId ,
size : exported.size ,
} )
// Временный файл экспорта на роутере больше не нужен (best-effort, не влияет на результат).
await this . removeFile ( exported . fileId ) . catch ( ( ) = > undefined )
return { fileName : exported.fileName , content , passphrase }
}
async removeCertificate ( nameOrId : string , timeoutMs = 30 _000 ) : Promise < void > {