Compare commits

...

10 Commits

10 changed files with 718 additions and 1 deletions

View File

@ -8,6 +8,7 @@ name = "pypi"
[packages] [packages]
aiosmtpd = "*" aiosmtpd = "*"
python-jata = "*" python-jata = "*"
asyncbasehttp = "*"
[requires] [requires]
python_version = "3" python_version = "3"

10
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5c16e20a67c73101d465516d657e21c6c1d3f853ae16dcfe782b7f9e8ba139e5" "sha256": "6b224d3d72187a39e4fba357ffb4b162dc28994bd869c7960fcfd5acbb56c959"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -24,6 +24,14 @@
"index": "pypi", "index": "pypi",
"version": "==1.4.4.post2" "version": "==1.4.4.post2"
}, },
"asyncbasehttp": {
"hashes": [
"sha256:2cea7b61113bb1e69d1c90f32919f004e7f56a7e1e2955e8a6286ce40f9da188",
"sha256:70fb983c4df630a9b63081cb8ef6d2e06beb51c89f42a88da6c9a9c7ebf9fb89"
],
"index": "pypi",
"version": "==1.0"
},
"atpublic": { "atpublic": {
"hashes": [ "hashes": [
"sha256:0f40433219e124edf115c6c363808ca6f0e1cfa7d160d86b2fb94793086d1294", "sha256:0f40433219e124edf115c6c363808ca6f0e1cfa7d160d86b2fb94793086d1294",

1
jsdev/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tmp

44
jsdev/air.toml Normal file
View File

@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "cp ./restart.sh ./tmp/main"
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

473
jsdev/index.html Normal file
View File

@ -0,0 +1,473 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<title>Mail4one Web config</title>
<script type="application/json" id="m41config">
{
"matches": [
{
"name": "mydomain",
"addr_rexs": [
".*@mydomain.com",
".*@m.mydomain.com"
]
},
{
"name": "personal",
"addrs": [
"first.last@mydomain.com",
"secret.name@mydomain.com"
]
}
],
"boxes": [
{
"name": "spam",
"rules": [
{
"match_name": "mydomain",
"negate": true,
"stop_check": true
}
]
},
{
"name": "important",
"rules": [
{
"match_name": "personal"
}
]
},
{
"name": "all",
"rules": [
{
"match_name": "default_match_all"
}
]
}
],
"users": [
{
"username": "mymobile",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "important"
},
{
"username": "mydesk",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "all"
}
]
}
</script>
<style>
td {
padding: 5px;
}
table {
border: 2px solid;
}
thead {
background-color: orange;
}
h1.page-title {
background-color: orange;
}
h3 {
text-align: center;
}
.outer {
display: flex;
justify-content: center;
background: grey;
}
.inner {
display: flex;
flex-direction: column;
align-items: center;
border: 2px solid;
background-color: lightyellow;
padding: 10px;
justify-content: space-around;
margin: 10px;
width: 1200px;
}
.m41-box {
display: grid;
grid-template-columns: 1fr 3fr;
margin: 10px;
// background-color: lightblue;
}
.multiline {
text-align: left;
padding: 5px;
padding-left: 15px;
}
#web-cfg-matches {
width: 90%;
text-align: center;
}
#web-cfg-matches tbody tr:nth-of-type(odd) {
background: lightblue;
}
#web-cfg-matches tbody tr:nth-of-type(even) {
background: lightgrey;
}
#web-cfg-boxes {
text-align: center;
width: 90%;
// display: grid;
// grid-template-columns: 1fr ;
//justify-content: space-around;
// justify-content: flex-start;
// background-color: blue;
}
#web-cfg-boxes tbody:nth-of-type(odd) {
background: lightblue;
}
#web-cfg-boxes tbody:nth-of-type(even) {
background: lightgrey;
}
#web-cfg-boxes tbody tr td div.dummy{
display: none;
}
#web-cfg-boxes tbody tr td div button.dummy {
display: none;
}
#web-cfg-boxes tbody:first-of-type tr td.box div button.up.dummy {
display: block;
}
#web-cfg-boxes tbody:first-of-type tr td.box div button.up.real {
display: none;
}
#web-cfg-boxes tbody:last-of-type tr td.box div button.down.dummy {
display: block;
}
#web-cfg-boxes tbody:last-of-type tr td.box div button.down.real {
display: none;
}
#web-cfg-boxes tbody:only-of-type tr td.box div.real {
display: none;
}
#web-cfg-boxes tbody:only-of-type tr td.box div.dummy {
display: flex;
}
td.box {
display: flex;
justify-content: center;
}
.button-group {
display: flex;
justify-content: center;
}
</style>
<script type="application/javascript">
"use strict"
// Globals
let server_config
let matches_table
let match_row_template
let boxes_table
let box_template
// let rule_template
function initGlobals() {
server_config = JSON.parse(document.getElementById('m41config').text)
matches_table = document.getElementById("web-cfg-matches")
match_row_template = document.getElementById("web-cfg-matches-row")
boxes_table = document.getElementById("web-cfg-boxes")
box_template = document.getElementById("web-cfg-box")
// rule_template = document.getElementById("web-cfg-boxes-rule-li")
}
function populate_match_table(matches_config) {
for (const { name: match_name, addrs, addr_rexs } of matches_config) {
const [match_type, match_values] = (() => {
if (addrs != undefined) {
return ["addrs", addrs]
} else {
return ["addr_rexs", addr_rexs]
}
})()
addMatchRow()
const last_row = matches_table.tBodies[0].lastElementChild
const [ ,name_cell, {firstElementChild: type_select}, value_cell ] = last_row.cells
name_cell.innerText = match_name
type_select.value = match_type
value_cell.innerText = match_values.join("\n")
}
}
function extract_match_table() {
let matches = []
for (let row of matches_table.tBodies[0].rows) {
const [ ,name_cell, {firstElementChild: type_select}, value_cell ] = row.cells
let m = {"name" : name_cell.innerText}
switch (type_select.value) {
case "addrs":
m["addrs"] = value_cell.innerText.split("\n")
break
case "addr_rexs":
m["addr_rexs"] = value_cell.innerText.split("\n")
break
}
matches.push(m)
}
return matches
}
function addMatchRow() {
let row_clone = match_row_template.content.cloneNode(true)
matches_table.tBodies[0].appendChild(row_clone)
}
function populate_boxes_list(boxes_config) {
for (const {name:box_name, rules} of boxes_config) {
addBox()
const tbody = boxes_table.lastElementChild
const box = tbody.firstElementChild
// console.log(box)
const [,
{children: [box_text, ]},
,
{firstElementChild: match_select},
{firstElementChild: negate_check},
{firstElementChild: stop_check},
] = box.children
box_text.value = box_name
const [first_rule, ...rest] = rules
const {match_name, negate = false, stop = false} = first_rule
match_select.value = match_name
negate_check.checked = negate
stop_check.checked = stop
for (const {match_name, negate = false, stop_check = false} of rest ) {
addRule(box)
const rule = tbody.lastElementChild
const [,
{firstElementChild: match_select},
{firstElementChild: negateCheck},
{firstElementChild: stopCheck}
] = rule.children
match_select.value = match_name
negate_check.checked = negate
stop_check.checked = stop
}
}
}
function addRule(box) {
box.parentElement.appendChild(box.cloneNode(true))
box.children[0].rowSpan++;
box.children[1].rowSpan++;
const newrule = box.parentElement.lastElementChild
newrule.removeClass("fist-tr")
newrule.firstElementChild.remove()
newrule.firstElementChild.remove()
}
function addBox() {
let box_clone = box_template.content.cloneNode(true)
boxes_table.appendChild(box_clone)
}
function moveUp(button) {
const li = button.parentElement.parentElement.parentElement
if (li.previousElementSibling != null) {
li.parentNode.insertBefore(li, li.previousElementSibling)
}
}
function moveDown(button) {
const li = button.parentElement.parentElement.parentElement
if (li.nextElementSibling != null) {
li.parentNode.insertBefore(li.nextElementSibling, li)
}
}
function main() {
initGlobals()
populate_match_table(server_config["matches"])
save()
document.getElementById("before").innerText = JSON.stringify(server_config["matches"], null, 2)
populate_boxes_list(server_config["boxes"])
}
function save() {
const matches = extract_match_table()
document.getElementById("after").innerText = JSON.stringify(matches, null, 2)
}
</script>
</head>
<body onload="main()">
<template id="web-cfg-matches-row">
<tr>
<td><button onClick="this.parentElement.parentElement.remove()"></button></td>
<td contentEditable></td>
<td>
<select>
<option value="addrs">List of addresses</option>
<option value="addr_rexs">List of regexes for addresses</option>
</select>
</td>
<td contentEditable class="multiline"></td>
</tr>
</template>
<template id="web-cfg-box">
<tbody>
<tr>
<td class="box">
<div class="button-group dummy">
<button disabled></button>
<button disabled></button>
<button disabled></button>
</div>
<div class="button-group real">
<button onClick="this.parentElement.parentElement.parentElement.parentElement.remove()"></button>
<button onClick="moveBoxUp(this)" class="real up"></button>
<button disabled class="dummy up"></button>
<button onClick="moveBoxDown(this)" class="real down"></button>
<button disabled class="dummy down"></button>
</div>
<button onClick="addRule(this.parentElement.parentElement)">+</button>
</td>
<td contentEditable>
<input type="text" contentEditable>
</td>
<td class="rule">
<div class="button-group dummy">
<button disabled></button>
<button disabled></button>
<button disabled></button>
</div>
<div class="button-group real">
<button onClick="this.parentElement.parentElement.parentElement.remove()"></button>
<button onClick="moveRuleUp(this)" class="real up"></button>
<button disabled class="dummy up"></button>
<button onClick="moveRuleDown(this)" class="real down"></button>
<button disabled class="dummy down"></button>
</div>
</td>
<td>
<select></select>
</td>
<td>
<input type=checkbox>
</td>
<td>
<input type=checkbox>
</td>
</tr>
</tbody>
</template>
<div class="outer">
<div class="inner">
<h1 class="page-title">Mail4one Web config</h1>
<div id="top-menu">
<button onClick="save()">Previous</button>
<button onClick="save()">Matches</button>
<button onClick="save()">Boxes</button>
<button onClick="save()">Users</button>
<button onClick="save()">JSON</button>
<button onClick="save()">Next</button>
</div>
<div id="match-page">
<h3> Matches </h3>
<table id="web-cfg-matches">
<thead>
<tr>
<th>
<button onClick="addMatchRow()">+</button>
</th>
<th>Match</th>
<th>Type</th>
<th>Values</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div id=box-page">
<h3>Boxes</h3>
<table id="web-cfg-boxes">
<thead>
<tr>
<th><button onClick="addBox()">+</button></th>
<th>Mailbox</th>
<th></th>
<th>Match</th>
<th>Invert</th>
<th>Stop</th>
</tr>
</thead>
</table>
</div>
<hr>
<h3>Before</h3>
<pre id="before"></pre>
<hr>
<h3>After</h3>
<pre id="after"></pre>
<hr>
</div>
</div>
</body>
</html>

11
jsdev/restart.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
# kill "$(cat current.pid)"
# echo "starting python server"
python3 -m http.server
# echo $! > current.pid
# echo "started python server"

View File

@ -16,6 +16,8 @@ VERSION = b"\x01"
SALT_LEN = 30 SALT_LEN = 30
KEY_LEN = 64 # This is python default KEY_LEN = 64 # This is python default
# len(VERSION) + SALT_LEN + KEY_LEN should be multiple of 5 to avoid base32 padding
def gen_pwhash(password: str) -> str: def gen_pwhash(password: str) -> str:
salt = os.urandom(SALT_LEN) salt = os.urandom(SALT_LEN)

View File

@ -10,6 +10,7 @@ from getpass import getpass
from .smtp import create_smtp_server_starttls, create_smtp_server from .smtp import create_smtp_server_starttls, create_smtp_server
from .pop3 import create_pop_server from .pop3 import create_pop_server
from .version import VERSION from .version import VERSION
from .web_config import create_web_config_server
from . import config from . import config
from . import pwhash from . import pwhash
@ -98,6 +99,14 @@ async def a_main(cfg: config.Config) -> None:
ssl_context=get_tls_context(smtp.tls), ssl_context=get_tls_context(smtp.tls),
) )
servers.append(smtp_server) servers.append(smtp_server)
elif scfg.server_type == "web_config":
web = config.ServerCfg(scfg)
web_server = await create_web_config_server(
host=get_host(web.host),
port=web.port,
ssl_context=get_tls_context(web.tls),
)
servers.append(web_server)
else: else:
logging.error(f"Unknown server {scfg.server_type=}") logging.error(f"Unknown server {scfg.server_type=}")

View File

@ -0,0 +1,88 @@
<!doctype html>
<html>
<head>
<title>Mail4one Web config</title>
<script type="application/json" id="m41config">
{
"matches": [
{
"name": "mydomain",
"addr_rexs": [
".*@mydomain.com",
".*@m.mydomain.com"
]
},
{
"name": "personal",
"addrs": [
"first.last@mydomain.com",
"secret.name@mydomain.com"
]
}
],
"boxes": [
{
"name": "spam",
"rules": [
{
"match_name": "mydomain",
"negate": true,
"stop_check": true
}
]
},
{
"name": "important",
"rules": [
{
"match_name": "personal"
}
]
},
{
"name": "all",
"rules": [
{
"match_name": "default_match_all"
}
]
}
],
"users": [
{
"username": "mymobile",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "important"
},
{
"username": "mydesk",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "all"
}
]
}
</script>
<script type="application/javascript">
function main() {
document.write("hello world")
}
</script>
</head>
<body onload="main()">
<template id="web-cfg-matches-row">
<tr>
<td>Name</td>
<td>Type</td>
<td>Values</td>
</tr>
</template>
<h1>Mail4one Web config</h1>
<table id="web-cfg-matches">
<tr>
<th>Name</th>
<th>Type</th>
<th>Values</th>
</tr>
</table>
</body>
</html>

80
mail4one/web_config.py Normal file
View File

@ -0,0 +1,80 @@
import asyncio
from asyncbasehttp import request_handler, Request, Response, RequestHandler
from typing import Optional, Tuple
import ssl
import logging
from pprint import pprint
import http
from base64 import b64decode
from .pwhash import gen_pwhash, parse_hash, PWInfo, check_pass
import pkgutil
def get_template() -> bytes:
if data:= pkgutil.get_data('mail4one', 'template_web_config.html'):
return data
raise Exception("Failed to get template data from 'template_web_config.html'")
def get_dummy_pwinfo() -> PWInfo:
pwhash = gen_pwhash("world")
return parse_hash(pwhash)
class WebonfigHandler(RequestHandler):
def __init__(self, username: str, pwinfo: PWInfo):
self.username = username.encode()
self.pwinfo = pwinfo
self.auth_required = True
def do_auth(self, req: Request) -> Tuple[bool, Optional[Response]]:
def resp_unauthorized():
resp = Response.no_body_response(http.HTTPStatus.UNAUTHORIZED)
resp.add_header("WWW-Authenticate", 'Basic realm="Mail4one"')
return resp
auth_header = req.headers["Authorization"]
if not auth_header:
return False, resp_unauthorized()
if not auth_header.startswith("Basic "):
logging.error("Authorization header malformed")
return False, Response.no_body_response(http.HTTPStatus.BAD_REQUEST)
userpassb64 = auth_header[len("Basic ") :]
try:
userpass = b64decode(userpassb64)
username, password = userpass.split(b":")
except:
logging.exception("bad request")
return False, Response.no_body_response(http.HTTPStatus.BAD_REQUEST)
if username == self.username and check_pass(password.decode(), self.pwinfo):
return True, None
return False, resp_unauthorized()
async def process_request(self, req: Request) -> Response:
if self.auth_required:
ok, resp = self.do_auth(req)
if not ok:
if resp:
return resp
else: # To silence mypy
raise Exception("Something went wrong!")
return Response.create_ok_response(get_template())
async def create_web_config_server(
host: str, port: int, ssl_context: Optional[ssl.SSLContext]
) -> asyncio.Server:
logging.info(f"template: {get_template().decode()}")
logging.info(f"Starting Webconfig server {host=}, {port=}, {ssl_context != None=}")
return await asyncio.start_server(
WebonfigHandler("hello", get_dummy_pwinfo()),
host=host,
port=port,
ssl=ssl_context,
)