@@ -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 > ; 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,74 @@ function matchesUploadedFile(entryName: string, requested: string): boolean {
|| entryName . endsWith ( ` / ${ base } ` )
}
/** Лимит команды `/file/get`: RouterOS отдаёт содержимое файлов не больше 60 KB. */
const MAX_FILE_GET_BYTES = 60 * 1024
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 REST отдаёт содержимое файла в JSON-подобной обёртке, но строку — в single-byte
* кодировке и экранирует лишь часть символов, из-за чего `JSON.parse` падает
* (см. https://forum.mikrotik.com/t/bug-rest-endpoint-producing-invalid-json/177486).
* Поле `contents` извлекаем напрямую из сырых байтов, без парсинга всего ответа.
* @see https://help.mikrotik.com/docs/spaces/ROS/pages/2555971/Files
*/
export function extractRosContentsField ( raw : Buffer ) : Buffer {
const marker = raw . indexOf ( CONTENTS_MARKER )
if ( marker < 0 ) throw new Error ( "RouterOS: ответ не содержит поле contents" )
let i = marker + CONTENTS_MARKER . length
while ( i < raw . length && isRosJsonSpace ( raw [ i ] ) ) i += 1
if ( raw [ i ] !== 0x22 ) throw new Error ( "RouterOS: поле contents не является строкой" )
i += 1
const out : number [ ] = [ ]
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 ( "RouterOS: некорректный \\u-escape в contents" )
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 ( "RouterOS: строка contents не закрыта" )
}
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 +694,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 +788,48 @@ 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
}
}
throw lastError instanceof Error
? lastError
: new Error ( ` Не удалось скачать файл ${ normalized } с RouterOS ` )
/**
* Чтение содержимого файла RouterOS командой `/file/get` (REST: POST).
* Путь `GET /file/<name>` не существует — RouterOS отвечает `no such command prefix`.
* @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 body : Record < string , string > = { ".proplist" : "contents" }
if ( target . id ) body [ ".id" ] = target . id
else if ( target . name ) body . name = target . name
else throw new Error ( "RouterOS: не задан файл для чтения" )
const raw = await rosRawRequest ( this . params , {
method : "POST" ,
path : "/file/get" ,
body ,
timeoutMs ,
} )
return extractRosContentsField ( raw )
}
/** Скачивание содержимого файла 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 +881,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 +906,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 > {