59 Commits

Author SHA1 Message Date
6f898a6280 Version bump to 1.7.1 2025-04-11 22:22:24 +02:00
59bad8def4 Use more PathBuf wehre appropriate and possible 2025-04-11 21:56:19 +02:00
f01f6a886d Use a PathBuf instead of a String for storing the mmdb locations 2025-04-11 21:49:36 +02:00
34e5e8f95c Update maxminddb dependency to 0.26 and adapt to breaking changes 2025-04-11 21:40:41 +02:00
5c322f2611 Apply non-breaking dependency updates in Cargo.toml 2025-04-11 21:36:17 +02:00
8f0b4c5883 cargo update
Fix the collowing advisories:
* RUSTSEC-2025-0024
* RUSTSEC-2025-0023
2025-04-11 21:23:18 +02:00
c3a039be16 Fix cron expression on audit workflow 2025-04-11 21:22:23 +02:00
03685b1bde Added some basic automatic security screening using cargo audit 2025-03-28 23:02:28 +01:00
d5c5d30d32 cargo update 2025-03-20 00:04:34 +01:00
38be0c05d0 Add links for looking up results on AbuseIPDB, CrowdSec and Spamhaus 2025-03-19 22:03:50 +01:00
e81ce74a2f Upgrade to hickory 0.25
This also solves a security advisory that could have lead to a denail of service via the ring crate.
2025-03-19 21:19:05 +01:00
7130c0d94a Version bump to 1.6.0 2025-02-09 16:41:38 +01:00
47fad2007b Add privacy recommendation for reverse proxies 2025-02-09 16:37:10 +01:00
ba34caf8fc Log not found errors to the debug channel
They are part of normal operation and shouldn't be logged in production.
2025-02-09 16:27:12 +01:00
caf47522e4 Use a fallback for when the requested dns resolver isn't available 2025-02-09 16:10:35 +01:00
b98bb67b4c Make clippy happy
Mostly cleaning up type system crimes from when I was still learning rust:
* Abuse of `match` and loops
* Non-use of helper functions (`is_empty`, `is_none`)
* Use of borrowed owned types (`&String`)
* Implementing `Into` instead of `From`
2025-02-09 15:45:23 +01:00
d902dae35d Use the log create instead of println 2025-02-09 15:11:17 +01:00
2aae2d6626 Version bump to 1.5.3 2025-02-09 14:51:44 +01:00
4079e24c43 cargo update 2025-02-09 14:47:49 +01:00
2b0c4eb3fb Updated to lib-humus 0.3 and axum 0.8 2025-02-09 14:41:12 +01:00
8d055682b6 Version bump to 1.5.2 2024-12-14 19:36:59 +01:00
ff8d86ff1d Dependency updates 2024-12-14 19:35:13 +01:00
ce7632d443 Version bump to 1.5.1 2024-10-26 18:23:32 +02:00
cf82db3e87 Update dependencies 2024-10-26 18:19:39 +02:00
fecbe68c7a Cargo update 2024-10-26 18:08:33 +02:00
2e5a2408b4 Add robots meta tag to templates to make it less likely for a crawler to get stuck 2024-08-06 19:36:17 +02:00
3b4e6eba4b Update README, add maintainence mode notice 2024-08-06 19:26:09 +02:00
708fb9c0b3 Improve configurability 2024-08-06 19:17:37 +02:00
0d711648a8 Update idna to 1.0 🥳 2024-08-06 18:40:49 +02:00
1863af50f8 Remove unused configuration option 2024-08-06 18:36:40 +02:00
52d2834e98 Replace lazy_static crate with "new" std::sync::LazyLock 2024-08-06 18:35:00 +02:00
da391003e4 cargo update 2024-08-06 18:12:10 +02:00
7e58423269 Update dependencies 2024-04-21 00:38:30 +02:00
2657aae847 Template fix 2024-04-21 00:26:15 +02:00
13cb85ac5a Added an opt-in to looking up own IP-Address 2024-04-21 00:00:04 +02:00
1a973e09a0 cargo update 2024-04-20 21:47:06 +02:00
f799927f90 Cargo update 2024-03-17 22:02:20 +01:00
8695f0026f lib-humus is now on crates.io 2024-02-11 14:15:13 +01:00
3b552dba8a Downgrade clap to 4.4.18 to support "older" rust versions 2024-02-11 12:38:33 +01:00
1ce60d8291 to_trust_resolver_config() -> to_hickory_resolver_config() 2024-02-11 11:52:33 +01:00
b5097b5a03 cargo update 2024-02-11 11:51:23 +01:00
610842abac Remove unused import 2024-02-11 11:51:08 +01:00
35c71aba64 Use absolute path for icons 2023-12-29 02:51:12 +01:00
d79d949d65 Use the more efficient icon 2023-12-29 02:49:04 +01:00
b3f94b0d90 cargo update 2023-12-29 02:41:33 +01:00
96207f3960 Added a way to display the icon as part of the sitename 2023-12-29 02:37:22 +01:00
cd7a7fbe05 Added a favicon 2023-12-29 02:26:32 +01:00
aaecdb84bb Release 1.2.4 hickory 2023-12-10 18:35:16 +01:00
b08c98376c Update trust_dns to hickory
It doesn't work yet because of:
https://github.com/hickory-dns/hickory-dns/issues/2108
2023-12-10 18:34:20 +01:00
51877fc4c3 1.2.3 quickfix don't encode as numbers (they are nubers internally so that is safe) 2023-12-10 10:43:19 +01:00
396bbdb348 Release 1.2.2 2023-12-10 10:18:31 +01:00
a582c74d18 urlencode queries to external services
and add crt.sh
2023-12-10 10:16:43 +01:00
e8a21ac95f Release 1.2.1 2023-12-09 23:26:12 +01:00
d706e7c614 Update to axum 0.7 2023-12-09 23:21:19 +01:00
0bffa0fd96 Update smaller dependencies 2023-12-09 12:01:00 +01:00
fb0ce1dc0b Update trust_dns to 23.2 2023-12-09 11:54:07 +01:00
a67631fa9b cargo update 2023-12-09 11:17:33 +01:00
636e10f786 Adapted to the new syncronous interface of the HumusEngine 2023-10-30 17:44:33 +01:00
0076db531a cargo update 2023-10-30 17:41:37 +01:00
28 changed files with 2222 additions and 1097 deletions

View File

@ -0,0 +1,26 @@
---
name: Cargo Audit
on:
push:
paths:
- 'Cargo.toml'
- 'Cargo.lock'
schedule:
# Frinday evening 18:20
- cron: 20 18 * * 5
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
audit:
name: Audit
runs-on: docker
container:
image: codeberg.org/unobtanium/unobtanium-ci-builder:latest
steps:
- uses: https://codeberg.org/slatian/shell-ci-checkout@v1
- run: cargo audit

2437
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,32 @@
[package]
name = "echoip-slatecave"
version = "1.2.0"
version = "1.7.1"
edition = "2021"
authors = ["Slatian <baschdel@disroot.org>"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
lib-humus = { version="0.1", features=["axum-view+cookie"], git="https://codeberg.org/slatian/lib-humus.git" }
lib-humus = { version="0.3", features=["axum-view+cookie"] }
axum = { version = "0.6", features = ["macros", "headers"] }
axum-extra = { version = "0.7", features = ["cookie"] }
axum-client-ip = "0.4"
clap = { version = "4", features = ["derive"] }
governor = "0.5"
idna = "0.3"
lazy_static = "1.4.0"
parking_lot = "0.12"
regex = "1.7"
serde = { version = "1", features = ["derive","rc"] }
tokio = { version = "1", features = ["macros","signal"] }
tera = "1"
toml = "0.7"
tower = "0.4"
tower-http = { version = "0.4", features = ["fs"] }
trust-dns-proto = "0.22"
trust-dns-resolver = { version = "0.22", features = ["dns-over-rustls","dns-over-https","dns-over-quic"] }
maxminddb = "0.23"
axum-client-ip = "0.7"
axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
axum = { version = "0.8", features = ["macros"] }
clap = { version = "4.5", features = ["derive"] }
env_logger = "0.11"
governor = "0.10"
hickory-proto = "0.25"
hickory-resolver = { version = "0.25", features = ["tls-ring","https-ring","quic-ring","rustls-platform-verifier","system-config"] }
http = "1.3"
idna = "1.0"
log = "0.4"
maxminddb = "0.26"
mime = "0.3"
parking_lot = "0.12"
regex = "1.11"
serde = { version = "1", features = ["derive","rc"] }
tera = "1"
tokio = { version = "1", features = ["macros","signal"] }
toml = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["fs"] }

View File

@ -1,9 +1,20 @@
# echoip-slatecave
This is a service inspired by ifconfig.co, but built from scratch with a more useful feature set (in rust !!1!).
This is a service inspired by ifconfig.co, but built from scratch with a more useful feature set. Currently live on [echoip.slatecave.net](https://echoip.slatecave.net).
It is Licensed under the AGPL-v3 license.
## Maintainence Mode
This project is in maintanance mode.
This means the following will happen:
* Dependency updates
* Bugfixes
* Small quality of life improvements
But no active feature development by Slatian.
## Building
Simply run `cargo build` after cloning. The binary should be called `target/debug/echoip-slatecave`.
@ -63,10 +74,16 @@ In addition to that the following fields are accessible from inside the template
The templates are covered by the AGPL as well, please share them with your users if you modified them.
### GeoLite2 database
### Geolocation databases
For geolocation to work you need a MaxMind format database, for full functionality you need the GeoLite2-ASN and GeoLite2-City databses. Unfortunately you have to sign up with [MaxMind](https://maxmind.com) to obtain them. Once you have a license key there is a helper script in [contrib/maxmind-download.sh](contrib/maxmind-download.sh) that helps you with keeping the databse updated.
As an alternative to MaxMind there is also [DB-IP who offer their free databases without a login](https://db-ip.com/db/lite.php). You want the City and ASN databases in mmdb format.
**Don't forget to set the atttribution in the template configuration appropriately.**
See the file `templates/extra.toml`.
Since v1.0 echoip-slatecave reloads the databses when it rececieves a `USR1` signal.
## Security Considerations
@ -81,6 +98,10 @@ Most noably you can disable reverse dns lookups, hide domains with given suffixe
`echoip-slatecave` only exposes an unencrypted http interface to keep the service itself simple.
For a public service you should use a reverse proxy like Caddy, apache2 or nginx and configure the `ip_header` option, see the echoip_config.toml file. Usually the preconfigured `RightmostXForwardedFor` is the correct one, but please doublecheck it matches your servers configuration, it should fail by simply not working, but no guarantees given.
Consider hiding the values of the following in your server logs for increased privacy:
* The `query` URL query paramter
* All paths subpath to `/ip/` and `/dig/`
### Denail of Service
`echoip-slatecave` has some simle ratelimiting built in (see the `[ratelimit]` section in the configuration file) this should help you with too frequest automated requests causung high load.

View File

@ -1,5 +1,7 @@
use serde::{Deserialize,Serialize};
use trust_dns_resolver::config::Protocol;
use hickory_proto::xfer::Protocol;
use hickory_resolver::config::ResolverConfig as HickoryResolverConfig;
use hickory_resolver::config::NameServerConfig;
use std::sync::Arc;
use std::collections::HashMap;
@ -41,10 +43,11 @@ pub struct DnsResolverConfig {
pub servers: Vec<SocketAddr>,
pub protocol: DnsProtocol,
pub tls_dns_name: Option<Arc<str>>,
pub http_endpoint: Option<Arc<str>>,
#[serde(skip_serializing)] //Don't leak our bind address to the outside
pub bind_address: Option<SocketAddr>,
#[serde(default="default_true")]
pub trust_nx_responses: bool,
#[serde(default="default_true", alias="trust_nx_responses")]
pub trust_negative_responses: bool,
}
fn zero() -> i32 {
@ -71,30 +74,30 @@ impl Default for DnsConfig {
}
}
impl Into<Protocol> for DnsProtocol {
fn into(self) -> Protocol {
match self {
Self::Udp => Protocol::Udp,
Self::Tcp => Protocol::Tcp,
Self::Tls => Protocol::Tls,
Self::Https => Protocol::Https,
Self::Quic => Protocol::Quic,
impl From<DnsProtocol> for Protocol {
fn from(value: DnsProtocol) -> Self {
match value {
DnsProtocol::Udp => Protocol::Udp,
DnsProtocol::Tcp => Protocol::Tcp,
DnsProtocol::Tls => Protocol::Tls,
DnsProtocol::Https => Protocol::Https,
DnsProtocol::Quic => Protocol::Quic,
}
}
}
impl DnsResolverConfig {
pub fn to_trust_resolver_config(
pub fn to_hickory_resolver_config(
&self
) -> trust_dns_resolver::config::ResolverConfig {
let mut resolver = trust_dns_resolver::config::ResolverConfig::new();
) -> HickoryResolverConfig {
let mut resolver = HickoryResolverConfig::new();
for server in &self.servers {
resolver.add_name_server(trust_dns_resolver::config::NameServerConfig{
resolver.add_name_server(NameServerConfig{
socket_addr: *server,
protocol: self.protocol.clone().into(),
tls_dns_name: self.tls_dns_name.clone().map(|s| s.to_string()),
trust_nx_responses: self.trust_nx_responses,
tls_config: None,
http_endpoint: self.http_endpoint.as_deref().map(ToString::to_string),
trust_negative_responses: self.trust_negative_responses,
bind_addr: self.bind_address,
});
}

View File

@ -3,10 +3,11 @@ use serde::Deserialize;
use std::net::SocketAddr;
use std::num::NonZeroU32;
use std::path::PathBuf;
mod dns;
pub use crate::config::dns::{DnsConfig, DnsProtocol, DnsResolverConfig};
pub use crate::config::dns::{DnsConfig, DnsResolverConfig};
#[derive(Deserialize, Default, Clone)]
pub struct EchoIpServiceConfig {
@ -23,14 +24,13 @@ pub struct ServerConfig {
pub ip_header: SecureClientIpSource,
pub allow_private_ip_lookup: bool,
pub static_location: Option<String>,
}
#[derive(Deserialize, Clone)]
pub struct GeoIpConfig {
pub asn_database: Option<String>,
pub location_database: Option<String>,
pub asn_database: Option<PathBuf>,
pub location_database: Option<PathBuf>,
}
#[derive(Deserialize, Clone)]
@ -53,7 +53,6 @@ impl Default for ServerConfig {
listen_on: "127.0.0.1:3000".parse().unwrap(),
ip_header: SecureClientIpSource::ConnectInfo,
allow_private_ip_lookup: false,
static_location: None,
}
}
}
@ -61,8 +60,8 @@ impl Default for ServerConfig {
impl Default for GeoIpConfig {
fn default() -> Self {
GeoIpConfig {
asn_database: Some("mmdb/GeoLite2-ASN.mmdb".to_string()),
location_database: Some("mmdb/GeoLite2-City.mmdb".to_string()),
asn_database: Some("mmdb/GeoLite2-ASN.mmdb".into()),
location_database: Some("mmdb/GeoLite2-City.mmdb".into()),
}
}
}

View File

@ -3,7 +3,7 @@
* that provides the results ready for templating.
*/
use maxminddb;
use log::{debug,info,warn,error};
use maxminddb::geoip2;
use parking_lot::RwLock;
@ -11,6 +11,7 @@ use parking_lot::RwLock;
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::path::Path;
use std::path::PathBuf;
/* Datatypes */
@ -51,11 +52,11 @@ pub struct AsnResult {
pub struct MMDBCarrier {
pub mmdb: RwLock<Option<maxminddb::Reader<Vec<u8>>>>,
pub name: String,
pub path: Option<String>,
pub path: Option<PathBuf>,
}
pub trait QueryLocation {
fn query_location_for_ip(&self, address: &IpAddr, laguages: &Vec<&String>) -> Option<LocationResult>;
fn query_location_for_ip(&self, address: &IpAddr, laguages: &[&str]) -> Option<LocationResult>;
}
pub trait QueryAsn {
@ -66,12 +67,12 @@ pub trait QueryAsn {
pub fn extract_localized_name(
names: &Option<BTreeMap<&str, &str>>,
languages: &Vec<&String>)
languages: &[&str])
-> Option<String> {
match names {
Some(names) => {
for language in languages {
if let Some(name) = names.get(language.as_str()){
if let Some(name) = names.get(language){
return Some(name.to_string())
}
}
@ -81,7 +82,7 @@ names: &Option<BTreeMap<&str, &str>>,
}
}
pub fn geoip2_city_to_named_location(item: geoip2::city::City, languages: &Vec<&String>) -> NamedLocation {
pub fn geoip2_city_to_named_location(item: geoip2::city::City, languages: &[&str]) -> NamedLocation {
NamedLocation {
iso_code: None,
geoname_id: item.geoname_id,
@ -89,7 +90,7 @@ pub fn geoip2_city_to_named_location(item: geoip2::city::City, languages: &Vec<&
}
}
pub fn geoip2_continent_to_named_location(item: geoip2::country::Continent, languages: &Vec<&String>) -> NamedLocation {
pub fn geoip2_continent_to_named_location(item: geoip2::country::Continent, languages: &[&str]) -> NamedLocation {
NamedLocation {
iso_code: item.code.map(ToString::to_string),
geoname_id: item.geoname_id,
@ -97,7 +98,7 @@ pub fn geoip2_continent_to_named_location(item: geoip2::country::Continent, lang
}
}
pub fn geoip2_country_to_named_location(item: geoip2::country::Country, languages: &Vec<&String>) -> NamedLocation {
pub fn geoip2_country_to_named_location(item: geoip2::country::Country, languages: &[&str]) -> NamedLocation {
NamedLocation {
iso_code: item.iso_code.map(ToString::to_string),
geoname_id: item.geoname_id,
@ -105,7 +106,7 @@ pub fn geoip2_country_to_named_location(item: geoip2::country::Country, language
}
}
pub fn geoip2_represented_country_to_named_location(item: geoip2::country::RepresentedCountry, languages: &Vec<&String>) -> NamedLocation {
pub fn geoip2_represented_country_to_named_location(item: geoip2::country::RepresentedCountry, languages: &[&str]) -> NamedLocation {
NamedLocation {
iso_code: item.iso_code.map(ToString::to_string),
geoname_id: item.geoname_id,
@ -113,7 +114,7 @@ pub fn geoip2_represented_country_to_named_location(item: geoip2::country::Repre
}
}
pub fn geoip2_subdivision_to_named_location(item: geoip2::city::Subdivision, languages: &Vec<&String>) -> NamedLocation {
pub fn geoip2_subdivision_to_named_location(item: geoip2::city::Subdivision, languages: &[&str]) -> NamedLocation {
NamedLocation {
iso_code: item.iso_code.map(ToString::to_string),
geoname_id: item.geoname_id,
@ -129,15 +130,21 @@ impl QueryAsn for MMDBCarrier {
match &*mmdb {
Some(mmdb) => {
match mmdb.lookup::<geoip2::Asn>(*address) {
Ok(res) => {
Ok(Some(res)) => {
Some(AsnResult {
asn: res.autonomous_system_number,
name: res.autonomous_system_organization.map(ToString::to_string),
})
},
Ok(None) => {
// Log to the debug channel.
// This isn't severe, and shouldn't be logged in production.
debug!("ASN not found in database for {address}.");
None
},
Err(e) => {
println!("Error while looking up ASN for {address}: {e}");
Default::default()
error!("Error while looking up ASN for {address}: {e}");
None
}
}
},
@ -147,12 +154,12 @@ impl QueryAsn for MMDBCarrier {
}
impl QueryLocation for MMDBCarrier {
fn query_location_for_ip(&self, address: &IpAddr, languages: &Vec<&String>) -> Option<LocationResult> {
fn query_location_for_ip(&self, address: &IpAddr, languages: &[&str]) -> Option<LocationResult> {
let mmdb = self.mmdb.read();
match &*mmdb {
Some(mmdb) => {
match mmdb.lookup::<geoip2::City>(*address) {
Ok(res) => {
Ok(Some(res)) => {
Some(LocationResult {
continent:
res.continent.map(|c| geoip2_continent_to_named_location(c, languages)),
@ -203,9 +210,15 @@ impl QueryLocation for MMDBCarrier {
},
})
},
Ok(None) => {
// Log to the debug channel.
// This isn't severe, and shouldn't be logged in production.
debug!("IP location not found in database for {address}");
None
},
Err(e) => {
println!("Error while looking up ASN for {address}: {e}");
Default::default()
error!("Error while looking up IP location for {address}: {e}");
None
}
}
},
@ -215,7 +228,7 @@ impl QueryLocation for MMDBCarrier {
}
impl MMDBCarrier {
pub fn new(name: String, path: Option<String>) -> MMDBCarrier {
pub fn new(name: String, path: Option<PathBuf>) -> MMDBCarrier {
MMDBCarrier {
mmdb: RwLock::new(None),
name: name,
@ -223,16 +236,16 @@ impl MMDBCarrier {
}
}
pub fn reload_database(&self) -> Result<(),maxminddb::MaxMindDBError> {
pub fn reload_database(&self) -> Result<(),maxminddb::MaxMindDbError> {
match &self.path {
Some(path) => self.load_database_from_path(Path::new(&path)),
Some(path) => self.load_database_from_path(path),
None => Ok(()),
}
}
pub fn load_database_from_path(&self, path: &Path) -> Result<(),maxminddb::MaxMindDBError> {
pub fn load_database_from_path(&self, path: &Path) -> Result<(),maxminddb::MaxMindDbError> {
let mut mmdb = self.mmdb.write();
println!("Loading {} from '{}' ...", &self.name, path.display());
info!("Loading {} from '{}' ...", &self.name, path.display());
match maxminddb::Reader::open_readfile(path) {
Ok(reader) => {
let wording = if mmdb.is_some() {
@ -241,13 +254,13 @@ impl MMDBCarrier {
"Loaded new"
};
*mmdb = Some(reader);
println!("{} {} with new one.", wording, &self.name);
info!("{} {} with new one.", wording, &self.name);
Ok(())
},
Err(e) => {
println!("Error while reading {}: {}", &self.name, &e);
error!("Error while reading {}: {}", &self.name, &e);
if mmdb.is_some() {
println!("Not replacing old database.");
warn!("Not replacing old database.");
}
Err(e)
},

View File

@ -14,7 +14,7 @@ pub enum NameType {
#[default]
Ascii,
Unicode,
IDN,
Idn,
}
// Note, that the
@ -32,8 +32,8 @@ pub struct IdnaName {
}
impl IdnaName {
pub fn from_string(s: &String) -> Self {
if s == "" {
pub fn from_str(s: &str) -> Self {
if s.is_empty() {
return Default::default();
}
@ -41,17 +41,17 @@ impl IdnaName {
let unicode: String;
let decoder_error;
if s.starts_with("xn--") && s.is_ascii() {
original_was = NameType::IDN;
original_was = NameType::Idn;
let (uc, ures) = idna::domain_to_unicode(s);
unicode = uc;
decoder_error = ures.map_or_else(|e| Some(e.to_string()), |_| None);
} else {
unicode = s.clone();
unicode = s.to_owned();
decoder_error = None;
};
let (idn, encoder_error) = match idna::domain_to_ascii(s) {
Ok(idn) => {
if &idn != s || original_was == NameType::IDN {
if idn != s || original_was == NameType::Idn {
(Some(idn), None)
} else {
original_was = NameType::Ascii;

View File

@ -28,6 +28,7 @@ pub enum AddressScope {
Loopback,
Reserved,
Documentation,
Nat64,
#[default]
Unknown,
}
@ -78,6 +79,9 @@ impl AddressInfo {
// Test for the documentation address 2001:db8::/32
} else if segments[0]==0x2001 && segments[1]==0x0db8 && segments[2]==0 && segments[3]==0 {
address_scope = AddressScope::Documentation;
// Test for NAT64 address 64:ff9b::/96
} else if segments[0]==0x64 && segments[1]==0xff9b {
address_scope = AddressScope::Nat64;
// Test for multicase scope
} else if addr.is_multicast() {
address_cast = AddressCast::Multicast;
@ -111,6 +115,7 @@ impl AddressInfo {
scope: address_scope
}
}
}

View File

@ -1,28 +1,35 @@
#![allow(clippy::redundant_field_names)]
#![allow(clippy::needless_return)]
use axum::{
body::Body,
extract::{
self,
Query,
State,
Extension,
},
headers,
http::Request,
handler::Handler,
http::Request,
middleware::{self, Next},
response::Response,
Router,
routing::get,
TypedHeader,
};
use axum_client_ip::SecureClientIp;
use axum_extra::headers;
use axum_extra::TypedHeader;
use clap::Parser;
use lazy_static::lazy_static;
use env_logger::Env;
use hickory_resolver::{name_server::TokioConnectionProvider, system_conf::read_system_conf, Name, ResolveError, Resolver};
use hickory_resolver::TokioResolver;
use log::{info,warn,error};
use nat64::resolve_nat64_address;
use regex::Regex;
use serde::{Deserialize,Serialize};
use tower::ServiceBuilder;
use tower_http::services::ServeDir;
use trust_dns_resolver::Name;
use trust_dns_resolver::TokioAsyncResolver;
use tower::ServiceBuilder;
use tokio::signal::unix::{
signal,
@ -32,7 +39,10 @@ use tokio::task;
use std::collections::HashMap;
use std::net::IpAddr;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::LazyLock;
use lib_humus::TemplateEngineLoader;
use lib_humus::read_toml_from_file;
@ -42,6 +52,7 @@ mod config;
mod geoip;
mod idna;
mod ipinfo;
mod nat64;
mod ratelimit;
mod settings;
mod simple_dns;
@ -57,7 +68,7 @@ use crate::idna::IdnaName;
use crate::simple_dns::DnsLookupResult;
use crate::settings::*;
use crate::view::View;
use crate::ipinfo::{AddressCast,AddressInfo,AddressScope};
use crate::ipinfo::{AddressInfo,AddressScope};
type TemplatingEngine = HumusEngine<View,QuerySettings,ResponseFormat>;
@ -66,6 +77,7 @@ pub struct SettingsQuery {
format: Option<ResponseFormat>,
lang: Option<String>,
dns: Option<String>,
dns_self_lookup: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone)]
@ -73,14 +85,35 @@ pub struct SearchQuery {
query: Option<String>,
}
/// Enumerates possible mapping strategies
#[derive(Deserialize, Serialize, Clone)]
#[serde(rename_all="snake_case")]
pub enum IpMappingStrategy {
/// See: https://en.wikipedia.org/wiki/NAT64
Nat64,
}
#[derive(Serialize, Clone)]
pub struct IpMapping {
strategy: IpMappingStrategy,
from_address: IpAddr,
to_address: IpAddr,
}
#[derive(Serialize, Clone)]
pub struct IpResult {
/// When the mapping is set the queried for address
/// was automtically replaced with the mapped to address.
mapping: Option<IpMapping>,
/// The address that was queried for or the mapping resulted in.
address: IpAddr,
hostname: Option<String>,
asn: Option<AsnResult>,
location: Option<LocationResult>,
ip_info: AddressInfo,
used_dns_resolver: Option<Arc<str>>,
reverse_dns_disabled_for_privacy: bool,
}
// We need this one to hide the partial lookup field when irelevant
@ -98,7 +131,7 @@ pub struct DigResult {
struct ServiceSharedState {
templating_engine: TemplatingEngine,
dns_resolvers: HashMap<Arc<str>,TokioAsyncResolver>,
dns_resolvers: HashMap<Arc<str>,TokioResolver>,
dns_resolver_aliases: HashMap<Arc<str>,Arc<str>>,
asn_db: geoip::MMDBCarrier,
location_db: geoip::MMDBCarrier,
@ -116,18 +149,18 @@ struct DerivedConfiguration {
#[command(author, version, long_about="A web service that tells you your ip-address and more …")]
struct CliArgs {
#[arg(short, long)]
config: Option<String>,
config: Option<PathBuf>,
#[arg(short, long)]
listen_on: Option<String>,
listen_on: Option<SocketAddr>,
#[arg(short, long)]
template_location: Option<String>,
#[arg(short,long)]
extra_config: Option<String>,
#[arg(short,long)]
static_location: Option<String>,
static_location: Option<PathBuf>,
}
fn match_domain_hidden_list(domain: &String, hidden_list: &Vec<String>) -> bool {
fn match_domain_hidden_list(domain: &str, hidden_list: &Vec<String>) -> bool {
let name = domain.trim_end_matches(".");
for suffix in hidden_list {
if name.ends_with(suffix) {
@ -139,6 +172,10 @@ fn match_domain_hidden_list(domain: &String, hidden_list: &Vec<String>) -> bool
#[tokio::main]
async fn main() {
// Initalize logger:
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
// Parse Command line arguments
let cli_args = CliArgs::parse();
@ -148,9 +185,9 @@ async fn main() {
match read_toml_from_file::<config::EchoIpServiceConfig>(&config_path) {
Ok(c) => c,
Err(e) => {
println!("Could not read confuration file!");
println!("{e}");
println!("Exiting ...");
error!("Could not read confuration file!");
error!("{e}");
error!("Exiting ...");
::std::process::exit(1);
}
}
@ -170,7 +207,7 @@ async fn main() {
let templating_engine = match template_loader.load_templates() {
Ok(t) => t.into(),
Err(e) => {
println!("{e}");
error!("{e}");
::std::process::exit(1);
}
};
@ -179,7 +216,7 @@ async fn main() {
let static_file_directory = template_loader.base_dir()+"/static";
println!("Static files will be served from: {static_file_directory}");
info!("Static files will be served from: {static_file_directory}");
// Initalize GeoIP Database
@ -198,24 +235,27 @@ async fn main() {
location_db.reload_database().ok();
// Initalize DNS resolver with os defaults
println!("Initalizing dns resolvers ...");
info!("Initalizing dns resolvers ...");
let mut dns_resolver_selectables = Vec::<Selectable>::new();
let mut dns_resolver_map: HashMap<Arc<str>,TokioAsyncResolver> = HashMap::new();
let mut dns_resolver_map: HashMap<Arc<str>,TokioResolver> = HashMap::new();
let mut dns_resolver_aliases: HashMap<Arc<str>,Arc<str>> = HashMap::new();
if config.dns.enable_system_resolver {
println!("Initalizing System resolver ...");
let res = TokioAsyncResolver::tokio_from_system_conf();
let resolver = match res {
Ok(resolver) => resolver,
info!("Initalizing System resolver ...");
match initalize_system_resolver() {
Ok(resolver) => {
info!("System resolver successfully Initalized.");
dns_resolver_map.insert(
config.dns.system_resolver_id.clone(),
resolver
);
},
Err(e) => {
println!("Error while setting up dns resolver: {e}");
error!("Problem setting up system resolver: {e}");
::std::process::exit(1);
}
};
dns_resolver_map.insert(config.dns.system_resolver_id.clone(), resolver);
dns_resolver_selectables.push(Selectable {
id: config.dns.system_resolver_id.clone(),
name: config.dns.system_resolver_name.clone(),
@ -224,11 +264,11 @@ async fn main() {
}
for (key, resolver_config) in &config.dns.resolver {
println!("Initalizing {} resolver ...", key);
let resolver = TokioAsyncResolver::tokio(
resolver_config.to_trust_resolver_config(),
info!("Initalizing {} resolver ...", key);
let resolver = TokioResolver::builder_with_config(
resolver_config.to_hickory_resolver_config(),
Default::default()
).unwrap();
).build();
dns_resolver_map.insert(key.clone(), resolver);
dns_resolver_selectables.push(Selectable {
id: key.clone(),
@ -240,7 +280,7 @@ async fn main() {
}
}
let listen_on = config.server.listen_on;
let listen_on = cli_args.listen_on.unwrap_or(config.server.listen_on);
let ip_header = config.server.ip_header.clone();
// Initialize shared state
@ -255,7 +295,7 @@ async fn main() {
});
dns_resolver_selectables.sort_by(|a,b| b.weight.cmp(&a.weight));
let default_resolver = dns_resolver_selectables.get(0)
let default_resolver = dns_resolver_selectables.first()
.map(|s| s.id.clone() )
.unwrap_or("none".into());
let derived_config = DerivedConfiguration {
@ -266,18 +306,18 @@ async fn main() {
let signal_usr1_handlers_state = shared_state.clone();
task::spawn(async move {
println!("Trying to register USR1 signal for reloading geoip databases");
info!("Trying to register USR1 signal for reloading geoip databases");
let mut signal_stream = match signal(SignalKind::user_defined1()) {
Ok(signal_stream) => signal_stream,
Err(e) => {
println!("Error while registring signal handler: {e}");
println!("Continuing without ...");
error!("Error while registring signal handler: {e}");
warn!("Continuing without geoip reaload signal ...");
return;
}
};
loop {
if None == signal_stream.recv().await { return; }
println!("Received signal USR1, reloading geoip databses!");
if signal_stream.recv().await.is_none() { return; }
info!("Received signal USR1, reloading geoip databses!");
signal_usr1_handlers_state.location_db.reload_database().ok();
signal_usr1_handlers_state.asn_db.reload_database().ok();
}
@ -286,9 +326,9 @@ async fn main() {
// Initalize axum server
let app = Router::new()
.route("/", get(handle_default_route))
.route("/dig/:name", get(handle_dig_route_with_path))
.route("/ip/:address", get(handle_ip_route_with_path))
.route("/dns_resolver/:resolver", get(handle_dns_resolver_route_with_path))
.route("/dig/{name}", get(handle_dig_route_with_path))
.route("/ip/{address}", get(handle_ip_route_with_path))
.route("/dns_resolver/{resolver}", get(handle_dns_resolver_route_with_path))
.route("/dns_resolver", get(handle_dns_resolver_route))
.route("/ua", get(user_agent_handler))
.route("/hi", get(hello_world_handler))
@ -296,7 +336,7 @@ async fn main() {
ServeDir::new(static_file_directory)
.fallback(not_found_handler.with_state(shared_state.clone()))
)
.with_state(shared_state)
.with_state(shared_state.clone())
.layer(
ServiceBuilder::new()
.layer(ip_header.into_extension())
@ -305,39 +345,62 @@ async fn main() {
.layer(middleware::from_fn(ratelimit::rate_limit_middleware))
.layer(Extension(config))
.layer(Extension(derived_config))
.layer(middleware::from_fn(settings_query_middleware))
.layer(middleware::from_fn_with_state(shared_state, settings_query_middleware))
)
;
println!("Starting Server on {} ...",listen_on);
info!("Starting Server on {} ...",listen_on);
axum::Server::bind(&listen_on)
.serve(app.into_make_service_with_connect_info::<std::net::SocketAddr>())
let listener = tokio::net::TcpListener::bind(&listen_on).await.unwrap();
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
.await
.unwrap();
}
fn initalize_system_resolver() -> Result<TokioResolver, ResolveError> {
let (system_conf, system_options) = read_system_conf()?;
let mut builder = Resolver::builder_with_config(
system_conf,
TokioConnectionProvider::default()
);
*builder.options_mut() = system_options;
async fn settings_query_middleware<B>(
return Ok(builder.build());
}
#[allow(clippy::too_many_arguments)]
async fn settings_query_middleware(
Query(query): Query<SettingsQuery>,
State(arc_state): State<Arc<ServiceSharedState>>,
Extension(config): Extension<config::EchoIpServiceConfig>,
Extension(derived_config): Extension<DerivedConfiguration>,
cookie_header: Option<TypedHeader<headers::Cookie>>,
user_agent_header: Option<TypedHeader<headers::UserAgent>>,
mut req: Request<B>,
next: Next<B>
mut req: Request<Body>,
next: Next,
) -> Response {
let state = Arc::clone(&arc_state);
let mut format = query.format;
let mut dns_resolver_id = derived_config.default_resolver;
let mut dns_resolver_id = derived_config.default_resolver.clone();
let mut test_for_resolver = false;
if let Some(resolver_id) = query.dns {
dns_resolver_id = resolver_id.into();
test_for_resolver = true;
} else if let Some(cookie_header) = cookie_header {
if let Some(resolver_id) = cookie_header.0.get("dns_resolver") {
dns_resolver_id = resolver_id.into();
test_for_resolver = true;
}
}
// Falls back to the default resolver if an invalid resolver id ws requested.
// This may be the case for bookmarked links or old cookies of a resolver was removed.
if test_for_resolver && !state.dns_resolvers.contains_key(&dns_resolver_id) {
dns_resolver_id = derived_config.default_resolver;
}
// Try to guess type from user agent
if format.is_none() {
if let Some(TypedHeader(user_agent)) = user_agent_header {
@ -356,6 +419,7 @@ async fn settings_query_middleware<B>(
lang: query.lang.unwrap_or("en".to_string()),
available_dns_resolvers: derived_config.dns_resolver_selectables,
dns_resolver_id: dns_resolver_id,
dns_disable_self_lookup: !query.dns_self_lookup.unwrap_or(false),
});
next.run(req).await
}
@ -369,7 +433,7 @@ async fn not_found_handler(
state.templating_engine.render_view(
&settings,
View::NotFound,
).await
)
}
async fn hello_world_handler(
@ -382,9 +446,9 @@ async fn hello_world_handler(
&settings,
View::Message{
title: "Hey There!".to_string(),
message: "You,You are an awesome Creature!".to_string()
message: "You are an awesome Creature!".to_string()
},
).await
)
}
@ -399,7 +463,7 @@ async fn handle_default_route(
State(arc_state): State<Arc<ServiceSharedState>>,
Extension(settings): Extension<QuerySettings>,
user_agent_header: Option<TypedHeader<headers::UserAgent>>,
SecureClientIp(address): SecureClientIp
SecureClientIp(client_ip): SecureClientIp
) -> Response {
let state = Arc::clone(&arc_state);
@ -410,17 +474,23 @@ async fn handle_default_route(
search_query,
false,
settings,
state
state,
&client_ip
).await;
}
}
let result = get_ip_result(&address, &settings.lang, &"default".into(), &state).await;
let result = get_ip_result(
&client_ip,
&settings.lang,
&settings.dns_resolver_id,
settings.dns_disable_self_lookup,
&client_ip,
&state,
).await;
let user_agent: Option<String> = match user_agent_header {
Some(TypedHeader(user_agent)) => Some(user_agent.to_string()),
None => None,
};
let user_agent: Option<String> = user_agent_header
.map(|TypedHeader(user_agent)| user_agent.to_string());
state.templating_engine.render_view(
&settings,
@ -428,34 +498,32 @@ async fn handle_default_route(
result: result,
user_agent: user_agent,
}
).await
)
}
static ASN_REGEX: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"^[Aa][Ss][Nn]?\s*(\d{1,7})$").unwrap() });
static VIA_REGEX: LazyLock<Regex> = LazyLock::new(|| { Regex::new(r"[Vv][Ii][Aa]\s+(\S+)").unwrap() });
async fn handle_search_request(
search_query: String,
this_should_have_been_an_ip: bool,
settings: QuerySettings,
arc_state: Arc<ServiceSharedState>,
client_ip: &IpAddr,
) -> Response {
let mut search_query = search_query.trim().to_string();
let mut settings = settings;
lazy_static!{
static ref ASN_REGEX: Regex = Regex::new(r"^[Aa][Ss][Nn]?\s*(\d{1,7})$").unwrap();
static ref VIA_REGEX: Regex = Regex::new(r"[Vv][Ii][Aa]\s+(\S+)").unwrap();
}
//If someone asked for an asn, give an asn answer
if let Some(asn_cap) = ASN_REGEX.captures(&search_query) {
if let Some(asn) = asn_cap.get(1).map_or(None, |m| m.as_str().parse::<u32>().ok()) {
if let Some(asn) = asn_cap.get(1).and_then(|m| m.as_str().parse::<u32>().ok()) {
// Render a dummy template that can at least link to other pages
let state = Arc::clone(&arc_state);
return state.templating_engine.render_view(
&settings,
View::Asn{asn: asn},
).await
)
}
}
@ -473,7 +541,7 @@ async fn handle_search_request(
// Try to interpret as an IP-Address
if let Ok(address) = search_query.parse() {
return handle_ip_request(address, settings, arc_state).await;
return handle_ip_request(address, settings, arc_state, client_ip).await;
}
// Fall back to treating it as a hostname
@ -494,7 +562,7 @@ async fn handle_dns_resolver_route(
state.templating_engine.render_view(
&settings,
View::DnsResolverList,
).await
)
}
@ -508,12 +576,12 @@ async fn handle_dns_resolver_route_with_path(
state.templating_engine.render_view(
&settings,
View::DnsResolver{ config: resolver.clone() },
).await
)
} else {
state.templating_engine.render_view(
&settings,
View::NotFound,
).await
)
}
}
@ -521,11 +589,12 @@ async fn handle_ip_route_with_path(
Extension(settings): Extension<QuerySettings>,
State(arc_state): State<Arc<ServiceSharedState>>,
extract::Path(query): extract::Path<String>,
SecureClientIp(client_ip): SecureClientIp
) -> Response {
if let Ok(address) = query.parse() {
return handle_ip_request(address, settings, arc_state).await
return handle_ip_request(address, settings, arc_state, &client_ip).await
} else {
return handle_search_request(query, true, settings, arc_state).await;
return handle_search_request(query, true, settings, arc_state, &client_ip).await;
}
}
@ -533,6 +602,7 @@ async fn handle_ip_request(
address: IpAddr,
settings: QuerySettings,
arc_state: Arc<ServiceSharedState>,
client_ip: &IpAddr,
) -> Response {
let state = Arc::clone(&arc_state);
@ -540,42 +610,75 @@ async fn handle_ip_request(
&address,
&settings.lang,
&settings.dns_resolver_id,
settings.dns_disable_self_lookup,
client_ip,
&state).await;
state.templating_engine.render_view(
&settings,
View::Ip{result: result}
).await
)
}
fn get_ip_mapping(address: &IpAddr) -> Option<IpMapping> {
if let IpAddr::V6(v6_address) = address {
if let Some(nat64_result) = resolve_nat64_address(*v6_address) {
return Some(IpMapping {
from_address: *address,
to_address: IpAddr::V4(nat64_result),
strategy: IpMappingStrategy::Nat64,
});
}
}
return None;
}
async fn get_ip_result(
address: &IpAddr,
lang: &String,
lang: &str,
dns_resolver_name: &Arc<str>,
dns_disable_self_lookup: bool,
client_ip: &IpAddr,
state: &ServiceSharedState,
) -> IpResult {
let ip_info = AddressInfo::new(&address);
let mapping = get_ip_mapping(address);
let original_address = address;
let address = &mapping.clone().map_or(*original_address, |m| m.to_address);
if !(ip_info.scope == AddressScope::Global || ip_info.scope == AddressScope::Shared) || ip_info.cast != AddressCast::Unicast {
if !((ip_info.scope == AddressScope::Private || ip_info.scope == AddressScope::LinkLocal) && state.config.server.allow_private_ip_lookup) {
let mut reverse_dns_disabled_for_privacy = false;
if state.config.dns.allow_reverse_lookup &&
(address == client_ip || original_address == client_ip) &&
dns_disable_self_lookup
{
reverse_dns_disabled_for_privacy = true;
}
let ip_info = AddressInfo::new(address);
// Return dummy result if:
//
// The address falls into a private range and lookup of private addresses is not allowed.
if (!state.config.server.allow_private_ip_lookup) && (ip_info.scope == AddressScope::Private || ip_info.scope == AddressScope::LinkLocal) {
return IpResult {
mapping: mapping,
address: *address,
hostname: None,
asn: None,
location: None,
ip_info: ip_info,
used_dns_resolver: None,
}
reverse_dns_disabled_for_privacy: reverse_dns_disabled_for_privacy,
}
}
// do reverse lookup
let mut hostname: Option<String> = None;
let mut used_dns_resolver: Option<Arc<str>> = None;
if state.config.dns.allow_reverse_lookup {
if state.config.dns.allow_reverse_lookup && !reverse_dns_disabled_for_privacy {
if let Some(dns_resolver) = &state.dns_resolvers.get(dns_resolver_name) {
hostname = simple_dns::reverse_lookup(&dns_resolver, &address).await;
hostname = simple_dns::reverse_lookup(dns_resolver, address).await;
used_dns_resolver = Some(dns_resolver_name.clone());
}
}
@ -586,24 +689,26 @@ async fn get_ip_result(
// location lookup
let location_result = state.location_db.query_location_for_ip(
address,
&vec![lang, &"en".to_string()]
&[lang, "en"]
);
// filter reverse lookup
if let Some(name) = &hostname {
if match_domain_hidden_list(&name, &state.config.dns.hidden_suffixes) {
if match_domain_hidden_list(name, &state.config.dns.hidden_suffixes) {
hostname = None;
used_dns_resolver = None;
}
}
IpResult{
mapping: mapping,
address: *address,
hostname: hostname,
asn: asn_result,
location: location_result,
ip_info: ip_info,
used_dns_resolver: used_dns_resolver,
reverse_dns_disabled_for_privacy: reverse_dns_disabled_for_privacy,
}
}
@ -634,26 +739,26 @@ async fn handle_dig_request(
state.templating_engine.render_view(
&settings,
View::Dig{ query: dig_query, result: dig_result}
).await
)
}
async fn get_dig_result(
dig_query: &String,
dig_query: &str,
dns_resolver_name: &Arc<str>,
state: &ServiceSharedState,
do_full_lookup: bool,
) -> DigResult {
let name = &dig_query.trim().trim_end_matches(".").to_string();
let idna_name = IdnaName::from_string(&name);
let idna_name = IdnaName::from_str(name);
if let Some(dns_resolver) = state.dns_resolvers.get(dns_resolver_name) {
if let Ok(domain_name) = Name::from_str_relaxed(name.to_owned()+".") {
if match_domain_hidden_list(&name, &state.config.dns.hidden_suffixes) {
if match_domain_hidden_list(name, &state.config.dns.hidden_suffixes) {
// Try to hide the fact that we didn't do dns resolution at all
// We resolve example.org as basic avoidance of timing sidechannels.
// WARNING: this timing sidechannel avoidance is very crude.
simple_dns::lookup(
&dns_resolver,
dns_resolver,
&Name::from_ascii("example.org.").expect("Static Dummy Name"),
do_full_lookup).await;
return DigResult {
@ -665,7 +770,7 @@ async fn get_dig_result(
} else {
return DigResult {
records: simple_dns::lookup(
&dns_resolver,
dns_resolver,
&domain_name,
do_full_lookup).await,
idn: idna_name,

23
src/nat64.rs Normal file
View File

@ -0,0 +1,23 @@
use std::net::Ipv4Addr;
use std::net::Ipv6Addr;
/// Resolves a NAT64 address if it is in the range of 64:ff9b::/96
pub fn resolve_nat64_address(from: Ipv6Addr) -> Option<Ipv4Addr> {
if is_nat64_address(&from) {
let segments = from.segments();
return Some(Ipv4Addr::new(
((segments[6] & 0xff00) >> 8) as u8,
(segments[6] & 0x00ff) as u8,
((segments[7] & 0xff00) >> 8) as u8,
(segments[7] & 0x00ff) as u8,
));
} else {
return None;
}
}
pub fn is_nat64_address(address: &Ipv6Addr) -> bool {
let segments = address.segments();
segments[0]==0x64 && segments[1]==0xff9b
}

View File

@ -1,5 +1,6 @@
use axum_client_ip::SecureClientIp;
use axum::{
body::Body,
extract::Extension,
http::{
Request,
@ -17,6 +18,7 @@ use governor::{
RateLimiter,
state::keyed::DefaultKeyedStateStore,
};
use log::debug;
use std::net::IpAddr;
use std::num::NonZeroU32;
@ -40,11 +42,11 @@ pub fn build_rate_limiting_state(
Extension(arc_limiter)
}
pub async fn rate_limit_middleware<B>(
pub async fn rate_limit_middleware(
SecureClientIp(address): SecureClientIp,
Extension(arc_limiter): Extension<Arc<SimpleRateLimiter<IpAddr>>>,
req: Request<B>,
next: Next<B>
req: Request<Body>,
next: Next
) -> Response {
let limiter = Arc::clone(&arc_limiter);
@ -54,10 +56,10 @@ pub async fn rate_limit_middleware<B>(
if limiter.check_key(&IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)).is_ok() {
let oldlen = limiter.len();
if oldlen > 100 {
println!("Doing limiter cleanup ...");
debug!("Doing limiter cleanup ...");
limiter.retain_recent();
limiter.shrink_to_fit();
println!("Old limiter store size: {oldlen} New limiter store size: {}", limiter.len());
debug!("Old limiter store size: {oldlen} New limiter store size: {}", limiter.len());
}
}
next.run(req).await

View File

@ -17,6 +17,7 @@ pub struct QuerySettings {
pub lang: String,
pub available_dns_resolvers: Vec<Selectable>,
pub dns_resolver_id: Arc<str>,
pub dns_disable_self_lookup: bool,
}
#[derive(Deserialize, Serialize, Clone)]

View File

@ -1,23 +1,24 @@
/*
* This module wraps the trust_dns_resolver library
* to generate results thaat are ready for serializing
* or templating.
* It does not aim to be reusable for any other purpose,
* the trust_dns_resolver library already does that.
*/
use trust_dns_proto::op::response_code::ResponseCode;
use trust_dns_proto::rr::{
//! This module wraps the hickory_resolver library
//! to generate results thaat are ready for serializing
//! or templating.
//! It does not aim to be reusable for any other purpose,
//! the hickory_resolver library already does that.
use hickory_proto::op::response_code::ResponseCode;
use hickory_proto::rr::{
RData,
record_type::RecordType,
};
use trust_dns_resolver::{
error::ResolveError,
error::ResolveErrorKind,
use hickory_proto::ProtoErrorKind;
use hickory_resolver::{
lookup::Lookup,
Name,
TokioAsyncResolver,
ResolveError,
ResolveErrorKind,
TokioResolver,
};
use log::{warn,error};
use tokio::join;
@ -42,6 +43,7 @@ pub struct DnsLookupResult {
pub dns_error: bool,
pub nxdomain: bool,
pub timeout: bool,
pub too_busy: bool,
pub invalid_name: bool,
pub unkown_resolver: bool,
}
@ -74,13 +76,13 @@ pub struct SrvRecord {
/* Lookup Functions*/
pub async fn reverse_lookup(
resolver: &TokioAsyncResolver,
resolver: &TokioResolver,
address: &IpAddr,
) -> Option<String> {
let revese_res = resolver.reverse_lookup(*address);
match revese_res.await {
Ok(lookup) => {
for name in lookup {
if let Some(name) = lookup.iter().next() {
return Some(name.to_string())
}
None
@ -88,11 +90,19 @@ pub async fn reverse_lookup(
Err(e) => {
let kind = e.kind();
match kind {
ResolveErrorKind::NoRecordsFound { .. } => {
ResolveErrorKind::Proto(protocol_error) => {
match protocol_error.kind() {
ProtoErrorKind::NoRecordsFound { .. } => {
//Ignore, that just happens …
// TODO: Add NSec when adding support for dnssec
}
_ => {
println!("Reverse lookup on {address} failed: {kind}");
error!("Reverse lookup on {address} failed with protocol error: {protocol_error}");
}
}
}
_ => {
error!("Reverse lookup on {address} failed: {kind}");
}
}
None
@ -121,9 +131,9 @@ pub fn set_default_if_none<T>(opt_vec: &mut Option<Vec<T>>) {
pub fn add_record_to_lookup_result(result: &mut DnsLookupResult, record: &RData){
match record {
RData::AAAA(address) => opush(&mut result.aaaa, std::net::IpAddr::V6(*address)),
RData::AAAA(aaaa) => opush(&mut result.aaaa, std::net::IpAddr::V6(aaaa.0)),
RData::ANAME(aname) => opush(&mut result.aname, aname.to_string()),
RData::A(address) => opush(&mut result.a, std::net::IpAddr::V4(*address)),
RData::A(a) => opush(&mut result.a, std::net::IpAddr::V4(a.0)),
RData::CAA(caa) => opush(&mut result.caa, caa.to_string()),
RData::CNAME(cname) => opush(&mut result.cname, cname.to_string()),
RData::MX(mx) => opush(&mut result.mx, MxRecord{
@ -154,7 +164,9 @@ pub fn add_record_to_lookup_result(result: &mut DnsLookupResult, record: &RData)
);
}
},
_ => { println!("Tried to add an unkown DNS record to results: {record}"); },
_ => {
warn!("Tried to add an unkown DNS record to results: {record}");
},
}
}
@ -177,9 +189,7 @@ pub fn integrate_lookup_result(dig_result: &mut DnsLookupResult, lookup_result:
let name = lookup.query().name();
for record in lookup.record_iter() {
if name == record.name() {
if let Some(data) = record.data() {
add_record_to_lookup_result(dig_result, data);
}
add_record_to_lookup_result(dig_result, record.data());
}
//TODO: handle additional responses
}
@ -187,27 +197,35 @@ pub fn integrate_lookup_result(dig_result: &mut DnsLookupResult, lookup_result:
Err(e) => {
match e.kind() {
ResolveErrorKind::Message(..) |
ResolveErrorKind::Msg(..) |
ResolveErrorKind::NoConnections |
ResolveErrorKind::Io(..) |
ResolveErrorKind::Proto(..) => {
dig_result.other_error = true;
println!("There was an error while doing a DNS Lookup: {e}");
ResolveErrorKind::Msg(..) => {
error!("There was an error message while doing a DNS Lookup: {e}");
}
ResolveErrorKind::Proto(protocol_error) => {
match protocol_error.kind() {
ProtoErrorKind::Busy => {
dig_result.too_busy = true;
warn!("A resource was too busy for doing a DNS Lookup.");
},
ResolveErrorKind::Timeout => {
ProtoErrorKind::Timeout => {
dig_result.timeout = true;
println!("There was a timeout while doing a DNS Lookup.");
warn!("There was a timeout while doing a DNS Lookup.");
},
ResolveErrorKind::NoRecordsFound{response_code, ..} => {
ProtoErrorKind::NoRecordsFound { response_code, .. } => {
match response_code {
ResponseCode::NXDomain => dig_result.nxdomain = true,
ResponseCode::NoError => {},
_ => {
println!("The DNS Server returned an error while doing a DNS Lookup: {response_code}");
error!("The DNS Server returned an error while doing a DNS Lookup: {response_code}");
dig_result.dns_error = true;
},
}
}
_ => {
dig_result.other_error = true;
error!("There was an error while doing a DNS Lookup: {protocol_error}");
}
}
},
_ => { /*Ignore for now*/ },
}
}
@ -218,7 +236,7 @@ pub fn integrate_lookup_result(dig_result: &mut DnsLookupResult, lookup_result:
// If do_full_lookup is false only the A and AAAA (CNAMEs planned for the future)
// records will be fetched.
pub async fn lookup(
resolver: &TokioAsyncResolver,
resolver: &TokioResolver,
name: &Name,
do_full_lookup: bool,
) -> DnsLookupResult {

View File

@ -51,10 +51,10 @@ impl HumusView<QuerySettings, ResponseFormat> for View {
fn get_cookie_header(&self, settings: &QuerySettings) -> Option<String> {
Some(
Cookie::build("dns_resolver",settings.dns_resolver_id.to_string())
Cookie::build(Cookie::new("dns_resolver",settings.dns_resolver_id.to_string()))
.path("/")
.same_site(cookie::SameSite::Strict)
.finish()
.build()
.to_string()
)
}

View File

@ -5,6 +5,9 @@
<meta charset="utf-8">
<title>{% block title %}{{ extra[view].title | default(value="…") }}{% endblock %} | {{extra.site_name|default(value="echoip")}}</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<meta name="color-scheme" content="echoip-slatecave <https://codeberg.org/slatian/service.echoip-slatecave>">
{% block robots_meta %}
{% endblock robots_meta %}
<!-- Open-Graph -->
{% block metadata %}
<meta name="description" property="og:description" content="{% block description %}{{ extra[view].description | default(value="One of the best echoip services") | escape_xml }}{% endblock %}" />
@ -24,7 +27,11 @@
<body>
<header>
<nav>
<a href="{{ extra.base_url }}" class="sitename">{{extra.site_name|default(value="echoip")}}</a>
<a href="{{ extra.base_url }}" class="sitename">
{%- if extra.display_icon -%}
<img src="{{extra.display_icon}}" alt="">
{%- endif -%}
{{extra.site_name|default(value="echoip")}}</a>
<form class="search" method="GET" action="{{ extra.base_url }}">
<input type="search" name="query" autocomplete="on" maxlength="260"
title="Search for an IP-Adress, Domain-Name, or ASN."

View File

@ -2,6 +2,8 @@
{% import "helpers.html" as helper %}
{% import "links.html" as links %}
{% block robots_meta %}<meta name="robots" content="noindex,nofollow">{% endblock %}
{% block title %}dig {{ data.query }}{% endblock %}
{% block og_title %}dig {{ data.query }}{% endblock %}
{% block h1 %}dig <code>{{ helper::breadcrumb_domain(extra=extra, name=data.query) }}</code> <small>via <a href="{{extra.base_url}}/dns_resolver/{{data.result.used_dns_resolver}}">{{data.result.used_dns_resolver}}</a></small>{% endblock %}

View File

@ -8,15 +8,26 @@ base_url="http://localhost:3000"
stylesheet = "/style.css"
# URL to and mimetype of your favicon
# favicon = ""
# favicon_mimetype = "image/png"
favicon = "/icon_64.png"
favicon_mimetype = "image/png"
# favicon_mimetype = "image/svg+xml"
# favicon_mimetype = "image/jpeg"
# Icon to display next to the title
display_icon = "/icon_64.png"
# URLs to look up v4 and v6 addresses explicitly
# If you have not configured them, comment them out, the button will stay hidden
v4_url="http://v4.localhost:3000/"
v6_url="http://v6.localhost:3000/"
# Geolocation Attribution for MaxMind
#geo_attribution_html="The Geolocation and ASN information is provided by the GeoLite2 database created by <a href='https://www.maxmind.com/'>MaxMind</a>."
# Geolocation Attribution for DB-IP
#geo_attribution_html="The Geolocation and ASN information is provided by <a href='https://db-ip.com/'>DB-IP</a>."
[404]
# configure the 404 page, this is available for other pages too!
# Use the template name as the section name.

View File

@ -17,8 +17,8 @@
<a href="{{ self::dig_link(extra=extra, name=name) }}">{% if prefix %}{{ prefix }} {% endif %}{% if fqdn or name=="." %}{{ name }}{% else %}{{ name | trim_end_matches(pat=".") }}{% endif %}</a>
{% endmacro dig %}
{% macro ip(extra, ip, text=false) %}
<a href="{{ extra.base_url }}/ip/{{ ip | urlencode_strict | replace(from="%2e", to=".") | replace(from="%3a", to=":") | safe }}"><code>{% if text %}{{ text }}{% else %}{{ ip }}{% endif %}</code></a>
{% macro ip(extra, ip, text=false, with_self_lookup=false) %}
<a href="{{ extra.base_url }}/ip/{{ ip | urlencode_strict | replace(from="%2e", to=".") | replace(from="%3a", to=":") | safe }}{% if with_self_lookup %}?dns_self_lookup=true{% endif %}"><code>{% if text %}{{ text }}{% else %}{{ ip }}{% endif %}</code></a>
{% endmacro dig %}
{% macro breadcrumb_domain(extra, name) %}

View File

@ -1,6 +1,8 @@
{% extends "ip.html" %}
{% import "helpers.html" as helper %}
{% block robots_meta %}{# Allow indexing for landing page #}{% endblock %}
{% block title %}Your IP: {{ data.result.address }}{% endblock %}
{% block og_title %}What is my IP-Address?{% endblock %}
{% block h1 %}Your IPv{% if data.result.ip_info.is_v6_address %}6{% else %}4{% endif %}: <code>{{ data.result.address }}</code>{% endblock %}

View File

@ -2,6 +2,8 @@
{% import "helpers.html" as helper %}
{% import "links.html" as links %}
{% block robots_meta %}<meta name="robots" content="noindex,nofollow">{% endblock %}
{% block title %}{{ data.result.address }}{% endblock %}
{% block og_title %}Lookup {{ data.result.address }}{% endblock %}
{% block h1 %}Lookup <code>{{ data.result.address }}</code>{% endblock %}
@ -10,6 +12,12 @@
{% block content %}
{% set r = data.result %}
{% if r.mapping %}
<section>
<h2>{{ r.mapping.strategy | title }} Mapping</h2>
<p>The address <code>{{ r.mapping.from_address }}</code> was automatically translated to <code>{{ r.mapping.to_address }}</code> using {{ r.mapping.strategy | title }}.</p>
</section>
{% endif %}
<section>
<h2>Network Information</h2>
<dl>
@ -18,6 +26,9 @@
{% if r.hostname %}
<dt>Hostname</dt>
<dd>{{ helper::dig(extra=extra, name=r.hostname) }}</dd>
{% elif r.reverse_dns_disabled_for_privacy %}
<dt>Hostname</dt>
<dd>Lookup disabled by default: {{ helper::ip(ip=r.address, extra=extra, text="enable", with_self_lookup=true)}}</dd>
{% endif %}
{% if r.asn %}
<dt><abbr="Autonomous System Number">ASN</abbr></dt>
@ -31,6 +42,7 @@
{% if r.location %}
<section>
<h2>Geolocation</h2>
{% if extra.geo_attribution_html %}
<dl>
{{ helper::place_dl(place=r.location.continent, label="Continent") }}
{{ helper::place_dl(place=r.location.country, label="Country") }}
@ -63,11 +75,11 @@
<dd><a target="_blank" href="{{ links::map_link(lat=r.location.coordinates.lat, lon=r.location.coordinates.lon)}}">lat: {{r.location.coordinates.lat}}, lon: {{r.location.coordinates.lon}}</a></dd>
{% endif %}
</dl>
<!--We have to put that there to comply with maxminds licensing-->
<p><small>
The GeoIP and ASN information is provided by the GeoLite2 database created by
<a target="_blank" href="https://www.maxmind.com">MaxMind</a>.
</small></p>
<p><small>{{extra.geo_attribution_html | safe}}</small></p>
{% else %}
<p><strong style="font-size: 2em">Please configure the <code>geo_attribution_html</code> key in the template extra configuration!</strong></p>
<p>The geolocation information will then become visible.</p>
{% endif %}
</section>
{% endif %}
{% block extra_content %}{% endblock %}

View File

@ -8,17 +8,31 @@
{% set r = data.result -%}
# {% block title %}Lookup {{ data.result.address }}{% endblock %}
{%- if r.mapping %}
## {{ r.mapping.strategy | title }} Mapping
The address {{ r.mapping.from_address }} was automatically translated to {{ r.mapping.to_address }} using {{ r.mapping.strategy | title }}.
{%- endif %}
## Network information
* Type of Address: {{ helper::ip_info(ip_info=r.ip_info) }}
{% if r.hostname -%}
* Hostname: {{ r.hostname }}
{%- elif r.reverse_dns_disabled_for_privacy %}
* Hostname: Lookup disabled by default
{%- endif %}
{% if r.asn -%}
* ASN: AS{{ r.asn.asn }}
* AS Name: {{r.asn.name}}
{%- endif -%}
{%- if r.reverse_dns_disabled_for_privacy %}
=> /ip/{{ data.result.address }}?dns_self_lookup=true Do a reverse DNS lookup
{% endif %}
{%- if r.location %}
## Geolocation
@ -52,7 +66,7 @@ lat: {{r.location.coordinates.lat}}, lon: {{r.location.coordinates.lon}}
=> {{ links::map_link(lat=r.location.coordinates.lat, lon=r.location.coordinates.lon)}}
{%- endif %}
The GeoIP and ASN information is provided by the GeoLite2 database created by MaxMind.
{{ extra.geo_attribution_html | default(value="Please configure the geo_attribution_html key in the template extra configuration.") | striptags }}
{% endif -%}
{%- block extra_content %}{% endblock -%}

View File

@ -6,22 +6,27 @@
<li><a target="_blank" href="https://client.rdap.org/?type=ip&object={{ address }}">… on client.rdap.org <small>(a modern whois, make sure to allow xhr to 3rd parties)</small></a></li>
<li><a target="_blank" href="https://www.shodan.io/host/{{ address }}">… on shodan.io <small>(limited queries per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://search.censys.io/search?resource=hosts&sort=RELEVANCE&per_page=25&virtual_hosts=EXCLUDE&q=ip%3D{{ address }}">… on search.censys.io <small>(10 query per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://www.abuseipdb.com/check/{{ address }}">… an AbuseIPDB.com</a></li>
<li><a target="_blank" href="https://app.crowdsec.net/cti/{{ address }}">… on CrowdSec.net CTI <small>(10 query's per day, wants an account)</small></a></li>
{% if not address is matching(":") %}
{# v4 only #}
<li><a target="_blank" href="https://www.virustotal.com/gui/ip-address/{{ address }}">… on virustotal.com</a></li>
{% endif %}
<li><a target="_blank" href="https://check.spamhaus.org/results?query={{ address }}">… on spamhaus.org</a></li>
</ul>
{% endmacro ip_address_links %}
{% macro domain_name_links(name) %}
<p>Look up <code>{{name}}</code></p>
<ul class="link-list">
<li><a target="_blank" href="https://www.shodan.io/domain/{{ name }}">… on shodan.io <small>(limited query's per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://search.censys.io/search?resource=hosts&sort=RELEVANCE&per_page=25&virtual_hosts=EXCLUDE&q={{ name }}">… on search.censys.io <small>(10 query's per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://www.virustotal.com/gui/domain/{{ name }}">… on virustotal.com</a></li>
<li><a target="_blank" href="https://observatory.mozilla.org/analyze/{{ name }}">… on the Mozilla Observatory (http and tls checks)</a></li>
<li><a target="_blank" href="https://internet.nl/site/{{ name }}">… on the Internet.nl Website test</a></li>
<li><a target="_blank" href="https://client.rdap.org/?type=domain&object={{ name }}">… on client.rdap.org <small>(a modern whois, make sure to allow xhr to 3rd parties)</small></a></li>
<li><a target="_blank" href="https://www.shodan.io/domain/{{ name | urlencode_strict }}">… on shodan.io <small>(limited query's per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://search.censys.io/search?resource=hosts&sort=RELEVANCE&per_page=25&virtual_hosts=EXCLUDE&q={{ name | urlencode_strict }}">… on search.censys.io <small>(10 query's per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://www.virustotal.com/gui/domain/{{ name | urlencode_strict }}">… on virustotal.com</a></li>
<li><a target="_blank" href="https://observatory.mozilla.org/analyze/{{ name | urlencode_strict }}">… on the Mozilla Observatory (http and tls checks)</a></li>
<li><a target="_blank" href="https://internet.nl/site/{{ name | urlencode_strict }}">… on the Internet.nl Website test</a></li>
<li><a target="_blank" href="https://client.rdap.org/?type=domain&object={{ name | urlencode_strict }}">… on client.rdap.org <small>(a modern whois, make sure to allow xhr to 3rd parties)</small></a></li>
<li><a target="_blank" href="https://crt.sh/?Identity={{ name | urlencode_strict }}&match==">… on crt.sh <small>(Certificate Transparancy Monitor)</small></a></li>
<li><a target="_blank" href="https://check.spamhaus.org/results?query={{ name | urlencode_strict }}">… on spamhaus.org</a></li>
</ul>
{% endmacro domain_name_links %}
@ -31,6 +36,8 @@
<li><a target="_blank" href="https://bgp.he.net/AS{{asn}}">… on Hurricane Electric BGP Toolkit</a></li>
<li><a target="_blank" href="https://radar.qrator.net/as{{asn}}">… on radar.qrator.net (BGP Tool)</a></li>
<li><a target="_blank" href="https://search.censys.io/search?resource=hosts&sort=RELEVANCE&per_page=25&virtual_hosts=EXCLUDE&q=autonomous_system.asn%3D{{asn}}">… on search.censys.io <small>(10 query's per day, wants an account)</small></a></li>
<li><a target="_blank" href="https://app.crowdsec.net/cti?q=as_num%3A{{ asn }}&page=1">… on CrowdSec.net <small>(30 queries per week, wants an account)</small></a></li>
<li><a target="_blank" href="https://check.spamhaus.org/results?query=AS{{ asn }}">… on spamhaus.org</a></li>
<li><a target="_blank" href="https://client.rdap.org/?type=autnum&object={{ asn }}">… on client.rdap.org <small>(a modern whois, make sure to allow xhr to 3rd parties)</small></a></li>
<li><a target="_blank" href="https://query.wikidata.org/#%23Select%20Wikipedia%20articles%20that%20belong%20to%20a%20given%20asn%0ASELECT%20DISTINCT%20%3Fitem%20%3Fwebsite%20%3FitemLabel%20%3FitemDescription%20%3Flang%20%3Farticle%20WHERE%20%7B%0A%20%20VALUES%20%3Fasn%20%7B%0A%20%20%20%20%22{{ asn }}%22%0A%20%20%7D%0A%20%20%3Fasn%20%5Ewdt%3AP3797%20%3Fitem.%0A%20%20OPTIONAL%20%7B%20%3Fitem%20wdt%3AP856%20%3Fwebsite.%20%7D%0A%20%20OPTIONAL%20%7B%0A%20%20%20%20%3Fitem%20%5Eschema%3Aabout%20%3Farticle.%0A%20%20%20%20%3Farticle%20schema%3AisPartOf%20_%3Ab64.%0A%20%20%20%20_%3Ab64%20wikibase%3AwikiGroup%20%22wikipedia%22.%0A%20%20%20%20%3Farticle%20schema%3AinLanguage%20%3Flang%3B%0A%20%20%20%20%20%20schema%3Aname%20%3Farticlename.%0A%20%20%20%20FILTER(((%3Flang%20%3D%20%22%5BAUTO_LANGUAGE%5D%22)%20%7C%7C%20(%3Flang%20%3D%20%22en%22))%20%7C%7C%20(%3Flang%20%3D%20%22de%22))%0A%20%20%7D%0A%20%20SERVICE%20wikibase%3Alabel%20%7B%0A%20%20%20%20bd%3AserviceParam%20wikibase%3Alanguage%20%22%5BAUTO_LANGUAGE%5D%2Cen%22.%0A%20%20%20%20%3Fitem%20rdfs%3Alabel%20%3FitemLabel%3B%0A%20%20%20%20%20%20schema%3Adescription%20%3FitemDescription.%0A%20%20%7D%0A%7D%0AORDER%20BY%20(UCASE(%3FitemLabel))">… on Wikidata and Wikipedia <small>(Press the run button in the sidebar to get results)</small></a></li>
</ul>

50
templates/static/icon.svg Normal file
View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 48 48"
version="1.1"
id="svg1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<linearGradient
id="linearGradient8">
<stop
style="stop-color:#fb9a00;stop-opacity:1;"
offset="0"
id="stop8" />
<stop
style="stop-color:#884f00;stop-opacity:1;"
offset="0.49966338"
id="stop10" />
<stop
style="stop-color:#be8700;stop-opacity:1;"
offset="1"
id="stop9" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient8"
id="linearGradient9"
x1="10.202637"
y1="35.241699"
x2="39.21582"
y2="12.833984"
gradientUnits="userSpaceOnUse" />
</defs>
<g
id="layer1">
<path
id="path2"
style="fill:url(#linearGradient9);fill-opacity:1;stroke-width:3.15427;stroke-linejoin:round;paint-order:stroke markers fill"
d="m 2,7 v 33.767595 l 1.586,0.0021 L 8.299716,45.41681 12.826,40.767584 H 46 V 7 Z" />
<path
id="rect1"
style="fill:#111111;stroke-width:3;stroke-linejoin:round;paint-order:stroke markers fill"
d="M 3 8 L 3 40 L 4.0019531 40 L 4 40.001953 L 8.2792969 44.205078 L 12.412109 40 L 45 40 L 45 8 L 3 8 z M 35.671875 11.712891 L 39.357422 11.712891 L 39.357422 36.287109 L 35.671875 36.287109 L 35.671875 17.033203 L 31.494141 21.363281 L 28.839844 18.804688 C 31.107109 16.462871 35.671875 11.712891 35.671875 11.712891 z M 8.6425781 21.542969 L 12.328125 21.542969 L 12.328125 25.228516 L 8.6425781 25.228516 L 8.6425781 21.542969 z M 20.927734 21.542969 L 24.615234 21.542969 L 24.615234 25.228516 L 20.927734 25.228516 L 20.927734 21.542969 z M 8.6425781 32.599609 L 12.328125 32.599609 L 12.328125 36.287109 L 8.6425781 36.287109 L 8.6425781 32.599609 z M 20.927734 32.599609 L 24.615234 32.599609 L 24.615234 36.287109 L 20.927734 36.287109 L 20.927734 32.599609 z " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -599,3 +599,10 @@ form.search {
background: var(--button-bg);
}
/* Custom icon style for sitename*/
.sitename > img {
height: 1.2em;
padding: 0 0.3ch;
margin-bottom: -.2em;
}