Implement SPF creator tool

This commit is contained in:
Reimar 2026-01-15 13:41:59 +01:00
parent 00c8a999e7
commit 5b8a3d9266
Signed by: Reimar
GPG Key ID: 93549FA07F0AE268
18 changed files with 191 additions and 32 deletions

View File

@ -5,9 +5,12 @@
export class Field {
displayName = null;
description = null;
categoryName = null;
isHidden = false;
constructor(key) {
this.key = key;
this.id = "field-" + key;
}
// Virtual methods
@ -31,4 +34,14 @@ export class Field {
this.description = description;
return this;
}
category(category) {
this.categoryName = category;
return this;
}
hidden() {
this.isHidden = true;
return this;
}
}

View File

@ -14,44 +14,73 @@ export class SpfRecord {
.pos(0),
new DomainMechanism("include")
.label("Include")
.desc("Also apply the SPF records from these domains")
.multiple()
.pos(1),
new DomainMechanism("a")
.label("Domains")
.desc("Select the IP address from these domains")
.value(ValueRequirement.OPTIONAL)
.multiple()
.pos(1),
new DomainMechanism("mx")
.label("MX Records")
.desc("Select the IP addresses from the MX records of these domains (or current domain if none specified)")
.value(ValueRequirement.OPTIONAL)
.multiple()
.pos(2),
new DomainMechanism("ptr")
.hidden()
.value(ValueRequirement.OPTIONAL)
.multiple()
.pos(2),
new IPv4Mechanism("ipv4")
.label("IPv4 addresses")
.desc("Select these IP addresses")
.multiple()
.pos(2),
new IPv6Mechanism("ipv6")
.label("IPv6 addresses")
.desc("Select these IP addresses")
.multiple()
.pos(2),
new DomainMechanism("exists")
.label("Exists")
.desc("Apply only if this domain exists (can be used with macro expansions)")
.category("advanced")
.multiple()
.pos(2),
new Mechanism("all")
.label("All others")
.desc("How to treat the rest of the IP addresses")
.value(ValueRequirement.PROHIBITED)
.pos(3),
new Modifier("redirect")
.label("Redirect")
.desc("Redirect to the SPF record of this domain if no IP addresses matched")
.category("advanced")
.pos(4),
new Modifier("exp")
.label("Explanation")
.desc("Points to a domain whose TXT record contains an error message if validation fails. Macros can be used here")
.category("advanced")
.pos(4),
];
static categories = {
"advanced": "Advanced",
};
constructor(text) {
this.text = text;
}
@ -121,4 +150,17 @@ export class SpfRecord {
lastPos = term.position;
}
}
static fieldsToString() {
const tokens = this.fields
.filter(field => !field.isHidden)
.filter(field => field.valueRequirement === ValueRequirement.REQUIRED ? field.getInputValue() : field.getInputQualifier())
.map(field =>
field.getInputQualifier()
+ field.key
+ (field.getInputValue() ? field.separator + field.getInputValue() : "")
);
return tokens.join(" ");
}
}

View File

@ -62,11 +62,13 @@ export class TagListRecord {
return true;
}
fieldsToString() {
let tokens = this.constructor.fields
static fieldsToString() {
const tokens = this.fields
.filter(field => !field.isHidden)
.filter(field => !field.defaultValue || field.getInputValue() !== field.defaultValue)
.filter(field => field.getInputValue())
.map(field => field.key + "=" + field.getInputValue());
return tokens.join("; ");
}
}

View File

@ -1,20 +1,16 @@
import { ValidationError } from "../ValidationError.js";
import { Mechanism } from "./Mechanism.js";
import { validateSpfDomain } from "./utils.js";
export class DomainMechanism extends Mechanism {
separator = ":";
placeholder = "example.com";
constructor(key) {
super(key);
}
validate(value) {
// https://www.rfc-editor.org/rfc/rfc7208#section-7.1
const valid = value
.split(".")
.every(segment => segment.match(/^([^%]|%_|%%|%-|%\{[slodiphcrtv]\d*r?[-.+,\/_=]*})+$/));
if (!valid) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`);
if (!validateSpfDomain(value)) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`);
return true;
}

View File

@ -2,7 +2,7 @@ import { ValidationError } from "../ValidationError.js";
import { Mechanism } from "./Mechanism.js";
export class IPv4Mechanism extends Mechanism {
separator = ":";
placeholder = "0.0.0.0";
constructor(key) {
super(key);

View File

@ -1,6 +1,8 @@
import { Mechanism } from "./Mechanism.js";
export class IPv6Mechanism extends Mechanism {
placeholder = "2001:db8::1";
constructor(key) {
super(key);
}

View File

@ -1,9 +1,36 @@
import { Term } from "./Term.js";
import { ValueRequirement } from "./ValueRequirement.js";
export class Mechanism extends Term {
separator = ":";
placeholder = null;
constructor(key) {
super(key);
}
getInputQualifier() {
return document.getElementById(this.id + "-qualifier").value;
}
getInputValue() {
return document.getElementById(this.id + "-value")?.value;
}
getInputHtml() {
const noValue = this.valueRequirement === ValueRequirement.PROHIBITED;
const placeholder = this.placeholder
+ (this.valueRequirement === ValueRequirement.OPTIONAL ? " (Optional)" : "");
return `
<select id="${this.id}-qualifier">
${this.isRequired ? "" : `<option value="">&lt;not set&gt;</option>`}
<option value="+">Pass</option>
<option value="-">Fail</option>
<option value="~">Soft fail</option>
<option value="?">Neutral</option>
</select>
${noValue ? "" : `<input id="${this.id}-value" type="text" placeholder="${placeholder}">`}
`;
}
}

View File

@ -1,5 +1,6 @@
import { Term } from "./Term.js";
import { ValidationError } from "../ValidationError.js";
import { validateSpfDomain } from "./utils.js";
export class Modifier extends Term {
separator = "=";
@ -8,8 +9,17 @@ export class Modifier extends Term {
super(key);
}
validate() {
// TODO validate
validate(value) {
if (!validateSpfDomain(value)) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`);
return true;
}
getInputValue() {
return document.getElementById(this.id).value;
}
getInputHtml() {
return `<input id="${this.id}" type="text" placeholder="example.com">`;
}
}

View File

@ -12,6 +12,12 @@ export class Term extends Field {
super(key)
}
// Virtual methods
getInputQualifier() {
return "";
}
// Builder methods
required() {

View File

@ -14,4 +14,8 @@ export class VersionTerm extends Term {
return true;
}
getInputValue() {
return this.version;
}
}

View File

@ -0,0 +1,6 @@
// https://www.rfc-editor.org/rfc/rfc7208#section-7.1
export function validateSpfDomain(domain) {
return domain
.split(".")
.every(segment => segment.match(/^([^%]|%_|%%|%-|%\{[slodiphcrtv]\d*r?[-.+,\/_=]*})+$/));
}

View File

@ -4,12 +4,10 @@ import { Field } from "../Field.js";
export class Tag extends Field {
isRequired = false;
defaultValue = null;
categoryName = null;
position = null;
constructor(key) {
super(key);
this.id = "tag-" + key;
}
// Virtual methods
@ -30,11 +28,6 @@ export class Tag extends Field {
return this;
}
category(category) {
this.categoryName = category;
return this;
}
pos(i) {
this.position = i;
return this;

View File

@ -1,7 +1,9 @@
import { DmarcRecord } from "./records/DmarcRecord.js";
import { DmarcRecord } from "../records/DmarcRecord.js";
import { SpfRecord } from "../records/SpfRecord.js";
const records = {
"/dmarc-creator": DmarcRecord,
"/spf-creator": SpfRecord,
};
const Record = records[location.pathname];
@ -21,7 +23,7 @@ for (const category of categories) {
function addFields(elem, fields) {
for (const field of fields) {
if (!field.getInputHtml()) continue;
if (field.isHidden || !field.getInputHtml()) continue;
elem.innerHTML += `
<label for="${field.key}">${field.displayName}</label>
@ -31,14 +33,11 @@ function addFields(elem, fields) {
}
}
document.getElementById("form").onchange = () => generate();
document.getElementById("form").onchange = generate;
generate();
function generate() {
const record = new Record();
document.getElementById("record").value = record.fieldsToString();
function generate(event) {
document.getElementById("record").value = Record.fieldsToString();
}
document.getElementById("record").onclick = (e) => {

View File

@ -1,5 +1,5 @@
import { DmarcRecord } from "./records/DmarcRecord.js";
import { SpfRecord } from "./records/SpfRecord.js";
import { DmarcRecord } from "../records/DmarcRecord.js";
import { SpfRecord } from "../records/SpfRecord.js";
const records = {
"/dmarc-validator": DmarcRecord,

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<title>DMARC Record Creator - Generate DMARC DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/creator.js"></script>
<script type="module" src="/assets/scripts/ui/creator.js"></script>
</head>
<body>
<h1>DMARC Record Creator</h1>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<title>DMARC Record Validator - Validate DMARC DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/validator.js"></script>
<script type="module" src="/assets/scripts/ui/validator.js"></script>
</head>
<body>
<h1>DMARC Record Validator</h1>

59
spf-creator/index.html Normal file
View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SPF Record Creator - Generate SPF DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/ui/creator.js"></script>
</head>
<body>
<h1>SPF Record Creator</h1>
<label for="record">DNS Record</label><br>
<input id="record" type="text" readonly>
<main>
<h2>Create an SPF DNS Record</h2>
<p>
Customize the options below to generate an SPF record, which will be shown in the input field above.
The qualifiers that can be used are:
</p>
<p>
<b>Pass</b> (Default): Selected IP addresses are authorized to send emails from this domain<br>
<b>Fail</b>: Selected IP addresses are NOT authorized to send emails from this domain<br>
<b>Soft fail</b>: Selected IP addresses might not be authorized to send emails from this domain (actual behavior differs)<br>
<b>Neutral</b>: Do not explicitly state whether the IP addresses are authorized or not (can be used for overriding other qualifiers)
</p>
<form id="form"></form>
<hr>
<p>
This tool allows you to create SPF DNS records, which are used to authorize emails, and are necessary
to be able to send emails to most providers, such as Gmail, Outlook and Yahoo Mail.
</p>
<p>
It works by specifying which IP addresses are authorized or not authorized to send emails.
You can specify IP addresses from a domain, from your MX records, or explicitly.
When specifying IP addresses, you then say whether these should pass or fail the validation.
</p>
<p>
For advanced usage, domain fields may contain macros. These start with a percentage sign and will expand
to a dynamic value. For example, <b>%{d}</b> expands to the current domain and <b>%{i}</b> to the
current IP address. See
<a href="https://www.rfc-editor.org/rfc/rfc7208#section-7.2" target="_blank">the SPF specification</a>
for a list of macros you can use.
</p>
<center>
<h3>More tools:</h3>
<a href="/spf-validator">SPF Validator Tool</a>
</center>
</main>
</body>
</html>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<title>SPF Record Validator - Validate SPF DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css">
<script type="module" src="/assets/scripts/validator.js"></script>
<script type="module" src="/assets/scripts/ui/validator.js"></script>
</head>
<body>
<h1>SPF Record Validator</h1>