Compare commits

...
10 Commits
Author SHA1 Message Date
pmb c8786a2a0b Route finger search through the cacheable /finger/<term> path
Build and Push Docker Image / build-and-push (push) Failing after 2m19s
The search form POSTed to /finger, an uncacheable URL with no search term in
it, so every lookup re-ran the finger command even for repeated searches of the
same user. Redirect any query/POST search to the canonical /finger/<username>
path (and submit the form via GET, with a JS fast-path straight to that URL) so
repeated lookups are served from the nginx response cache. A bare /finger with
no user still lists local system users.
2026-06-17 10:24:16 -07:00
pmb 3115ac64b2 Add per-IP rate limiting to prevent finger-daemon abuse
The app sits behind nginx, which caches 200s for 30s — so repeated lookups
of the same user are cheap. What bypasses the cache is enumeration of distinct
usernames: each is a unique cache key -> miss -> a fresh finger call to the
mammut daemon, all attributed to admin's single IP (so the daemon cannot ban
the real source). The app only ever receives cache misses, so a per-IP limit
here throttles exactly that uncached path without touching the cached hot path.

- Flask-Limiter keyed per client IP: 30/min on the finger lookup endpoints,
  10/min on /api/upload (auth brute-force), 120/min global default. Index and
  the container healthcheck are exempt. All limits env-tunable (RATELIMIT_*).
- ProxyFix(x_for=1): trust nginx's X-Forwarded-For so the real client IP is
  used for keying and logging. Without it the app only saw the Docker bridge
  gateway (172.20.0.1) and every client shared one bucket.
- 429 handler (JSON for /api, HTML 429.html otherwise) and WARNING logging of
  failed/invalid lookups and limit hits, so enumeration is observable.
2026-06-17 10:23:58 -07:00
pmb ad4b9b9af8 Use minimal single-column layout for all finger results
Unify the interactive /finger page on the same results-first layout
introduced for direct /finger/<user> links: drop the two-column view
(About Finger panel, success banner, timestamp, back-to-home button)
and the now-unused direct flag.
2026-06-15 20:36:41 -07:00
pmb a49545796a Minimal single-column layout for direct /finger/<user> links
Direct links now render a results-first single-column view: drop the
About Finger panel, success banner, timestamp and back-to-home button,
and replace the lookup card with a small inline form next to the heading.
The interactive /finger page keeps its original two-column layout.
2026-06-15 20:26:55 -07:00
pmb b99636d3c0 Fix BuildError: nav links to renamed api_info endpoint
base.html referenced url_for('api_hello'), but that endpoint is now
api_info (/api/info). Since every page extends base.html, the dead
reference raised werkzeug BuildError and returned HTTP 500 site-wide --
including /finger/<user>, which federated Mastodon instances fetch for
link previews. The 500s also defeated nginx's 200-only cache, so every
fetch re-ran the finger subprocess against the daemon on mammut.
2026-06-15 17:01:56 -07:00
waffle2k 295a123717 Update README with API, CLI, and MCP documentation 2026-05-07 12:16:39 -07:00
waffle2k 178d3f361c Add JSON API endpoint, CLI tool, and MCP server
- Add /api/finger and /api/finger/<username> JSON endpoints
- Remove hardcoded default credentials from config.py; require BASIC_AUTH_USERS env var
- Add cli/finger.py: query and plan-upload CLI using the JSON API
- Add mcp/server.py: FastMCP server exposing finger_user and upload_plan tools
- All credentials and base URL are read from environment variables
2026-05-07 12:03:23 -07:00
pmb 1e53363271 Handle utf decoding 2025-07-02 17:54:32 -07:00
pmb 7515431fb5 Add missing package to the Dockerfile 2025-06-27 15:30:31 -07:00
pmb fd493f13e9 Support file uploads for setting finger status 2025-06-27 15:20:54 -07:00
12 changed files with 824 additions and 465 deletions
+1
View File
@@ -15,6 +15,7 @@ RUN apt-get update && apt-get install -y \
curl \
gcc \
finger \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache
+141 -284
View File
@@ -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
- **Contact Form**: Functional contact form with validation and flash messages
- **JSON API**: RESTful API endpoints for data exchange
- **Responsive Design**: Mobile-first design using Bootstrap 5
- **Error Handling**: Custom 404 and 500 error pages
- **Modern UI**: Clean, professional interface with animations
- **Form Validation**: Client-side and server-side validation
- **Configuration Management**: Environment-based configuration
| Path | Description |
|------|-------------|
| `app.py` | Flask web app and JSON API |
| `cli/finger.py` | Command-line client |
| `mcp/server.py` | MCP server for Claude |
## 📁 Project Structure
---
```
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
```
## Web App
## 🛠️ Technologies Used
### Requirements
### Backend
- **Python 3.x** - Programming language
- **Flask 2.3.3** - Web framework
- **Jinja2** - Template engine
- **Werkzeug** - WSGI toolkit
- Python 3.9+
- A `finger` binary available on the server's PATH
### Frontend
- **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
### Installation
```bash
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
python app.py
```
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
# or with gunicorn
gunicorn -w 4 -b 0.0.0.0:5000 app:app
```
## 🔒 Security Considerations
### Docker
- Change the `SECRET_KEY` in production
- Use environment variables for sensitive data
- 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
```bash
docker-compose up -d
```
---
**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 |
+330 -59
View File
@@ -1,40 +1,97 @@
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 subprocess
import shlex
import datetime
from config import Config
app = Flask(__name__)
app.config.from_object(Config)
@app.route('/')
def index():
"""Home page route"""
return render_template('index.html', title='Home')
# 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,
)
@app.route('/finger', methods=['GET', 'POST'])
def finger():
"""Finger command route"""
result = None
error = None
username = request.args.get('user', '') or request.form.get('username', '')
# Initialize HTTP Basic Auth
auth = HTTPBasicAuth()
if request.method == 'POST' or username:
# 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:
# Sanitize username input
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']
return raw_output.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
if not error:
# Execute the command safely
# 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,
@@ -46,82 +103,296 @@ def finger():
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:
error = "Command timed out. Please try again."
return False, "Command timed out. Please try again.", True
except FileNotFoundError:
error = "Finger command not available on this system."
return False, "Finger command not available on this system.", True
except Exception as e:
error = f"An error occurred: {str(e)}"
return False, f"An error occurred: {str(e)}", True
@app.route('/')
@limiter.exempt
def index():
"""Home page route (exempt from limits; also serves the container healthcheck)"""
return render_template('index.html', title='Home')
@app.route('/finger', methods=['GET', 'POST'])
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def finger():
"""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
error = None
success, output, is_error = run_finger_command(['finger'])
if success:
result = output
else:
error = output
return render_template('finger.html', title='Finger', result=result, error=error, username=username)
@app.route('/finger/<path:username>')
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def finger_direct(username):
"""Direct finger command route with username in URL"""
result = None
error = None
try:
# Sanitize username input
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."
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 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."
# Execute the command with robust encoding handling
success, output, is_error = run_finger_command(cmd)
if success:
result = output
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)}"
error = output
app.logger.warning("finger lookup failed for %r from %s: %s",
username, get_remote_address(), output.strip()[:200])
return render_template('finger.html', title=f'Finger - {username}', result=result, error=error, username=username)
@app.route('/api/hello')
def api_hello():
"""Simple JSON API endpoint"""
return jsonify({
'message': 'Hello from Flask API!',
'status': 'success',
'version': '1.0'
})
@app.route('/api/finger')
@app.route('/api/finger/<path:username>')
@limiter.limit(lambda: app.config['RATELIMIT_FINGER'])
def api_finger(username=None):
"""JSON API endpoint for finger queries"""
if username is None:
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')
def api_info():
"""API info endpoint"""
return jsonify({
'app_name': 'Finger Web Flask App',
'app_name': 'finger-web',
'routes': [
'/',
'/finger',
'/finger/<username>',
'/api/hello',
'/api/info'
'/api/finger',
'/api/finger/<username>',
'/api/info',
'/api/upload'
],
'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)
def not_found_error(error):
"""Handle 404 errors"""
+114
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
requests>=2.31.0
+43
View File
@@ -15,3 +15,46 @@ class Config:
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
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()
+2
View File
@@ -0,0 +1,2 @@
mcp[cli]
requests>=2.31.0
+85
View File
@@ -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()
+2
View File
@@ -5,3 +5,5 @@ MarkupSafe==2.1.3
itsdangerous==2.1.2
click==8.1.7
blinker==1.6.3
Flask-HTTPAuth==4.8.0
Flask-Limiter==3.5.1
+26
View File
@@ -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
View File
@@ -33,7 +33,7 @@
<a class="nav-link" href="{{ url_for('finger') }}">Finger</a>
</li>
<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>
</ul>
</div>
+24 -67
View File
@@ -3,54 +3,24 @@
{% block content %}
<div class="row">
<div class="col-lg-10 mx-auto">
<h1 class="mb-4">🔍 Finger Command</h1>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">User Lookup</h5>
<form method="POST" action="{{ url_for('finger') }}">
<div class="mb-3">
<label for="username" class="form-label">Username (optional)</label>
<input type="text" class="form-control" id="username" name="username"
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h1 class="h5 mb-0">🔍 Finger Command</h1>
<!-- GET so the search lands on a cacheable URL; JS sends it straight
to /finger/<term>, and without JS the server redirects there. -->
<form method="GET" action="{{ url_for('finger') }}" class="d-flex" role="search"
onsubmit="return gotoFinger(event)">
<input type="text" class="form-control form-control-sm me-2" id="username" name="username"
value="{{ username or '' }}"
placeholder="Enter username or email (e.g., [email protected])"
placeholder="[email protected]"
pattern="[a-zA-Z0-9.\-_@]*"
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed">
<div class="form-text">
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
title="Alphanumeric characters, dots, hyphens, underscores, and @ symbol allowed"
style="max-width: 260px;">
<button type="submit" class="btn btn-sm btn-primary">
<i class="fas fa-search"></i> Finger
</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">
@@ -60,11 +30,6 @@
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 %}
@@ -73,18 +38,13 @@
<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>
<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>Enter a username and click "Run Finger" to see user information,<br>
or leave username empty to see all logged-in users.</p>
<p>No information available.</p>
</div>
{% endif %}
</div>
@@ -92,24 +52,21 @@
</div>
</div>
<div class="text-center mt-4">
<a href="{{ url_for('index') }}" class="btn btn-outline-primary">
<i class="fas fa-home"></i> Back to Home
</a>
</div>
</div>
</div>
<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() {
document.getElementById('username').value = '';
// Optionally reload the page to clear results
window.location.href = "{{ url_for('finger') }}";
}
// Auto-focus on username input when page loads
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
</script>
{% endblock %}