diff --git a/package-lock.json b/package-lock.json index 2f0295f..0597d42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,15 @@ "license": "ISC", "dependencies": { "@prisma/client": "^4.15.0", + "@types/netmask": "^2.0.1", "body-parser": "^1.20.2", "bree": "^9.1.3", "dotenv": "^16.1.4", "express": "^4.18.2", "jsonwebtoken": "^9.0.0", "ldapts": "^4.2.6", - "net-snmp": "^3.9.6" + "net-snmp": "^3.9.6", + "netmask": "^2.0.2" }, "devDependencies": { "@types/express": "^4.17.17", @@ -622,6 +624,11 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "node_modules/@types/netmask": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/netmask/-/netmask-2.0.1.tgz", + "integrity": "sha512-SSj+JNRxpaljp9Uy1td4MM6p6hJfmKk3Z/5h5MhJZbX7KI90DNvS8L3aNuevAOaGftaGAXLUrEd161Tk3iwaTg==" + }, "node_modules/@types/node": { "version": "20.3.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz", @@ -1912,6 +1919,14 @@ "smart-buffer": "^4.1.0" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nodemon": { "version": "2.0.22", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", diff --git a/package.json b/package.json index 662068e..d9964f4 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,14 @@ }, "dependencies": { "@prisma/client": "^4.15.0", + "@types/netmask": "^2.0.1", "body-parser": "^1.20.2", "bree": "^9.1.3", "dotenv": "^16.1.4", "express": "^4.18.2", "jsonwebtoken": "^9.0.0", "ldapts": "^4.2.6", - "net-snmp": "^3.9.6" + "net-snmp": "^3.9.6", + "netmask": "^2.0.2" } } diff --git a/prisma/migrations/20230621170219_/migration.sql b/prisma/migrations/20230621170219_/migration.sql new file mode 100644 index 0000000..5448048 --- /dev/null +++ b/prisma/migrations/20230621170219_/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - You are about to drop the column `hostname` on the `Printer` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `PrinterStatus` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Printer" DROP COLUMN "hostname"; + +-- AlterTable +ALTER TABLE "PrinterStatus" DROP COLUMN "createdAt", +ADD COLUMN "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "Network" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "shortName" TEXT NOT NULL, + "cidr" TEXT NOT NULL, + + CONSTRAINT "Network_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Network_name_key" ON "Network"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Network_shortName_key" ON "Network"("shortName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Network_cidr_key" ON "Network"("cidr"); + +-- CreateIndex +CREATE INDEX "Network_id_idx" ON "Network"("id"); + +-- CreateIndex +CREATE INDEX "PrinterStatus_timestamp_idx" ON "PrinterStatus"("timestamp"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef5d5bf..3a7f481 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + campus String? roles Role[] @default([USER]) } @@ -37,7 +38,6 @@ model Printer { location String? serialNumber String? @unique - hostname String? ip String @unique model String @@ -49,7 +49,9 @@ model Printer { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - status PrinterStatus[] + status PrinterStatus[] + network Network @relation(fields: [networkId], references: [id]) + networkId Int } model PrinterStatus { @@ -62,8 +64,21 @@ model PrinterStatus { counter Int - createdAt DateTime @default(now()) + timestamp DateTime @default(now()) printerId Int printer Printer @relation(fields: [printerId], references: [id], onDelete: Cascade) + + @@index([timestamp]) +} + +model Network { + id Int @id @default(autoincrement()) + name String @unique + shortName String @unique + cidr String @unique + + printers Printer[] + + @@index([id]) } diff --git a/prisma/seed.ts b/prisma/seed.ts index de5ef55..6e03565 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,37 +3,119 @@ import { PrismaClient } from '@prisma/client' export const prisma = new PrismaClient() async function main() { + console.log('Seeding printers...') + + console.log('Seeding subnets...') + + await prisma.network.createMany({ + data: [ + { + shortName: 'RT1', + name: 'Reitoria', + cidr: '10.0.0.0/21' + }, + { + shortName: 'RT2', + name: 'Reitoria 2', + cidr: '10.1.0.0/21' + }, + { + shortName: 'AQ', + name: 'Aquidauana', + cidr: '10.2.0.0/21' + }, + { + shortName: 'CG', + name: 'Campo Grande', + cidr: '10.3.0.0/21' + }, + { + shortName: 'CB', + name: 'Corumbá', + cidr: '10.4.0.0/21' + }, + { + shortName: 'CX', + name: 'Coxim', + cidr: '10.5.0.0/21' + }, + { + shortName: 'NA', + name: 'Nova Andradina', + cidr: '10.6.0.0/21' + }, + { + shortName: 'PP', + name: 'Ponta Porã', + cidr: '10.7.0.0/21' + }, + { + shortName: 'TL', + name: 'Três Lagoas', + cidr: '10.8.0.0/21' + }, + { + shortName: 'JD', + name: 'Jardim', + cidr: '10.9.0.0/21' + }, + { + shortName: 'NV', + name: 'Naviraí', + cidr: '10.10.0.0/21' + }, + { + shortName: 'DR', + name: 'Dourados', + cidr: '10.11.0.0/21' + } + ], + skipDuplicates: true + }) + await prisma.printer.createMany({ data: [ { friendlyName: 'P04', ip: '10.7.0.134', model: 'ECOSYS M3655idn', - serialNumber: 'R4P1478461' + serialNumber: 'R4P1478461', + networkId: 8 }, { friendlyName: 'P05', ip: '10.7.0.135', model: 'ECOSYS M2040dn', - serialNumber: 'VR91483974' + serialNumber: 'VR91483974', + networkId: 8 }, { friendlyName: 'P06', ip: '10.7.0.136', model: 'ECOSYS M2040dn', - serialNumber: 'VR91586433' + serialNumber: 'VR91586433', + networkId: 8 }, { friendlyName: 'P07', ip: '10.7.0.137', model: 'ECOSYS M2040dn', - serialNumber: 'VR91586432' + serialNumber: 'VR91586432', + networkId: 8 }, { friendlyName: 'P08', ip: '10.7.0.138', model: 'ECOSYS P6235cdn', - serialNumber: 'RCG0304510' + serialNumber: 'RCG0304510', + networkId: 8 + }, + { + friendlyName: 'PNA', + ip: '10.6.0.32', + model: 'ECOSYS M2040dn', + serialNumber: 'VR91586430', + networkId: 8 } ] }) diff --git a/src/controllers/PrinterController.ts b/src/controllers/PrinterController.ts index aff04f3..f49b85d 100644 --- a/src/controllers/PrinterController.ts +++ b/src/controllers/PrinterController.ts @@ -4,6 +4,8 @@ import { hasRolesMiddleware } from '../middlewares/hasRolesMiddleware.js' import { prisma } from '../prisma.js' import { PrinterStatusService } from '../services/PrinterStatusService.js' import { distributedCopy } from '../utils/distributedCopy.js' +import { Netmask } from 'netmask' +import { isIPv4 } from 'net' const router = Router() @@ -16,22 +18,22 @@ class PrinterController { static async show(req: Request, res: Response) { const { id } = req.params - const { take, minutes = 43200 } = req.query + const { take = 64, days = 60 } = req.query - const gte = new Date(Date.now() - 1000 * 60 * Number(minutes)) + const gte = new Date(Date.now() - 1000 * 60 * 60 * 24 * Number(days)) const printer = await prisma.printer.findUnique({ where: { id: Number(id) }, include: { status: { where: { - createdAt: { + timestamp: { gte } }, orderBy: { - createdAt: 'desc' + timestamp: 'desc' } } } @@ -48,10 +50,24 @@ class PrinterController { static async create(req: Request, res: Response) { const { friendlyName, ip } = req.body + const ipBlock = new Netmask(ip) + + if (!isIPv4(ip)) { + res.status(400).json({ error: 'Invalid IP' }) + return + } + try { const model = await PrinterStatusService.getPrinterModel(ip) const printer = await prisma.printer.create({ - data: { friendlyName, ip, model } + data: { + friendlyName, + ip, + model, + network: { + connect: {} + } + } }) new PrinterStatusService(printer) @@ -60,7 +76,7 @@ class PrinterController { } catch (e) { res .status(400) - .json({ error: 'Este endereço não é de uma impressora suportada.' }) + .json({ error: 'Este IP não é de uma impressora suportada.' }) return } } diff --git a/src/controllers/PrinterDiscoveryController.ts b/src/controllers/PrinterDiscoveryController.ts new file mode 100644 index 0000000..8d45605 --- /dev/null +++ b/src/controllers/PrinterDiscoveryController.ts @@ -0,0 +1,58 @@ +import { Router, Request, Response } from 'express' + +import { hasRolesMiddleware } from '../middlewares/hasRolesMiddleware.js' +import { PrinterDiscoveryService } from '../services/PrinterDiscoveryService.js' +import { prisma } from '../prisma.js' +import { PrinterStatusService } from '../services/PrinterStatusService.js' +import { Printer } from '@prisma/client' + +const router = Router() + +class PrinterDiscoveryController { + static async discovery(req: Request, res: Response) { + const networks = await prisma.network.findMany() + + const newPrinters: Printer[] = [] + const discoveredPrintersIPs: string[] = [] + + for (const network of networks) { + console.log('Discovering printers for network', network.cidr) + + try { + const discoveredPrintersIPsForNetwork = + await PrinterDiscoveryService.discovery(network.cidr) + + discoveredPrintersIPs.push(...discoveredPrintersIPsForNetwork) + } catch (error) { + console.log(error) + } + + const printers = await prisma.printer.findMany() + + const newPrintersIPs = discoveredPrintersIPs.filter( + ip => !printers.find(printer => printer.ip === ip) + ) + + await Promise.allSettled( + newPrintersIPs.map(async ip => { + const model = await PrinterStatusService.getPrinterModel(ip) + const printer = await prisma.printer.create({ + data: { ip, model, networkId: network.id } + }) + + new PrinterStatusService(printer) + + newPrinters.push(printer) + }) + ) + } + + res.json({ discoveredPrintersIPs, newPrinters }) + } +} + +router.use(hasRolesMiddleware(['ADMIN', 'INSPECTOR'])) + +router.post('/', PrinterDiscoveryController.discovery) + +export default router diff --git a/src/server.ts b/src/server.ts index 5d2d25b..41fe7a5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { authMiddleware } from './middlewares/authMiddleware.js' import LoginRouter from './controllers/LoginController.js' import PrinterRouter from './controllers/PrinterController.js' import PrinterStatusRouter from './controllers/PrinterStatusController.js' +import PrinterDiscoveryRouter from './controllers/PrinterDiscoveryController.js' export const app = express() @@ -18,6 +19,7 @@ app.use(populateUserMiddleware) app.use('/api/login', LoginRouter) app.use('/api/printer', PrinterRouter) app.use('/api/printer-status', PrinterStatusRouter) +app.use('/api/discovery', PrinterDiscoveryRouter) app.get('/api/me', authMiddleware, async (req: Request, res: Response) => res.json(res.locals.user) diff --git a/src/services/LdapService.ts b/src/services/LdapService.ts index 72e7cc1..b481b02 100644 --- a/src/services/LdapService.ts +++ b/src/services/LdapService.ts @@ -16,6 +16,7 @@ type LdapUser = { displayName: string thumbnailPhoto: string | null groups?: string[] + campus?: string } export class LdapService extends Client implements LdapClientInterface { @@ -56,7 +57,8 @@ export class LdapService extends Client implements LdapClientInterface { 'sAMAccountName', 'displayName', 'thumbnailPhoto', - 'dn' + 'dn', + 'extensionAttribute1' ], explicitBufferAttributes: ['thumbnailPhoto'] }) @@ -64,8 +66,14 @@ export class LdapService extends Client implements LdapClientInterface { if (!searchEntries.length) throw new Error('User not found on LDAP server.') - const { sAMAccountName, displayName, mail, thumbnailPhoto, dn } = - searchEntries[0] + const { + sAMAccountName, + displayName, + mail, + thumbnailPhoto, + dn, + extensionAttribute1 + } = searchEntries[0] const ldapUser: LdapUser = { username: sAMAccountName.toString(), @@ -74,7 +82,8 @@ export class LdapService extends Client implements LdapClientInterface { thumbnailPhoto: `data:image/png;base64,${Buffer.from( thumbnailPhoto as Buffer ).toString('base64')}`, - groups: await this.getGroupsForUser(dn.toString()) + groups: await this.getGroupsForUser(dn.toString()), + campus: extensionAttribute1?.toString().split('-')[0] || '--' } return ldapUser diff --git a/src/services/PrinterDiscoveryService.ts b/src/services/PrinterDiscoveryService.ts new file mode 100644 index 0000000..8944786 --- /dev/null +++ b/src/services/PrinterDiscoveryService.ts @@ -0,0 +1,68 @@ +import snmp from 'net-snmp' +import netmask from 'netmask' + +export class PrinterDiscoveryService { + private static async isPrinter(ip: string) { + if (ip == '10.7.0.51') return false + return new Promise((resolve, reject) => { + const session = snmp.createSession(ip, 'public', { timeout: 1000 }) + + const CHECK_OID = '1.3.6.1.2.1.1.1.0' + const CHECK_STRING = 'KYOCERA Document Solutions Printing System' + + session.get( + [CHECK_OID], + (error: any, varbinds: any) => { + if (error) { + resolve(false) + } else { + if (varbinds[0].value.toString() === CHECK_STRING) { + resolve(true) + } else { + resolve(false) + } + } + session.close() + }, + { timeout: 1000 } + ) + }) + } + + // Check every IP in the range for a printer and return an array of IPs that are printers + static async discovery(cdir: string): Promise { + const printers: string[] = [] + const blockIPs: string[] = [] + + try { + const block = new netmask.Netmask(cdir) + + const BLACK_LISTED_IPS = ['10.7.1.1', '10.7.0.51'] + + block.forEach(ip => { + if (!BLACK_LISTED_IPS.includes(ip)) blockIPs.push(ip) + }) + } catch (err) { + throw new Error('Invalid IP CIDR') + } + + try { + await Promise.allSettled( + blockIPs.map(async ip => { + try { + if (await PrinterDiscoveryService.isPrinter(ip)) { + printers.push(ip) + console.log(`Found printer at ${ip}!`) + } + } catch (error: any) { + console.log(`Error checking ${ip}: ${error.message}`) + } + }) + ) + } catch (err) { + console.log(err) + } + + return printers + } +} diff --git a/src/services/PrinterStatusService.ts b/src/services/PrinterStatusService.ts index f4e43b1..82cb792 100644 --- a/src/services/PrinterStatusService.ts +++ b/src/services/PrinterStatusService.ts @@ -93,7 +93,7 @@ export class PrinterStatusService { private deBufferizeVarbinds(varbinds: Varbind[]) { const varbindsString: VarbindString[] = [] - varbinds.forEach((varbind: Varbind) => { + varbinds?.forEach((varbind: Varbind) => { if (varbind.value instanceof Buffer) varbindsString.push({ ...varbind, value: varbind.value.toString() }) else varbindsString.push({ ...varbind, value: varbind.value }) @@ -144,7 +144,7 @@ export class PrinterStatusService { if (typeof current === 'undefined' || typeof max === 'undefined') throw new Error('current or max is undefined') - return (+current! / +max!) * 100 + return Math.floor((+current! / +max!) * 100) } private objectIDsToPrinterInfo(varbinds: Varbind[]): PrinterInfo {