Compare commits

...

8 Commits

Author SHA1 Message Date
Jacob Kiers 6859de9f7d Use prebuilt image
continuous-integration/drone/push Build is passing Details
Signed-off-by: Jacob Kiers <code@kiers.eu>
2023-06-10 15:25:14 +02:00
Jacob Kiers 007f9f9112 Remove docker service, no need for it
continuous-integration/drone/push Build is passing Details
Signed-off-by: Jacob Kiers <code@kiers.eu>
2023-06-10 14:28:25 +02:00
Jacob Kiers 7d75389a09 Update build to use zigbuild instead of cross
continuous-integration/drone/push Build is passing Details
Signed-off-by: Jacob Kiers <code@kiers.eu>
2023-06-10 14:17:29 +02:00
Jacob Kiers 054324cdd7 Split message readers into their own files
continuous-integration/drone/push Build is failing Details
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers 47e0bd9012 Convert enum arguments to structs
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers 0569591aa5 Add protection features to service definition
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers ac51541c0c Fix some more clippy lints
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
Jacob Kiers a3317592e1 Update all cargo dependencies
Signed-off-by: Jacob Kiers <jacob@jacobkiers.net>
2023-06-10 10:04:22 +02:00
12 changed files with 832 additions and 664 deletions

View File

@ -1,5 +1,5 @@
local executableName = 'newsletter-to-web';
local cross_image = 'img.kie.rs/jjkiers/rust-dind-cross:1.66-full';
local build_image = 'img.kie.rs/jjkiers/rust-cross:rust1.70-zig';
local archs = [
{ target: 'aarch64-unknown-linux-musl', short: 'arm64-musl' },
@ -12,26 +12,30 @@ local getStepName(arch) = 'Build for ' + arch.short;
local builtExecutableName(arch) = executableName + if std.length(std.findSubstr(arch.short, 'windows')) > 0 then '.exe' else '';
local targetExecutableName(arch) = executableName + '-' + arch.target + if std.length(std.findSubstr(arch.short, 'windows')) > 0 then '.exe' else '';
local getVolumeName(arch) = 'target-' + arch.target;
local getLocalVolumes(arch) = [
{
name: getVolumeName(arch),
temp: {},
}
for arch in archs
];
local add_build_steps() = [
{
name: getStepName(arch),
image: cross_image,
volumes: [
{
name: 'dockersock',
path: '/var/run',
},
],
image: build_image,
commands: [
'echo Hello World from Jsonnet on ' + arch.target + '!',
'cross build --release --target ' + arch.target,
'cargo zigbuild --release --target ' + arch.target,
'cp target/' + arch.target + '/release/' + builtExecutableName(arch) + ' artifacts/' + targetExecutableName(arch),
'rm -rf target/' + arch.target + '/release/*',
],
environment: {
CROSS_REMOTE: true,
},
depends_on: ['Wait for Docker'],
depends_on: ['Prepare'],
volumes: [{
name: getVolumeName(arch),
path: '/drone/src/target',
}],
}
for arch in archs
];
@ -45,30 +49,20 @@ local add_build_steps() = [
},
steps:
[{
name: 'Wait for Docker',
image: cross_image,
name: 'Prepare',
image: build_image,
commands: [
'mkdir artifacts',
'echo Using image: ' + cross_image,
'while ! docker image ls; do sleep 1; done',
'echo Using image: ' + build_image,
'cargo --version',
'rustc --version',
'docker info',
'docker pull hello-world:latest',
],
environment: {
CROSS_REMOTE: true,
},
volumes: [{
name: 'dockersock',
path: '/var/run',
}],
}] +
add_build_steps() +
[
{
name: 'Show built artifacts',
image: cross_image,
image: build_image,
commands: [
'ls -lah artifacts',
],
@ -92,28 +86,7 @@ local add_build_steps() = [
},
],
services: [{
name: 'docker',
image: 'docker:dind',
privileged: true,
volumes: [
{
name: 'dockersock',
path: '/var/run',
},
{
name: 'docker-storage',
path: '/var/lib/docker',
},
],
}],
volumes: [
{
name: 'dockersock',
temp: {},
},
],
volumes: getLocalVolumes(archs),
image_pull_secrets: ['docker_private_repo'],
}

View File

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
* Added protection features to example systemd service file
## [0.2.3] - 2022-12-29
### Added

884
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -11,15 +11,13 @@ authors = [
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
atom_syndication = "^0.11.0"
base16ct = { version = "^0.1.0", features = [ "alloc" ] }
chrono = "^0.4"
clap = { version = "^4.0.22", features = [ "derive" ] }
imap = { version = "^2.4.1", default-features = false }
mail-parser = "^0.8.0"
rustls-connector = { version = "^0.16.1", default-features = false, features = [ "webpki-roots-certs", "quic" ] }
sha2 = "^0.10.2"
self_update = { version = "0.33.0", default-features = false, features = ["rustls"] }
[patch.crates-io]
atom_syndication = { git = "https://github.com/rust-syndication/atom", rev = "5cf8d161e5e5af7d93cca8d2c117b7af879a99b7" }
atom_syndication = "0.12.0"
base16ct = { version = "0.2.0", features = [ "alloc" ] }
chrono = "0.4.23"
clap = { version = "4.2.7", features = [ "derive" ] }
imap = { version = "3.0.0-alpha.10", default-features = false }
mail-parser = "0.8.2"
rustls-connector = { version = "0.17.0", default-features = false, features = [ "webpki-roots-certs" ] }
self_update = { version = "0.36.0", default-features = false, features = ["rustls"] }
sha2 = "0.10.6"
reqwest = { version = "0.11.14", default-features = false, features = ["rustls", "blocking"] }

43
scripts/prepare-build.sh Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -x
apt-get update && apt-get install -y jq curl
pushd $(mktemp -d)
# Install minisign
wget -nv https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-linux.tar.gz -O - | tar --strip-components 2 -C /usr/local/bin/ -vxzf - minisign-linux/x86_64/minisign
# Installing zig, checking its validity
mkdir -p /usr/local/bin
wget -nv https://ziglang.org/download/index.json -O zig-versions.json
echo "$(jq -r '.master."x86_64-linux".shasum' zig-versions.json) zig.tar.xz" > zig.tar.xz.shasum
wget -nv -c $(jq -r '.master."x86_64-linux".tarball' zig-versions.json) -O zig.tar.xz
wget -nv -c "$(jq -r '.master."x86_64-linux".tarball' zig-versions.json).minisig" -O zig.tar.xz.minisig
sha256sum -c zig.tar.xz.shasum
if [ $? -ne 0 ]; then
echo "Error in SHA256 hash!"
exit 1
else
echo "SHA256 hash OK"
fi
minisign -P 'RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U' -Vm zig.tar.xz
if [ $? -ne 0 ]; then
echo "Error in signature!"
exit 1
else
echo "Signature OK"
fi
tar -C /usr/local/bin --strip-components 1 -xf zig.tar.xz
rm -rf zig*
# Installing binstall and zigbuild
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
cargo binstall -y cargo-zigbuild
popd

View File

@ -1,6 +1,6 @@
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[clap(author, version, about, long_about = None)]
@ -9,35 +9,41 @@ pub(crate) struct Cli {
pub command: Command,
}
#[derive(Args)]
pub(crate) struct ImapSettings {
#[clap(short, long, value_parser)]
pub server: String,
#[clap(long, value_parser, default_value_t = 993)]
pub port: u16,
#[clap(short, long, value_parser)]
pub username: String,
#[clap(short, long, value_parser)]
pub password: String,
}
#[derive(Args)]
pub(crate) struct FeedBuilderSettings {
/// Host name hosting the feed
pub hostname: String,
/// Feed file
#[clap(value_parser, default_value = "output/feed.xml")]
pub filename: PathBuf,
/// Create an HTML file for each message
#[clap(short, long, value_parser, default_value_t = false)]
pub include_html: bool,
}
#[derive(Subcommand)]
pub(crate) enum Command {
/// Fetch emails from an IMAP server
FetchFromImap {
#[clap(short, long, value_parser)]
server: String,
#[clap(long, value_parser, default_value_t = 993)]
port: u16,
#[clap(short, long, value_parser)]
username: String,
#[clap(short, long, value_parser)]
password: String,
},
FetchFromImap(ImapSettings),
/// Fetch an email from a .eml file
FetchFromFile {
#[clap(value_parser)]
filename: PathBuf,
},
/// Build an ATOM feed containing the full contents of the email
BuildFeed {
/// Host name hosting the feed
hostname: String,
/// Feed file
#[clap(value_parser, default_value = "output/feed.xml")]
filename: PathBuf,
/// Create an HTML file for each message
#[clap(short, long, value_parser, default_value_t = false)]
include_html: bool,
},
BuildFeed(FeedBuilderSettings),
/// Exports the emails as HTML files
ExportHtml {
/// The directory in which the emails will be stored

View File

@ -11,8 +11,8 @@ use mail_parser::HeaderValue;
pub(crate) fn add_entry_to_feed(
feed: &mut Feed,
message: &Message,
processed_html: &String,
hostname: &String,
processed_html: &str,
hostname: &str,
include_html: bool,
) {
let parsed = message.get_parsed().unwrap();
@ -39,7 +39,7 @@ pub(crate) fn add_entry_to_feed(
uri: None,
},
title: parsed.subject().expect("Expected a subject").to_string(),
content: Some(processed_html.clone()),
content: Some(processed_html.to_owned()),
id: url.clone(),
published: Utc.timestamp_opt(date.to_timestamp(), 0).unwrap(),
url: match include_html {
@ -53,13 +53,13 @@ pub(crate) fn add_entry_to_feed(
}
pub(crate) fn build_atom_feed(hostname: &String, feed_file: &str) -> Feed {
let feed_url = format!("https://{}/{}", hostname, feed_file);
let feed_url = format!("https://{hostname}/{feed_file}");
FeedBuilder::default()
.title("JJKiers Newsletters")
.id(&feed_url)
.link(
LinkBuilder::default()
.href(format!("https://{}/", hostname))
.href(format!("https://{hostname}/"))
.rel("alternate".to_string())
.build(),
)

View File

@ -1,4 +1,4 @@
#[warn(missing_docs)]
#[deny(missing_docs)]
#[doc = include_str!("../README.md")]
mod cli;
mod command;
@ -29,24 +29,22 @@ fn main() -> Result<(), Box<dyn Error>> {
let data_directory = "data";
let result = match &cli.command {
cli::Command::FetchFromImap {
server,
port,
username,
password,
} => fetch_from_imap(
cli::Command::FetchFromImap(imap_settings) => fetch_from_imap(
data_directory,
server.to_owned(),
*port,
username.to_owned(),
password.to_owned(),
imap_settings.server.to_owned(),
imap_settings.port,
imap_settings.username.to_owned(),
imap_settings.password.to_owned(),
),
cli::Command::BuildFeed {
filename,
hostname,
include_html,
} => build_feed(filename, hostname, *include_html),
cli::Command::BuildFeed(settings) => build_feed(
&settings.filename,
&settings.hostname,
settings.include_html,
),
cli::Command::Update => command::update::self_update(),
_ => unimplemented!("This method is not yet implemented."),
};
@ -85,7 +83,7 @@ fn build_feed(
.to_str()
.expect("Feed path should be printable.");
let mut feed = feed::build_atom_feed(&hostname, feed_file);
let mut feed = feed::build_atom_feed(hostname, feed_file);
let mut reader = DataDirectoryMessageReader::new(Path::new("data").to_path_buf());
@ -111,10 +109,10 @@ fn build_feed(
if include_html {
let path: PathBuf = [dir, Path::new(&get_path(&parsed, &msg))].iter().collect();
write_file(&path, processed_html.as_bytes())?;
write_file(path, processed_html.as_bytes())?;
}
feed::add_entry_to_feed(&mut feed, &msg, &processed_html, &hostname, include_html);
feed::add_entry_to_feed(&mut feed, &msg, &processed_html, hostname, include_html);
}
if !feed.entries.is_empty() {
@ -143,7 +141,7 @@ fn fetch_from_imap(
) -> Result<(), Box<dyn Error>> {
create_directory(data_directory)?;
print!("Getting mail from {} for mailbox {}", server, username);
print!("Getting mail from {server} for mailbox {username}");
let mut reader = ImapReader::new(server, port, username, password);
@ -168,12 +166,9 @@ fn fetch_from_imap(
);
let path = get_path(&parsed, &msg);
let html_path: PathBuf = [
Path::new(data_directory),
Path::new(&format!("{}.eml", path)),
]
.iter()
.collect();
let html_path: PathBuf = [Path::new(data_directory), Path::new(&format!("{path}.eml"))]
.iter()
.collect();
println!("Storing to {}", &html_path.display());

View File

@ -1,165 +1,13 @@
use std::{
collections::HashMap,
error::Error,
fs::{read_dir, DirEntry},
net::TcpStream,
path::PathBuf,
vec::IntoIter,
};
use std::vec::IntoIter;
use imap::Session;
use rustls_connector::RustlsConnector;
pub(crate) mod data_directory;
pub(crate) mod imap;
pub(crate) use data_directory::DataDirectoryMessageReader;
pub(crate) use crate::message_reader::imap::ImapReader;
use crate::Message;
pub(crate) trait EmailReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>>;
}
pub(crate) struct DataDirectoryMessageReader {
path: PathBuf,
}
impl DataDirectoryMessageReader {
pub fn new(path: PathBuf) -> Self {
DataDirectoryMessageReader { path }
}
}
impl EmailReader for DataDirectoryMessageReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
println!("Reading files in {}", &self.path.display());
let reader = match read_dir(&self.path) {
Ok(r) => r,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = reader
.filter(|i| i.is_ok())
.map(|i| i.unwrap() as DirEntry)
.filter(|d| match d.path().extension() {
Some(ext) => ext == "eml",
None => false,
})
.filter_map(|i| {
let uid = i
.path()
.file_stem()
.unwrap()
.to_owned()
.into_string()
.expect("Could not convert filename to string.")
.split('_')
.collect::<Vec<&str>>()[0]
.trim_start_matches('0')
.to_string();
if let Ok(data) = std::fs::read(i.path()) {
Some((uid, data))
} else {
None
}
})
.map(|i| Message::new(i.0, i.1));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}
pub struct ImapReader {
host: String,
port: u16,
username: String,
password: String,
}
impl ImapReader {
pub fn new(host: String, port: u16, username: String, password: String) -> Self {
ImapReader {
host,
port,
username,
password,
}
}
fn connect(&self) -> Result<HashMap<String, Vec<u8>>, Box<dyn Error>> {
let mut session = self.open_session()?;
session.examine("INBOX")?;
let items = match session.uid_search("ALL") {
Ok(i) => i,
Err(e) => return Err(Box::new(e)),
};
let mut msgs = HashMap::<String, Vec<u8>>::with_capacity(items.len());
//println!("# of messages: {}", &items.len());
for item in items {
let msg = session.uid_fetch(&item.to_string(), "(BODY.PEEK[] UID)")?;
let message = if let Some(m) = msg.iter().next() {
m
} else {
continue;
};
let body = message.body().expect("Message did not have a body.");
msgs.insert(item.to_string(), body.to_owned());
}
session.logout().expect("Could not log out");
Ok(msgs)
}
fn open_session(
&self,
) -> Result<
Session<
rustls_connector::rustls::StreamOwned<
rustls_connector::rustls::ClientConnection,
TcpStream,
>,
>,
Box<dyn Error + 'static>,
> {
let stream = TcpStream::connect((self.host.as_ref(), self.port))?;
let tls = RustlsConnector::new_with_webpki_roots_certs();
let tls_stream = tls.connect(&self.host, stream)?;
let client = imap::Client::new(tls_stream);
Ok(client
.login(&self.username, &self.password)
.map_err(|e| e.0)?)
}
}
impl EmailReader for ImapReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
let msgs = match self.connect() {
Ok(m) => m,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = msgs
.iter()
.map(|i| Message::new(i.0.to_owned(), i.1.to_owned()));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}

View File

@ -0,0 +1,62 @@
use std::{
fs::{read_dir, DirEntry},
path::PathBuf,
vec::IntoIter,
};
use crate::message::Message;
use crate::message_reader::EmailReader;
pub(crate) struct DataDirectoryMessageReader {
path: PathBuf,
}
impl DataDirectoryMessageReader {
pub fn new(path: PathBuf) -> Self {
DataDirectoryMessageReader { path }
}
}
impl EmailReader for DataDirectoryMessageReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
println!("Reading files in {}", &self.path.display());
let reader = match read_dir(&self.path) {
Ok(r) => r,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = reader
.filter(|i| i.is_ok())
.map(|i| i.unwrap() as DirEntry)
.filter(|d| match d.path().extension() {
Some(ext) => ext == "eml",
None => false,
})
.filter_map(|i| {
let uid = i
.path()
.file_stem()
.unwrap()
.to_owned()
.into_string()
.expect("Could not convert filename to string.")
.split('_')
.collect::<Vec<&str>>()[0]
.trim_start_matches('0')
.to_string();
if let Ok(data) = std::fs::read(i.path()) {
Some((uid, data))
} else {
None
}
})
.map(|i| Message::new(i.0, i.1));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}

104
src/message_reader/imap.rs Normal file
View File

@ -0,0 +1,104 @@
use std::{
collections::HashMap,
error::Error,
net::TcpStream,
vec::IntoIter,
};
use imap::Session;
use rustls_connector::RustlsConnector;
use crate::message::Message;
use crate::message_reader::EmailReader;
pub struct ImapReader {
host: String,
port: u16,
username: String,
password: String,
}
impl ImapReader {
pub fn new(host: String, port: u16, username: String, password: String) -> Self {
ImapReader {
host,
port,
username,
password,
}
}
fn connect(&self) -> Result<HashMap<String, Vec<u8>>, Box<dyn Error>> {
let mut session = self.open_session()?;
session.examine("INBOX")?;
let items = match session.uid_search("ALL") {
Ok(i) => i,
Err(e) => return Err(Box::new(e)),
};
let mut msgs = HashMap::<String, Vec<u8>>::with_capacity(items.len());
//println!("# of messages: {}", &items.len());
for item in items {
let msg = session.uid_fetch(&item.to_string(), "(BODY.PEEK[] UID)")?;
let message = if let Some(m) = msg.iter().next() {
m
} else {
continue;
};
let body = message.body().expect("Message did not have a body.");
msgs.insert(item.to_string(), body.to_owned());
}
session.logout().expect("Could not log out");
Ok(msgs)
}
fn open_session(
&self,
) -> Result<
Session<
rustls_connector::rustls::StreamOwned<
rustls_connector::rustls::ClientConnection,
TcpStream,
>,
>,
Box<dyn Error + 'static>,
> {
let stream = TcpStream::connect((self.host.as_ref(), self.port))?;
let tls = RustlsConnector::new_with_webpki_roots_certs();
let tls_stream = tls.connect(&self.host, stream)?;
let client = imap::Client::new(tls_stream);
Ok(client
.login(&self.username, &self.password)
.map_err(|e| e.0)?)
}
}
impl EmailReader for ImapReader {
fn read_rfc822_messages(&mut self) -> Box<IntoIter<Message>> {
let msgs = match self.connect() {
Ok(m) => m,
Err(e) => {
dbg!(e);
return Box::new(Vec::new().into_iter());
}
};
let items = msgs
.iter()
.map(|i| Message::new(i.0.to_owned(), i.1.to_owned()));
let iter = items.collect::<Vec<Message>>().into_iter();
Box::new(iter)
}
}

View File

@ -1,7 +1,42 @@
[Unit]
Description=Create newsletter feed
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/n2w
WorkingDirectory=/home/n2w/n2w
ExecStart=/home/n2w/build-feed.sh
User=n2w
# Security
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
SystemCallFilter=@system-service
#SystemCallFilter=@basic-io @file-system @network-io mprotect
CapabilityBoundingSet=
NoNewPrivileges=yes
ProtectProc=invisible
RemoveIPC=yes
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=yes
PrivateUsers=yes
# ProtectHostname and ProcSubset=pid cannot go together
# see: https://github.com/systemd/systemd/pull/22203
# This is fixed in systemd v251
#ProtectHostname=yes
ProtectClock=yes
ProtectKernalTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
ProcSubset=pid
UMask=0077
SystemCallArchitectures=native
RestrictSUIDSGID=yes