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 { export class Field {
displayName = null; displayName = null;
description = null; description = null;
categoryName = null;
isHidden = false;
constructor(key) { constructor(key) {
this.key = key; this.key = key;
this.id = "field-" + key;
} }
// Virtual methods // Virtual methods
@ -31,4 +34,14 @@ export class Field {
this.description = description; this.description = description;
return this; 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), .pos(0),
new DomainMechanism("include") new DomainMechanism("include")
.label("Include")
.desc("Also apply the SPF records from these domains")
.multiple() .multiple()
.pos(1), .pos(1),
new DomainMechanism("a") new DomainMechanism("a")
.label("Domains")
.desc("Select the IP address from these domains")
.value(ValueRequirement.OPTIONAL)
.multiple() .multiple()
.pos(1), .pos(1),
new DomainMechanism("mx") 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() .multiple()
.pos(2), .pos(2),
new DomainMechanism("ptr") new DomainMechanism("ptr")
.hidden()
.value(ValueRequirement.OPTIONAL)
.multiple() .multiple()
.pos(2), .pos(2),
new IPv4Mechanism("ipv4") new IPv4Mechanism("ipv4")
.label("IPv4 addresses")
.desc("Select these IP addresses")
.multiple() .multiple()
.pos(2), .pos(2),
new IPv6Mechanism("ipv6") new IPv6Mechanism("ipv6")
.label("IPv6 addresses")
.desc("Select these IP addresses")
.multiple() .multiple()
.pos(2), .pos(2),
new DomainMechanism("exists") new DomainMechanism("exists")
.label("Exists")
.desc("Apply only if this domain exists (can be used with macro expansions)")
.category("advanced")
.multiple() .multiple()
.pos(2), .pos(2),
new Mechanism("all") new Mechanism("all")
.label("All others")
.desc("How to treat the rest of the IP addresses")
.value(ValueRequirement.PROHIBITED) .value(ValueRequirement.PROHIBITED)
.pos(3), .pos(3),
new Modifier("redirect") new Modifier("redirect")
.label("Redirect")
.desc("Redirect to the SPF record of this domain if no IP addresses matched")
.category("advanced")
.pos(4), .pos(4),
new Modifier("exp") 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), .pos(4),
]; ];
static categories = {
"advanced": "Advanced",
};
constructor(text) { constructor(text) {
this.text = text; this.text = text;
} }
@ -121,4 +150,17 @@ export class SpfRecord {
lastPos = term.position; 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; return true;
} }
fieldsToString() { static fieldsToString() {
let tokens = this.constructor.fields const tokens = this.fields
.filter(field => !field.isHidden)
.filter(field => !field.defaultValue || field.getInputValue() !== field.defaultValue) .filter(field => !field.defaultValue || field.getInputValue() !== field.defaultValue)
.filter(field => field.getInputValue()) .filter(field => field.getInputValue())
.map(field => field.key + "=" + field.getInputValue()); .map(field => field.key + "=" + field.getInputValue());
return tokens.join("; "); return tokens.join("; ");
} }
} }

View File

@ -1,20 +1,16 @@
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
import { Mechanism } from "./Mechanism.js"; import { Mechanism } from "./Mechanism.js";
import { validateSpfDomain } from "./utils.js";
export class DomainMechanism extends Mechanism { export class DomainMechanism extends Mechanism {
separator = ":"; placeholder = "example.com";
constructor(key) { constructor(key) {
super(key); super(key);
} }
validate(value) { validate(value) {
// https://www.rfc-editor.org/rfc/rfc7208#section-7.1 if (!validateSpfDomain(value)) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`);
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`);
return true; return true;
} }

View File

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

View File

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

View File

@ -1,9 +1,36 @@
import { Term } from "./Term.js"; import { Term } from "./Term.js";
import { ValueRequirement } from "./ValueRequirement.js";
export class Mechanism extends Term { export class Mechanism extends Term {
separator = ":"; separator = ":";
placeholder = null;
constructor(key) { constructor(key) {
super(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 { Term } from "./Term.js";
import { ValidationError } from "../ValidationError.js"; import { ValidationError } from "../ValidationError.js";
import { validateSpfDomain } from "./utils.js";
export class Modifier extends Term { export class Modifier extends Term {
separator = "="; separator = "=";
@ -8,8 +9,17 @@ export class Modifier extends Term {
super(key); super(key);
} }
validate() { validate(value) {
// TODO validate if (!validateSpfDomain(value)) throw new ValidationError(`Value for "${this.key}" is not a valid domain name`);
return true; 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) super(key)
} }
// Virtual methods
getInputQualifier() {
return "";
}
// Builder methods // Builder methods
required() { required() {

View File

@ -14,4 +14,8 @@ export class VersionTerm extends Term {
return true; 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 { export class Tag extends Field {
isRequired = false; isRequired = false;
defaultValue = null; defaultValue = null;
categoryName = null;
position = null; position = null;
constructor(key) { constructor(key) {
super(key); super(key);
this.id = "tag-" + key;
} }
// Virtual methods // Virtual methods
@ -30,11 +28,6 @@ export class Tag extends Field {
return this; return this;
} }
category(category) {
this.categoryName = category;
return this;
}
pos(i) { pos(i) {
this.position = i; this.position = i;
return this; 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 = { const records = {
"/dmarc-creator": DmarcRecord, "/dmarc-creator": DmarcRecord,
"/spf-creator": SpfRecord,
}; };
const Record = records[location.pathname]; const Record = records[location.pathname];
@ -21,7 +23,7 @@ for (const category of categories) {
function addFields(elem, fields) { function addFields(elem, fields) {
for (const field of fields) { for (const field of fields) {
if (!field.getInputHtml()) continue; if (field.isHidden || !field.getInputHtml()) continue;
elem.innerHTML += ` elem.innerHTML += `
<label for="${field.key}">${field.displayName}</label> <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(); generate();
function generate() { function generate(event) {
const record = new Record(); document.getElementById("record").value = Record.fieldsToString();
document.getElementById("record").value = record.fieldsToString();
} }
document.getElementById("record").onclick = (e) => { document.getElementById("record").onclick = (e) => {

View File

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

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>DMARC Record Creator - Generate DMARC DNS Records</title> <title>DMARC Record Creator - Generate DMARC DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css"> <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> </head>
<body> <body>
<h1>DMARC Record Creator</h1> <h1>DMARC Record Creator</h1>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>DMARC Record Validator - Validate DMARC DNS Records</title> <title>DMARC Record Validator - Validate DMARC DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css"> <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> </head>
<body> <body>
<h1>DMARC Record Validator</h1> <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"> <meta charset="UTF-8">
<title>SPF Record Validator - Validate SPF DNS Records</title> <title>SPF Record Validator - Validate SPF DNS Records</title>
<link rel="stylesheet" href="/assets/styles/main.css"> <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> </head>
<body> <body>
<h1>SPF Record Validator</h1> <h1>SPF Record Validator</h1>