Initial commit: Add NaaS (No as a Service) implementation

Add complete NaaS HTTP service implementation with:
- Pure Rust implementation using only standard library
- Multiple response formats (text, JSON, XML, YAML, boolean)
- Embedded web frontend with mobile-responsive design
- Container support with Podman/Docker
- Systemd service configuration
- Health check endpoint
- CORS support

The service always responds with "no" in various formats,
optimized for minimal binary size (~1MB) and fast response times.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-09-27 16:42:47 +02:00
commit 858e14a520
5 changed files with 498 additions and 0 deletions

7
Cargo.lock generated Normal file
View file

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "naas"
version = "1.0.0"

13
Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "naas"
version = "1.0.0"
edition = "2021"
[dependencies]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"

56
Containerfile Normal file
View file

@ -0,0 +1,56 @@
# Build stage
FROM docker.io/rust:1-alpine AS builder
# Install build dependencies
RUN apk add --no-cache musl-dev
WORKDIR /app
# Copy manifests
COPY Cargo.toml ./
# Create dummy main.rs for dependency caching
RUN mkdir src && echo "fn main() {}" > src/main.rs
# Build dependencies
RUN cargo build --release
RUN rm -rf src
# Copy source code
COPY src ./src
# Touch main.rs to ensure rebuild
RUN touch src/main.rs
# Build the application
RUN cargo build --release
# Runtime stage
FROM docker.io/alpine:latest
# Install runtime dependencies
RUN apk add --no-cache ca-certificates
# Create non-root user
RUN adduser -D -u 1000 naas
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/target/release/naas /app/naas
RUN chmod +x /app/naas
# Change ownership
RUN chown -R naas:naas /app
USER naas
# Expose port
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# Run the application
CMD ["./naas"]

50
naas.service Normal file
View file

@ -0,0 +1,50 @@
[Unit]
Description=No as a Service (NaaS)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=10
TimeoutStopSec=30
# User configuration (adjust as needed)
User=naas
Group=naas
# Environment configuration
Environment="PORT=8080"
Environment="BUILDAH_FORMAT=docker"
# Container management
ExecStartPre=/usr/bin/podman rm -f naas-container 2>/dev/null || true
ExecStartPre=/usr/bin/podman build -t naas:latest /opt/naas
ExecStart=/usr/bin/podman run \
--name naas-container \
--rm \
--network=host \
--read-only \
--security-opt no-new-privileges \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
-e PORT=8080 \
naas:latest
ExecStop=/usr/bin/podman stop naas-container
ExecStopPost=/usr/bin/podman rm -f naas-container 2>/dev/null || true
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=
# Resource limits
MemoryLimit=256M
CPUQuota=50%
[Install]
WantedBy=multi-user.target

372
src/main.rs Normal file
View file

@ -0,0 +1,372 @@
use std::io::prelude::*;
use std::net::{TcpListener, TcpStream};
use std::thread;
const HTML_FRONTEND: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>No as a Service</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #ffffff;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.container {
max-width: 800px;
width: 100%;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 60px;
animation: fadeIn 0.5s ease-in;
}
h1 {
font-size: clamp(2.5rem, 8vw, 4rem);
margin-bottom: 15px;
background: linear-gradient(45deg, #e94560, #ff6b6b);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: 1.2rem;
opacity: 0.8;
}
.main-response {
text-align: center;
margin: 40px 0;
animation: pulse 2s infinite;
}
.big-no {
font-size: clamp(4rem, 15vw, 8rem);
font-weight: bold;
color: #ff6b6b;
text-shadow: 0 0 30px rgba(255, 107, 107, 0.5);
}
.buttons {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 15px;
margin: 40px 0;
}
.btn {
background: rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 107, 107, 0.5);
color: #ffffff;
padding: 15px 25px;
border-radius: 10px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.btn:hover {
background: rgba(255, 107, 107, 0.2);
border-color: #ff6b6b;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(255, 107, 107, 0.3);
}
.btn:active {
transform: translateY(0);
}
.response-box {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 25px;
margin-top: 30px;
backdrop-filter: blur(10px);
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.response-content {
font-family: 'Courier New', monospace;
color: #00ff9f;
font-size: 1.1rem;
word-break: break-all;
}
.endpoint-info {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 15px;
margin-top: 40px;
}
.endpoint-info h3 {
color: #ff6b6b;
margin-bottom: 15px;
}
.endpoint-list {
list-style: none;
}
.endpoint-list li {
padding: 8px 0;
color: rgba(255, 255, 255, 0.8);
font-family: 'Courier New', monospace;
}
.code {
background: rgba(0, 0, 0, 0.3);
padding: 2px 8px;
border-radius: 4px;
color: #00ff9f;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@media (max-width: 600px) {
.buttons {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>No as a Service</h1>
<p class="tagline">The API that always says no</p>
</header>
<div class="main-response">
<div class="big-no">NO</div>
</div>
<div class="buttons">
<button class="btn" onclick="fetchNo('text')">Plain Text</button>
<button class="btn" onclick="fetchNo('json')">JSON</button>
<button class="btn" onclick="fetchNo('bool')">Boolean</button>
<button class="btn" onclick="fetchNo('xml')">XML</button>
<button class="btn" onclick="fetchNo('yaml')">YAML</button>
<button class="btn" onclick="fetchHealth()">Health Check</button>
</div>
<div class="response-box">
<div class="response-content" id="response">
Click a button to test the API
</div>
</div>
<div class="endpoint-info">
<h3>API Endpoints</h3>
<ul class="endpoint-list">
<li><span class="code">GET /api/no</span> - Returns "no"</li>
<li><span class="code">GET /api/no?format=json</span> - Returns JSON</li>
<li><span class="code">GET /api/no?format=bool</span> - Returns boolean</li>
<li><span class="code">GET /api/no?format=xml</span> - Returns XML</li>
<li><span class="code">GET /api/no?format=yaml</span> - Returns YAML</li>
<li><span class="code">GET /health</span> - Health check</li>
</ul>
</div>
</div>
<script>
async function fetchNo(format) {
const responseEl = document.getElementById('response');
try {
const url = format === 'text' ? '/api/no' : `/api/no?format=${format}`;
const response = await fetch(url);
const contentType = response.headers.get('Content-Type');
const text = await response.text();
responseEl.textContent = text;
responseEl.style.color = '#00ff9f';
if (contentType && contentType.includes('application/json')) {
try {
const json = JSON.parse(text);
responseEl.textContent = JSON.stringify(json, null, 2);
} catch (e) {}
}
} catch (error) {
responseEl.textContent = `Error: ${error.message}`;
responseEl.style.color = '#ff6b6b';
}
}
async function fetchHealth() {
const responseEl = document.getElementById('response');
try {
const response = await fetch('/health');
const text = await response.text();
responseEl.textContent = `Status: ${response.status} - ${text}`;
responseEl.style.color = '#00ff9f';
} catch (error) {
responseEl.textContent = `Error: ${error.message}`;
responseEl.style.color = '#ff6b6b';
}
}
</script>
</body>
</html>"#;
fn main() {
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
let addr = format!("0.0.0.0:{}", port);
let listener = TcpListener::bind(&addr).expect("Failed to bind to address");
println!("No as a Service running on http://{}", addr);
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn(|| {
handle_connection(stream);
});
}
Err(e) => {
eprintln!("Connection failed: {}", e);
}
}
}
}
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
if let Err(e) = stream.read(&mut buffer) {
eprintln!("Failed to read from stream: {}", e);
return;
}
let request = String::from_utf8_lossy(&buffer[..]);
let request_line = request.lines().next().unwrap_or("");
let (status, content_type, body) = route_request(request_line);
let response = format!(
"HTTP/1.1 {}\r\n\
Content-Type: {}\r\n\
Content-Length: {}\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
\r\n\
{}",
status,
content_type,
body.len(),
body
);
if let Err(e) = stream.write_all(response.as_bytes()) {
eprintln!("Failed to write response: {}", e);
}
if let Err(e) = stream.flush() {
eprintln!("Failed to flush stream: {}", e);
}
}
fn route_request(request_line: &str) -> (&str, &str, String) {
let parts: Vec<&str> = request_line.split_whitespace().collect();
if parts.len() < 2 {
return ("400 Bad Request", "text/plain", "Bad Request".to_string());
}
let method = parts[0];
let path = parts[1];
if method == "OPTIONS" {
return ("200 OK", "text/plain", "".to_string());
}
if method != "GET" {
return ("405 Method Not Allowed", "text/plain", "Method Not Allowed".to_string());
}
match path {
"/" => ("200 OK", "text/html; charset=utf-8", HTML_FRONTEND.to_string()),
"/health" => ("200 OK", "text/plain", "OK".to_string()),
path if path.starts_with("/api/no") => handle_api_no(path),
_ => ("404 Not Found", "text/plain", "Not Found".to_string()),
}
}
fn handle_api_no(path: &str) -> (&'static str, &'static str, String) {
let format = if let Some(query_start) = path.find('?') {
let query = &path[query_start + 1..];
parse_query_param(query, "format")
} else {
None
};
match format.as_deref() {
Some("json") => (
"200 OK",
"application/json",
r#"{"answer":"no"}"#.to_string()
),
Some("bool") => (
"200 OK",
"application/json",
"false".to_string()
),
Some("xml") => (
"200 OK",
"application/xml",
r#"<?xml version="1.0" encoding="UTF-8"?><response><answer>no</answer></response>"#.to_string()
),
Some("yaml") => (
"200 OK",
"text/yaml",
"answer: no\n".to_string()
),
_ => (
"200 OK",
"text/plain",
"no".to_string()
),
}
}
fn parse_query_param(query: &str, param_name: &str) -> Option<String> {
for pair in query.split('&') {
let mut parts = pair.split('=');
if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
if key == param_name {
return Some(value.to_string());
}
}
}
None
}