diff --git a/.gitignore b/.gitignore index aaadf73..7ef1293 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# SQLite +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a9499c9..229fba6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,12 +19,18 @@ RUN go build -o server main.go # Final Stage FROM alpine:latest -WORKDIR /root/ +# Create non-root user +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser + +WORKDIR /home/appuser COPY --from=builder /app/server . # Create directory for sqlite db -RUN mkdir -p data +RUN mkdir -p data && chown -R appuser:appuser /home/appuser + +USER appuser EXPOSE 8000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..935e87b --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# CalSpot + +A lightweight, self-hosted calendar server providing WebDAV access for calendar management and public HTTP endpoints for sharing calendars. Built with Go and SQLite. + +## Features + +- **WebDAV Interface**: Upload and sync `.ics` calendar files using any CalDAV-compatible client +- **Public Calendar Sharing**: Share calendars via simple HTTP URLs with security-through-obscurity +- **Authentication**: HTTP Basic Auth protection for WebDAV endpoints +- **User Management**: Interactive REPL for managing users +- **Secure Storage**: bcrypt password hashing and SQLite backend +- **Lightweight**: Single binary with no external dependencies +- **Docker Support**: Production-ready containerized deployment + +## Architecture + +- **Language**: Go 1.25.5 +- **Database**: SQLite3 (file-based) +- **Authentication**: HTTP Basic Auth with bcrypt password hashing +- **Calendar Format**: iCalendar (`.ics`) files +- **Storage**: Single calendar per user with automatic versioning + +## Quick Start + +### Local Development + +```bash +# Clone the repository +git clone +cd calspot + +# Build the server +go build -o calspot main.go + +# Run the server +./calspot +``` + +The server will start on port 8000 with an interactive REPL for user management. + +### Docker Deployment + +```bash +# Build the Docker image +docker build -t calspot . + +# Run the container +docker run -it -p 8000:8000 -v $(pwd)/data:/home/appuser/data calspot +``` + +## User Management (REPL) + +The server includes an interactive command-line interface for managing users: + +### Commands + +#### Add User +``` +add [username] [password] +``` +- If no username provided: generates a UUID username +- If no password provided: generates a secure 16-character password +- Displays the public calendar ID (UUID) for sharing + +Example: +``` +> add alice MySecurePass123 +User alice created. Public ID: 01933b2c-8f5e-7890-a234-567890abcdef +``` + +#### Delete User +``` +del +``` +Removes user and their associated calendar data. + +#### List Users +``` +list +``` +Shows all registered users with their public IDs. + +#### Reset Password +``` +resetpassword [newpassword] +``` +Updates user password. Generates secure password if not provided. + +## API Endpoints + +### WebDAV (Authenticated) + +**Endpoint**: `/webdav/` +**Authentication**: HTTP Basic Auth +**Methods**: `GET`, `PUT`, `DELETE`, `PROPFIND`, etc. + +Upload a calendar: +```bash +curl -u username:password \ + -T calendar.ics \ + http://localhost:8000/webdav/calendar.ics +``` + +Download via WebDAV: +```bash +curl -u username:password \ + http://localhost:8000/webdav/calendar.ics +``` + +### Public Calendar Access (No Authentication) + +**Endpoint**: `//calendar.ics` +**Authentication**: None (security via obscure UUID) +**Method**: `GET` + +Access public calendar: +```bash +curl http://localhost:8000/01933b2c-8f5e-7890-a234-567890abcdef/calendar.ics +``` + +Subscribe in calendar apps: +``` +http://localhost:8000/01933b2c-8f5e-7890-a234-567890abcdef/calendar.ics +``` + +## Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DB_PATH` | `./data/cal.db` | Path to SQLite database file | + +Example: +```bash +DB_PATH=/var/lib/calspot/calendar.db ./calspot +``` + +### Reverse Proxy Setup + +CalSpot is designed to run behind a reverse proxy (Nginx, Caddy, Traefik) for HTTPS termination. + +#### Nginx Example +```nginx +server { + listen 443 ssl http2; + server_name calendar.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Important for WebDAV + proxy_set_header Depth $http_depth; + proxy_set_header Destination $http_destination; + } +} +``` + +## CalDAV Client Setup + +### Thunderbird +1. Go to Calendar → New Calendar → On the Network → CalDAV +2. Location: `https://calendar.example.com/webdav/calendar.ics` +3. Username: your username +4. Password: your password + +### iOS/macOS +1. Settings → Calendar → Accounts → Add Account → Other → CalDAV +2. Server: `calendar.example.com` +3. Username: your username +4. Password: your password +5. Path: `/webdav/calendar.ics` + +### Android (DAVx⁵) +1. Install DAVx⁵ from F-Droid or Play Store +2. Add account → Login with URL and username +3. Base URL: `https://calendar.example.com/webdav/` +4. Username and password + +## Security Considerations + +### Implemented Security Features +- ✅ Bcrypt password hashing (cost factor 10) +- ✅ HTTP Basic Auth for WebDAV endpoints +- ✅ Cryptographically secure password generation +- ✅ UUID-based public calendar URLs (security through obscurity) +- ✅ File size limits (10MB per calendar) +- ✅ Input validation on usernames +- ✅ Security headers (X-Content-Type-Options, X-Frame-Options, etc.) +- ✅ Non-root container execution + +### Production Recommendations +- **Always use HTTPS**: Deploy behind a reverse proxy with TLS +- **Secure public IDs**: Treat user IDs as secrets for calendar access +- **Regular backups**: Backup the SQLite database regularly +- **Monitor access**: Use reverse proxy logs to monitor unusual activity +- **Network isolation**: Run in a private network or with firewall rules + +## Limitations + +- **Single calendar per user**: Each user can store one `.ics` file +- **No calendar merging**: Multiple calendars must be managed at the client level +- **No collaborative features**: Designed for personal use, not team sharing +- **Flat file structure**: No folder organization support + +## Technical Details + +### Database Schema + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, -- UUIDv7 for public calendar access + username TEXT UNIQUE, -- Login username + password_hash TEXT -- bcrypt hash +); + +CREATE TABLE calendars ( + user_id TEXT PRIMARY KEY, -- Foreign key to users.id + filename TEXT, -- Original filename (e.g., calendar.ics) + content BLOB, -- iCalendar file content + mod_time DATETIME -- Last modification time +); +``` + +### Dependencies + +- `github.com/google/uuid` - UUID generation (v1.6.0) +- `github.com/mattn/go-sqlite3` - SQLite driver (v1.14.32) +- `golang.org/x/crypto` - bcrypt hashing (v0.46.0) +- `golang.org/x/net/webdav` - WebDAV implementation (v0.48.0) + +## Building from Source + +```bash +# Clone repository +git clone +cd calspot + +# Install dependencies +go mod download + +# Build +go build -o calspot main.go + +# Run +./calspot +``` + +### Build Requirements +- Go 1.25.5 or later +- GCC (for CGO/SQLite3 compilation) + +## License + +[Specify your license here] + +## Contributing + +[Contribution guidelines if applicable] + +## Support + +For issues, questions, or feature requests, please [open an issue](link-to-issues). diff --git a/main.go b/main.go index eef2e6e..d293963 100644 --- a/main.go +++ b/main.go @@ -211,7 +211,14 @@ func (f *sqlFileBuffer) Read(p []byte) (n int, err error) { return 0 func (f *sqlFileBuffer) Seek(offset int64, whence int) (int64, error) { return 0, nil } func (f *sqlFileBuffer) Readdir(count int) ([]os.FileInfo, error) { return nil, os.ErrInvalid } func (f *sqlFileBuffer) Stat() (os.FileInfo, error) { return &memFileInfo{name: f.filename}, nil } -func (f *sqlFileBuffer) Write(p []byte) (int, error) { return f.buffer.Write(p) } +func (f *sqlFileBuffer) Write(p []byte) (int, error) { + // Limit calendar file size to 10MB to prevent DoS + const maxSize = 10 * 1024 * 1024 + if f.buffer.Len()+len(p) > maxSize { + return 0, errors.New("file size exceeds maximum allowed (10MB)") + } + return f.buffer.Write(p) +} func (f *sqlFileBuffer) Close() error { // Flush buffer to DB _, err := db.Exec(` @@ -247,6 +254,12 @@ func publicCalendarHandler(w http.ResponseWriter, r *http.Request) { } userID := parts[0] + // Add security headers + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "no-referrer") + var content []byte err := db.QueryRow("SELECT content FROM calendars WHERE user_id = ?", userID).Scan(&content) if err != nil { @@ -258,6 +271,16 @@ func publicCalendarHandler(w http.ResponseWriter, r *http.Request) { w.Write(content) } +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} + func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() @@ -267,6 +290,13 @@ func authMiddleware(next http.Handler) http.Handler { return } + // Input validation for username + if len(user) > 255 || len(user) == 0 { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + var id, hash string err := db.QueryRow("SELECT id, password_hash FROM users WHERE username = ?", user).Scan(&id, &hash) if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) != nil { @@ -286,7 +316,10 @@ func generatePassword() string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" b := make([]byte, 16) for i := range b { - num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + log.Fatalf("Failed to generate secure random password: %v", err) + } b[i] = charset[num.Int64()] } return string(b) @@ -318,6 +351,11 @@ func runREPL() { username := "" if len(parts) > 1 { username = parts[1] + // Validate username length and characters + if len(username) > 255 || len(username) == 0 { + fmt.Println("Error: username must be 1-255 characters") + break + } } else { u, _ := uuid.NewV7() username = u.String() @@ -440,7 +478,7 @@ func main() { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/webdav/") { // Strip prefix for WebDAV handler so it sees relative root - http.StripPrefix("/webdav", authMiddleware(davHandler)).ServeHTTP(w, r) + http.StripPrefix("/webdav", securityHeadersMiddleware(authMiddleware(davHandler))).ServeHTTP(w, r) return } // Public Calendar