diff --git a/.codeclimate.yml b/.codeclimate.yml index dfe3bb6..c889eb8 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,10 +1,10 @@ engines: eslint: enabled: true - channel: "eslint-6" + channel: 'eslint-8' config: - config: ".eslintrc.yaml" + config: '.eslintrc.yaml' ratings: - paths: - - "**.js" + paths: + - '**.js' diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 6e9dc9e..035a400 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -2,23 +2,6 @@ env: node: true es6: true mocha: true + es2022: true -plugins: - - haraka - -extends: - - eslint:recommended - - plugin:haraka/recommended - -rules: - indent: [2, 2, {"SwitchCase": 1}] - -root: true - -globals: - OK: true - CONT: true - DENY: true - DENYSOFT: true - DENYDISCONNECT: true - DENYSOFTDISCONNECT: true +extends: ['@haraka'] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a3b4db0..5ccd7ed 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,12 +1,13 @@ -Fixes # - Changes proposed in this pull request: -- -- + +- +- + +Fixes # Checklist: + - [ ] docs updated - [ ] tests updated - [ ] Changes.md updated - [ ] package.json.version bumped -- [ ] published to NPM (will be done by @core) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..df04b68 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'monthly' + allow: + - dependency-type: production diff --git a/.github/workflows/ci-test-win.yml b/.github/workflows/ci-test-win.yml deleted file mode 100644 index d27a59a..0000000 --- a/.github/workflows/ci-test-win.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Plugin Tests - Windows - -on: [ push, pull_request ] - -jobs: - - ci-test-win: - - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ windows-latest ] - node-version: [10.x, 12.x, 14.x] - fail-fast: false - - steps: - - uses: actions/checkout@v1 - name: Checkout Plugin - with: - fetch-depth: 1 - - - uses: actions/setup-node@v1 - name: Use Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - name: npm install and test - run: | - npm install - npm run test - - env: - CI: true diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml deleted file mode 100644 index 49f1ec2..0000000 --- a/.github/workflows/ci-test.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Plugin Tests - -on: [ push, pull_request ] - -jobs: - - ci-test: - - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ ubuntu-latest ] - node-version: [10.x, 12.x, 14.x] - fail-fast: false - - steps: - - uses: actions/checkout@v1 - name: Checkout Plugin - with: - fetch-depth: 1 - - - uses: actions/setup-node@v1 - name: Use Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - name: npm install - run: npm install - - - name: Run test suite - run: npm run test - - env: - CI: true - - # services: - # redis: - # image: redis - # ports: - # - 6379/tcp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d01042 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: [push, pull_request] + +env: + CI: true + +jobs: + lint: + uses: haraka/.github/.github/workflows/lint.yml@master + + # coverage: + # uses: haraka/.github/.github/workflows/coverage.yml@master + # secrets: inherit + + ubuntu: + needs: [lint] + uses: haraka/.github/.github/workflows/ubuntu.yml@master + + windows: + needs: [lint] + uses: haraka/.github/.github/workflows/windows.yml@master diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..816e8c3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,13 @@ +name: 'CodeQL' + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: '18 7 * * 4' + +jobs: + codeql: + uses: haraka/.github/.github/workflows/codeql.yml@master diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml deleted file mode 100644 index 896f9c5..0000000 --- a/.github/workflows/coveralls.yml +++ /dev/null @@ -1,32 +0,0 @@ -on: [ pull_request ] - -name: Test Coverage - -jobs: - - coverage: - name: Codecov - runs-on: ubuntu-latest - - steps: - - - uses: actions/checkout@master - name: Checkout Plugin - with: - fetch-depth: 1 - - - name: Use Node.js 10 - uses: actions/setup-node@master - with: - node-version: 10.x - - - name: install, run - run: | - npm install - npm install --no-save nyc codecov - npm run cover - - - name: Coveralls - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.github_token }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 9f816d8..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Lint - -on: [ push, pull_request ] - -jobs: - - lint: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [ 12.x ] - - steps: - - uses: actions/checkout@v1 - name: Checkout Plugin - with: - fetch-depth: 1 - - - uses: actions/setup-node@v1 - name: Use Node.js ${{ matrix.node-version }} - with: - node-version: ${{ matrix.node-version }} - - - name: npm install - run: npm install - - - name: Lint using eslint - run: npm run lint - - env: - CI: true \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e81c15f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,16 @@ +name: publish + +on: + push: + branches: + - master + paths: + - package.json + +env: + CI: true + +jobs: + publish: + uses: haraka/.github/.github/workflows/publish.yml@master + secrets: inherit diff --git a/.gitignore b/.gitignore index f8faa6b..625981f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ jspm_packages .node_repl_history package-lock.json +bower_components +# Optional npm cache directory +.npmrc +.idea +.DS_Store +haraka-update.sh \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a8e94cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".release"] + path = .release + url = git@github.com:msimerson/.release.git diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..8ded5e0 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1,2 @@ +singleQuote: true +semi: false diff --git a/.release b/.release new file mode 160000 index 0000000..7cd5707 --- /dev/null +++ b/.release @@ -0,0 +1 @@ +Subproject commit 7cd5707f7d69f8d4dca1ec407ada911890e59d0a diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2fbd57a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +### Unreleased + +### 1.1.0 - 2024-05-07 + +- initial release + +[1.1.0]: https://github.com/haraka/haraka-plugin-template/releases/tag/1.1.0 diff --git a/Changes.md b/Changes.md deleted file mode 100644 index fe2c02d..0000000 --- a/Changes.md +++ /dev/null @@ -1,4 +0,0 @@ - -# 1.0.0 - 201_-__-__ - -- initial release diff --git a/README.md b/README.md index c8047a7..a054456 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,57 @@ -[![Unix Build Status][ci-img]][ci-url] -[![Windows Build Status][ci-win-img]][ci-win-url] +[![CI Test Status][ci-img]][ci-url] [![Code Climate][clim-img]][clim-url] -[![NPM][npm-img]][npm-url] - -# haraka-plugin-template - -Clone me, to create a new plugin! -# Template Instructions - -These instructions will not self-destruct after use. Use and destroy. +[![NPM][npm-img]][npm-url] -See also, [How to Write a Plugin](https://github.com/haraka/Haraka/wiki/Write-a-Plugin) and [Plugins.md](https://github.com/haraka/Haraka/blob/master/docs/Plugins.md) for additional plugin writing information. +# haraka-plugin-avg -## Create a new repo for your plugin +## avg - Anti-Virus scanner -Haraka plugins are named like `haraka-plugin-something`. All the namespace after `haraka-plugin-` is yours for the taking. Please check the [Plugins](https://github.com/haraka/Haraka/blob/master/Plugins.md) page and a Google search to see what plugins already exist. +Implement virus scanning with AVG's TCPD daemon, available for Linux/FreeBSD. AVG linux is [free for personal or commercial use](http://www.avg.com/gb-en/faq.pnuid-faq_v3_linux) and can be downloaded from [free.avg.com](http://free.avg.com/gb-en/download.prd-alf). -Once you've settled on a name, create the GitHub repo. On the repo's main page, click the _Clone or download_ button and copy the URL. Then paste that URL into a local ENV variable with a command like this: +Messages that AVG detects as infected are rejected. Errors will cause the plugin to return temporary failures unless the defer options are changed (see below). -```sh -export MY_GITHUB_ORG=haraka -export MY_PLUGIN_NAME=haraka-plugin-SOMETHING -``` +## Configuration -Clone and rename the template repo: +The following options can be set in avg.ini: -```sh -git clone git@github.com:haraka/haraka-plugin-template.git -mv haraka-plugin-template $MY_PLUGIN_NAME -cd $MY_PLUGIN_NAME -git remote rm origin -git remote add origin "git@github.com:$MY_GITHUB_ORG/$MY_PLUGIN_NAME.git" -``` +- port (default: 54322) -Now you'll have a local git repo to begin authoring your plugin + TCP port to communicate with the AVG TCPD on. -## rename boilerplate +- tmpdir (default: /tmp) -Replaces all uses of the word `template` with your plugin's name. + AVG TCPD requires that the message be written to disk and scanned. This setting configures where any temporary files are written to. After scanning, the temporary files are automatically removed. -./redress.sh [something] +- connect_timeout (default: 10) -You'll then be prompted to update package.json and then force push this repo onto the GitHub repo you've created earlier. + Maximum seconds to wait for the socket to connect. Connections taking longer will cause a temporary failure to be sent to the remote MTA. +- session_timeout -# Add your content here + Maximum number of seconds to wait for a reply to a command before failing. A timeout will cause a temporary failure to be sent to the remote MTA. -## INSTALL +- [defer] -```sh -cd /path/to/local/haraka -npm install haraka-plugin-template -echo "template" >> config/plugins -service haraka restart -``` +By default, this plugin defers when errors or timeouts are encountered. To +fail open (let messages pass when errors are enounctered), set the error +and/or timeout values to false. -### Configuration + [defer] + error=true + timeout=true -If the default configuration is not sufficient, copy the config file from the distribution into your haraka config dir and then modify it: +cp node_modules/haraka-plugin-avg/config/avg.ini config/avg.ini +$EDITOR config/avg.ini -```sh -cp node_modules/haraka-plugin-template/config/template.ini config/template.ini -$EDITOR config/template.ini -``` ## USAGE -[ci-img]: https://github.com/haraka/haraka-plugin-template/workflows/Plugin%20Tests/badge.svg -[ci-url]: https://github.com/haraka/haraka-plugin-template/actions?query=workflow%3A%22Plugin+Tests%22 -[ci-win-img]: https://github.com/haraka/haraka-plugin-template/workflows/Plugin%20Tests%20-%20Windows/badge.svg -[ci-win-url]: https://github.com/haraka/haraka-plugin-template/actions?query=workflow%3A%22Plugin+Tests+-+Windows%22 -[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-template/badges/gpa.svg -[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-template -[npm-img]: https://nodei.co/npm/haraka-plugin-template.png -[npm-url]: https://www.npmjs.com/package/haraka-plugin-template +[ci-img]: https://github.com/haraka/haraka-plugin-avg/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-avg/actions/workflows/ci.yml +[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-avg/badges/gpa.svg +[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-avg +[npm-img]: https://nodei.co/npm/haraka-plugin-avg.png +[npm-url]: https://www.npmjs.com/package/haraka-plugin-avg diff --git a/config/avg.ini b/config/avg.ini new file mode 100644 index 0000000..e7b682d --- /dev/null +++ b/config/avg.ini @@ -0,0 +1,6 @@ +[main] +;host= +;port=54322 +;tmpdir=/tmp +;connect_timeout=10 +;session_timeout=30 diff --git a/config/template.ini b/config/template.ini deleted file mode 100644 index 2a92888..0000000 --- a/config/template.ini +++ /dev/null @@ -1,2 +0,0 @@ - -[main] diff --git a/index.js b/index.js index 7c1b269..5f7cb3d 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,161 @@ +// avg - AVG virus scanner 'use strict' +const fs = require('node:fs') +const net = require('node:net') +const path = require('node:path') + +const net_utils = require('haraka-net-utils') + +const smtp_regexp = /^(\d{3})([ -])(.*)/ + exports.register = function () { - this.load_template_ini() + this.load_avg_ini() +} + +exports.load_avg_ini = function () { + this.cfg = this.config.get( + 'avg.ini', + { + booleans: ['+defer.timeout', '+defer.error'], + }, + () => { + this.load_avg_ini() + }, + ) } -exports.load_template_ini = function () { +exports.get_tmp_file = function (transaction) { + const tmpdir = this.cfg.main.tmpdir || '/tmp' + return path.join(tmpdir, `${transaction.uuid}.tmp`) +} + +exports.hook_data_post = function (next, connection) { + if (!connection?.transaction) return next() + const plugin = this + const tmpfile = plugin.get_tmp_file(connection.transaction) + const ws = fs.createWriteStream(tmpfile) - plugin.cfg = plugin.config.get('template.ini', { - booleans: [ - '+enabled', // plugin.cfg.main.enabled=true - '-disabled', // plugin.cfg.main.disabled=false - '+feature_section.yes' // plugin.cfg.feature_section.yes=true - ] - }, - function () { - plugin.load_example_ini() + ws.once('error', (err) => { + connection.results.add(plugin, { + err: `Error writing temporary file: ${err.message}`, + }) + if (!plugin.cfg.defer.error) return next() + return next(DENYSOFT, 'Virus scanner error (AVG)') }) + + ws.once('close', () => { + const start_time = Date.now() + const socket = new net.Socket() + net_utils.add_line_processor(socket) + socket.setTimeout((plugin.cfg.main.connect_timeout || 10) * 1000) + let connected = false + let command = 'connect' + let response = [] + + function do_next(code, msg) { + fs.unlink(tmpfile, () => {}) + return next(code, msg) + } + + socket.send_command = function (cmd, data) { + const line = cmd + (data ? ` ${data}` : '') + connection.logprotocol(plugin, `> ${line}`) + this.write(`${line}\r\n`) + command = cmd.toLowerCase() + response = [] + } + + socket.on('timeout', () => { + const msg = `${connected ? 'connection' : 'session'} timed out` + connection.results.add(plugin, { err: msg }) + if (!plugin.cfg.defer.timeout) return do_next() + return do_next(DENYSOFT, 'Virus scanner timeout (AVG)') + }) + + socket.on('error', (err) => { + connection.results.add(plugin, { err: err.message }) + if (!plugin.cfg.defer.error) return do_next() + return do_next(DENYSOFT, 'Virus scanner error (AVG)') + }) + + socket.on('connect', function () { + connected = true + this.setTimeout((plugin.cfg.main.session_timeout || 30) * 1000) + }) + + socket.on('line', (line) => { + const matches = smtp_regexp.exec(line) + connection.logprotocol(plugin, `< ${line}`) + if (!matches) { + connection.results.add(plugin, { + err: `Unrecognized response: ${line}`, + }) + socket.end() + if (!plugin.cfg.defer.error) return do_next() + return do_next(DENYSOFT, 'Virus scanner error (AVG)') + } + + const code = matches[1] + const cont = matches[2] + const rest = matches[3] + response.push(rest) + if (cont !== ' ') return + + switch (command) { + case 'connect': + if (code !== '220') { + // Error + connection.results.add(plugin, { + err: `Unrecognized response: ${line}`, + }) + if (!plugin.cfg.defer.timeout) return do_next() + return do_next(DENYSOFT, 'Virus scanner error (AVG)') + } else { + socket.send_command('SCAN', tmpfile) + } + break + case 'scan': { + const elapsed = Date.now() - start_time + connection.loginfo(plugin, { + time: `${elapsed}ms`, + code, + response: `"${response.join(' ')}"`, + }) + // Check code + switch (code) { + case '200': // 200 ok + // Message did not contain a virus + connection.results.add(plugin, { pass: 'clean' }) + socket.send_command('QUIT') + return do_next() + case '403': + // File 'eicar.com', 'Virus identified EICAR_Test' + connection.results.add(plugin, { + fail: response.join(' '), + }) + socket.send_command('QUIT') + return do_next(DENY, response.join(' ')) + default: + // Any other result is an error + connection.results.add(plugin, { + err: `Bad response: ${response.join(' ')}`, + }) + } + socket.send_command('QUIT') + if (!plugin.cfg.defer.error) return do_next() + return do_next(DENYSOFT, 'Virus scanner error (AVG)') + } + case 'quit': + socket.end() + break + default: + throw new Error(`Unknown command: ${command}`) + } + }) + socket.connect(plugin.cfg.main.port || 54322, plugin.cfg.main.host) + }) + + connection.transaction.message_stream.pipe(ws) } diff --git a/package.json b/package.json index 123dc72..3c5a1be 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,43 @@ { - "name": "haraka-plugin-template", - "version": "1.0.0", - "description": "Haraka plugin that frobnicates email connections", + "name": "haraka-plugin-avg", + "version": "1.1.0", + "description": "Haraka plugin for the AVG virus scanner", "main": "index.js", + "files": [ + "CHANGELOG.md", + "config" + ], "scripts": { - "lint": "npx eslint *.js test/*.js", - "lintfix": "npx eslint --fix *.js test/*.js", - "cover": "NODE_ENV=cov npx nyc --reporter=lcovonly npm run test", + "format": "npm run prettier:fix && npm run lint:fix", + "lint": "npx eslint@^8 *.js test", + "lint:fix": "npx eslint@^8 *.js test --fix", + "prettier": "npx prettier . --check", + "prettier:fix": "npx prettier . --write --log-level=warn", + "test": "node --test", "versions": "npx dependency-version-checker check", - "test": "npx mocha" + "versions:fix": "npx dependency-version-checker update" }, "repository": { "type": "git", - "url": "git+https://github.com/haraka/haraka-plugin-template.git" + "url": "git+https://github.com/haraka/haraka-plugin-avg.git" }, "keywords": [ "haraka", "plugin", - "template" + "avg" ], - "author": "Welcome Member ", + "author": "Welcome Member ", "license": "MIT", "bugs": { - "url": "https://github.com/haraka/haraka-plugin-template/issues" + "url": "https://github.com/haraka/haraka-plugin-avg/issues" }, - "homepage": "https://github.com/haraka/haraka-plugin-template#readme", + "homepage": "https://github.com/haraka/haraka-plugin-avg#readme", "devDependencies": { - "eslint": "*", - "eslint-plugin-haraka": "*", - "haraka-test-fixtures": "*", - "mocha": "*" + "@haraka/eslint-config": "1.1.3", + "haraka-test-fixtures": "1.3.5" + }, + "dependencies": { + "haraka-config": "^1.3.0", + "haraka-net-utils": "^1.7.0" } } diff --git a/redress.sh b/redress.sh deleted file mode 100755 index ca9ff96..0000000 --- a/redress.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh - -if [ -z "$1" ]; then - echo "$0 something" - exit -fi - -sed -i '' -e "s/template/${1}/g" README.md - -sed -i '' \ - -e "s/template/${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - test/index.js - -sed -i '' -e "s/template/${1}/g" package.json -sed -i '' \ - -e "s/_template/_${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - index.js - -git mv config/template.ini "config/$1.ini" -git add package.json README.md index.js test config -git commit -m "renamed template to $1" -npm install -npm run lint && npm test || exit 1 -git rm redress.sh - -echo "success!" -echo "" -echo "Next Steps: update package.json and force push this onto your repo:" -echo "" -echo " \$EDITOR package.json" -echo " git push --set-upstream origin master -f" -echo "" diff --git a/test/index.js b/test/index.js index 3c98977..9ce6ec5 100644 --- a/test/index.js +++ b/test/index.js @@ -1,36 +1,35 @@ - -// node.js built-in modules -const assert = require('assert') +const assert = require('node:assert/strict') +const { beforeEach, describe, it } = require('node:test') // npm modules const fixtures = require('haraka-test-fixtures') -// start of tests -// assert: https://nodejs.org/api/assert.html -// mocha: http://mochajs.org - -beforeEach(function (done) { - this.plugin = new fixtures.plugin('template') - done() // if a test hangs, assure you called done() +beforeEach(function () { + this.plugin = new fixtures.plugin('avg') }) -describe('template', function () { - it('loads', function (done) { +describe('avg', function () { + it('loads', function () { assert.ok(this.plugin) - done() }) }) -describe('load_template_ini', function () { - it('loads template.ini from config/template.ini', function (done) { - this.plugin.load_template_ini() +describe('load_avg_ini', function () { + it('loads avg.ini from config/avg.ini', function () { + this.plugin.load_avg_ini() assert.ok(this.plugin.cfg) - done() + }) +}) + +describe('uses text fixtures', function () { + it('sets up a connection', function () { + this.connection = fixtures.connection.createConnection({}) + assert.ok(this.connection.server) }) - it('initializes enabled boolean', function (done) { - this.plugin.load_template_ini() - assert.equal(this.plugin.cfg.main.enabled, true, this.plugin.cfg) - done() + it('sets up a transaction', function () { + this.connection = fixtures.connection.createConnection({}) + this.connection.transaction = fixtures.transaction.createTransaction({}) + assert.ok(this.connection.transaction.header) }) })