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:
parent
dbe9e90942
commit
858e14a520
5 changed files with 498 additions and 0 deletions
7
Cargo.lock
generated
Normal file
7
Cargo.lock
generated
Normal 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
13
Cargo.toml
Normal 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
56
Containerfile
Normal 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
50
naas.service
Normal 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
372
src/main.rs
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue