Authentication, more styles

This commit is contained in:
asonix 2020-09-12 17:12:48 -05:00
parent ab42817c2b
commit 8d3b3eb23c
14 changed files with 590 additions and 122 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target /target
/router-db-0-34-3

44
Cargo.lock generated
View file

@ -32,7 +32,7 @@ checksum = "f7001367fde4c768a19d1029f0a8be5abd9308e1119846d5bd9ad26297b8faf5"
dependencies = [ dependencies = [
"aes-soft", "aes-soft",
"aesni", "aesni",
"block-cipher", "block-cipher 0.7.1",
] ]
[[package]] [[package]]
@ -43,7 +43,7 @@ checksum = "86f5007801316299f922a6198d1d09a0bae95786815d066d5880d13f7c45ead1"
dependencies = [ dependencies = [
"aead", "aead",
"aes", "aes",
"block-cipher", "block-cipher 0.7.1",
"ghash", "ghash",
"subtle", "subtle",
] ]
@ -54,7 +54,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4925647ee64e5056cf231608957ce7c81e12d6d6e316b9ce1404778cc1d35fa7" checksum = "4925647ee64e5056cf231608957ce7c81e12d6d6e316b9ce1404778cc1d35fa7"
dependencies = [ dependencies = [
"block-cipher", "block-cipher 0.7.1",
"byteorder", "byteorder",
"opaque-debug 0.2.3", "opaque-debug 0.2.3",
] ]
@ -65,7 +65,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d050d39b0b7688b3a3254394c3e30a9d66c41dcf9b05b0e2dbdc623f6505d264" checksum = "d050d39b0b7688b3a3254394c3e30a9d66c41dcf9b05b0e2dbdc623f6505d264"
dependencies = [ dependencies = [
"block-cipher", "block-cipher 0.7.1",
"opaque-debug 0.2.3", "opaque-debug 0.2.3",
] ]
@ -327,6 +327,18 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff"
[[package]]
name = "bcrypt"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2cab630912253fb9dc92c0e2fabd0a7b51f5a5a4007177cfa31e517015b7204"
dependencies = [
"base64",
"blowfish",
"byteorder",
"getrandom",
]
[[package]] [[package]]
name = "bincode" name = "bincode"
version = "1.3.1" version = "1.3.1"
@ -376,6 +388,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-cipher"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f337a3e6da609650eb74e02bc9fac7b735049f7623ab12f2e4c719316fcc7e80"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "blocking" name = "blocking"
version = "0.5.2" version = "0.5.2"
@ -403,6 +424,17 @@ dependencies = [
"waker-fn", "waker-fn",
] ]
[[package]]
name = "blowfish"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f06850ba969bc59388b2cc0a4f186fc6d9d37208863b15b84ae3866ac90ac06"
dependencies = [
"block-cipher 0.8.0",
"byteorder",
"opaque-debug 0.3.0",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.4.0" version = "3.4.0"
@ -1360,11 +1392,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-process", "async-process",
"async-trait",
"base64",
"bcrypt",
"blocking 1.0.0", "blocking 1.0.0",
"config", "config",
"futures-lite 1.3.0", "futures-lite 1.3.0",
"mime", "mime",
"once_cell", "once_cell",
"rand",
"regex", "regex",
"ructe", "ructe",
"serde 1.0.115", "serde 1.0.115",

View file

@ -10,11 +10,15 @@ build = "src/build.rs"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
async-process = "1.0.0" async-process = "1.0.0"
async-trait = "0.1.40"
base64 = "0.12.3"
bcrypt = "0.8.2"
blocking = "1.0.0" blocking = "1.0.0"
config = { version = "0.10.1", features = ["toml"] } config = { version = "0.10.1", features = ["toml"] }
futures-lite = "1.1.0" futures-lite = "1.1.0"
mime = "0.3" mime = "0.3"
once_cell = "1.4.1" once_cell = "1.4.1"
rand = "0.7.3"
regex = "1.3.9" regex = "1.3.9"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

View file

@ -6,3 +6,7 @@ internal = [
[network] [network]
shared-internal = true shared-internal = true
[server]
debug = true
secret = "8tr5C1kCmWpce9Exk2he+g0C72juZdxvxF+xKrRyeBFhVzYbJmxIxcE0ND4nP3il"

View file

@ -1,3 +1,7 @@
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
background-color: #f5f5f5; background-color: #f5f5f5;
@ -27,6 +31,13 @@ section {
border-radius: 2px; border-radius: 2px;
} }
.content {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 2px;
padding: 16px;
}
table { table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
@ -81,6 +92,97 @@ form {
} }
} }
a {
text-decoration: none;
font-weight: 500;
&, &:focus, &:hover, &:visited {
color: #dd08dd;
}
&:hover {
color: #dd08dd;
text-decoration: underline;
}
}
.select-wrapper {
position: relative;
width: 150px;
border-radius: 2px;
&:before {
position: absolute;
display: block;
content: '';
top: 7px;
right: 10px;
}
select {
position: relative;
width: 100%;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: transparent;
border: 1px solid #ddd;
border-radius: 2px;
padding: 8px 24px 8px 8px;
font-weight: 600;
&:hover {
border: 1px solid #eae;
}
&:focus {
border: 1px solid #eae;
}
}
&:hover {
background-color: #fff3fc;
}
&:focus {
background-color: #fff3fc;
box-shadow: 0 1px 3px #9d0f9d1f;
}
}
button[type="submit"] {
padding: 8px 24px;
background: #992d99;
color: #fff;
border: none;
border-radius: 2px;
font-size: 14px;
&:hover {
background: #b034b0;
}
}
input[type="text"],
input[type="number"],
input[type="password"] {
width: 150px;
background: #fff;
border: 1px solid #ddd;
border-radius: 2px;
padding: 8px;
box-shadow: inset 0 0 3px #9d0f9d1f;
&:focus {
border: 1px solid #eae;
box-shadow: inset 0 0 3px #9d0f9d1f, 0 1px 3px #9d0f9d1f;
}
}
p {
margin: 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
@media(max-width: 650px) { @media(max-width: 650px) {
body { body {
padding: 8px 0 48px; padding: 8px 0 48px;
@ -100,9 +202,13 @@ form {
.table-wrapper { .table-wrapper {
overflow-x: auto; overflow-x: auto;
border-left: none;
border-right: none;
} }
table, form { .content, .table-wrapper, form {
border-radius: 0; border-radius: 0;
border-left: none;
border-right: none;
} }
} }

View file

@ -2,19 +2,36 @@ use blocking::unblock;
use futures_lite::*; use futures_lite::*;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use sled::Db; use sled::Db;
use tide::log::{self, LogMiddleware}; use std::time::Duration;
use tide::{
log::{self, LogMiddleware},
sessions::{MemoryStore, Session, SessionMiddleware},
};
include!(concat!(env!("OUT_DIR"), "/templates.rs")); include!(concat!(env!("OUT_DIR"), "/templates.rs"));
mod iptables; mod iptables;
mod middleware;
mod rules; mod rules;
mod session;
mod startup; mod startup;
use self::{rules::Rule, startup::Interfaces, templates::statics::StaticFile}; use self::{
middleware::AuthMiddleware,
rules::Rule,
startup::{Config, Interfaces, ServerState},
templates::statics::StaticFile,
};
static CONFIG: Lazy<Config> = Lazy::new(|| Config::read().unwrap());
static SERVER: Lazy<ServerState> = Lazy::new(|| ServerState::init(&CONFIG).unwrap());
static INTERFACES: Lazy<Interfaces> = Lazy::new(|| { static INTERFACES: Lazy<Interfaces> = Lazy::new(|| {
let interfaces = Interfaces::init_blocking().unwrap(); let interfaces = Interfaces::init_blocking(&CONFIG).unwrap();
interfaces.reset_blocking().unwrap(); if !SERVER.debug {
interfaces.reset_blocking().unwrap();
}
interfaces interfaces
}); });
@ -46,7 +63,9 @@ async fn save_rule(mut req: tide::Request<()>) -> tide::Result {
rules::save(&DB, &rule).await?; rules::save(&DB, &rule).await?;
rules::apply(&INTERFACES, rule).await?; if !SERVER.debug {
rules::apply(&INTERFACES, rule).await?;
}
Ok(to_rules_page()) Ok(to_rules_page())
} }
@ -54,17 +73,101 @@ async fn save_rule(mut req: tide::Request<()>) -> tide::Result {
async fn delete_rule(req: tide::Request<()>) -> tide::Result { async fn delete_rule(req: tide::Request<()>) -> tide::Result {
let id = req.param("id")?; let id = req.param("id")?;
let rule = rules::delete(&DB, id).await?; let rule = rules::delete(&DB, id).await?;
rules::unset(&INTERFACES, rule).await?;
if !SERVER.debug {
rules::unset(&INTERFACES, rule).await?;
}
Ok(to_rules_page()) Ok(to_rules_page())
} }
fn to_rules_page() -> tide::Response { #[derive(serde::Deserialize)]
tide::Response::builder(301) struct LoginForm {
.header("Location", "/rules") username: String,
password: String,
}
async fn login(mut req: tide::Request<()>) -> tide::Result {
tide::log::debug!("LOGIN");
let body_string = req.body_string().await?;
let login_form: LoginForm = QS.deserialize_str(&body_string)?;
session::login(&DB, login_form.username, login_form.password)?;
let session = req.session_mut();
session.insert("logged_in", true)?;
if let Some(path) = session.get("return_to") {
let path: String = path; // tell the compiler what type i want it to be
session.remove("return_to");
Ok(to_custom(&path))
} else {
Ok(to_home())
}
}
async fn login_page(req: tide::Request<()>) -> tide::Result {
tide::log::debug!("LOGIN PAGE");
let session = req.session();
let logged_in: bool = session.get("logged_in").unwrap_or(false);
if logged_in {
tide::log::debug!("TO HOME");
return Ok(to_home());
}
let mut html = Vec::new();
templates::login(&mut html)?;
Ok(tide::Response::builder(200)
.body(html)
.content_type(
"text/html;charset=utf-8"
.parse::<tide::http::Mime>()
.unwrap(),
)
.build())
}
async fn home_page(_: tide::Request<()>) -> tide::Result {
let mut html = Vec::new();
templates::home_html(&mut html, INTERFACES.external.ip.to_string())?;
Ok(tide::Response::builder(200)
.body(html)
.content_type(
"text/html;charset=utf-8"
.parse::<tide::http::Mime>()
.unwrap(),
)
.build())
}
fn to_custom(location: &str) -> tide::Response {
tide::Response::builder(302)
.header("Location", location)
.header("Cache-Control", "no-store")
.build() .build()
} }
fn to_home() -> tide::Response {
to_custom("/")
}
fn to_login(session: &mut Session, from: &str) -> tide::Result {
session.insert("return_to", from)?;
Ok(to_custom("/login"))
}
fn to_rules_page() -> tide::Response {
to_custom("/rules")
}
async fn statics(req: tide::Request<()>) -> tide::Result { async fn statics(req: tide::Request<()>) -> tide::Result {
let file: String = req.param("file")?; let file: String = req.param("file")?;
@ -84,21 +187,42 @@ fn main() -> Result<(), anyhow::Error> {
log::with_level(log::LevelFilter::Debug); log::with_level(log::LevelFilter::Debug);
rules::apply_all(&DB, &INTERFACES).await?; session::create_admin(&DB).await?;
if !SERVER.debug {
rules::apply_all(&DB, &INTERFACES).await?;
}
let mut app = tide::new(); let mut app = tide::new();
app.with(
SessionMiddleware::new(MemoryStore::new(), &SERVER.secret)
.with_cookie_name("router.cookie")
.with_session_ttl(Some(Duration::from_secs(60 * 15))),
);
app.with(LogMiddleware::new()); app.with(LogMiddleware::new());
app.at("/static/:file").get(statics); app.at("/static/:file").get(statics);
app.at("/rules").get(rules_page).post(save_rule); app.at("/login").get(login_page).post(login);
app.at("/rules/:id").get(delete_rule); app.at("/rules")
.with(AuthMiddleware)
.get(rules_page)
.post(save_rule)
.nest({
let mut app = tide::new();
app.at("/:id").get(delete_rule);
app
});
app.at("/").get(home_page);
let listeners: Vec<String> = INTERFACES if SERVER.debug {
.internal app.listen("127.0.0.1:8080").await?;
.iter() } else {
.map(|info| format!("{}:8080", info.ip)) let listeners: Vec<String> = INTERFACES
.collect(); .internal
.iter()
.map(|info| format!("{}:80", info.ip))
.collect();
app.listen(listeners).await?; app.listen(listeners).await?;
}
Ok(()) as Result<(), anyhow::Error> Ok(()) as Result<(), anyhow::Error>
})?; })?;

22
src/middleware.rs Normal file
View file

@ -0,0 +1,22 @@
use crate::to_login;
use tide::{Middleware, Next, Request};
pub(crate) struct AuthMiddleware;
#[async_trait::async_trait]
impl<State> Middleware<State> for AuthMiddleware
where
State: Clone + Send + Sync + 'static,
{
async fn handle(&self, mut request: Request<State>, next: Next<'_, State>) -> tide::Result {
let path = request.url().path().to_string();
let session = request.session_mut();
let logged_in: bool = session.get("logged_in").unwrap_or(false);
if logged_in {
return Ok(next.run(request).await);
}
to_login(session, &path)
}
}

57
src/session.rs Normal file
View file

@ -0,0 +1,57 @@
use once_cell::sync::OnceCell;
use sled::{Db, Tree};
static USER_TREE: OnceCell<Tree> = OnceCell::new();
fn user_tree(db: &Db) -> &'static Tree {
USER_TREE.get_or_init(|| db.open_tree("users").unwrap())
}
pub(crate) fn login(db: &Db, username: String, password: String) -> Result<(), anyhow::Error> {
let tree = user_tree(db);
let hash = tree
.get(username.as_bytes())?
.ok_or_else(|| anyhow::anyhow!("Missing user {}", username))?;
let password_hash = String::from_utf8_lossy(&hash);
if bcrypt::verify(password, &password_hash)? {
return Ok(());
}
Err(anyhow::anyhow!("Invalid password"))
}
async fn add_user(db: &Db, username: String, password: String) -> Result<(), anyhow::Error> {
let tree = user_tree(db);
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?;
if tree
.compare_and_swap(username, None as Option<&[u8]>, Some(hash.as_bytes()))?
.is_err()
{
return Err(anyhow::anyhow!("User already exists"));
}
tree.flush_async().await?;
Ok(())
}
pub(crate) async fn create_admin(db: &Db) -> Result<(), anyhow::Error> {
use rand::Rng;
let password = rand::thread_rng()
.sample_iter(rand::distributions::Alphanumeric)
.take(16)
.collect::<String>();
if add_user(db, String::from("admin"), password.clone())
.await
.is_ok()
{
println!("Admin password is '{}'", password);
}
Ok(())
}

View file

@ -9,9 +9,10 @@ use std::{
mod preload; mod preload;
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct Config { pub(crate) struct Config {
interface: InterfaceConfig, interface: InterfaceConfig,
network: NetworkConfig, network: NetworkConfig,
server: ServerConfig,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@ -26,6 +27,12 @@ struct NetworkConfig {
shared_internal: bool, shared_internal: bool,
} }
#[derive(serde::Deserialize)]
struct ServerConfig {
debug: bool,
secret: String,
}
pub(crate) struct Interfaces { pub(crate) struct Interfaces {
pub(crate) external: InterfaceInfo, pub(crate) external: InterfaceInfo,
pub(crate) internal: Vec<InterfaceInfo>, pub(crate) internal: Vec<InterfaceInfo>,
@ -38,8 +45,24 @@ pub(crate) struct InterfaceInfo {
pub(crate) mask: u8, pub(crate) mask: u8,
} }
pub(crate) struct ServerState {
pub(crate) debug: bool,
pub(crate) secret: Vec<u8>,
}
impl ServerState {
pub(crate) fn init(config: &Config) -> Result<Self, anyhow::Error> {
let secret = base64::decode(&config.server.secret)?;
Ok(ServerState {
debug: config.server.debug,
secret,
})
}
}
impl Config { impl Config {
fn read() -> Result<Self, anyhow::Error> { pub(crate) fn read() -> Result<Self, anyhow::Error> {
let mut config = config::Config::new(); let mut config = config::Config::new();
config.merge(config::File::with_name("config.toml"))?; config.merge(config::File::with_name("config.toml"))?;
@ -48,8 +71,23 @@ impl Config {
} }
impl Interfaces { impl Interfaces {
pub(crate) fn init_blocking() -> Result<Self, anyhow::Error> { pub(crate) fn init_blocking(config: &Config) -> Result<Self, anyhow::Error> {
let config = Config::read()?; if config.server.debug {
// Fake Data from Fake Things for Faking :tm:
return Ok(Interfaces {
external: InterfaceInfo {
interface: String::from("eth0"),
ip: "123.123.123.123".parse()?,
mask: 20,
},
internal: vec![InterfaceInfo {
interface: String::from("enp1s0"),
ip: "192.168.6.1".parse()?,
mask: 24,
}],
shared_internal: false,
});
}
let output = Command::new("ip").arg("addr").output()?; let output = Command::new("ip").arg("addr").output()?;
let output = String::from_utf8_lossy(&output.stdout); let output = String::from_utf8_lossy(&output.stdout);

24
templates/home.rs.html Normal file
View file

@ -0,0 +1,24 @@
@use crate::templates::layout_html;
@(ip: String)
@:layout_html("Router", {
<section>
<h1>Home</h1>
</section>
<section>
<h4>Info</h4>
<div class="content">
<p>IP: @ip</p>
</div>
</section>
<section>
<h4>Admin</h4>
<nav class="content">
<ul>
<li><a href="/rules">Add traffic rules</a></li>
</ul>
</nav>
</section>
})

17
templates/layout.rs.html Normal file
View file

@ -0,0 +1,17 @@
@use crate::templates::statics::index_css;
@(title: &str, content: Content)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>@title</title>
<link rel="stylesheet" href="/static/@index_css.name" type="text/css" />
</head>
<body>
@:content()
</body>
</html>

27
templates/login.rs.html Normal file
View file

@ -0,0 +1,27 @@
@use crate::templates::{layout_html, return_home_html};
@()
@:layout_html("Login", {
<section>
<h1>Login</h1>
</section>
<section>
<form method="POST" action="/login">
<div class="form-body">
<label for="username">
<h4>Username</h4>
<input name="username" type="text" />
</label>
<label for="password">
<h4>Password</h4>
<input name="password" type="password" />
</label>
</div>
<div class="submit">
<button type="submit">Login!</button>
</div>
</form>
</section>
@:return_home_html()
})

View file

@ -0,0 +1,7 @@
@()
<section>
<div class="content">
<a href="/">Return Home</a>
</div>
</section>

View file

@ -1,102 +1,103 @@
@use crate::{ @use crate::{
rules::Rule, rules::Rule,
templates::statics::index_css, templates::{layout_html, return_home_html},
}; };
@(rules: &[(String, Rule)]) @(rules: &[(String, Rule)])
<!DOCTYPE html> @:layout_html("Rules", {
<section>
<html lang="en"> <h1>Rules</h1>
<head> </section>
<meta charset="utf-8" /> <section>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <div class="table-wrapper">
<title>Rules</title> <table>
<link rel="stylesheet" href="/static/@index_css.name" type="text/css" /> <thead>
</head> <th>Kind</th>
<body> <th>Protocol</th>
<section> <th class="port">Port</th>
<h1>Rules</h1> <th>Destination IP</th>
</section> <th class="port">Destination Port</th>
<section> <th></th>
<div class="table-wrapper"> </thead>
<table> </tbody>
<thead> @for (id, rule) in rules {
<th>Kind</th> <tr>
<th>Protocol</th> @if let Some((dest_ip, dest_port)) = rule.as_forward() {
<th class="port">Port</th> <td>Forward</td>
<th>Destination IP</th> <td>@rule.proto</td>
<th class="port">Destination Port</th> <td class="port">@rule.port</td>
<th></th> <td>@dest_ip</td>
</thead> <td class="port">@dest_port</td>
</tbody> } else {
@for (id, rule) in rules { <td>Accept</td>
<tr> <td>@rule.proto</td>
@if let Some((dest_ip, dest_port)) = rule.as_forward() { <td class="port">@rule.port</td>
<td>Forward</td> <td></td>
<td>@rule.proto</td> <td></td>
<td class="port">@rule.port</td> }
<td>@dest_ip</td> <td class="delete"><a href="/rules/@id">Delete</a></td>
<td class="port">@dest_port</td> </tr>
} else { }
<td>Accept</td> </tbody>
<td>@rule.proto</td> </table>
<td class="port">@rule.port</td> </div>
<td></td> </section>
<td></td> <section>
} <h4>Forward Port</h4>
<td class="delete"><a href="/rules/@id">Delete</a></td> <form method="POST" action="/rules">
</tr> <div class="form-body">
} <label for="proto">
</tbody> <h4>Protocol</h4>
</table> <div class="select-wrapper">
<select name="proto">
<option value="Tcp">TCP</option>
<option value="Udp">UDP</option>
</select>
</div>
</label>
<label for="port">
<h4>External Port</h4>
<input name="port" type="number" />
</label>
<input name="kind[type]" value="Forward" type="hidden" />
<label for="kind[dest_port]">
<h4>Internal Port</h4>
<input name="kind[dest_port]" type="number" />
</label>
<label for="kind[dest_ip]">
<h4>IP Address</h4>
<input name="kind[dest_ip]" type="text" />
</label>
</div> </div>
</section> <div class="submit">
<section> <button type="submit">Forward!</button>
<h4>Forward Port</h4> </div>
<form method="POST" action="/rules"> </form>
<div class="form-body"> </section>
<select name="proto"> <section>
<option value="Tcp">TCP</option> <h4>Accept Port</h4>
<option value="Udp">UDP</option> <form method="POST" action="/rules">
</select> <div class="form-body">
<label for="port"> <label for="proto">
<h4>External Port</h4> <h4>Protocol</h4>
<input name="port" type="number" /> <div class="select-wrapper">
</label> <select name="proto">
<input name="kind[type]" value="Forward" type="hidden" /> <option value="Tcp">TCP</option>
<label for="kind[dest_port]"> <option value="Udp">UDP</option>
<h4>Internal Port</h4> </select>
<input name="kind[dest_port]" type="number" /> </div>
</label> </label>
<label for="kind[dest_ip]"> <label for="port">
<h4>IP Address</h4> <h4>External Port</h4>
<input name="kind[dest_ip]" type="text" /> <input name="port" type="number" />
</label> </label>
</div> <input name="kind[type]" value="Accept" type="hidden" />
<div class="submit"> </div>
<button type="submit">Forward!</button> <div class="submit">
</div> <button type="submit">Accept!</button>
</form> </div>
</section> </form>
<section> </section>
<h4>Accept Port</h4> @:return_home_html()
<form method="POST" action="/rules"> })
<div class="form-body">
<select name="proto">
<option value="Tcp">TCP</option>
<option value="Udp">UDP</option>
</select>
<label for="port">
<h4>External Port</h4>
<input name="port" type="number" />
</label>
<input name="kind[type]" value="Accept" type="hidden" />
</div>
<div class="submit">
<button type="submit">Accept!</button>
</div>
</form>
</section>
</body>
</html>