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