Compare commits

...

6 Commits

Author SHA1 Message Date
Dustin 2c58796b33 backend: web: Refactor error handling
Trying to make errors more ergonomic and idiomatic.  Requiring the
handler functions to return a two-tuple with the HTTP status and the
`Json` guard would have gotten cumbersome very quickly.  To make it
simpler, there are two new error enumerations:

* `Error` is a general-purpose type that can be returned from the
  service layer.  There is a variant for each type of problem that can
  occur.
* `ApiError` is specifically for the API layer, and implements the
  Rocket `Responder` trait.  This is where mapping errors to HTTP status
  codes takes place.

The `From` trait is implemented for `ApiError`, allowing `Error` values
returned from the service layer to be passed directly back from request
handlers using the `?` operator.  Additionally, the `Error` enum
implements `From` for JSON-RPC errors, essentially mapping any unhandled
error from the RPC layer to HTTP 503 Service Unavailable, indicating a
problem communicating with the daemon.
2022-01-15 22:10:12 -06:00
Dustin ef97448b72 web: context: Own the Config
The `server::context::Context` struct now owns the
`server::config::Config` struct.  Consumers who need to access
configuration properties can obtain a reference to the configuration
using the `config()` method.

This change will ultimately support the "service" structures, which will
be constructed and owned by the context.  They will need to reference
the config as well.
2022-01-13 20:12:15 -06:00
Dustin ea89601f49 auth: Add authentication method
The *POST /auth/login* operation authenticates a user given a username
and password.  The actual authentication is preformed by the daemon,
since it is privileged and will be able to read `/etc/shadow` via the
`pam_unix.so` module.

Upon successful login, a signed cookie containing the username is set
for the client.  I'm not sure if any other information will need to be
stored in the cookie yet.  The cookie contains a JSON document that we
can extend if necessary.
2022-01-11 21:37:43 -06:00
Dustin 96ede2a407 backend: web: Begin JSON-RPC client impl
Starting work on the JSON-RPC client in the web server.

The `Context` structure, which is stored in Rocket's managed state and
available to route functions through the `State` request guard, provides
a method to get an RPC client.  Each request that needs to communicate
with the daemon will have its own RPC connection.  This ensures that a
valid connection is always available, even if the daemon has restarted
between web requests.  I had considered storing the connection in the
context, testing it each time it was needed, and reconnecting if the
connection was broken.  This proved very difficult, since the context is
passed to request handlers as an immutable reference.  Mutating its
state would require locking, and I could not make that work easily.
Besides, the overhead of "pinging" the server for every request is
probably greater than just reconnecting every time, so it would have
been a waste.

The *GET /status* operation returns a document that indicates the status
of the daemon and the web server.
2022-01-09 14:58:59 -06:00
Dustin 7806b67531 backend: daemon: Begin JSON-RPC implementation
Beginning the implementation of the JSON-RPC server in the privileged
daemon.  We're using *jsonrpc-core* for the JSON-RPC implementation,
which includes serialization, connection handling, and method dispatch.

The first RPC method is a simple status query, which returns the daemon
version and the number of seconds the daemon process has been running.
2022-01-09 12:49:19 -06:00
Dustin ed142322bd backend: Begin server process work
The backend will be split into two processes:

* The privileged daemon
* The unprivileged web server

These processes will communicate using JSON-RPC over a UNIX socket.  The
web server is unprivileged to mitigate the risk of vulnerabilities being
exploited to gain control of the system.  The privileged daemon will
handle all actual firewall management.  Additionally, the backend also
acts as a command-line interface for communicating with the daemon.

The application's main entry point determines which process to launch
based on the name of the executable.  This can be controlled, e.g. by
creating symbolic links pointing to the binary, or using the `@` prefix
in the `ExecStart` setting in a systemd unit.

This first commit introduces the Rocket framework for the web process.
Unfortunately, Rocket is rather inflexible in how it is started.  It
expects to have complete control of the `main` function, and does not
provide any mechanism for passing data to its initialization routines.
Thus, in order to configure it using command-line arguments, arguments
have to be parsed inside Rocket's main function; they cannot be parsed
ahead of time and passed in.
2022-01-08 17:28:42 -06:00
19 changed files with 2899 additions and 3 deletions

4
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target/
weywot-daemon
weywot-web
weywot-web.toml

2470
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,25 @@ name = "weywot"
version = "0.1.0" version = "0.1.0"
edition = "2018" edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
argh = "^0.1"
chrono = "*"
figment = "^0.10"
jsonrpc-core = "~18.0"
jsonrpc-derive = "~18.0"
jsonrpc-ipc-server = "~18.0"
pam-client = "^0.3.1"
serde = "^1.0"
serde_json = "^1.0"
[dependencies.jsonrpc-core-client]
version = "~18.0"
features = ["ipc"]
[dependencies.procfs]
version = "^0.12"
features = ["chrono"]
[dependencies.rocket]
version = "^0.5.0-rc.1"
features = ["json", "secrets", "tls"]

1
backend/rustfmt.toml Normal file
View File

@ -0,0 +1 @@
max_width = 79

View File

@ -0,0 +1,15 @@
use pam_client::{Context, Error, Flag};
use pam_client::conv_mock::Conversation;
const PAM_SERVICE: &'static str = "weywot";
pub fn authenticate(username: &str, password: &str) -> Result<(), Error> {
let mut ctx = Context::new(
PAM_SERVICE,
None,
Conversation::with_credentials(username, password)
)?;
ctx.authenticate(Flag::NONE)?;
Ok(())
}

32
backend/src/daemon/mod.rs Normal file
View File

@ -0,0 +1,32 @@
mod auth;
mod rpc;
use crate::rpc::WeywotRpc;
use argh::FromArgs;
use jsonrpc_core::IoHandler;
use jsonrpc_ipc_server::ServerBuilder;
use std::env;
#[derive(Debug, FromArgs)]
/// Weywot privileged daemon
struct Arguments {
#[argh(option, short = 's')]
/// socket path
socket: Option<String>,
}
pub fn main() {
let args: Arguments = argh::from_env();
let socket = if let Ok(v) = env::var("WEYWOT_SOCKET") {
v
} else {
if let Some(v) = args.socket {
v
} else {
"weywot.sock".into()
}
};
let mut io = IoHandler::new();
io.extend_with(rpc::RpcDaemon.to_delegate());
let server = ServerBuilder::new(io).start(&socket).unwrap();
server.wait();
}

50
backend/src/daemon/rpc.rs Normal file
View File

@ -0,0 +1,50 @@
use super::auth;
use crate::models::status::DaemonStatus;
use crate::rpc::WeywotRpc;
use chrono::Local;
use jsonrpc_core::Error as JsonRpcError;
use jsonrpc_core::ErrorCode as JsonRpcErrorCode;
use jsonrpc_core::Result as JsonRpcResult;
use procfs::process::Process;
use std::convert::TryInto;
use std::error::Error;
use std::process;
pub struct RpcDaemon;
impl WeywotRpc for RpcDaemon {
/// Authenticate a user
///
/// Authenticates a user with the given username and password.
/// Returns nothing if authetication succeeded, or the PAM error
/// code and message if authentication failed.
fn authenticate(
&self,
username: String,
password: String,
) -> JsonRpcResult<()> {
match auth::authenticate(&username, &password) {
Ok(_) => Ok(()),
Err(e) => Err(JsonRpcError {
code: JsonRpcErrorCode::ServerError(e.code().repr().into()),
message: e.message().unwrap_or_default().into(),
data: None,
})
}
}
fn status(&self) -> JsonRpcResult<DaemonStatus> {
Ok(DaemonStatus {
version: env!("CARGO_PKG_VERSION").into(),
runtime: proc_runtime().unwrap_or(0),
})
}
}
fn proc_runtime() -> Result<i64, Box<dyn Error>> {
let pid: i32 = process::id().try_into()?;
let proc = Process::new(pid)?;
let starttime = proc.stat.starttime()?;
let now = Local::now();
Ok((now - starttime).num_seconds())
}

View File

@ -1,3 +1,42 @@
mod daemon;
mod models;
mod rpc;
mod server;
use std::env;
use std::path::Path;
const NAME: &'static str = "weywot";
fn main() { fn main() {
println!("Hello, world!"); let mut args = env::args();
let arg0 = args.next();
let command = Path::new(arg0.as_ref().map_or(NAME, |x| x.as_str()))
.file_name()
.map_or(NAME, |x| x.to_str().unwrap_or(NAME));
match command {
"weywot-daemon" => daemon_main(),
"weywot-web" => web_main(),
_ => cli_main(),
}
}
/// Main entry point for the privileged daemon
fn daemon_main() {
daemon::main();
}
/// Main entry point for the web server
fn web_main() {
match server::main() {
Ok(_) => {}
Err(e) => {
eprintln!("Failed to start web server: {}", e);
}
}
}
/// Main entry point for the CLI/REPL
fn cli_main() {
/* start the CLI */
} }

View File

@ -0,0 +1,4 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct LoginResponse {}

View File

@ -0,0 +1,2 @@
pub mod auth;
pub mod status;

View File

@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct DaemonStatus {
pub version: String,
pub runtime: i64,
}
#[derive(Debug, Serialize)]
pub struct WebServerStatus {
pub version: String,
}
#[derive(Debug, Serialize)]
pub struct StatusResponse {
pub daemon: DaemonStatus,
pub web: WebServerStatus,
}

12
backend/src/rpc.rs Normal file
View File

@ -0,0 +1,12 @@
use crate::models::status::DaemonStatus;
use jsonrpc_core::Result;
use jsonrpc_derive::rpc;
#[rpc]
pub trait WeywotRpc {
#[rpc(name = "status")]
fn status(&self) -> Result<DaemonStatus>;
#[rpc(name = "authenticate")]
fn authenticate(&self, username: String, password: String) -> Result<()>;
}

View File

@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
pub address: String,
pub port: u16,
pub socket: String
}
impl Default for Config {
fn default() -> Config {
Config {
address: "::".into(),
port: 8998,
socket: "weywot.sock".into(),
}
}
}

View File

@ -0,0 +1,30 @@
use super::config::Config;
use crate::rpc::gen_client::Client;
use crate::server::error::Error;
use jsonrpc_core_client::transports::ipc::connect;
pub struct Context {
config: Config,
}
impl Context {
/// Construct a new Context
///
/// The context takes ownership of the configuration. To access it,
/// use [`Self::config()`].
pub fn new(config: Config) -> Self {
Self { config }
}
pub async fn client(&self) -> Result<Client, Error> {
match connect(&self.config.socket).await {
Ok(client) => Ok(client),
Err(e) => Err(format!("Cannot connect to daemon: {}", e)),
}
}
/// Get a reference to the web server application configuration
pub fn config(&self) -> &Config {
&self.config
}
}

View File

@ -0,0 +1,50 @@
use jsonrpc_core_client::RpcError;
use rocket::response::Responder;
use rocket::serde::json::Json;
use rocket::serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
}
impl ErrorResponse {
pub fn new<S: Into<String>>(message: S) -> Self {
Self {
error: message.into(),
}
}
}
#[derive(Debug, Responder)]
pub enum ApiError {
#[response(status = 503, content_type = "json")]
ServiceUnavailable(Json<ErrorResponse>),
#[response(status = 401, content_type = "json")]
AuthError(Json<ErrorResponse>),
}
pub enum Error {
ServiceUnavailable(String),
AuthError(String),
}
impl From<Error> for ApiError {
fn from(error: Error) -> Self {
match error {
Error::ServiceUnavailable(e) => {
Self::ServiceUnavailable(Json(ErrorResponse::new(e)))
}
Error::AuthError(e) => {
Self::AuthError(Json(ErrorResponse::new(e)))
}
}
}
}
impl From<RpcError> for Error {
fn from(error: RpcError) -> Self {
Self::ServiceUnavailable(error.to_string())
}
}

68
backend/src/server/mod.rs Normal file
View File

@ -0,0 +1,68 @@
mod config;
mod context;
mod error;
mod routes;
use config::Config;
use argh::FromArgs;
use context::Context;
use rocket;
use rocket::figment::providers::{Env, Format, Serialized, Toml};
use rocket::figment::Figment;
const DEFAULT_CONFIG: &'static str = "weywot-web.toml";
#[derive(Debug, FromArgs)]
/// Weywot web server
struct Arguments {
/// bind address
#[argh(option)]
address: Option<String>,
/// bind port
#[argh(option)]
port: Option<u16>,
/// configuration file
#[argh(option, short = 'c')]
config: Option<String>,
/// weywot daemon socket path
#[argh(option, short = 's')]
socket: Option<String>,
}
#[rocket::main]
pub async fn main() -> Result<(), rocket::Error> {
let args: Arguments = argh::from_env();
let mut figment = Figment::from(rocket::Config::default())
.merge(Serialized::defaults(Config::default()))
.merge(Toml::file(
args.config.as_ref().map_or(DEFAULT_CONFIG, |x| x.as_str()),
))
.merge(Env::prefixed("WEYWOT_WEB_").global())
.merge(("ident", "Weywot/web"));
if let Some(address) = args.address {
figment = figment.merge(("address", address));
}
if let Some(port) = args.port {
figment = figment.merge(("port", port));
}
if let Some(socket) = args.socket {
figment = figment.merge(("socket", socket));
}
let config: Config = figment.extract().unwrap();
let context = Context::new(config);
rocket::custom(figment)
.mount(
"/",
rocket::routes![routes::status::get_status, routes::auth::login],
)
.manage(context)
.ignite()
.await?
.launch()
.await
}

View File

@ -0,0 +1,44 @@
use super::super::context::Context;
use super::super::error::{ApiError, Error};
use crate::models::auth::LoginResponse;
use rocket::form::{Form, FromForm};
use rocket::http::{Cookie, CookieJar};
use rocket::serde::json::Json;
use rocket::serde::Serialize;
use rocket::State;
use serde_json;
#[derive(FromForm)]
pub struct LoginForm<'r> {
username: &'r str,
password: &'r str,
}
#[derive(Serialize)]
struct AuthCookie {
username: String,
}
#[rocket::post("/auth/login", data = "<form>")]
pub async fn login(
context: &State<Context>,
cookies: &CookieJar<'_>,
form: Form<LoginForm<'_>>,
) -> Result<Json<LoginResponse>, ApiError> {
let client = context.client().await?;
match client
.authenticate(form.username.into(), form.password.into())
.await
{
Ok(_) => {}
Err(e) => return Err(Error::AuthError(e.to_string()).into()),
};
let cookie = AuthCookie {
username: form.username.into(),
};
cookies.add_private(Cookie::new(
"weywot.auth",
serde_json::to_string(&cookie).unwrap(),
));
Ok(Json(LoginResponse {}))
}

View File

@ -0,0 +1,2 @@
pub mod auth;
pub mod status;

View File

@ -0,0 +1,18 @@
use crate::models::status::{StatusResponse, WebServerStatus};
use crate::server::context::Context;
use crate::server::error::ApiError;
use rocket::serde::json::Json;
use rocket::State;
#[rocket::get("/status")]
pub async fn get_status(
state: &State<Context>,
) -> Result<Json<StatusResponse>, ApiError> {
let client = state.client().await?;
Ok(Json(StatusResponse {
daemon: client.status().await.unwrap(),
web: WebServerStatus {
version: env!("CARGO_PKG_VERSION").into(),
},
}))
}