Compare commits
10
Commits
1576c4508b
...
c8786a2a0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8786a2a0b | ||
|
|
3115ac64b2 | ||
|
|
ad4b9b9af8 | ||
|
|
a49545796a | ||
|
|
b99636d3c0 | ||
|
|
295a123717 | ||
|
|
178d3f361c | ||
|
|
1e53363271 | ||
|
|
7515431fb5 | ||
|
|
fd493f13e9 |
@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
curl \
|
curl \
|
||||||
gcc \
|
gcc \
|
||||||
finger \
|
finger \
|
||||||
|
openssh-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first to leverage Docker cache
|
# Copy requirements first to leverage Docker cache
|
||||||
|
|||||||
@@ -1,307 +1,164 @@
|
|||||||
# Finger Web Flask Application
|
# finger-web
|
||||||
|
|
||||||
A simple, modern Flask web application demonstrating basic web development concepts with clean code structure, responsive design, and best practices.
|
A Flask web application that fronts a finger daemon, with a JSON API, a CLI client, and an MCP server for Claude integration.
|
||||||
|
|
||||||
## 🚀 Features
|
## Components
|
||||||
|
|
||||||
- **Multiple Routes**: Home, About, Contact pages with clean navigation
|
| Path | Description |
|
||||||
- **Contact Form**: Functional contact form with validation and flash messages
|
|------|-------------|
|
||||||
- **JSON API**: RESTful API endpoints for data exchange
|
| `app.py` | Flask web app and JSON API |
|
||||||
- **Responsive Design**: Mobile-first design using Bootstrap 5
|
| `cli/finger.py` | Command-line client |
|
||||||
- **Error Handling**: Custom 404 and 500 error pages
|
| `mcp/server.py` | MCP server for Claude |
|
||||||
- **Modern UI**: Clean, professional interface with animations
|
|
||||||
- **Form Validation**: Client-side and server-side validation
|
|
||||||
- **Configuration Management**: Environment-based configuration
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
---
|
||||||
|
|
||||||
```
|
## Web App
|
||||||
finger-web/
|
|
||||||
├── app.py # Main Flask application
|
|
||||||
├── config.py # Configuration settings
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── README.md # Project documentation
|
|
||||||
├── .gitignore # Git ignore file
|
|
||||||
├── Dockerfile # Docker container configuration
|
|
||||||
├── .dockerignore # Docker ignore file
|
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
|
||||||
├── templates/ # HTML templates
|
|
||||||
│ ├── base.html # Base template with navigation
|
|
||||||
│ ├── index.html # Home page
|
|
||||||
│ ├── about.html # About page
|
|
||||||
│ ├── contact.html # Contact form
|
|
||||||
│ ├── 404.html # 404 error page
|
|
||||||
│ └── 500.html # 500 error page
|
|
||||||
└── static/ # Static assets
|
|
||||||
├── css/
|
|
||||||
│ └── style.css # Custom styles
|
|
||||||
└── js/
|
|
||||||
└── main.js # JavaScript functionality
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Technologies Used
|
### Requirements
|
||||||
|
|
||||||
### Backend
|
- Python 3.9+
|
||||||
- **Python 3.x** - Programming language
|
- A `finger` binary available on the server's PATH
|
||||||
- **Flask 2.3.3** - Web framework
|
|
||||||
- **Jinja2** - Template engine
|
|
||||||
- **Werkzeug** - WSGI toolkit
|
|
||||||
|
|
||||||
### Frontend
|
### Installation
|
||||||
- **HTML5** - Markup language
|
|
||||||
- **CSS3** - Styling with custom animations
|
|
||||||
- **JavaScript (ES6+)** - Interactive functionality
|
|
||||||
- **Bootstrap 5.3** - CSS framework for responsive design
|
|
||||||
|
|
||||||
## 📋 Prerequisites
|
|
||||||
|
|
||||||
- Python 3.7 or higher
|
|
||||||
- pip (Python package installer)
|
|
||||||
- Virtual environment (recommended)
|
|
||||||
|
|
||||||
## 🚀 Installation & Setup
|
|
||||||
|
|
||||||
### 1. Clone or Download the Project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If using git
|
|
||||||
git clone <repository-url>
|
|
||||||
cd finger-web
|
|
||||||
|
|
||||||
# Or download and extract the project files
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create Virtual Environment (Recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create virtual environment
|
|
||||||
python -m venv venv
|
|
||||||
|
|
||||||
# Activate virtual environment
|
|
||||||
# On Windows:
|
|
||||||
venv\Scripts\activate
|
|
||||||
# On macOS/Linux:
|
|
||||||
source venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run the Application
|
### Configuration
|
||||||
|
|
||||||
|
All settings are read from environment variables.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `SECRET_KEY` | Flask secret key | `dev-secret-key-change-in-production` |
|
||||||
|
| `FLASK_DEBUG` | Enable debug mode | `True` |
|
||||||
|
| `BASIC_AUTH_USERS` | Comma-separated `user:pass` pairs for upload auth | _(none — upload disabled)_ |
|
||||||
|
| `SCP_ENABLED` | Enable SCP transfer of uploaded plan files | `false` |
|
||||||
|
| `REMOTE_HOST` | Remote host for SCP | — |
|
||||||
|
| `REMOTE_USER` | Remote user for SCP | — |
|
||||||
|
| `REMOTE_PATH` | Remote path for SCP destination | — |
|
||||||
|
| `REMOTE_PORT` | Remote SSH port | `22` |
|
||||||
|
| `REMOTE_PRIVATE_KEY` | Path to SSH private key | — |
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python app.py
|
python app.py
|
||||||
```
|
# or with gunicorn
|
||||||
|
|
||||||
The application will start on `http://localhost:5000`
|
|
||||||
|
|
||||||
## 🌐 Available Routes
|
|
||||||
|
|
||||||
| Route | Method | Description |
|
|
||||||
|-------|--------|-------------|
|
|
||||||
| `/` | GET | Home page |
|
|
||||||
| `/about` | GET | About page |
|
|
||||||
| `/contact` | GET, POST | Contact form |
|
|
||||||
| `/api/hello` | GET | Simple JSON API endpoint |
|
|
||||||
| `/api/info` | GET | Application information API |
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
The application uses environment variables for configuration. You can set these in your environment or create a `.env` file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Flask Configuration
|
|
||||||
FLASK_DEBUG=True
|
|
||||||
SECRET_KEY=your-secret-key-here
|
|
||||||
|
|
||||||
# Database (for future use)
|
|
||||||
DATABASE_URL=sqlite:///app.db
|
|
||||||
|
|
||||||
# Mail Configuration (for future use)
|
|
||||||
MAIL_SERVER=smtp.gmail.com
|
|
||||||
MAIL_PORT=587
|
|
||||||
MAIL_USE_TLS=True
|
|
||||||
MAIL_USERNAME=[email protected]
|
|
||||||
MAIL_PASSWORD=your-app-password
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📱 API Endpoints
|
|
||||||
|
|
||||||
### GET /api/hello
|
|
||||||
Returns a simple greeting message.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Hello from Flask API!",
|
|
||||||
"status": "success",
|
|
||||||
"version": "1.0"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /api/info
|
|
||||||
Returns application information and available routes.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"app_name": "Finger Web Flask App",
|
|
||||||
"routes": ["/", "/about", "/contact", "/api/hello", "/api/info"],
|
|
||||||
"framework": "Flask"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Customization
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
- Edit `static/css/style.css` to customize the appearance
|
|
||||||
- The app uses Bootstrap 5 classes for responsive design
|
|
||||||
- Custom CSS variables and animations are included
|
|
||||||
|
|
||||||
### JavaScript
|
|
||||||
- Modify `static/js/main.js` for additional functionality
|
|
||||||
- Includes form validation, animations, and keyboard shortcuts
|
|
||||||
- API helper functions are available
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
- All HTML templates extend `templates/base.html`
|
|
||||||
- Use Jinja2 template syntax for dynamic content
|
|
||||||
- Bootstrap components are readily available
|
|
||||||
|
|
||||||
## 🔍 Features in Detail
|
|
||||||
|
|
||||||
### Contact Form
|
|
||||||
- Client-side validation with real-time feedback
|
|
||||||
- Server-side validation and sanitization
|
|
||||||
- Flash messages for user feedback
|
|
||||||
- Form submission with loading states
|
|
||||||
|
|
||||||
### Responsive Design
|
|
||||||
- Mobile-first approach
|
|
||||||
- Bootstrap grid system
|
|
||||||
- Custom breakpoints and animations
|
|
||||||
- Touch-friendly interface
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Custom 404 and 500 error pages
|
|
||||||
- Graceful error handling in routes
|
|
||||||
- User-friendly error messages
|
|
||||||
|
|
||||||
### JavaScript Features
|
|
||||||
- Form validation and enhancement
|
|
||||||
- Smooth scrolling navigation
|
|
||||||
- Card animations on scroll
|
|
||||||
- Keyboard shortcuts (Alt+H, Alt+A, Alt+C)
|
|
||||||
- API interaction helpers
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
python app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
#### Option 1: Using Docker directly
|
|
||||||
```bash
|
|
||||||
# Build the Docker image
|
|
||||||
docker build -t finger-web .
|
|
||||||
|
|
||||||
# Run the container
|
|
||||||
docker run -d -p 5000:5000 --name finger-web-app finger-web
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option 2: Using Docker Compose (Recommended)
|
|
||||||
```bash
|
|
||||||
# Build and start the application
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Stop the application
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker Commands
|
|
||||||
```bash
|
|
||||||
# Build image
|
|
||||||
docker build -t finger-web .
|
|
||||||
|
|
||||||
# Run container with environment variables
|
|
||||||
docker run -d \
|
|
||||||
-p 5000:5000 \
|
|
||||||
-e FLASK_ENV=production \
|
|
||||||
-e SECRET_KEY=your-secret-key \
|
|
||||||
--name finger-web-app \
|
|
||||||
finger-web
|
|
||||||
|
|
||||||
# View container logs
|
|
||||||
docker logs finger-web-app
|
|
||||||
|
|
||||||
# Stop and remove container
|
|
||||||
docker stop finger-web-app
|
|
||||||
docker rm finger-web-app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
For production deployment, consider using:
|
|
||||||
- **Docker** for containerization (included)
|
|
||||||
- **Docker Compose** for orchestration (included)
|
|
||||||
- **Gunicorn** as WSGI server
|
|
||||||
- **Nginx** as reverse proxy
|
|
||||||
- **Heroku**, **DigitalOcean**, or **AWS** for hosting
|
|
||||||
|
|
||||||
Example with Gunicorn:
|
|
||||||
```bash
|
|
||||||
pip install gunicorn
|
|
||||||
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔒 Security Considerations
|
### Docker
|
||||||
|
|
||||||
- Change the `SECRET_KEY` in production
|
```bash
|
||||||
- Use environment variables for sensitive data
|
docker-compose up -d
|
||||||
- Enable HTTPS in production
|
```
|
||||||
- Implement rate limiting for forms
|
|
||||||
- Validate and sanitize all user inputs
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
1. Fork the project
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes
|
|
||||||
4. Test thoroughly
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
This project is open source and available under the [MIT License](LICENSE).
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
If you encounter any issues or have questions:
|
|
||||||
1. Check the existing documentation
|
|
||||||
2. Review the code comments
|
|
||||||
3. Test in a clean virtual environment
|
|
||||||
4. Create an issue with detailed information
|
|
||||||
|
|
||||||
## 🎯 Future Enhancements
|
|
||||||
|
|
||||||
- Database integration with SQLAlchemy
|
|
||||||
- User authentication and sessions
|
|
||||||
- Email functionality for contact form
|
|
||||||
- Admin dashboard
|
|
||||||
- API rate limiting
|
|
||||||
- Unit tests
|
|
||||||
- CI/CD pipeline
|
|
||||||
- Kubernetes deployment manifests
|
|
||||||
- Monitoring and logging integration
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Built with ❤️ using Flask and Bootstrap**
|
## API
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth | Description |
|
||||||
|
|----------|--------|------|-------------|
|
||||||
|
| `/finger` | GET/POST | — | Web UI finger query |
|
||||||
|
| `/finger/<username>` | GET | — | Web UI finger query (URL form) |
|
||||||
|
| `/api/finger` | GET | — | JSON: list logged-in users |
|
||||||
|
| `/api/finger/<username>` | GET | — | JSON: finger a specific user |
|
||||||
|
| `/api/upload` | POST | Basic | Upload a plan file |
|
||||||
|
| `/api/info` | GET | — | API metadata |
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5000/api/finger/[email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"username": "[email protected]",
|
||||||
|
"result": "Login: pete\t\t\tName: Pete Blair\n..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r cli/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FINGER_WEB_URL=http://localhost:5000
|
||||||
|
export FINGER_USER=youruser # only needed for plan uploads
|
||||||
|
export FINGER_PASS=yourpassword # only needed for plan uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Finger a user
|
||||||
|
python cli/finger.py query [email protected]
|
||||||
|
|
||||||
|
# Upload your plan file
|
||||||
|
python cli/finger.py plan ~/.plan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Server
|
||||||
|
|
||||||
|
Exposes finger query and plan upload as tools for Claude.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r mcp/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FINGER_WEB_URL=http://localhost:5000
|
||||||
|
export FINGER_USER=youruser # only needed for upload_plan tool
|
||||||
|
export FINGER_PASS=yourpassword # only needed for upload_plan tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python mcp/server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Desktop configuration
|
||||||
|
|
||||||
|
Add to your `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"finger": {
|
||||||
|
"command": "python",
|
||||||
|
"args": ["/path/to/finger-web/mcp/server.py"],
|
||||||
|
"env": {
|
||||||
|
"FINGER_WEB_URL": "http://localhost:5000",
|
||||||
|
"FINGER_USER": "youruser",
|
||||||
|
"FINGER_PASS": "yourpassword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `finger_user(username)` | Query finger info for a user, or leave empty to list logged-in users |
|
||||||
|
| `upload_plan(filename, content)` | Upload or update a plan file |
|
||||||
|
|||||||
@@ -1,127 +1,398 @@
|
|||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
|
||||||
|
from flask_httpauth import HTTPBasicAuth
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import shlex
|
import shlex
|
||||||
|
import datetime
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
|
# Trust the front-end nginx's X-Forwarded-For so the real client IP is used for
|
||||||
|
# logging and rate limiting. Without this the app only ever sees the Docker
|
||||||
|
# bridge gateway (172.20.0.1), so every client on the internet would share one
|
||||||
|
# rate-limit bucket and log line.
|
||||||
|
app.wsgi_app = ProxyFix(
|
||||||
|
app.wsgi_app,
|
||||||
|
x_for=app.config['PROXY_FORWARDED_COUNT'],
|
||||||
|
x_proto=1,
|
||||||
|
x_host=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize HTTP Basic Auth
|
||||||
|
auth = HTTPBasicAuth()
|
||||||
|
|
||||||
|
# Per-client-IP rate limiting. The app only ever receives nginx cache *misses*
|
||||||
|
# (upstream caches 200s for 30s), so these limits throttle exactly the uncached
|
||||||
|
# finger-daemon lookups (e.g. username enumeration) without touching the cached
|
||||||
|
# hot path that legitimate Fediverse previews ride on.
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
app=app,
|
||||||
|
default_limits=[l.strip() for l in app.config['RATELIMIT_DEFAULT'].split(';') if l.strip()],
|
||||||
|
storage_uri=app.config['RATELIMIT_STORAGE_URI'],
|
||||||
|
enabled=app.config['RATELIMIT_ENABLED'],
|
||||||
|
headers_enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@auth.verify_password
|
||||||
|
def verify_password(username, password):
|
||||||
|
"""Verify basic authentication credentials against multiple users"""
|
||||||
|
return (username in app.config['BASIC_AUTH_USERS'] and
|
||||||
|
app.config['BASIC_AUTH_USERS'][username] == password)
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
"""Check if file extension is allowed"""
|
||||||
|
return ('.' in filename and
|
||||||
|
filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS'])
|
||||||
|
|
||||||
|
def safe_decode_output(raw_output):
|
||||||
|
"""
|
||||||
|
Safely decode subprocess output with fallback encoding strategies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_output (bytes): Raw bytes output from subprocess
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Decoded string output
|
||||||
|
"""
|
||||||
|
if isinstance(raw_output, str):
|
||||||
|
return raw_output
|
||||||
|
|
||||||
|
# List of encodings to try in order
|
||||||
|
encodings = ['utf-8', 'latin-1', 'cp1252', 'iso-8859-1']
|
||||||
|
|
||||||
|
for encoding in encodings:
|
||||||
|
try:
|
||||||
|
return raw_output.decode(encoding)
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If all encodings fail, use utf-8 with error handling
|
||||||
|
try:
|
||||||
|
return raw_output.decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
# Last resort: convert to string representation
|
||||||
|
return str(raw_output, errors='ignore')
|
||||||
|
|
||||||
|
def run_finger_command(cmd):
|
||||||
|
"""
|
||||||
|
Execute finger command with robust encoding handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd (list): Command list to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, result_or_error, is_error)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# First try with text=True (UTF-8 decoding)
|
||||||
|
process = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
result = process.stdout
|
||||||
|
if not result.strip():
|
||||||
|
result = "No information available."
|
||||||
|
return True, result, False
|
||||||
|
else:
|
||||||
|
error = process.stderr or "Finger command failed."
|
||||||
|
return False, error, True
|
||||||
|
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# Handle UTF-8 decoding error by using binary mode
|
||||||
|
try:
|
||||||
|
process = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=False, # Get raw bytes
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
result = safe_decode_output(process.stdout)
|
||||||
|
if not result.strip():
|
||||||
|
result = "No information available."
|
||||||
|
return True, result, False
|
||||||
|
else:
|
||||||
|
error = safe_decode_output(process.stderr) or "Finger command failed."
|
||||||
|
return False, error, True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Encoding error occurred: {str(e)}", True
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Command timed out. Please try again.", True
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "Finger command not available on this system.", True
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"An error occurred: {str(e)}", True
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
@limiter.exempt
|
||||||
def index():
|
def index():
|
||||||
"""Home page route"""
|
"""Home page route (exempt from limits; also serves the container healthcheck)"""
|
||||||
return render_template('index.html', title='Home')
|
return render_template('index.html', title='Home')
|
||||||
|
|
||||||
@app.route('/finger', methods=['GET', 'POST'])
|
@app.route('/finger', methods=['GET', 'POST'])
|
||||||
|
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
|
||||||
def finger():
|
def finger():
|
||||||
"""Finger command route"""
|
"""Finger search form.
|
||||||
|
|
||||||
|
Any submitted username is redirected to the canonical
|
||||||
|
``/finger/<username>`` path so the lookup happens on a cacheable URL with
|
||||||
|
the search term in it — repeated searches for the same user are served from
|
||||||
|
the nginx response cache instead of re-running the finger command. A bare
|
||||||
|
``/finger`` with no user shows the local system users (no daemon enumeration
|
||||||
|
risk: it never takes external input).
|
||||||
|
"""
|
||||||
|
username = (request.args.get('user', '')
|
||||||
|
or request.args.get('username', '')
|
||||||
|
or request.form.get('username', '')).strip()
|
||||||
|
|
||||||
|
if username:
|
||||||
|
# Send the search to the cacheable canonical path.
|
||||||
|
return redirect(url_for('finger_direct', username=username))
|
||||||
|
|
||||||
|
# No user supplied: list logged-in system users.
|
||||||
result = None
|
result = None
|
||||||
error = None
|
error = None
|
||||||
username = request.args.get('user', '') or request.form.get('username', '')
|
success, output, is_error = run_finger_command(['finger'])
|
||||||
|
if success:
|
||||||
if request.method == 'POST' or username:
|
result = output
|
||||||
try:
|
else:
|
||||||
# Sanitize username input
|
error = output
|
||||||
if username:
|
|
||||||
# Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames
|
|
||||||
if not all(c.isalnum() or c in '.-_@' for c in username):
|
|
||||||
error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed."
|
|
||||||
else:
|
|
||||||
# Execute finger command with specific user
|
|
||||||
cmd = ['finger', username]
|
|
||||||
else:
|
|
||||||
# Execute finger command without user (show all logged in users)
|
|
||||||
cmd = ['finger']
|
|
||||||
|
|
||||||
if not error:
|
|
||||||
# Execute the command safely
|
|
||||||
process = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if process.returncode == 0:
|
|
||||||
result = process.stdout
|
|
||||||
if not result.strip():
|
|
||||||
result = "No information available."
|
|
||||||
else:
|
|
||||||
error = process.stderr or "Finger command failed."
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
error = "Command timed out. Please try again."
|
|
||||||
except FileNotFoundError:
|
|
||||||
error = "Finger command not available on this system."
|
|
||||||
except Exception as e:
|
|
||||||
error = f"An error occurred: {str(e)}"
|
|
||||||
|
|
||||||
return render_template('finger.html', title='Finger', result=result, error=error, username=username)
|
return render_template('finger.html', title='Finger', result=result, error=error, username=username)
|
||||||
|
|
||||||
@app.route('/finger/<path:username>')
|
@app.route('/finger/<path:username>')
|
||||||
|
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
|
||||||
def finger_direct(username):
|
def finger_direct(username):
|
||||||
"""Direct finger command route with username in URL"""
|
"""Direct finger command route with username in URL"""
|
||||||
result = None
|
result = None
|
||||||
error = None
|
error = None
|
||||||
|
|
||||||
try:
|
# Sanitize username input
|
||||||
# Sanitize username input
|
if username:
|
||||||
if username:
|
# Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames
|
||||||
# Allow alphanumeric characters, dots, hyphens, underscores, and @ symbol for email-style usernames
|
if not all(c.isalnum() or c in '.-_@' for c in username):
|
||||||
if not all(c.isalnum() or c in '.-_@' for c in username):
|
error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed."
|
||||||
error = "Invalid username format. Only alphanumeric characters, dots, hyphens, underscores, and @ symbol are allowed."
|
app.logger.warning("rejected invalid finger username %r from %s",
|
||||||
|
username, get_remote_address())
|
||||||
|
else:
|
||||||
|
# Execute finger command with specific user
|
||||||
|
cmd = ['finger', username]
|
||||||
|
|
||||||
|
# Execute the command with robust encoding handling
|
||||||
|
success, output, is_error = run_finger_command(cmd)
|
||||||
|
if success:
|
||||||
|
result = output
|
||||||
else:
|
else:
|
||||||
# Execute finger command with specific user
|
error = output
|
||||||
cmd = ['finger', username]
|
app.logger.warning("finger lookup failed for %r from %s: %s",
|
||||||
|
username, get_remote_address(), output.strip()[:200])
|
||||||
# Execute the command safely
|
|
||||||
process = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
|
|
||||||
if process.returncode == 0:
|
|
||||||
result = process.stdout
|
|
||||||
if not result.strip():
|
|
||||||
result = "No information available."
|
|
||||||
else:
|
|
||||||
error = process.stderr or "Finger command failed."
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
error = "Command timed out. Please try again."
|
|
||||||
except FileNotFoundError:
|
|
||||||
error = "Finger command not available on this system."
|
|
||||||
except Exception as e:
|
|
||||||
error = f"An error occurred: {str(e)}"
|
|
||||||
|
|
||||||
return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username)
|
return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username)
|
||||||
|
|
||||||
@app.route('/api/hello')
|
@app.route('/api/finger')
|
||||||
def api_hello():
|
@app.route('/api/finger/<path:username>')
|
||||||
"""Simple JSON API endpoint"""
|
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
|
||||||
return jsonify({
|
def api_finger(username=None):
|
||||||
'message': 'Hello from Flask API!',
|
"""JSON API endpoint for finger queries"""
|
||||||
'status': 'success',
|
if username is None:
|
||||||
'version': '1.0'
|
username = request.args.get('user', '')
|
||||||
})
|
|
||||||
|
if username:
|
||||||
|
if not all(c.isalnum() or c in '.-_@' for c in username):
|
||||||
|
return jsonify({'error': 'Invalid username', 'status': 'error'}), 400
|
||||||
|
cmd = ['finger', username]
|
||||||
|
else:
|
||||||
|
cmd = ['finger']
|
||||||
|
|
||||||
|
success, output, is_error = run_finger_command(cmd)
|
||||||
|
if success:
|
||||||
|
return jsonify({'result': output, 'username': username, 'status': 'success'})
|
||||||
|
return jsonify({'error': output, 'status': 'error'}), 500
|
||||||
|
|
||||||
@app.route('/api/info')
|
@app.route('/api/info')
|
||||||
def api_info():
|
def api_info():
|
||||||
"""API info endpoint"""
|
"""API info endpoint"""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'app_name': 'Finger Web Flask App',
|
'app_name': 'finger-web',
|
||||||
'routes': [
|
'routes': [
|
||||||
'/',
|
'/',
|
||||||
'/finger',
|
'/finger',
|
||||||
'/finger/<username>',
|
'/finger/<username>',
|
||||||
'/api/hello',
|
'/api/finger',
|
||||||
'/api/info'
|
'/api/finger/<username>',
|
||||||
|
'/api/info',
|
||||||
|
'/api/upload'
|
||||||
],
|
],
|
||||||
'framework': 'Flask'
|
'framework': 'Flask'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route('/api/upload', methods=['POST'])
|
||||||
|
@limiter.limit(lambda: app.config['RATELIMIT_UPLOAD'])
|
||||||
|
@auth.login_required
|
||||||
|
def upload_file():
|
||||||
|
"""File upload endpoint with basic authentication and SCP transfer"""
|
||||||
|
try:
|
||||||
|
# Extract authenticated username
|
||||||
|
authenticated_user = auth.current_user()
|
||||||
|
|
||||||
|
# Check if the post request has the file part
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'No file part in the request',
|
||||||
|
'status': 'error'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
|
||||||
|
# Check if user selected a file
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({
|
||||||
|
'error': 'No file selected',
|
||||||
|
'status': 'error'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check if file is allowed
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({
|
||||||
|
'error': f'File type not allowed. Allowed types: {", ".join(app.config["ALLOWED_EXTENSIONS"])}',
|
||||||
|
'status': 'error'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if file:
|
||||||
|
# Generate secure filename with timestamp
|
||||||
|
original_filename = secure_filename(file.filename)
|
||||||
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
filename = f"{timestamp}_{original_filename}"
|
||||||
|
|
||||||
|
# Ensure upload directory exists
|
||||||
|
upload_folder = app.config['UPLOAD_FOLDER']
|
||||||
|
if not os.path.exists(upload_folder):
|
||||||
|
os.makedirs(upload_folder)
|
||||||
|
|
||||||
|
# Save file locally
|
||||||
|
file_path = os.path.join(upload_folder, filename)
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Get file info
|
||||||
|
file_size = os.path.getsize(file_path)
|
||||||
|
|
||||||
|
# Initialize response data
|
||||||
|
response_data = {
|
||||||
|
'message': 'File uploaded successfully',
|
||||||
|
'status': 'success',
|
||||||
|
'file_info': {
|
||||||
|
'original_filename': original_filename,
|
||||||
|
'saved_filename': filename,
|
||||||
|
'local_file_path': file_path,
|
||||||
|
'file_size': file_size,
|
||||||
|
'upload_time': datetime.datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# SCP file to remote server if enabled
|
||||||
|
if app.config['SCP_ENABLED']:
|
||||||
|
try:
|
||||||
|
remote_file_path = os.path.join(app.config['REMOTE_PATH'], authenticated_user).replace('\\', '/')
|
||||||
|
scp_destination = f"{app.config['REMOTE_USER']}@{app.config['REMOTE_HOST']}:{remote_file_path}"
|
||||||
|
|
||||||
|
# Build SCP command
|
||||||
|
scp_cmd = [
|
||||||
|
'scp',
|
||||||
|
'-P', str(app.config['REMOTE_PORT']),
|
||||||
|
'-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'UserKnownHostsFile=/dev/null'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add private key if specified
|
||||||
|
if app.config['REMOTE_PRIVATE_KEY']:
|
||||||
|
scp_cmd.extend(['-i', app.config['REMOTE_PRIVATE_KEY']])
|
||||||
|
|
||||||
|
# Add source and destination
|
||||||
|
scp_cmd.extend([file_path, scp_destination])
|
||||||
|
|
||||||
|
# Execute SCP command
|
||||||
|
scp_process = subprocess.run(
|
||||||
|
scp_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if scp_process.returncode == 0:
|
||||||
|
response_data['scp_info'] = {
|
||||||
|
'remote_host': app.config['REMOTE_HOST'],
|
||||||
|
'remote_path': remote_file_path,
|
||||||
|
'scp_status': 'success',
|
||||||
|
'scp_message': 'File successfully transferred to remote server'
|
||||||
|
}
|
||||||
|
response_data['message'] = 'File uploaded and transferred to remote server successfully'
|
||||||
|
else:
|
||||||
|
response_data['scp_info'] = {
|
||||||
|
'scp_status': 'failed',
|
||||||
|
'scp_error': scp_process.stderr or 'SCP transfer failed',
|
||||||
|
'scp_message': 'File uploaded locally but remote transfer failed'
|
||||||
|
}
|
||||||
|
response_data['message'] = 'File uploaded locally but remote transfer failed'
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
response_data['scp_info'] = {
|
||||||
|
'scp_status': 'timeout',
|
||||||
|
'scp_error': 'SCP transfer timed out',
|
||||||
|
'scp_message': 'File uploaded locally but remote transfer timed out'
|
||||||
|
}
|
||||||
|
response_data['message'] = 'File uploaded locally but remote transfer timed out'
|
||||||
|
|
||||||
|
except Exception as scp_error:
|
||||||
|
response_data['scp_info'] = {
|
||||||
|
'scp_status': 'error',
|
||||||
|
'scp_error': str(scp_error),
|
||||||
|
'scp_message': 'File uploaded locally but SCP failed'
|
||||||
|
}
|
||||||
|
response_data['message'] = 'File uploaded locally but SCP failed'
|
||||||
|
else:
|
||||||
|
response_data['scp_info'] = {
|
||||||
|
'scp_status': 'disabled',
|
||||||
|
'scp_message': 'SCP transfer is disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(response_data), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'Upload failed: {str(e)}',
|
||||||
|
'status': 'error'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.errorhandler(429)
|
||||||
|
def ratelimit_handler(error):
|
||||||
|
"""Handle rate-limit (429) responses; JSON for the API, HTML otherwise"""
|
||||||
|
app.logger.warning(
|
||||||
|
"Rate limit exceeded for %s on %s (%s)",
|
||||||
|
get_remote_address(), request.path, error.description,
|
||||||
|
)
|
||||||
|
if request.path.startswith('/api/'):
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Rate limit exceeded',
|
||||||
|
'detail': str(error.description),
|
||||||
|
'status': 'error',
|
||||||
|
}), 429
|
||||||
|
return render_template('429.html', title='Too Many Requests',
|
||||||
|
detail=error.description), 429
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found_error(error):
|
def not_found_error(error):
|
||||||
"""Handle 404 errors"""
|
"""Handle 404 errors"""
|
||||||
|
|||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""finger-web CLI — query finger users and manage plan files.
|
||||||
|
|
||||||
|
Configuration via environment variables:
|
||||||
|
FINGER_WEB_URL Base URL of the finger-web instance (default: http://localhost:5000)
|
||||||
|
FINGER_USER Username for authenticated endpoints (upload)
|
||||||
|
FINGER_PASS Password for authenticated endpoints (upload)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
except ImportError:
|
||||||
|
print("requests is required: pip install requests", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("FINGER_WEB_URL", "http://localhost:5000").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_auth():
|
||||||
|
user = os.environ.get("FINGER_USER", "")
|
||||||
|
passwd = os.environ.get("FINGER_PASS", "")
|
||||||
|
return (user, passwd) if user and passwd else None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_query(args):
|
||||||
|
target = args.username
|
||||||
|
url = f"{BASE_URL}/api/finger/{target}" if target else f"{BASE_URL}/api/finger"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("status") == "success":
|
||||||
|
print(data.get("result", "").rstrip())
|
||||||
|
else:
|
||||||
|
print(data.get("error", "Unknown error"), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"Cannot connect to {BASE_URL}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f"HTTP error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_plan(args):
|
||||||
|
auth = _get_auth()
|
||||||
|
if not auth:
|
||||||
|
print(
|
||||||
|
"Set FINGER_USER and FINGER_PASS environment variables to authenticate.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
plan_file = args.file
|
||||||
|
if not os.path.exists(plan_file):
|
||||||
|
print(f"File not found: {plan_file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(plan_file, "rb") as f:
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{BASE_URL}/api/upload",
|
||||||
|
files={"file": (os.path.basename(plan_file), f, "text/plain")},
|
||||||
|
auth=auth,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
print(data.get("message", "Upload successful."))
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"Cannot connect to {BASE_URL}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f"HTTP error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="finger-web",
|
||||||
|
description="Query finger users and manage plan files via finger-web.",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
||||||
|
|
||||||
|
q = sub.add_parser("query", aliases=["q"], help="Finger a user")
|
||||||
|
q.add_argument("username", nargs="?", default="", help="User to finger, e.g. [email protected]")
|
||||||
|
|
||||||
|
sub.add_parser("plan", help="Upload your plan file").add_argument(
|
||||||
|
"file", help="Path to the plan text file"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command in ("query", "q"):
|
||||||
|
cmd_query(args)
|
||||||
|
elif args.command == "plan":
|
||||||
|
cmd_plan(args)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
requests>=2.31.0
|
||||||
@@ -15,3 +15,46 @@ class Config:
|
|||||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
|
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
|
||||||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||||||
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
||||||
|
|
||||||
|
# File upload configuration
|
||||||
|
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_FILE_SIZE', 16 * 1024 * 1024)) # 16MB default
|
||||||
|
UPLOAD_FOLDER = '/tmp'
|
||||||
|
ALLOWED_EXTENSIONS = {'txt'}
|
||||||
|
|
||||||
|
# Remote server configuration for SCP
|
||||||
|
REMOTE_HOST = os.environ.get('REMOTE_HOST') or 'example.com'
|
||||||
|
REMOTE_USER = os.environ.get('REMOTE_USER') or 'user'
|
||||||
|
REMOTE_PATH = os.environ.get('REMOTE_PATH') or '/home/user/uploads/'
|
||||||
|
REMOTE_PORT = int(os.environ.get('REMOTE_PORT', 22))
|
||||||
|
REMOTE_PRIVATE_KEY = os.environ.get('REMOTE_PRIVATE_KEY') # Path to SSH private key file
|
||||||
|
SCP_ENABLED = os.environ.get('SCP_ENABLED', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# Rate limiting (Flask-Limiter), keyed per client IP.
|
||||||
|
# The app only ever receives nginx cache *misses* (upstream caches 200s for
|
||||||
|
# 30s), so these limits throttle exactly the uncached finger-daemon lookups
|
||||||
|
# (e.g. username enumeration) without affecting the cached hot path.
|
||||||
|
# Limit strings are ';'-separated, e.g. "30 per minute;600 per hour".
|
||||||
|
RATELIMIT_ENABLED = os.environ.get('RATELIMIT_ENABLED', 'true').lower() == 'true'
|
||||||
|
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI') or 'memory://'
|
||||||
|
# Global default applied to every route.
|
||||||
|
RATELIMIT_DEFAULT = os.environ.get('RATELIMIT_DEFAULT') or '120 per minute;2000 per hour'
|
||||||
|
# Stricter limit for endpoints that shell out to the finger daemon.
|
||||||
|
RATELIMIT_FINGER = os.environ.get('RATELIMIT_FINGER') or '30 per minute;600 per hour'
|
||||||
|
# Limit for the authenticated upload endpoint (anti brute-force / spam).
|
||||||
|
RATELIMIT_UPLOAD = os.environ.get('RATELIMIT_UPLOAD') or '10 per minute;60 per hour'
|
||||||
|
# Number of trusted reverse-proxy hops in front of the app. nginx on the
|
||||||
|
# same host appends one X-Forwarded-For entry, so the default is 1. Without
|
||||||
|
# this the app only sees the Docker bridge gateway and every client would
|
||||||
|
# share a single rate-limit bucket.
|
||||||
|
PROXY_FORWARDED_COUNT = int(os.environ.get('PROXY_FORWARDED_COUNT', 1))
|
||||||
|
|
||||||
|
# Basic authentication credentials - multiple users support
|
||||||
|
# Environment variable format: "user1:pass1,user2:pass2"
|
||||||
|
BASIC_AUTH_USERS_STR = os.environ.get('BASIC_AUTH_USERS', '')
|
||||||
|
|
||||||
|
# Parse users string into dictionary
|
||||||
|
BASIC_AUTH_USERS = {}
|
||||||
|
for user_pass in BASIC_AUTH_USERS_STR.split(','):
|
||||||
|
if ':' in user_pass:
|
||||||
|
username, password = user_pass.strip().split(':', 1)
|
||||||
|
BASIC_AUTH_USERS[username.strip()] = password.strip()
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mcp[cli]
|
||||||
|
requests>=2.31.0
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""MCP server for finger-web.
|
||||||
|
|
||||||
|
Exposes finger query and plan-upload operations as Claude tools.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
FINGER_WEB_URL Base URL of the finger-web instance (default: http://localhost:5000)
|
||||||
|
FINGER_USER Username for authenticated endpoints
|
||||||
|
FINGER_PASS Password for authenticated endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("FINGER_WEB_URL", "http://localhost:5000").rstrip("/")
|
||||||
|
|
||||||
|
mcp = FastMCP("finger")
|
||||||
|
|
||||||
|
|
||||||
|
def _auth():
|
||||||
|
user = os.environ.get("FINGER_USER", "")
|
||||||
|
passwd = os.environ.get("FINGER_PASS", "")
|
||||||
|
return (user, passwd) if user and passwd else None
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def finger_user(username: str = "") -> str:
|
||||||
|
"""Query finger information for a user via finger-web.
|
||||||
|
|
||||||
|
Pass a bare username (e.g. 'pete') or a user@host address
|
||||||
|
(e.g. '[email protected]'). Leave username empty to list currently
|
||||||
|
logged-in users.
|
||||||
|
"""
|
||||||
|
if username and not all(c.isalnum() or c in ".-_@" for c in username):
|
||||||
|
return f"Invalid username: {username!r}"
|
||||||
|
|
||||||
|
url = f"{BASE_URL}/api/finger/{username}" if username else f"{BASE_URL}/api/finger"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("status") == "success":
|
||||||
|
return data.get("result", "No information available.").strip()
|
||||||
|
return data.get("error", "Unknown error.")
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return f"Cannot connect to {BASE_URL}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def upload_plan(filename: str, content: str) -> str:
|
||||||
|
"""Upload or update a finger plan file via finger-web.
|
||||||
|
|
||||||
|
filename The plan filename (typically your username, e.g. 'pete').
|
||||||
|
content Plain-text content to publish as your .plan.
|
||||||
|
|
||||||
|
Requires FINGER_USER and FINGER_PASS environment variables to be set.
|
||||||
|
"""
|
||||||
|
auth = _auth()
|
||||||
|
if not auth:
|
||||||
|
return "Set FINGER_USER and FINGER_PASS environment variables to authenticate."
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{BASE_URL}/api/upload",
|
||||||
|
files={"file": (filename, content.encode(), "text/plain")},
|
||||||
|
auth=auth,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("message", "Upload successful.")
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return f"Cannot connect to {BASE_URL}"
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
return f"HTTP error: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run()
|
||||||
@@ -5,3 +5,5 @@ MarkupSafe==2.1.3
|
|||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
blinker==1.6.3
|
blinker==1.6.3
|
||||||
|
Flask-HTTPAuth==4.8.0
|
||||||
|
Flask-Limiter==3.5.1
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 mx-auto text-center">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="display-1 text-muted">429</h1>
|
||||||
|
<h2 class="mb-3">Too Many Requests</h2>
|
||||||
|
<p class="lead">
|
||||||
|
You've sent too many requests in a short period. Please slow
|
||||||
|
down and try again in a moment.
|
||||||
|
</p>
|
||||||
|
{% if detail %}
|
||||||
|
<p class="text-muted"><small>Limit: {{ detail }}</small></p>
|
||||||
|
{% endif %}
|
||||||
|
<hr class="my-4">
|
||||||
|
<div class="d-grid gap-2 d-md-block">
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-primary">Go Home</a>
|
||||||
|
<a href="javascript:history.back()" class="btn btn-outline-secondary">Go Back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
<a class="nav-link" href="{{ url_for('finger') }}">Finger</a>
|
<a class="nav-link" href="{{ url_for('finger') }}">Finger</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('api_hello') }}" target="_blank">API</a>
|
<a class="nav-link" href="{{ url_for('api_info') }}" target="_blank">API</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+54
-97
@@ -3,113 +3,70 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-10 mx-auto">
|
<div class="col-lg-10 mx-auto">
|
||||||
<h1 class="mb-4">🔍 Finger Command</h1>
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||||||
|
<h1 class="h5 mb-0">🔍 Finger Command</h1>
|
||||||
<div class="row">
|
<!-- GET so the search lands on a cacheable URL; JS sends it straight
|
||||||
<div class="col-md-4">
|
to /finger/<term>, and without JS the server redirects there. -->
|
||||||
<div class="card">
|
<form method="GET" action="{{ url_for('finger') }}" class="d-flex" role="search"
|
||||||
<div class="card-body">
|
onsubmit="return gotoFinger(event)">
|
||||||
<h5 class="card-title">User Lookup</h5>
|
<input type="text" class="form-control form-control-sm me-2" id="username" name="username"
|
||||||
<form method="POST" action="{{ url_for('finger') }}">
|
value="{{ username or '' }}"
|
||||||
<div class="mb-3">
|
placeholder="[email protected]"
|
||||||
<label for="username" class="form-label">Username (optional)</label>
|
pattern="[a-zA-Z0-9.\-_@]*"
|
||||||
<input type="text" class="form-control" id="username" name="username"
|
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed"
|
||||||
value="{{ username or '' }}"
|
style="max-width: 260px;">
|
||||||
placeholder="Enter username or email (e.g., [email protected])"
|
<button type="submit" class="btn btn-sm btn-primary">
|
||||||
pattern="[a-zA-Z0-9.\-_@]*"
|
<i class="fas fa-search"></i> Finger
|
||||||
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed">
|
</button>
|
||||||
<div class="form-text">
|
</form>
|
||||||
Leave empty to show all logged-in users, or enter a username or email address.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="fas fa-search"></i> Run Finger
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary ms-2" onclick="clearForm()">
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-title">ℹ️ About Finger</h6>
|
|
||||||
<p class="card-text small">
|
|
||||||
The finger command displays information about users on the system, including:
|
|
||||||
</p>
|
|
||||||
<ul class="small">
|
|
||||||
<li>Login name and real name</li>
|
|
||||||
<li>Terminal and login time</li>
|
|
||||||
<li>Idle time</li>
|
|
||||||
<li>Home directory and shell</li>
|
|
||||||
<li>Plan and project files (if available)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h5 class="mb-0">
|
|
||||||
{% if username %}
|
|
||||||
Results for "{{ username }}"
|
|
||||||
{% else %}
|
|
||||||
System Users
|
|
||||||
{% endif %}
|
|
||||||
</h5>
|
|
||||||
{% if result or error %}
|
|
||||||
<small class="text-muted">
|
|
||||||
{{ moment().format('YYYY-MM-DD HH:mm:ss') if moment else '' }}
|
|
||||||
</small>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger" role="alert">
|
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
|
||||||
<strong>Error:</strong> {{ error }}
|
|
||||||
</div>
|
|
||||||
{% elif result %}
|
|
||||||
<div class="alert alert-success mb-3" role="alert">
|
|
||||||
<i class="fas fa-check-circle"></i>
|
|
||||||
Command executed successfully
|
|
||||||
</div>
|
|
||||||
<div class="finger-output">
|
|
||||||
<pre class="rounded"><code>{{ result }}</code></pre>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center text-muted py-5">
|
|
||||||
<i class="fas fa-terminal fa-3x mb-3"></i>
|
|
||||||
<p>Enter a username and click "Run Finger" to see user information,<br>
|
|
||||||
or leave username empty to see all logged-in users.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center mt-4">
|
<div class="card">
|
||||||
<a href="{{ url_for('index') }}" class="btn btn-outline-primary">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<i class="fas fa-home"></i> Back to Home
|
<h5 class="mb-0">
|
||||||
</a>
|
{% if username %}
|
||||||
|
Results for "{{ username }}"
|
||||||
|
{% else %}
|
||||||
|
System Users
|
||||||
|
{% endif %}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% elif result %}
|
||||||
|
<div class="finger-output">
|
||||||
|
<pre class="rounded mb-0"><code>{{ result }}</code></pre>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="fas fa-terminal fa-3x mb-3"></i>
|
||||||
|
<p>No information available.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Navigate straight to the cacheable /finger/<term> path so the nginx response
|
||||||
|
// cache is hit for repeated lookups (avoids a redirect round-trip).
|
||||||
|
function gotoFinger(event) {
|
||||||
|
var term = document.getElementById('username').value.trim();
|
||||||
|
if (!term) { return true; } // empty -> let GET /finger list system users
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = "{{ url_for('finger') }}/" + encodeURIComponent(term);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function clearForm() {
|
function clearForm() {
|
||||||
document.getElementById('username').value = '';
|
document.getElementById('username').value = '';
|
||||||
// Optionally reload the page to clear results
|
// Optionally reload the page to clear results
|
||||||
window.location.href = "{{ url_for('finger') }}";
|
window.location.href = "{{ url_for('finger') }}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-focus on username input when page loads
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.getElementById('username').focus();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user