Compare commits
18 Commits
main
...
imwald-v0.
| Author | SHA1 | Date |
|---|---|---|
|
|
e0a40925a9 | 3 months ago |
|
|
9a0841d321 | 3 months ago |
|
|
71f5a91b76 | 3 months ago |
|
|
ce2b5b63b4 | 3 months ago |
|
|
69eccb21e2 | 4 months ago |
|
|
e36c39e3f0 | 4 months ago |
|
|
951e98ca3e | 4 months ago |
|
|
7d7f16dee7 | 4 months ago |
|
|
4bd9421a1d | 4 months ago |
|
|
402b298e42 | 4 months ago |
|
|
90765506ee | 4 months ago |
|
|
a22194e97e | 4 months ago |
|
|
b160c32a4b | 4 months ago |
|
|
435a7b3302 | 4 months ago |
|
|
219fa7a85f | 4 months ago |
|
|
b3e8aad8f0 | 4 months ago |
|
|
ecc5f7f050 | 4 months ago |
|
|
583ced9b7a | 4 months ago |
60 changed files with 4136 additions and 847 deletions
@ -0,0 +1,82 @@ |
|||||||
|
# Multi-stage Dockerfile for ORLY relay with embedded web UI |
||||||
|
|
||||||
|
# Stage 1: Web UI build |
||||||
|
FROM node:20-bookworm AS web-builder |
||||||
|
|
||||||
|
WORKDIR /web |
||||||
|
|
||||||
|
# Copy web UI files |
||||||
|
COPY app/web/package.json app/web/package-lock.json* app/web/ ./ |
||||||
|
|
||||||
|
# Install dependencies and build |
||||||
|
RUN npm ci && npm run build |
||||||
|
|
||||||
|
# Stage 2: Go build stage |
||||||
|
FROM golang:1.25-bookworm AS builder |
||||||
|
|
||||||
|
# Install build dependencies |
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git make && rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
# Set working directory |
||||||
|
WORKDIR /build |
||||||
|
|
||||||
|
# Copy go mod files and build context first (needed for replace directives in go.mod) |
||||||
|
COPY go.mod go.sum ./ |
||||||
|
COPY .docker-build-context/ ./docker-build-context/ |
||||||
|
|
||||||
|
# Copy vendored dependencies (created by build script) |
||||||
|
COPY vendor/ ./vendor/ |
||||||
|
|
||||||
|
# Download dependencies (will use vendor if available) |
||||||
|
RUN go mod download |
||||||
|
|
||||||
|
# Copy source code |
||||||
|
COPY . . |
||||||
|
|
||||||
|
# Copy built web UI from web-builder stage |
||||||
|
COPY --from=web-builder /web/dist ./app/web/dist |
||||||
|
|
||||||
|
# Build the binary with CGO disabled |
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly -ldflags="-w -s" . |
||||||
|
|
||||||
|
# Stage 3: Runtime stage |
||||||
|
FROM debian:bookworm-slim |
||||||
|
|
||||||
|
# Install runtime dependencies |
||||||
|
RUN apt-get update && \ |
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl libsecp256k1-1 && \ |
||||||
|
rm -rf /var/lib/apt/lists/* |
||||||
|
|
||||||
|
# Create app user |
||||||
|
RUN groupadd -g 1000 orly && \ |
||||||
|
useradd -m -u 1000 -g orly orly |
||||||
|
|
||||||
|
# Set working directory |
||||||
|
WORKDIR /app |
||||||
|
|
||||||
|
# Copy binary (libsecp256k1.so.1 is already installed via apt) |
||||||
|
COPY --from=builder /build/orly /app/orly |
||||||
|
|
||||||
|
# Create data directory |
||||||
|
RUN mkdir -p /data && chown -R orly:orly /data /app |
||||||
|
|
||||||
|
# Switch to app user |
||||||
|
USER orly |
||||||
|
|
||||||
|
# Expose ports |
||||||
|
EXPOSE 3334 |
||||||
|
|
||||||
|
# Health check |
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=3 \ |
||||||
|
CMD curl -f http://localhost:3334/ || exit 1 |
||||||
|
|
||||||
|
# Set default environment variables |
||||||
|
ENV ORLY_LISTEN=0.0.0.0 \ |
||||||
|
ORLY_PORT=3334 \ |
||||||
|
ORLY_DATA_DIR=/data \ |
||||||
|
ORLY_LOG_LEVEL=info \ |
||||||
|
ORLY_WEB_DISABLE=false \ |
||||||
|
ORLY_WEB_DEV_PROXY_URL="" |
||||||
|
|
||||||
|
# Run the binary |
||||||
|
ENTRYPOINT ["/app/orly"] |
||||||
@ -0,0 +1,129 @@ |
|||||||
|
# Running ORLY Relay Remotely |
||||||
|
|
||||||
|
## Quick Start |
||||||
|
|
||||||
|
### 1. Pull the Image |
||||||
|
```bash |
||||||
|
docker pull silberengel/next-orly:v0.58.5 |
||||||
|
``` |
||||||
|
|
||||||
|
### 2. Run with Docker Run |
||||||
|
```bash |
||||||
|
docker run -d \ |
||||||
|
--name orly-relay \ |
||||||
|
--restart always \ |
||||||
|
-p 3334:3334 \ |
||||||
|
-v /var/lib/orly:/data \ |
||||||
|
-e ORLY_DATA_DIR=/data \ |
||||||
|
-e ORLY_LISTEN=0.0.0.0 \ |
||||||
|
-e ORLY_PORT=3334 \ |
||||||
|
-e ORLY_LOG_LEVEL=info \ |
||||||
|
silberengel/next-orly:v0.58.5 |
||||||
|
``` |
||||||
|
|
||||||
|
### 3. Or Use Docker Compose |
||||||
|
|
||||||
|
Create a `docker-compose.yml`: |
||||||
|
```yaml |
||||||
|
version: '3.8' |
||||||
|
|
||||||
|
services: |
||||||
|
orly-relay: |
||||||
|
image: silberengel/next-orly:v0.58.5 |
||||||
|
container_name: orly-relay |
||||||
|
restart: always |
||||||
|
ports: |
||||||
|
- "3334:3334" |
||||||
|
volumes: |
||||||
|
- /var/lib/orly:/data |
||||||
|
environment: |
||||||
|
- ORLY_DATA_DIR=/data |
||||||
|
- ORLY_LISTEN=0.0.0.0 |
||||||
|
- ORLY_PORT=3334 |
||||||
|
- ORLY_LOG_LEVEL=info |
||||||
|
``` |
||||||
|
|
||||||
|
Then run: |
||||||
|
```bash |
||||||
|
docker-compose up -d |
||||||
|
``` |
||||||
|
|
||||||
|
## Configuration Options |
||||||
|
|
||||||
|
### Basic Configuration |
||||||
|
- `ORLY_DATA_DIR` - Data directory (default: `/data`) |
||||||
|
- `ORLY_LISTEN` - Listen address (default: `0.0.0.0`) |
||||||
|
- `ORLY_PORT` - Port number (default: `3334`) |
||||||
|
- `ORLY_LOG_LEVEL` - Log level: `debug`, `info`, `warn`, `error` |
||||||
|
|
||||||
|
### Advanced Configuration |
||||||
|
- `ORLY_ADMINS` - Comma-separated list of admin npub keys |
||||||
|
- `ORLY_OWNERS` - Comma-separated list of owner npub keys |
||||||
|
- `ORLY_ACL_MODE` - ACL mode: `open`, `follows`, `managed` |
||||||
|
- `ORLY_SPIDER_MODE` - Spider mode: `off`, `follows`, `all` |
||||||
|
- `ORLY_RELAY_URL` - Public relay URL (for metadata) |
||||||
|
- `ORLY_MAX_CONNECTIONS` - Max concurrent connections (default: 1000) |
||||||
|
- `ORLY_MAX_EVENT_SIZE` - Max event size in bytes (default: 65536) |
||||||
|
|
||||||
|
## Useful Commands |
||||||
|
|
||||||
|
### View Logs |
||||||
|
```bash |
||||||
|
docker logs -f orly-relay |
||||||
|
``` |
||||||
|
|
||||||
|
### Stop Container |
||||||
|
```bash |
||||||
|
docker stop orly-relay |
||||||
|
``` |
||||||
|
|
||||||
|
### Start Container |
||||||
|
```bash |
||||||
|
docker start orly-relay |
||||||
|
``` |
||||||
|
|
||||||
|
### Restart Container |
||||||
|
```bash |
||||||
|
docker restart orly-relay |
||||||
|
``` |
||||||
|
|
||||||
|
### Remove Container |
||||||
|
```bash |
||||||
|
docker rm -f orly-relay |
||||||
|
``` |
||||||
|
|
||||||
|
### Update to Latest Version |
||||||
|
```bash |
||||||
|
docker pull silberengel/next-orly:v0.58.5 |
||||||
|
docker stop orly-relay |
||||||
|
docker rm orly-relay |
||||||
|
# Then run your docker run or docker-compose command again |
||||||
|
``` |
||||||
|
|
||||||
|
## Behind a Reverse Proxy |
||||||
|
|
||||||
|
If running behind nginx/Caddy/etc, bind to localhost only: |
||||||
|
```bash |
||||||
|
docker run -d \ |
||||||
|
--name orly-relay \ |
||||||
|
--restart always \ |
||||||
|
-p 127.0.0.1:3334:3334 \ |
||||||
|
-v /var/lib/orly:/data \ |
||||||
|
-e ORLY_DATA_DIR=/data \ |
||||||
|
-e ORLY_LISTEN=0.0.0.0 \ |
||||||
|
-e ORLY_PORT=3334 \ |
||||||
|
silberengel/next-orly:v0.58.5 |
||||||
|
``` |
||||||
|
|
||||||
|
## Data Persistence |
||||||
|
|
||||||
|
Data is stored in `/var/lib/orly` on the host (or whatever path you mount). |
||||||
|
To backup: |
||||||
|
```bash |
||||||
|
tar -czf orly-backup-$(date +%Y%m%d).tar.gz /var/lib/orly |
||||||
|
``` |
||||||
|
|
||||||
|
To restore: |
||||||
|
```bash |
||||||
|
tar -xzf orly-backup-YYYYMMDD.tar.gz -C / |
||||||
|
``` |
||||||
@ -1,132 +0,0 @@ |
|||||||
package app |
|
||||||
|
|
||||||
import ( |
|
||||||
"crypto/tls" |
|
||||||
"crypto/x509" |
|
||||||
"fmt" |
|
||||||
"strings" |
|
||||||
"sync" |
|
||||||
|
|
||||||
"golang.org/x/crypto/acme/autocert" |
|
||||||
"lol.mleku.dev/chk" |
|
||||||
"lol.mleku.dev/log" |
|
||||||
) |
|
||||||
|
|
||||||
// TLSConfig returns a TLS configuration that works with LetsEncrypt automatic SSL cert issuer
|
|
||||||
// as well as any provided certificate files from providers.
|
|
||||||
//
|
|
||||||
// The certs are provided in the form of paths where .pem and .key files exist
|
|
||||||
func TLSConfig(m *autocert.Manager, certs ...string) (tc *tls.Config) { |
|
||||||
certMap := make(map[string]*tls.Certificate) |
|
||||||
var mx sync.Mutex |
|
||||||
|
|
||||||
for _, certPath := range certs { |
|
||||||
if certPath == "" { |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
var err error |
|
||||||
var c tls.Certificate |
|
||||||
|
|
||||||
// Load certificate and key files
|
|
||||||
if c, err = tls.LoadX509KeyPair( |
|
||||||
certPath+".pem", certPath+".key", |
|
||||||
); chk.E(err) { |
|
||||||
log.E.F("failed to load certificate from %s: %v", certPath, err) |
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
// Extract domain names from certificate
|
|
||||||
if len(c.Certificate) > 0 { |
|
||||||
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil { |
|
||||||
// Use the common name as the primary domain
|
|
||||||
if x509Cert.Subject.CommonName != "" { |
|
||||||
certMap[x509Cert.Subject.CommonName] = &c |
|
||||||
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName) |
|
||||||
} |
|
||||||
// Also add any subject alternative names
|
|
||||||
for _, san := range x509Cert.DNSNames { |
|
||||||
if san != "" { |
|
||||||
certMap[san] = &c |
|
||||||
log.I.F("loaded certificate for SAN domain: %s", san) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if m == nil { |
|
||||||
// Create a basic TLS config without autocert
|
|
||||||
tc = &tls.Config{ |
|
||||||
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
|
||||||
mx.Lock() |
|
||||||
defer mx.Unlock() |
|
||||||
|
|
||||||
// Check for exact match first
|
|
||||||
if cert, exists := certMap[helo.ServerName]; exists { |
|
||||||
return cert, nil |
|
||||||
} |
|
||||||
|
|
||||||
// Check for wildcard matches
|
|
||||||
for domain, cert := range certMap { |
|
||||||
if strings.HasPrefix(domain, "*.") { |
|
||||||
baseDomain := domain[2:] // Remove "*."
|
|
||||||
if strings.HasSuffix(helo.ServerName, baseDomain) { |
|
||||||
return cert, nil |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName) |
|
||||||
}, |
|
||||||
} |
|
||||||
} else { |
|
||||||
tc = m.TLSConfig() |
|
||||||
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
|
||||||
mx.Lock() |
|
||||||
|
|
||||||
// Check for exact match first
|
|
||||||
if cert, exists := certMap[helo.ServerName]; exists { |
|
||||||
mx.Unlock() |
|
||||||
return cert, nil |
|
||||||
} |
|
||||||
|
|
||||||
// Check for wildcard matches
|
|
||||||
for domain, cert := range certMap { |
|
||||||
if strings.HasPrefix(domain, "*.") { |
|
||||||
baseDomain := domain[2:] // Remove "*."
|
|
||||||
if strings.HasSuffix(helo.ServerName, baseDomain) { |
|
||||||
mx.Unlock() |
|
||||||
return cert, nil |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
mx.Unlock() |
|
||||||
|
|
||||||
// Fall back to autocert for domains not in our certificate map
|
|
||||||
return m.GetCertificate(helo) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return tc |
|
||||||
} |
|
||||||
|
|
||||||
// ValidateTLSConfig checks if the TLS configuration is valid
|
|
||||||
func ValidateTLSConfig(domains []string, certs []string) (err error) { |
|
||||||
if len(domains) == 0 { |
|
||||||
return fmt.Errorf("no TLS domains specified") |
|
||||||
} |
|
||||||
|
|
||||||
// Validate domain names
|
|
||||||
for _, domain := range domains { |
|
||||||
if domain == "" { |
|
||||||
continue |
|
||||||
} |
|
||||||
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") { |
|
||||||
return fmt.Errorf("invalid domain name: %s", domain) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
return nil |
|
||||||
} |
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 379 KiB |
@ -1,69 +0,0 @@ |
|||||||
html, |
|
||||||
body { |
|
||||||
position: relative; |
|
||||||
width: 100%; |
|
||||||
height: 100%; |
|
||||||
} |
|
||||||
|
|
||||||
body { |
|
||||||
color: #333; |
|
||||||
margin: 0; |
|
||||||
padding: 8px; |
|
||||||
box-sizing: border-box; |
|
||||||
font-family: |
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, |
|
||||||
Cantarell, "Helvetica Neue", sans-serif; |
|
||||||
} |
|
||||||
|
|
||||||
a { |
|
||||||
color: rgb(0, 100, 200); |
|
||||||
text-decoration: none; |
|
||||||
} |
|
||||||
|
|
||||||
a:hover { |
|
||||||
text-decoration: underline; |
|
||||||
} |
|
||||||
|
|
||||||
a:visited { |
|
||||||
color: rgb(0, 80, 160); |
|
||||||
} |
|
||||||
|
|
||||||
label { |
|
||||||
display: block; |
|
||||||
} |
|
||||||
|
|
||||||
input, |
|
||||||
button, |
|
||||||
select, |
|
||||||
textarea { |
|
||||||
font-family: inherit; |
|
||||||
font-size: inherit; |
|
||||||
-webkit-padding: 0.4em 0; |
|
||||||
padding: 0.4em; |
|
||||||
margin: 0 0 0.5em 0; |
|
||||||
box-sizing: border-box; |
|
||||||
border: 1px solid #ccc; |
|
||||||
border-radius: 2px; |
|
||||||
} |
|
||||||
|
|
||||||
input:disabled { |
|
||||||
color: #ccc; |
|
||||||
} |
|
||||||
|
|
||||||
button { |
|
||||||
color: #333; |
|
||||||
background-color: #f4f4f4; |
|
||||||
outline: none; |
|
||||||
} |
|
||||||
|
|
||||||
button:disabled { |
|
||||||
color: #999; |
|
||||||
} |
|
||||||
|
|
||||||
button:not(:disabled):active { |
|
||||||
background-color: #ddd; |
|
||||||
} |
|
||||||
|
|
||||||
button:focus { |
|
||||||
border-color: #666; |
|
||||||
} |
|
||||||
@ -1,44 +1 @@ |
|||||||
<!doctype html> |
<!-- Placeholder - will be replaced by Docker build --> |
||||||
<html lang="en"> |
|
||||||
<head> |
|
||||||
<meta charset="utf-8" /> |
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" /> |
|
||||||
<meta name="color-scheme" content="light dark" /> |
|
||||||
|
|
||||||
<title>ORLY?</title> |
|
||||||
|
|
||||||
<style> |
|
||||||
:root { |
|
||||||
color-scheme: light dark; |
|
||||||
} |
|
||||||
html, body { |
|
||||||
background-color: #fff; |
|
||||||
color: #000; |
|
||||||
} |
|
||||||
@media (prefers-color-scheme: dark) { |
|
||||||
html, body { |
|
||||||
background-color: #000; |
|
||||||
color: #fff; |
|
||||||
} |
|
||||||
} |
|
||||||
</style> |
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" /> |
|
||||||
<link rel="manifest" href="/manifest.json" /> |
|
||||||
<link rel="apple-touch-icon" href="/icon-192.png" /> |
|
||||||
<meta name="theme-color" content="#000000" /> |
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" /> |
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" /> |
|
||||||
<link rel="stylesheet" href="/global.css" /> |
|
||||||
<link rel="stylesheet" href="/bundle.css" /> |
|
||||||
|
|
||||||
<script defer src="/bundle.js"></script> |
|
||||||
</head> |
|
||||||
|
|
||||||
<body></body> |
|
||||||
<script> |
|
||||||
if ('serviceWorker' in navigator) { |
|
||||||
navigator.serviceWorker.register('/sw.js'); |
|
||||||
} |
|
||||||
</script> |
|
||||||
</html> |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 514 KiB |
@ -0,0 +1,222 @@ |
|||||||
|
#!/bin/bash |
||||||
|
# Build script for next-orly Docker image (auto-detects version) |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
# Auto-detect version from git tag, version file, or use default |
||||||
|
detect_version() { |
||||||
|
# Try git tag first (most reliable) |
||||||
|
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "") |
||||||
|
|
||||||
|
# Fallback to version file |
||||||
|
if [ -z "$VERSION" ] && [ -f "pkg/version/version" ]; then |
||||||
|
VERSION=$(cat pkg/version/version | tr -d ' \n' | sed 's/^v//') |
||||||
|
fi |
||||||
|
|
||||||
|
# Fallback to latest git tag |
||||||
|
if [ -z "$VERSION" ]; then |
||||||
|
VERSION=$(git tag | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 | sed 's/^v//' || echo "") |
||||||
|
fi |
||||||
|
|
||||||
|
# Last resort: use default or prompt |
||||||
|
if [ -z "$VERSION" ]; then |
||||||
|
echo "Warning: Could not auto-detect version. Using default v0.58.10" |
||||||
|
VERSION="0.58.10" |
||||||
|
fi |
||||||
|
|
||||||
|
# Ensure it starts with 'v' for consistency |
||||||
|
if [[ ! "$VERSION" =~ ^v ]]; then |
||||||
|
VERSION="v${VERSION}" |
||||||
|
fi |
||||||
|
|
||||||
|
echo "$VERSION" |
||||||
|
} |
||||||
|
|
||||||
|
VERSION=$(detect_version) |
||||||
|
echo "Detected version: $VERSION" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check for Docker |
||||||
|
DOCKER_CMD="" |
||||||
|
if command -v docker >/dev/null 2>&1; then |
||||||
|
DOCKER_CMD="docker" |
||||||
|
elif command -v podman >/dev/null 2>&1; then |
||||||
|
DOCKER_CMD="podman" |
||||||
|
elif command -v docker.io >/dev/null 2>&1; then |
||||||
|
DOCKER_CMD="docker.io" |
||||||
|
else |
||||||
|
echo "Error: Docker is not installed or not in PATH." |
||||||
|
echo "" |
||||||
|
echo "To install Docker on Ubuntu/Debian:" |
||||||
|
echo " sudo apt install docker.io" |
||||||
|
echo " sudo systemctl enable --now docker" |
||||||
|
echo " sudo usermod -aG docker $USER" |
||||||
|
echo " # Then log out and back in" |
||||||
|
echo "" |
||||||
|
echo "Or install Podman (Docker alternative):" |
||||||
|
echo " sudo apt install podman" |
||||||
|
echo "" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Check if we're on the correct tag (informational only) |
||||||
|
CURRENT_TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "") |
||||||
|
if [ -n "$CURRENT_TAG" ] && [ "$CURRENT_TAG" != "$VERSION" ]; then |
||||||
|
echo "Info: Current git tag is $CURRENT_TAG, but building version $VERSION" |
||||||
|
echo "Continuing with version $VERSION..." |
||||||
|
echo "" |
||||||
|
fi |
||||||
|
|
||||||
|
# Always rebuild web UI to ensure latest changes are included |
||||||
|
# Remove old dist folder if it exists to force fresh build |
||||||
|
if [ -d "app/web/dist" ]; then |
||||||
|
echo "Removing old web UI build to force fresh rebuild..." |
||||||
|
rm -rf app/web/dist |
||||||
|
fi |
||||||
|
|
||||||
|
# Create minimal placeholder for Go embed directive (needed for local development) |
||||||
|
# Docker will replace this with the actual build |
||||||
|
mkdir -p app/web/dist |
||||||
|
echo "<!-- Placeholder - will be replaced by Docker build -->" > app/web/dist/index.html |
||||||
|
|
||||||
|
echo "Using Dockerfile.with-web (will build web UI in Docker with latest changes)..." |
||||||
|
DOCKERFILE="Dockerfile.with-web" |
||||||
|
|
||||||
|
# Ensure dev proxy is disabled in Dockerfile to prevent 502 errors |
||||||
|
echo "Ensuring dev proxy is disabled in Dockerfile..." |
||||||
|
# Remove any existing ORLY_WEB lines to avoid duplicates |
||||||
|
sed -i '/ORLY_WEB_DISABLE/d' "$DOCKERFILE" |
||||||
|
sed -i '/ORLY_WEB_DEV_PROXY_URL/d' "$DOCKERFILE" |
||||||
|
# Remove trailing backslash from ORLY_LOG_LEVEL if it exists (we'll add it back) |
||||||
|
sed -i 's/^\( ORLY_LOG_LEVEL=info\) \\$/\1/' "$DOCKERFILE" |
||||||
|
# Now add the new lines properly formatted |
||||||
|
awk '/^ ORLY_LOG_LEVEL=info$/ { |
||||||
|
print $0 " \\" |
||||||
|
print " ORLY_WEB_DISABLE=false \\" |
||||||
|
print " ORLY_WEB_DEV_PROXY_URL=\"\"" |
||||||
|
next |
||||||
|
} 1' "$DOCKERFILE" > "$DOCKERFILE.tmp" && mv "$DOCKERFILE.tmp" "$DOCKERFILE" |
||||||
|
echo "Added ORLY_WEB_DISABLE=false and ORLY_WEB_DEV_PROXY_URL=\"\" to Dockerfile" |
||||||
|
|
||||||
|
# Check if local nostr clone exists and prepare it for Docker build |
||||||
|
NOSTR_PATH="${NOSTR_PATH:-/home/firefly/Dokumente/repos/nostr}" |
||||||
|
mkdir -p .docker-build-context |
||||||
|
|
||||||
|
# Backup go.mod and go.sum before making changes |
||||||
|
cp go.mod go.mod.backup |
||||||
|
cp go.sum go.sum.backup 2>/dev/null || true |
||||||
|
|
||||||
|
if [ -d "$NOSTR_PATH" ] && [ -f "$NOSTR_PATH/go.mod" ]; then |
||||||
|
echo "Found local nostr clone at: $NOSTR_PATH" |
||||||
|
|
||||||
|
# Remove any existing replace directive |
||||||
|
sed -i '/^replace git.mleku.dev\/mleku\/nostr/d' go.mod |
||||||
|
|
||||||
|
# First, use the actual local path for vendoring (absolute path) |
||||||
|
echo "replace git.mleku.dev/mleku/nostr => $NOSTR_PATH" >> go.mod |
||||||
|
echo "Using local nostr module for vendoring" |
||||||
|
|
||||||
|
# Vendor all dependencies locally (using local network, not Docker's network) |
||||||
|
echo "Vendoring all dependencies locally..." |
||||||
|
go mod vendor || { |
||||||
|
echo "Error: Failed to vendor dependencies. Make sure you have network access." |
||||||
|
exit 1 |
||||||
|
} |
||||||
|
|
||||||
|
# Copy nostr into build context for Docker |
||||||
|
rm -rf .docker-build-context/nostr |
||||||
|
cp -r "$NOSTR_PATH" .docker-build-context/nostr |
||||||
|
|
||||||
|
# Now update go.mod to use Docker build context path |
||||||
|
echo "Preparing for Docker build..." |
||||||
|
sed -i '/^replace git.mleku.dev\/mleku\/nostr/d' go.mod |
||||||
|
echo "replace git.mleku.dev/mleku/nostr => ./docker-build-context/nostr" >> go.mod |
||||||
|
|
||||||
|
# Update vendor/modules.txt to match the new replace path |
||||||
|
# The nostr code is already in vendor/, we just need to update the metadata |
||||||
|
if [ -f vendor/modules.txt ]; then |
||||||
|
# Replace the absolute path with the relative Docker build context path |
||||||
|
sed -i "s|=> $NOSTR_PATH|=> ./docker-build-context/nostr|g" vendor/modules.txt |
||||||
|
fi |
||||||
|
echo "Using local nostr module from build context" |
||||||
|
else |
||||||
|
echo "Local nostr clone not found at $NOSTR_PATH" |
||||||
|
echo "Will try to fetch from remote (may have DNS issues)..." |
||||||
|
# Create empty directory so COPY doesn't fail |
||||||
|
mkdir -p .docker-build-context/nostr |
||||||
|
|
||||||
|
# Remove any existing replace directive |
||||||
|
sed -i '/^replace git.mleku.dev\/mleku\/nostr/d' go.mod |
||||||
|
|
||||||
|
# Vendor all dependencies locally |
||||||
|
echo "Vendoring all dependencies locally..." |
||||||
|
go mod vendor || { |
||||||
|
echo "Error: Failed to vendor dependencies. Make sure you have network access." |
||||||
|
exit 1 |
||||||
|
} |
||||||
|
fi |
||||||
|
|
||||||
|
# Function to restore go.mod and go.sum on exit |
||||||
|
restore_gomod() { |
||||||
|
if [ -f go.mod.backup ]; then |
||||||
|
mv go.mod.backup go.mod |
||||||
|
echo "Restored go.mod" |
||||||
|
fi |
||||||
|
if [ -f go.sum.backup ]; then |
||||||
|
mv go.sum.backup go.sum |
||||||
|
echo "Restored go.sum" |
||||||
|
fi |
||||||
|
# Clean up build context and vendor |
||||||
|
rm -rf .docker-build-context |
||||||
|
rm -rf vendor |
||||||
|
} |
||||||
|
trap restore_gomod EXIT |
||||||
|
|
||||||
|
# Build the Docker image with both version and latest tags |
||||||
|
# Local nostr clone (if found) is already copied into build context |
||||||
|
IMAGE_NAME="silberengel/next-orly" |
||||||
|
echo "Building Docker image ${IMAGE_NAME}:${VERSION} using $DOCKER_CMD..." |
||||||
|
if [ "$DOCKER_CMD" = "docker" ]; then |
||||||
|
# Try using host network mode first (uses host DNS) - best for DNS issues |
||||||
|
if docker build --help 2>/dev/null | grep -q "\-\-network"; then |
||||||
|
echo "Using host network mode for better DNS resolution..." |
||||||
|
$DOCKER_CMD build --network=host -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" . |
||||||
|
elif docker build --help 2>/dev/null | grep -q "\-\-dns"; then |
||||||
|
# Fallback to DNS configuration |
||||||
|
echo "Using DNS configuration (8.8.8.8, 8.8.4.4)..." |
||||||
|
$DOCKER_CMD build --dns 8.8.8.8 --dns 8.8.4.4 -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" . |
||||||
|
else |
||||||
|
# Last resort - no DNS config |
||||||
|
echo "Warning: No DNS configuration available, build may fail..." |
||||||
|
$DOCKER_CMD build -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" . |
||||||
|
fi |
||||||
|
else |
||||||
|
# buildx or other - try DNS if available |
||||||
|
if docker buildx build --help 2>/dev/null | grep -q "\-\-dns"; then |
||||||
|
$DOCKER_CMD build --dns 8.8.8.8 --dns 8.8.4.4 -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" . |
||||||
|
else |
||||||
|
$DOCKER_CMD build -t "${IMAGE_NAME}:${VERSION}" -t "${IMAGE_NAME}:latest" -f "$DOCKERFILE" . |
||||||
|
fi |
||||||
|
fi |
||||||
|
|
||||||
|
echo "" |
||||||
|
echo "Build complete! Image tags:" |
||||||
|
echo " - ${IMAGE_NAME}:${VERSION}" |
||||||
|
echo " - ${IMAGE_NAME}:latest" |
||||||
|
echo "" |
||||||
|
echo "To push to Docker Hub:" |
||||||
|
echo " $DOCKER_CMD push ${IMAGE_NAME}:${VERSION}" |
||||||
|
echo " $DOCKER_CMD push ${IMAGE_NAME}:latest" |
||||||
|
echo "" |
||||||
|
echo "Or run this script with --push to build and push automatically:" |
||||||
|
echo " $0 --push" |
||||||
|
|
||||||
|
# Push if requested |
||||||
|
if [ "$1" == "--push" ]; then |
||||||
|
echo "" |
||||||
|
echo "Pushing images to Docker Hub..." |
||||||
|
$DOCKER_CMD push "${IMAGE_NAME}:${VERSION}" |
||||||
|
$DOCKER_CMD push "${IMAGE_NAME}:latest" |
||||||
|
echo "" |
||||||
|
echo "✅ Images pushed successfully!" |
||||||
|
fi |
||||||
@ -0,0 +1,131 @@ |
|||||||
|
//go:build !(js && wasm)
|
||||||
|
|
||||||
|
package db |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"lol.mleku.dev" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
|
||||||
|
"next.orly.dev/pkg/database" |
||||||
|
) |
||||||
|
|
||||||
|
func runImport(args []string) { |
||||||
|
var inputFile string |
||||||
|
var showHelp bool |
||||||
|
|
||||||
|
for i, arg := range args { |
||||||
|
if arg == "--file" || arg == "-f" { |
||||||
|
if i+1 < len(args) { |
||||||
|
inputFile = args[i+1] |
||||||
|
} |
||||||
|
} else if arg == "--help" || arg == "-h" { |
||||||
|
showHelp = true |
||||||
|
} else if arg != "" && !strings.HasPrefix(arg, "-") && inputFile == "" { |
||||||
|
inputFile = arg |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if showHelp { |
||||||
|
printImportHelp() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if inputFile == "" { |
||||||
|
fmt.Fprintln(os.Stderr, "error: input file required") |
||||||
|
fmt.Fprintln(os.Stderr, "usage: orly db import <file.jsonl>") |
||||||
|
fmt.Fprintln(os.Stderr, " or: orly db import --file <file.jsonl>") |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
|
||||||
|
cfg := loadConfig() |
||||||
|
lol.SetLogLevel(cfg.LogLevel) |
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background()) |
||||||
|
defer cancel() |
||||||
|
|
||||||
|
// Create database configuration
|
||||||
|
dbCfg := &database.DatabaseConfig{ |
||||||
|
DataDir: cfg.DataDir, |
||||||
|
LogLevel: cfg.LogLevel, |
||||||
|
BlockCacheMB: cfg.BlockCacheMB, |
||||||
|
IndexCacheMB: cfg.IndexCacheMB, |
||||||
|
QueryCacheSizeMB: cfg.QueryCacheSizeMB, |
||||||
|
QueryCacheMaxAge: cfg.QueryCacheMaxAge, |
||||||
|
QueryCacheDisabled: cfg.QueryCacheDisabled, |
||||||
|
SerialCachePubkeys: cfg.SerialCachePubkeys, |
||||||
|
SerialCacheEventIds: cfg.SerialCacheEventIds, |
||||||
|
ZSTDLevel: cfg.ZSTDLevel, |
||||||
|
} |
||||||
|
|
||||||
|
// Initialize database directly
|
||||||
|
log.I.F("initializing database at %s for import", cfg.DataDir) |
||||||
|
db, err := database.NewWithConfig(ctx, cancel, dbCfg) |
||||||
|
if chk.E(err) { |
||||||
|
log.E.F("failed to initialize database: %v", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
defer db.Close() |
||||||
|
|
||||||
|
// Wait for database to be ready
|
||||||
|
log.I.F("waiting for database to be ready...") |
||||||
|
<-db.Ready() |
||||||
|
log.I.F("database ready") |
||||||
|
|
||||||
|
// Open input file
|
||||||
|
log.I.F("opening input file: %s", inputFile) |
||||||
|
file, err := os.Open(inputFile) |
||||||
|
if chk.E(err) { |
||||||
|
log.E.F("failed to open input file: %v", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
defer file.Close() |
||||||
|
|
||||||
|
// Get file size for progress
|
||||||
|
fileInfo, err := file.Stat() |
||||||
|
if chk.E(err) { |
||||||
|
log.E.F("failed to stat file: %v", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
fileSizeMB := float64(fileInfo.Size()) / 1024 / 1024 |
||||||
|
log.I.F("importing %.2f MB from %s", fileSizeMB, inputFile) |
||||||
|
|
||||||
|
// Import events
|
||||||
|
log.I.F("starting import...") |
||||||
|
db.Import(file) |
||||||
|
|
||||||
|
log.I.F("import completed successfully") |
||||||
|
fmt.Fprintf(os.Stdout, "✓ Imported events from %s (%.2f MB)\n", inputFile, fileSizeMB) |
||||||
|
} |
||||||
|
|
||||||
|
func printImportHelp() { |
||||||
|
fmt.Println(` |
||||||
|
Import events from a JSONL file directly into the database. |
||||||
|
|
||||||
|
Usage: |
||||||
|
orly db import <file.jsonl> |
||||||
|
orly db import --file <file.jsonl> |
||||||
|
|
||||||
|
This command imports events from a JSONL (JSON Lines) file directly into |
||||||
|
the Badger database, bypassing the HTTP API. This is much faster for large |
||||||
|
imports when you have direct access to the database. |
||||||
|
|
||||||
|
The input file should contain one JSON event per line (JSONL format). |
||||||
|
|
||||||
|
Environment variables: |
||||||
|
ORLY_DATA_DIR - Database data directory (required) |
||||||
|
ORLY_DB_LOG_LEVEL - Log level (default: info) |
||||||
|
ORLY_DB_BLOCK_CACHE_MB - Block cache size in MB (default: 1024) |
||||||
|
ORLY_DB_INDEX_CACHE_MB - Index cache size in MB (default: 512) |
||||||
|
ORLY_DB_ZSTD_LEVEL - ZSTD compression level (default: 3) |
||||||
|
|
||||||
|
Examples: |
||||||
|
orly db import events.jsonl |
||||||
|
orly db import --file /path/to/large-export.jsonl |
||||||
|
`) |
||||||
|
} |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
version: '3.8' |
||||||
|
|
||||||
|
services: |
||||||
|
orly-relay: |
||||||
|
image: silberengel/next-orly:v0.48.10 |
||||||
|
container_name: orly-relay |
||||||
|
restart: always |
||||||
|
ports: |
||||||
|
- "127.0.0.1:3334:3334" |
||||||
|
- "127.0.0.1:7777:7777" |
||||||
|
volumes: |
||||||
|
# Use bind mount to host filesystem for large datasets (20GB+) |
||||||
|
# Change /var/lib/orly to your desired data directory on the host |
||||||
|
- /var/lib/orly:/data |
||||||
|
environment: |
||||||
|
# Relay Configuration |
||||||
|
- ORLY_DATA_DIR=/data |
||||||
|
- ORLY_LISTEN=0.0.0.0 |
||||||
|
- ORLY_PORT=7777 |
||||||
|
- ORLY_LOG_LEVEL=Info |
||||||
|
|
||||||
|
# Admin and Owner Configuration |
||||||
|
- ORLY_ADMINS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw |
||||||
|
- ORLY_OWNERS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw |
||||||
|
|
||||||
|
# ACL Configuration (follows mode for access control based on admin follow lists) |
||||||
|
- ORLY_ACL_MODE=follows |
||||||
|
|
||||||
|
# Spider Configuration (syncs events for followed pubkeys) |
||||||
|
- ORLY_SPIDER_MODE=follows |
||||||
|
|
||||||
|
# Relay URL (for dashboard and metadata) |
||||||
|
- ORLY_RELAY_URL=wss://orly-relay.imwald.eu |
||||||
|
|
||||||
|
# Sprocket Configuration (event processing plugin system) |
||||||
|
- ORLY_SPROCKET_ENABLED=false |
||||||
|
|
||||||
|
# Database Logging |
||||||
|
- ORLY_DB_LOG_LEVEL=error |
||||||
|
|
||||||
|
# Database Cache Tuning for Large Datasets (20GB+) |
||||||
|
# Increased caches for better performance with large working sets |
||||||
|
- ORLY_DB_BLOCK_CACHE_MB=2048 # 2GB block cache (default: 1024MB) |
||||||
|
- ORLY_DB_INDEX_CACHE_MB=1024 # 1GB index cache (default: 512MB) |
||||||
|
- ORLY_SERIAL_CACHE_PUBKEYS=500000 # 500k pubkeys cache (default: 100k) |
||||||
|
- ORLY_SERIAL_CACHE_EVENT_IDS=2000000 # 2M event IDs cache (default: 500k) |
||||||
|
- ORLY_DB_ZSTD_LEVEL=9 # ZSTD compression level 9 (best compression, reduces disk IO) |
||||||
|
|
||||||
|
# Storage GC Configuration for Large Archives |
||||||
|
# Enable GC with aggressive eviction to manage storage growth |
||||||
|
- ORLY_GC_ENABLED=true # Enable storage garbage collection |
||||||
|
- ORLY_GC_BATCH_SIZE=5000 # GC batch size for efficient processing |
||||||
|
- ORLY_MAX_STORAGE_BYTES=107374182400 # 100GB storage cap (adjust as needed) |
||||||
|
|
||||||
|
# Bootstrap relay for initial sync |
||||||
|
- ORLY_BOOTSTRAP_RELAYS=wss://profiles.nostr1.com,wss://purplepag.es,wss://relay.damus.io |
||||||
|
|
||||||
|
# Disable subscription/payment requirements |
||||||
|
- ORLY_SUBSCRIPTION_ENABLED=false |
||||||
|
- ORLY_MONTHLY_PRICE_SAT=0 |
||||||
|
|
||||||
|
# Performance Settings |
||||||
|
- ORLY_MAX_CONNECTIONS=1000 |
||||||
|
- ORLY_MAX_EVENT_SIZE=65536 |
||||||
|
- ORLY_MAX_SUBSCRIPTIONS=20 |
||||||
|
|
||||||
|
healthcheck: |
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:7777"] |
||||||
|
interval: 30s |
||||||
|
timeout: 10s |
||||||
|
retries: 3 |
||||||
|
start_period: 20s |
||||||
|
|
||||||
|
# Resource limits - Increased for large imports (20GB+) |
||||||
|
# Memory increased to handle Badger caches and import operations |
||||||
|
deploy: |
||||||
|
resources: |
||||||
|
limits: |
||||||
|
memory: 4096M # 4GB - increased from 1GB for large dataset imports |
||||||
|
cpus: "2.0" # Increased CPU for faster compaction during imports |
||||||
|
reservations: |
||||||
|
memory: 2048M # 2GB reservation |
||||||
|
cpus: "1.0" |
||||||
|
|
||||||
|
# Logging configuration |
||||||
|
logging: |
||||||
|
driver: "json-file" |
||||||
|
options: |
||||||
|
max-size: "10m" |
||||||
|
max-file: "3" |
||||||
|
|
||||||
|
# Note: Using bind mount instead of named volume for large datasets |
||||||
|
# Data is stored directly on host filesystem at /var/lib/orly |
||||||
|
# To use a different path, change the volume mount above |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
{ |
||||||
|
"name": "next.orly.dev", |
||||||
|
"lockfileVersion": 3, |
||||||
|
"requires": true, |
||||||
|
"packages": {} |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
// Package transport defines the interface for pluggable network transports.
|
||||||
|
package transport |
||||||
|
|
||||||
|
import "context" |
||||||
|
|
||||||
|
// Transport represents a network transport that serves the relay.
|
||||||
|
type Transport interface { |
||||||
|
// Name returns the transport identifier (e.g., "tcp", "tls", "tor").
|
||||||
|
Name() string |
||||||
|
// Start begins accepting connections through this transport.
|
||||||
|
Start(ctx context.Context) error |
||||||
|
// Stop gracefully shuts down the transport.
|
||||||
|
Stop(ctx context.Context) error |
||||||
|
// Addresses returns the addresses this transport is reachable on.
|
||||||
|
Addresses() []string |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
// Package transport provides a manager for pluggable network transports.
|
||||||
|
package transport |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"lol.mleku.dev/log" |
||||||
|
|
||||||
|
iface "next.orly.dev/pkg/interfaces/transport" |
||||||
|
) |
||||||
|
|
||||||
|
// Manager manages multiple transports and coordinates their lifecycle.
|
||||||
|
type Manager struct { |
||||||
|
mu sync.RWMutex |
||||||
|
transports []iface.Transport |
||||||
|
} |
||||||
|
|
||||||
|
// NewManager creates a new transport manager.
|
||||||
|
func NewManager() *Manager { |
||||||
|
return &Manager{} |
||||||
|
} |
||||||
|
|
||||||
|
// Add registers a transport with the manager.
|
||||||
|
func (m *Manager) Add(t iface.Transport) { |
||||||
|
m.mu.Lock() |
||||||
|
defer m.mu.Unlock() |
||||||
|
m.transports = append(m.transports, t) |
||||||
|
} |
||||||
|
|
||||||
|
// StartAll starts all registered transports in order.
|
||||||
|
// If a transport fails to start, it is logged and skipped.
|
||||||
|
// Returns an error only if no transports started successfully.
|
||||||
|
func (m *Manager) StartAll(ctx context.Context) error { |
||||||
|
m.mu.RLock() |
||||||
|
defer m.mu.RUnlock() |
||||||
|
|
||||||
|
started := 0 |
||||||
|
for _, t := range m.transports { |
||||||
|
log.I.F("starting transport: %s", t.Name()) |
||||||
|
if err := t.Start(ctx); err != nil { |
||||||
|
log.E.F("transport %s failed to start: %v (skipping)", t.Name(), err) |
||||||
|
continue |
||||||
|
} |
||||||
|
log.I.F("transport started: %s", t.Name()) |
||||||
|
started++ |
||||||
|
} |
||||||
|
if started == 0 { |
||||||
|
return fmt.Errorf("no transports started successfully") |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// StopAll stops all transports in reverse order.
|
||||||
|
func (m *Manager) StopAll(ctx context.Context) error { |
||||||
|
m.mu.RLock() |
||||||
|
defer m.mu.RUnlock() |
||||||
|
|
||||||
|
var firstErr error |
||||||
|
for i := len(m.transports) - 1; i >= 0; i-- { |
||||||
|
t := m.transports[i] |
||||||
|
log.I.F("stopping transport: %s", t.Name()) |
||||||
|
if err := t.Stop(ctx); err != nil { |
||||||
|
log.E.F("failed to stop transport %s: %v", t.Name(), err) |
||||||
|
if firstErr == nil { |
||||||
|
firstErr = err |
||||||
|
} |
||||||
|
} else { |
||||||
|
log.I.F("transport stopped: %s", t.Name()) |
||||||
|
} |
||||||
|
} |
||||||
|
return firstErr |
||||||
|
} |
||||||
|
|
||||||
|
// Addresses returns all addresses from all transports.
|
||||||
|
func (m *Manager) Addresses() []string { |
||||||
|
m.mu.RLock() |
||||||
|
defer m.mu.RUnlock() |
||||||
|
|
||||||
|
var addrs []string |
||||||
|
for _, t := range m.transports { |
||||||
|
addrs = append(addrs, t.Addresses()...) |
||||||
|
} |
||||||
|
return addrs |
||||||
|
} |
||||||
@ -0,0 +1,78 @@ |
|||||||
|
// Package tcp provides a plain HTTP transport for the relay.
|
||||||
|
package tcp |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"lol.mleku.dev/log" |
||||||
|
) |
||||||
|
|
||||||
|
// Config holds TCP transport configuration.
|
||||||
|
type Config struct { |
||||||
|
// Addr is the listen address (e.g., "0.0.0.0:3334").
|
||||||
|
Addr string |
||||||
|
// Handler is the HTTP handler to serve.
|
||||||
|
Handler http.Handler |
||||||
|
} |
||||||
|
|
||||||
|
// Transport serves HTTP over plain TCP.
|
||||||
|
type Transport struct { |
||||||
|
cfg *Config |
||||||
|
server *http.Server |
||||||
|
mu sync.Mutex |
||||||
|
} |
||||||
|
|
||||||
|
// New creates a new TCP transport.
|
||||||
|
func New(cfg *Config) *Transport { |
||||||
|
return &Transport{cfg: cfg} |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Name() string { return "tcp" } |
||||||
|
|
||||||
|
func (t *Transport) Start(ctx context.Context) error { |
||||||
|
t.mu.Lock() |
||||||
|
defer t.mu.Unlock() |
||||||
|
|
||||||
|
t.server = &http.Server{ |
||||||
|
Addr: t.cfg.Addr, |
||||||
|
Handler: t.cfg.Handler, |
||||||
|
} |
||||||
|
|
||||||
|
log.I.F("starting listener on http://%s", t.cfg.Addr) |
||||||
|
|
||||||
|
errCh := make(chan error, 1) |
||||||
|
go func() { |
||||||
|
if err := t.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
||||||
|
errCh <- err |
||||||
|
} |
||||||
|
close(errCh) |
||||||
|
}() |
||||||
|
|
||||||
|
// Give the server a moment to fail on bind errors
|
||||||
|
select { |
||||||
|
case err := <-errCh: |
||||||
|
if err != nil { |
||||||
|
return fmt.Errorf("tcp listen on %s: %w", t.cfg.Addr, err) |
||||||
|
} |
||||||
|
default: |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Stop(ctx context.Context) error { |
||||||
|
t.mu.Lock() |
||||||
|
defer t.mu.Unlock() |
||||||
|
|
||||||
|
if t.server == nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return t.server.Shutdown(ctx) |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Addresses() []string { |
||||||
|
return []string{"ws://" + t.cfg.Addr + "/"} |
||||||
|
} |
||||||
@ -0,0 +1,247 @@ |
|||||||
|
// Package tls provides a TLS/ACME transport for the relay.
|
||||||
|
package tls |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"crypto/tls" |
||||||
|
"crypto/x509" |
||||||
|
"fmt" |
||||||
|
"net/http" |
||||||
|
"os" |
||||||
|
"path/filepath" |
||||||
|
"strings" |
||||||
|
"sync" |
||||||
|
|
||||||
|
"golang.org/x/crypto/acme/autocert" |
||||||
|
"lol.mleku.dev/chk" |
||||||
|
"lol.mleku.dev/log" |
||||||
|
) |
||||||
|
|
||||||
|
// Config holds TLS transport configuration.
|
||||||
|
type Config struct { |
||||||
|
// Domains is the list of domains for ACME auto-cert.
|
||||||
|
Domains []string |
||||||
|
// Certs is a list of manual certificate paths (without extension).
|
||||||
|
// For each path, .pem and .key files are loaded.
|
||||||
|
Certs []string |
||||||
|
// DataDir is the base data directory for the autocert cache.
|
||||||
|
DataDir string |
||||||
|
// Handler is the HTTP handler to serve.
|
||||||
|
Handler http.Handler |
||||||
|
} |
||||||
|
|
||||||
|
// Transport serves HTTPS with automatic or manual TLS certificates.
|
||||||
|
// It runs two servers: HTTPS on :443 and HTTP on :80 for ACME challenges.
|
||||||
|
type Transport struct { |
||||||
|
cfg *Config |
||||||
|
tlsServer *http.Server |
||||||
|
httpServer *http.Server |
||||||
|
mu sync.Mutex |
||||||
|
} |
||||||
|
|
||||||
|
// New creates a new TLS transport.
|
||||||
|
func New(cfg *Config) *Transport { |
||||||
|
return &Transport{cfg: cfg} |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Name() string { return "tls" } |
||||||
|
|
||||||
|
func (t *Transport) Start(ctx context.Context) error { |
||||||
|
t.mu.Lock() |
||||||
|
defer t.mu.Unlock() |
||||||
|
|
||||||
|
if err := ValidateConfig(t.cfg.Domains, t.cfg.Certs); err != nil { |
||||||
|
return fmt.Errorf("invalid TLS configuration: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Create cache directory for autocert
|
||||||
|
cacheDir := filepath.Join(t.cfg.DataDir, "autocert") |
||||||
|
if err := os.MkdirAll(cacheDir, 0700); err != nil { |
||||||
|
return fmt.Errorf("failed to create autocert cache directory: %w", err) |
||||||
|
} |
||||||
|
|
||||||
|
// Set up autocert manager
|
||||||
|
m := &autocert.Manager{ |
||||||
|
Prompt: autocert.AcceptTOS, |
||||||
|
Cache: autocert.DirCache(cacheDir), |
||||||
|
HostPolicy: autocert.HostWhitelist(t.cfg.Domains...), |
||||||
|
} |
||||||
|
|
||||||
|
// Create TLS server on port 443
|
||||||
|
t.tlsServer = &http.Server{ |
||||||
|
Addr: ":443", |
||||||
|
Handler: t.cfg.Handler, |
||||||
|
TLSConfig: tlsConfig(m, t.cfg.Certs...), |
||||||
|
} |
||||||
|
|
||||||
|
// Create HTTP server for ACME challenges and redirects on port 80
|
||||||
|
t.httpServer = &http.Server{ |
||||||
|
Addr: ":80", |
||||||
|
Handler: m.HTTPHandler(nil), |
||||||
|
} |
||||||
|
|
||||||
|
log.I.F("TLS enabled for domains: %v", t.cfg.Domains) |
||||||
|
|
||||||
|
// Start TLS server
|
||||||
|
go func() { |
||||||
|
log.I.F("starting TLS listener on https://:443") |
||||||
|
if err := t.tlsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { |
||||||
|
log.E.F("TLS server error: %v", err) |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
// Start HTTP server for ACME challenges
|
||||||
|
go func() { |
||||||
|
log.I.F("starting HTTP listener on http://:80 for ACME challenges") |
||||||
|
if err := t.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { |
||||||
|
log.E.F("HTTP server error: %v", err) |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Stop(ctx context.Context) error { |
||||||
|
t.mu.Lock() |
||||||
|
defer t.mu.Unlock() |
||||||
|
|
||||||
|
var firstErr error |
||||||
|
|
||||||
|
if t.tlsServer != nil { |
||||||
|
if err := t.tlsServer.Shutdown(ctx); err != nil { |
||||||
|
log.E.F("TLS server shutdown error: %v", err) |
||||||
|
firstErr = err |
||||||
|
} else { |
||||||
|
log.I.F("TLS server shutdown completed") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if t.httpServer != nil { |
||||||
|
if err := t.httpServer.Shutdown(ctx); err != nil { |
||||||
|
log.E.F("HTTP server shutdown error: %v", err) |
||||||
|
if firstErr == nil { |
||||||
|
firstErr = err |
||||||
|
} |
||||||
|
} else { |
||||||
|
log.I.F("HTTP server shutdown completed") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return firstErr |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Addresses() []string { |
||||||
|
var addrs []string |
||||||
|
for _, domain := range t.cfg.Domains { |
||||||
|
addrs = append(addrs, "wss://"+domain+"/") |
||||||
|
} |
||||||
|
return addrs |
||||||
|
} |
||||||
|
|
||||||
|
// ValidateConfig checks if the TLS configuration is valid.
|
||||||
|
func ValidateConfig(domains []string, certs []string) error { |
||||||
|
if len(domains) == 0 { |
||||||
|
return fmt.Errorf("no TLS domains specified") |
||||||
|
} |
||||||
|
|
||||||
|
for _, domain := range domains { |
||||||
|
if domain == "" { |
||||||
|
continue |
||||||
|
} |
||||||
|
if strings.Contains(domain, " ") || strings.Contains(domain, "\t") { |
||||||
|
return fmt.Errorf("invalid domain name: %s", domain) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// tlsConfig returns a TLS configuration that works with LetsEncrypt automatic
|
||||||
|
// SSL cert issuer as well as any provided certificate files.
|
||||||
|
//
|
||||||
|
// Certs are provided as paths where .pem and .key files exist.
|
||||||
|
func tlsConfig(m *autocert.Manager, certs ...string) *tls.Config { |
||||||
|
certMap := make(map[string]*tls.Certificate) |
||||||
|
var mx sync.Mutex |
||||||
|
|
||||||
|
for _, certPath := range certs { |
||||||
|
if certPath == "" { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
var err error |
||||||
|
var c tls.Certificate |
||||||
|
|
||||||
|
if c, err = tls.LoadX509KeyPair( |
||||||
|
certPath+".pem", certPath+".key", |
||||||
|
); chk.E(err) { |
||||||
|
log.E.F("failed to load certificate from %s: %v", certPath, err) |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
if len(c.Certificate) > 0 { |
||||||
|
if x509Cert, err := x509.ParseCertificate(c.Certificate[0]); err == nil { |
||||||
|
if x509Cert.Subject.CommonName != "" { |
||||||
|
certMap[x509Cert.Subject.CommonName] = &c |
||||||
|
log.I.F("loaded certificate for domain: %s", x509Cert.Subject.CommonName) |
||||||
|
} |
||||||
|
for _, san := range x509Cert.DNSNames { |
||||||
|
if san != "" { |
||||||
|
certMap[san] = &c |
||||||
|
log.I.F("loaded certificate for SAN domain: %s", san) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if m == nil { |
||||||
|
return &tls.Config{ |
||||||
|
GetCertificate: func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
||||||
|
mx.Lock() |
||||||
|
defer mx.Unlock() |
||||||
|
|
||||||
|
if cert, exists := certMap[helo.ServerName]; exists { |
||||||
|
return cert, nil |
||||||
|
} |
||||||
|
|
||||||
|
for domain, cert := range certMap { |
||||||
|
if strings.HasPrefix(domain, "*.") { |
||||||
|
baseDomain := domain[2:] |
||||||
|
if strings.HasSuffix(helo.ServerName, baseDomain) { |
||||||
|
return cert, nil |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nil, fmt.Errorf("no certificate found for %s", helo.ServerName) |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
tc := m.TLSConfig() |
||||||
|
tc.GetCertificate = func(helo *tls.ClientHelloInfo) (*tls.Certificate, error) { |
||||||
|
mx.Lock() |
||||||
|
|
||||||
|
if cert, exists := certMap[helo.ServerName]; exists { |
||||||
|
mx.Unlock() |
||||||
|
return cert, nil |
||||||
|
} |
||||||
|
|
||||||
|
for domain, cert := range certMap { |
||||||
|
if strings.HasPrefix(domain, "*.") { |
||||||
|
baseDomain := domain[2:] |
||||||
|
if strings.HasSuffix(helo.ServerName, baseDomain) { |
||||||
|
mx.Unlock() |
||||||
|
return cert, nil |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
mx.Unlock() |
||||||
|
|
||||||
|
return m.GetCertificate(helo) |
||||||
|
} |
||||||
|
|
||||||
|
return tc |
||||||
|
} |
||||||
@ -0,0 +1,91 @@ |
|||||||
|
// Package tor provides a Tor hidden service transport for the relay.
|
||||||
|
// It wraps the existing pkg/tor service as a pluggable transport.
|
||||||
|
package tor |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"lol.mleku.dev/log" |
||||||
|
|
||||||
|
torservice "next.orly.dev/pkg/tor" |
||||||
|
) |
||||||
|
|
||||||
|
// Config holds Tor transport configuration.
|
||||||
|
type Config struct { |
||||||
|
// Port is the internal port for the hidden service.
|
||||||
|
Port int |
||||||
|
// DataDir is the directory for Tor data (torrc, keys, hostname, etc.).
|
||||||
|
DataDir string |
||||||
|
// Binary is the path to the tor executable.
|
||||||
|
Binary string |
||||||
|
// SOCKSPort is the port for outbound SOCKS connections (0 = disabled).
|
||||||
|
SOCKSPort int |
||||||
|
// Handler is the HTTP handler to serve.
|
||||||
|
Handler http.Handler |
||||||
|
} |
||||||
|
|
||||||
|
// Transport serves the relay as a Tor hidden service.
|
||||||
|
type Transport struct { |
||||||
|
cfg *Config |
||||||
|
service *torservice.Service |
||||||
|
} |
||||||
|
|
||||||
|
// New creates a new Tor transport.
|
||||||
|
func New(cfg *Config) *Transport { |
||||||
|
return &Transport{cfg: cfg} |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Name() string { return "tor" } |
||||||
|
|
||||||
|
func (t *Transport) Start(ctx context.Context) error { |
||||||
|
svcCfg := &torservice.Config{ |
||||||
|
Port: t.cfg.Port, |
||||||
|
DataDir: t.cfg.DataDir, |
||||||
|
Binary: t.cfg.Binary, |
||||||
|
SOCKSPort: t.cfg.SOCKSPort, |
||||||
|
Handler: t.cfg.Handler, |
||||||
|
} |
||||||
|
|
||||||
|
var err error |
||||||
|
t.service, err = torservice.New(svcCfg) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if err = t.service.Start(); err != nil { |
||||||
|
t.service = nil |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if addr := t.service.OnionWSAddress(); addr != "" { |
||||||
|
log.I.F("Tor hidden service listening on port %d, address: %s", t.cfg.Port, addr) |
||||||
|
} else { |
||||||
|
log.I.F("Tor hidden service listening on port %d (waiting for .onion address)", t.cfg.Port) |
||||||
|
} |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Stop(ctx context.Context) error { |
||||||
|
if t.service == nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return t.service.Stop() |
||||||
|
} |
||||||
|
|
||||||
|
func (t *Transport) Addresses() []string { |
||||||
|
if t.service == nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
if addr := t.service.OnionWSAddress(); addr != "" { |
||||||
|
return []string{addr} |
||||||
|
} |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// Service returns the underlying Tor service for access to Tor-specific
|
||||||
|
// functionality (e.g., OnionAddress, DataDir).
|
||||||
|
func (t *Transport) Service() *torservice.Service { |
||||||
|
return t.service |
||||||
|
} |
||||||
@ -0,0 +1,124 @@ |
|||||||
|
#!/bin/bash |
||||||
|
# Run Orly relay using docker run (alternative to docker-compose) |
||||||
|
# Optimized for large dataset imports (20GB+) with memory-conscious settings |
||||||
|
# |
||||||
|
# Memory optimizations applied: |
||||||
|
# - Reduced cache sizes (block: 256MB, index: 128MB) |
||||||
|
# - Lower serial caches (pubkeys: 50k, event IDs: 250k) |
||||||
|
# - Explicit rate limit target (1500MB) to prevent auto-detection issues |
||||||
|
# - Fixed emergency thresholds (1.167/0.833 instead of 4.0/3.0) |
||||||
|
# - Query result limit (256) to prevent unbounded memory usage |
||||||
|
# - Reduced max connections (500 instead of 1000) |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
CONTAINER_NAME="orly-relay" |
||||||
|
# Auto-detect version or use default |
||||||
|
# Try multiple methods to detect version |
||||||
|
VERSION="" |
||||||
|
if command -v git &> /dev/null && [ -d .git ]; then |
||||||
|
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "") |
||||||
|
fi |
||||||
|
if [ -z "$VERSION" ] && [ -f pkg/version/version ]; then |
||||||
|
VERSION=$(cat pkg/version/version 2>/dev/null | tr -d ' \n' | sed 's/^v//' || echo "") |
||||||
|
fi |
||||||
|
# Fallback to default if still empty |
||||||
|
if [ -z "$VERSION" ]; then |
||||||
|
VERSION="0.58.10" |
||||||
|
fi |
||||||
|
# Ensure version starts with 'v' |
||||||
|
if [[ ! "$VERSION" =~ ^v ]]; then |
||||||
|
VERSION="v${VERSION}" |
||||||
|
fi |
||||||
|
IMAGE="silberengel/next-orly:${VERSION}" |
||||||
|
|
||||||
|
# Data directory on host filesystem (change this to your desired path) |
||||||
|
# Using bind mount instead of volume for better performance with large datasets |
||||||
|
DATA_DIR="${ORLY_DATA_DIR:-/var/lib/orly}" |
||||||
|
|
||||||
|
# Create data directory if it doesn't exist |
||||||
|
mkdir -p "${DATA_DIR}" |
||||||
|
# Set ownership to UID 1000 (orly user in container) and permissions |
||||||
|
chown -R 1000:1000 "${DATA_DIR}" 2>/dev/null || { |
||||||
|
echo "Warning: Could not set ownership of ${DATA_DIR} to UID 1000" |
||||||
|
echo "You may need to run: sudo chown -R 1000:1000 ${DATA_DIR}" |
||||||
|
} |
||||||
|
chmod 755 "${DATA_DIR}" |
||||||
|
|
||||||
|
# Pull the latest image |
||||||
|
echo "Pulling ${IMAGE}..." |
||||||
|
docker pull ${IMAGE} |
||||||
|
|
||||||
|
# Check if container already exists |
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then |
||||||
|
echo "Container ${CONTAINER_NAME} already exists." |
||||||
|
echo "Removing existing container..." |
||||||
|
docker rm -f ${CONTAINER_NAME} 2>/dev/null || true |
||||||
|
fi |
||||||
|
|
||||||
|
# Run the container |
||||||
|
echo "Starting ${CONTAINER_NAME}..." |
||||||
|
docker run -d \ |
||||||
|
--name ${CONTAINER_NAME} \ |
||||||
|
--restart always \ |
||||||
|
-p 0.0.0.0:3334:3334 \ |
||||||
|
-p 0.0.0.0:7777:7777 \ |
||||||
|
-v "${DATA_DIR}:/data" \ |
||||||
|
--memory=4096m \ |
||||||
|
--cpus="2.0" \ |
||||||
|
--oom-kill-disable=false \ |
||||||
|
--health-cmd="curl -f http://localhost:7777/ || exit 1" \ |
||||||
|
--health-interval=10s \ |
||||||
|
--health-timeout=5s \ |
||||||
|
--health-start-period=20s \ |
||||||
|
--health-retries=3 \ |
||||||
|
-e ORLY_DATA_DIR=/data \ |
||||||
|
-e ORLY_LISTEN=0.0.0.0 \ |
||||||
|
-e ORLY_PORT=7777 \ |
||||||
|
-e ORLY_LOG_LEVEL=Warn \ |
||||||
|
-e NOSTR_PRIVATE_KEY="${NOSTR_PRIVATE_KEY:-}" \ |
||||||
|
-e ORLY_ADMINS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw \ |
||||||
|
-e ORLY_OWNERS=npub1m4ny6hjqzepn4rxknuq94c2gpqzr29ufkkw7ttcxyak7v43n6vvsajc2jl,npub1v30tsz9vw6ylpz63g0a702nj3xa26t3m7p5us8f2y2sd8v6cnsvq465zjx,npub12umrfdjgvdxt45g0y3ghwcyfagssjrv5qlm3t6pu2aa5vydwdmwq8q0z04,npub18cddpua960qjy3wmw7y9gmzr4h3ajlrwq3k9jnmqzlxke4qkg6gqeyaztw \ |
||||||
|
-e ORLY_ACL_MODE=follows \ |
||||||
|
-e ORLY_SPIDER_MODE=follows \ |
||||||
|
-e ORLY_RELAY_URL=wss://orly-relay.imwald.eu \ |
||||||
|
-e ORLY_SPROCKET_ENABLED=false \ |
||||||
|
-e ORLY_DB_LOG_LEVEL=error \ |
||||||
|
-e ORLY_DB_BLOCK_CACHE_MB=256 \ |
||||||
|
-e ORLY_DB_INDEX_CACHE_MB=128 \ |
||||||
|
-e ORLY_SERIAL_CACHE_PUBKEYS=50000 \ |
||||||
|
-e ORLY_SERIAL_CACHE_EVENT_IDS=250000 \ |
||||||
|
-e ORLY_DB_ZSTD_LEVEL=9 \ |
||||||
|
-e ORLY_GC_ENABLED=true \ |
||||||
|
-e ORLY_GC_BATCH_SIZE=5000 \ |
||||||
|
-e ORLY_MAX_STORAGE_BYTES=107374182400 \ |
||||||
|
-e ORLY_BOOTSTRAP_RELAYS=wss://profiles.nostr1.com,wss://purplepag.es,wss://relay.nostr.band,wss://relay.damus.io \ |
||||||
|
-e ORLY_SUBSCRIPTION_ENABLED=false \ |
||||||
|
-e ORLY_MONTHLY_PRICE_SAT=0 \ |
||||||
|
-e ORLY_MAX_CONNECTIONS=500 \ |
||||||
|
-e ORLY_MAX_EVENT_SIZE=65536 \ |
||||||
|
-e ORLY_MAX_SUBSCRIPTIONS=20 \ |
||||||
|
-e ORLY_QUERY_RESULT_LIMIT=256 \ |
||||||
|
-e ORLY_WEB_DISABLE=false \ |
||||||
|
-e ORLY_WEB_DEV_PROXY_URL="" \ |
||||||
|
-e ORLY_RATE_LIMIT_ENABLED=true \ |
||||||
|
-e ORLY_RATE_LIMIT_TARGET_MB=1500 \ |
||||||
|
-e ORLY_RATE_LIMIT_WRITE_TARGET=0.70 \ |
||||||
|
-e ORLY_RATE_LIMIT_WRITE_KP=1.0 \ |
||||||
|
-e ORLY_RATE_LIMIT_WRITE_KI=0.2 \ |
||||||
|
-e ORLY_RATE_LIMIT_MAX_WRITE_MS=2000 \ |
||||||
|
-e ORLY_RATE_LIMIT_EMERGENCY_THRESHOLD=1.167 \ |
||||||
|
-e ORLY_RATE_LIMIT_RECOVERY_THRESHOLD=0.833 \ |
||||||
|
-e ORLY_RATE_LIMIT_EMERGENCY_MAX_MS=5000 \ |
||||||
|
${IMAGE} |
||||||
|
|
||||||
|
echo "" |
||||||
|
echo "Container started!" |
||||||
|
echo "Data directory: ${DATA_DIR}" |
||||||
|
echo "View logs: docker logs -f ${CONTAINER_NAME}" |
||||||
|
echo "Stop: docker stop ${CONTAINER_NAME}" |
||||||
|
echo "Start: docker start ${CONTAINER_NAME}" |
||||||
|
echo "Remove: docker rm -f ${CONTAINER_NAME}" |
||||||
|
echo "" |
||||||
|
echo "For large imports (20GB+), use the web UI or API:" |
||||||
|
echo " curl -X POST -F 'file=@your-events.jsonl' http://localhost:7777/api/import" |
||||||
@ -0,0 +1 @@ |
|||||||
|
<!-- Placeholder - will be replaced by Docker build --> |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
#!/bin/bash |
||||||
|
|
||||||
|
# Cleanup stuck import containers on remote server |
||||||
|
# Usage: ./scripts/cleanup-stuck-imports.sh [remote_host] [remote_user] |
||||||
|
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-${1:-217.154.126.125}}" |
||||||
|
REMOTE_USER="${REMOTE_USER:-${2:-root}}" |
||||||
|
|
||||||
|
echo "Cleaning up stuck import containers on ${REMOTE_USER}@${REMOTE_HOST}..." |
||||||
|
|
||||||
|
# Find and stop all containers running orly db import |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps --filter 'ancestor=silberengel/next-orly:v0.58.10' --format '{{.ID}} {{.Command}}' | grep -E 'db import|sh -c.*orly db' | awk '{print \$1}'" | while read container_id; do |
||||||
|
if [ -n "$container_id" ]; then |
||||||
|
echo "Stopping container: $container_id" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker stop $container_id 2>/dev/null && docker rm $container_id 2>/dev/null" || true |
||||||
|
fi |
||||||
|
done |
||||||
|
|
||||||
|
echo "Cleanup complete. Remaining orly containers:" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps -a | grep orly || echo 'No orly containers found'" |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
package main |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
"time" |
||||||
|
|
||||||
|
"git.mleku.dev/mleku/nostr/encoders/bech32encoding" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/event" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/kind" |
||||||
|
"git.mleku.dev/mleku/nostr/encoders/tag" |
||||||
|
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k" |
||||||
|
) |
||||||
|
|
||||||
|
func main() { |
||||||
|
// Get NOSTR_PRIVATE_KEY from environment
|
||||||
|
nsec := os.Getenv("NOSTR_PRIVATE_KEY") |
||||||
|
if nsec == "" { |
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: NOSTR_PRIVATE_KEY environment variable not set\n") |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
|
||||||
|
// Decode nsec to get private key bytes
|
||||||
|
secretBytes, err := bech32encoding.NsecToBytes([]byte(nsec)) |
||||||
|
if err != nil { |
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: Failed to decode nsec: %v\n", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
|
||||||
|
// Create signer and initialize with private key
|
||||||
|
signer, err := p8k.New() |
||||||
|
if err != nil { |
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: Failed to create signer: %v\n", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
if err = signer.InitSec(secretBytes); err != nil { |
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: Failed to initialize signer: %v\n", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
|
||||||
|
// Create a test event (kind 1 - text note)
|
||||||
|
ev := event.New() |
||||||
|
ev.CreatedAt = time.Now().Unix() |
||||||
|
ev.Kind = kind.TextNote.K |
||||||
|
ev.Content = []byte("Pre-flight test event for import verification") |
||||||
|
ev.Tags = tag.NewS() |
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
if err := ev.Sign(signer); err != nil { |
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: Failed to sign event: %v\n", err) |
||||||
|
os.Exit(1) |
||||||
|
} |
||||||
|
|
||||||
|
// Serialize to JSON
|
||||||
|
eventJSON := ev.Serialize() |
||||||
|
|
||||||
|
// Output the JSON event
|
||||||
|
fmt.Println(string(eventJSON)) |
||||||
|
} |
||||||
@ -0,0 +1,754 @@ |
|||||||
|
#!/bin/bash |
||||||
|
|
||||||
|
# Import JSONL files to remote Orly relay |
||||||
|
# This script copies files from local exports directory to remote server and imports them |
||||||
|
# Usage: ./scripts/import-exports-remote.sh [remote_host] [remote_user] |
||||||
|
|
||||||
|
set -e |
||||||
|
|
||||||
|
# Configuration |
||||||
|
REMOTE_HOST="${REMOTE_HOST:-${1:-217.154.126.125}}" |
||||||
|
REMOTE_USER="${REMOTE_USER:-${2:-root}}" |
||||||
|
REMOTE_TEMP_DIR="${REMOTE_TEMP_DIR:-/root/tmp/orly}" |
||||||
|
DOCKER_CONTAINER="${DOCKER_CONTAINER:-orly-relay}" |
||||||
|
# NOSTR_PRIVATE_KEY for localhost authentication (reads from environment, e.g., .bashrc) |
||||||
|
NOSTR_PRIVATE_KEY="${NOSTR_PRIVATE_KEY:-}" |
||||||
|
|
||||||
|
# Colors |
||||||
|
GREEN='\033[0;32m' |
||||||
|
YELLOW='\033[1;33m' |
||||||
|
RED='\033[0;31m' |
||||||
|
BLUE='\033[0;34m' |
||||||
|
CYAN='\033[0;36m' |
||||||
|
NC='\033[0m' |
||||||
|
|
||||||
|
# Get script directory and calculate exports path |
||||||
|
# Script is in scripts/, exports are in ../scripts/exports relative to script |
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
||||||
|
EXPORTS_DIR="$(cd "$SCRIPT_DIR/../../scripts/exports" 2>/dev/null && pwd || echo "")" |
||||||
|
|
||||||
|
# IMPORTANT: This script NEVER deletes source files from EXPORTS_DIR |
||||||
|
# It only copies files to remote server and moves them to a "done" folder after successful import |
||||||
|
|
||||||
|
# Helper function to query an event ID from the relay |
||||||
|
# Returns 0 if event found, 1 if not found |
||||||
|
query_event_on_relay() { |
||||||
|
local event_id="$1" |
||||||
|
local relay_ws_http="ws://${REMOTE_HOST}:7777/" |
||||||
|
local relay_ws_https="wss://${REMOTE_HOST}:7777/" |
||||||
|
local event_found=0 |
||||||
|
|
||||||
|
for relay_ws in "$relay_ws_http" "$relay_ws_https"; do |
||||||
|
if command -v websocat >/dev/null 2>&1; then |
||||||
|
local query_msg="[\"REQ\",\"verify-query-$(date +%s)\",{\"ids\":[\"${event_id}\"]}]" |
||||||
|
local query_result=$(timeout 10 bash -c "echo '$query_msg' | websocat '$relay_ws' 2>&1" | head -20) |
||||||
|
|
||||||
|
if echo "$query_result" | grep -q "\"id\":\"${event_id}\""; then |
||||||
|
return 0 |
||||||
|
fi |
||||||
|
elif command -v python3 >/dev/null 2>&1; then |
||||||
|
python3 <<PYEOF |
||||||
|
import json |
||||||
|
import sys |
||||||
|
import asyncio |
||||||
|
import ssl |
||||||
|
try: |
||||||
|
import websockets |
||||||
|
except ImportError: |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
async def query_event(relay_url, event_id): |
||||||
|
try: |
||||||
|
ssl_context = None |
||||||
|
if relay_url.startswith('wss://'): |
||||||
|
ssl_context = ssl.create_default_context() |
||||||
|
ssl_context.check_hostname = False |
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE |
||||||
|
|
||||||
|
async with websockets.connect(relay_url, timeout=10, ssl=ssl_context) as ws: |
||||||
|
# Read initial messages (AUTH, etc.) but don't return True for them |
||||||
|
try: |
||||||
|
initial_response = await asyncio.wait_for(ws.recv(), timeout=3.0) |
||||||
|
# Just consume it, don't check it |
||||||
|
except asyncio.TimeoutError: |
||||||
|
pass |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
|
||||||
|
# Send REQ for the specific event ID |
||||||
|
req_msg = ["REQ", f"verify-query-{int(__import__('time').time())}", {"ids": [event_id]}] |
||||||
|
await ws.send(json.dumps(req_msg)) |
||||||
|
|
||||||
|
# Wait for responses - we need to find the actual EVENT with matching ID |
||||||
|
# Keep reading until we get EOSE or timeout |
||||||
|
found_event = False |
||||||
|
try: |
||||||
|
while True: |
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0) |
||||||
|
data = json.loads(response) |
||||||
|
if isinstance(data, list) and len(data) > 0: |
||||||
|
if data[0] == "EVENT" and len(data) > 2: |
||||||
|
event_data = data[2] |
||||||
|
if isinstance(event_data, dict) and event_data.get("id") == event_id: |
||||||
|
found_event = True |
||||||
|
break |
||||||
|
elif data[0] == "EOSE": |
||||||
|
# End of stored events - stop waiting |
||||||
|
break |
||||||
|
except asyncio.TimeoutError: |
||||||
|
pass |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
|
||||||
|
return found_event |
||||||
|
except Exception: |
||||||
|
return False |
||||||
|
|
||||||
|
relay_url = "${relay_ws}" |
||||||
|
event_id = "${event_id}" |
||||||
|
result = asyncio.run(query_event(relay_url, event_id)) |
||||||
|
if result: |
||||||
|
sys.exit(0) |
||||||
|
else: |
||||||
|
sys.exit(1) |
||||||
|
PYEOF |
||||||
|
if [ $? -eq 0 ]; then |
||||||
|
return 0 |
||||||
|
fi |
||||||
|
fi |
||||||
|
done |
||||||
|
|
||||||
|
return 1 |
||||||
|
} |
||||||
|
|
||||||
|
# Check if exports directory exists |
||||||
|
if [ -z "$EXPORTS_DIR" ] || [ ! -d "$EXPORTS_DIR" ]; then |
||||||
|
echo -e "${RED}Error: Exports directory not found at: $SCRIPT_DIR/../scripts/exports${NC}" |
||||||
|
echo "Expected path: $(cd "$SCRIPT_DIR/.." && pwd)/scripts/exports" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Find all JSONL files |
||||||
|
jsonl_files=("$EXPORTS_DIR"/*.jsonl) |
||||||
|
|
||||||
|
# Check if any JSONL files exist |
||||||
|
if [ ! -e "${jsonl_files[0]}" ]; then |
||||||
|
echo -e "${YELLOW}No JSONL files found in: $EXPORTS_DIR${NC}" |
||||||
|
exit 0 |
||||||
|
fi |
||||||
|
|
||||||
|
# Count files |
||||||
|
file_count=0 |
||||||
|
for file in "${jsonl_files[@]}"; do |
||||||
|
if [ -f "$file" ]; then |
||||||
|
file_count=$((file_count + 1)) |
||||||
|
fi |
||||||
|
done |
||||||
|
|
||||||
|
if [ "$file_count" -eq 0 ]; then |
||||||
|
echo -e "${YELLOW}No JSONL files found in: $EXPORTS_DIR${NC}" |
||||||
|
exit 0 |
||||||
|
fi |
||||||
|
|
||||||
|
echo -e "${BLUE}=== Import Exports to Remote Server ===${NC}" |
||||||
|
echo "" |
||||||
|
echo "Exports directory: $EXPORTS_DIR" |
||||||
|
echo "Remote server: ${REMOTE_USER}@${REMOTE_HOST}" |
||||||
|
echo "Remote temp directory: ${REMOTE_TEMP_DIR}" |
||||||
|
echo "Docker container: ${DOCKER_CONTAINER}" |
||||||
|
echo "Files found: $file_count" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check if SSH is available |
||||||
|
if ! command -v ssh >/dev/null 2>&1; then |
||||||
|
echo -e "${RED}Error: ssh command not found. Please install OpenSSH client.${NC}" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Check if SCP is available |
||||||
|
if ! command -v scp >/dev/null 2>&1; then |
||||||
|
echo -e "${RED}Error: scp command not found. Please install OpenSSH client.${NC}" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Test SSH connection |
||||||
|
echo -e "${CYAN}Testing SSH connection to ${REMOTE_USER}@${REMOTE_HOST}...${NC}" |
||||||
|
if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_HOST}" "echo 'Connection successful'" 2>/dev/null; then |
||||||
|
echo -e "${YELLOW}Warning: SSH connection test failed. You may be prompted for password/key.${NC}" |
||||||
|
echo "Continuing anyway..." |
||||||
|
fi |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check if Docker container exists on remote |
||||||
|
echo -e "${CYAN}Checking Docker container on remote server...${NC}" |
||||||
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps --format '{{.Names}}' | grep -q '^${DOCKER_CONTAINER}$'" 2>/dev/null; then |
||||||
|
echo -e "${RED}Error: Docker container '${DOCKER_CONTAINER}' not found on remote server.${NC}" |
||||||
|
echo "Available containers:" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker ps --format '{{.Names}}'" 2>/dev/null || true |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
echo -e "${GREEN}✓ Docker container '${DOCKER_CONTAINER}' found${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Check if NOSTR_PRIVATE_KEY is available (local or in container) |
||||||
|
echo -e "${CYAN}Checking for NOSTR_PRIVATE_KEY...${NC}" |
||||||
|
container_has_key=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} sh -c 'echo \$NOSTR_PRIVATE_KEY'" 2>/dev/null | grep -v '^$' || echo "") |
||||||
|
|
||||||
|
if [ -n "$container_has_key" ]; then |
||||||
|
echo -e "${GREEN}✓ NOSTR_PRIVATE_KEY: Found in container (will use for localhost authentication)${NC}" |
||||||
|
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then |
||||||
|
echo -e "${GREEN}✓ NOSTR_PRIVATE_KEY: Found in local environment${NC}" |
||||||
|
echo -e "${CYAN} Will pass it to container for localhost authentication${NC}" |
||||||
|
else |
||||||
|
echo -e "${YELLOW}⚠ NOSTR_PRIVATE_KEY: Not found${NC}" |
||||||
|
echo -e "${YELLOW} Set it locally: export NOSTR_PRIVATE_KEY=nsec1your_key_here${NC}" |
||||||
|
echo -e "${YELLOW} Or add to container environment when running docker${NC}" |
||||||
|
fi |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Create temp directory on remote (needed for pre-flight test) |
||||||
|
echo -e "${CYAN}Creating temporary directory on remote server...${NC}" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p '${REMOTE_TEMP_DIR}'" || { |
||||||
|
echo -e "${RED}Error: Failed to create temp directory on remote server${NC}" |
||||||
|
exit 1 |
||||||
|
} |
||||||
|
echo -e "${GREEN}✓ Temp directory created${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Pre-flight test: Send a test event and verify it appears on the relay |
||||||
|
# This runs BEFORE copying files to catch configuration issues early |
||||||
|
echo -e "${CYAN}=== Pre-flight Test: Verifying Import Works ===${NC}" |
||||||
|
echo " Creating and sending a test event..." |
||||||
|
echo " (This verifies configuration before copying large files)" |
||||||
|
|
||||||
|
# Determine which NOSTR_PRIVATE_KEY to use |
||||||
|
TEST_NOSTR_KEY="" |
||||||
|
if [ -n "$container_has_key" ]; then |
||||||
|
TEST_NOSTR_KEY="$container_has_key" |
||||||
|
echo " Using NOSTR_PRIVATE_KEY from container for test event" |
||||||
|
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then |
||||||
|
TEST_NOSTR_KEY="$NOSTR_PRIVATE_KEY" |
||||||
|
echo " Using NOSTR_PRIVATE_KEY from local environment for test event" |
||||||
|
else |
||||||
|
echo -e "${RED}✗ NOSTR_PRIVATE_KEY not found - cannot create signed test event${NC}" |
||||||
|
echo -e "${RED}✗ Pre-flight test FAILED - Need NOSTR_PRIVATE_KEY for write permissions${NC}" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Create a test event using Go |
||||||
|
TEST_EVENT_FILE=$(mktemp /tmp/orly-test-event-XXXXXX.jsonl) |
||||||
|
TEST_EVENT_ID="" |
||||||
|
|
||||||
|
# Get the script directory to find the Go program |
||||||
|
GO_PROGRAM="${SCRIPT_DIR}/create-test-event.go" |
||||||
|
|
||||||
|
# Compile and run the Go program |
||||||
|
if command -v go >/dev/null 2>&1; then |
||||||
|
echo " Creating signed test event with Go..." |
||||||
|
|
||||||
|
# Compile the Go program to a temporary binary |
||||||
|
TEMP_BINARY=$(mktemp /tmp/orly-test-event-XXXXXX) |
||||||
|
TEMP_ERR=$(mktemp /tmp/orly-test-event-err-XXXXXX) |
||||||
|
|
||||||
|
if ! go build -o "$TEMP_BINARY" "$GO_PROGRAM" 2>"$TEMP_ERR"; then |
||||||
|
COMPILE_ERR=$(cat "$TEMP_ERR" 2>/dev/null || echo "") |
||||||
|
echo -e "${RED}✗ Failed to compile test event generator${NC}" |
||||||
|
if [ -n "$COMPILE_ERR" ]; then |
||||||
|
echo " Compile error: $COMPILE_ERR" |
||||||
|
fi |
||||||
|
rm -f "$TEST_EVENT_FILE" "$TEMP_BINARY" "$TEMP_ERR" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
rm -f "$TEMP_ERR" |
||||||
|
|
||||||
|
# Run it with NOSTR_PRIVATE_KEY |
||||||
|
TEST_EVENT_JSON=$(NOSTR_PRIVATE_KEY="$TEST_NOSTR_KEY" "$TEMP_BINARY" 2>"$TEMP_ERR") |
||||||
|
EXIT_CODE=$? |
||||||
|
ERROR_OUTPUT=$(cat "$TEMP_ERR" 2>/dev/null || echo "") |
||||||
|
|
||||||
|
# Clean up binary and error file |
||||||
|
rm -f "$TEMP_BINARY" "$TEMP_ERR" |
||||||
|
|
||||||
|
if [ $EXIT_CODE -ne 0 ] || [ -z "$TEST_EVENT_JSON" ]; then |
||||||
|
echo -e "${RED}✗ Failed to create signed test event${NC}" |
||||||
|
if [ -n "$ERROR_OUTPUT" ]; then |
||||||
|
echo " Error: $ERROR_OUTPUT" |
||||||
|
fi |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Save the event JSON |
||||||
|
echo "$TEST_EVENT_JSON" > "$TEST_EVENT_FILE" |
||||||
|
|
||||||
|
# Extract event ID |
||||||
|
if command -v jq >/dev/null 2>&1; then |
||||||
|
TEST_EVENT_ID=$(echo "$TEST_EVENT_JSON" | jq -r '.id' 2>/dev/null || echo "") |
||||||
|
elif command -v python3 >/dev/null 2>&1; then |
||||||
|
TEST_EVENT_ID=$(echo "$TEST_EVENT_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))" 2>/dev/null || echo "") |
||||||
|
else |
||||||
|
# Fallback: extract ID using grep/sed |
||||||
|
TEST_EVENT_ID=$(echo "$TEST_EVENT_JSON" | grep -o '"id":"[^"]*"' | sed 's/"id":"\([^"]*\)"/\1/' | head -1) |
||||||
|
fi |
||||||
|
else |
||||||
|
echo -e "${RED}✗ Go compiler not found - cannot create signed test event${NC}" |
||||||
|
echo -e "${YELLOW} Install Go: https://golang.org/dl/${NC}" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
if [ -z "$TEST_EVENT_ID" ] || [ ! -s "$TEST_EVENT_FILE" ]; then |
||||||
|
echo -e "${RED}✗ Failed to create test event or extract event ID${NC}" |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
echo " Test event ID: ${TEST_EVENT_ID:0:16}..." |
||||||
|
echo " Uploading test event to container..." |
||||||
|
|
||||||
|
# Copy test event to remote server first, then into container |
||||||
|
REMOTE_TEMP_EVENT="/tmp/orly-test-event-$(date +%s).jsonl" |
||||||
|
if ! scp "$TEST_EVENT_FILE" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_TEMP_EVENT}" 2>&1; then |
||||||
|
echo -e "${RED}✗ Failed to copy test event to remote server${NC}" |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Now copy from remote server into container |
||||||
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker cp ${REMOTE_TEMP_EVENT} ${DOCKER_CONTAINER}:/tmp/test_event.jsonl" 2>&1; then |
||||||
|
echo -e "${RED}✗ Failed to copy test event to container${NC}" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_TEMP_EVENT}" 2>/dev/null || true |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
# Fix ownership and permissions so the orly user can read it |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u root ${DOCKER_CONTAINER} chown orly:orly /tmp/test_event.jsonl && docker exec -u root ${DOCKER_CONTAINER} chmod 644 /tmp/test_event.jsonl" 2>/dev/null || true |
||||||
|
|
||||||
|
# Clean up remote temp file |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_TEMP_EVENT}" 2>/dev/null || true |
||||||
|
|
||||||
|
# Send test event via import API |
||||||
|
echo " Sending test event via import API..." |
||||||
|
REMOTE_RESPONSE_FILE="/tmp/orly-test-response-$(date +%s).txt" |
||||||
|
|
||||||
|
if [ -n "$container_has_key" ]; then |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout 30 bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time 25 --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/test_event.jsonl\" \"http://127.0.0.1:7777/api/import?async=true\" > ${REMOTE_RESPONSE_FILE} 2>&1'" 2>&1 |
||||||
|
CURL_EXIT=$? |
||||||
|
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then |
||||||
|
escaped_key=$(echo "$NOSTR_PRIVATE_KEY" | sed "s/'/'\\\\''/g") |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout 30 bash -c 'docker exec -u orly ${DOCKER_CONTAINER} sh -c \"export NOSTR_PRIVATE_KEY=\\\"$escaped_key\\\" && curl --max-time 25 --connect-timeout 5 -s -w \\\"\\\\n%{http_code}\\\" -X POST -F \\\"file=@/tmp/test_event.jsonl\\\" \\\"http://127.0.0.1:7777/api/import?async=true\\\"\" > ${REMOTE_RESPONSE_FILE} 2>&1'" 2>&1 |
||||||
|
CURL_EXIT=$? |
||||||
|
else |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout 30 bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time 25 --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/test_event.jsonl\" \"http://127.0.0.1:7777/api/import?async=true\" > ${REMOTE_RESPONSE_FILE} 2>&1'" 2>&1 |
||||||
|
CURL_EXIT=$? |
||||||
|
fi |
||||||
|
|
||||||
|
# Retrieve the response file |
||||||
|
test_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_RESPONSE_FILE} 2>/dev/null" 2>/dev/null || echo "") |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_RESPONSE_FILE}" 2>/dev/null || true |
||||||
|
|
||||||
|
# Check if curl timed out or failed |
||||||
|
if [ $CURL_EXIT -eq 124 ]; then |
||||||
|
echo -e "${RED}✗ Test upload timed out after 30 seconds${NC}" |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true |
||||||
|
exit 1 |
||||||
|
elif [ $CURL_EXIT -ne 0 ]; then |
||||||
|
echo -e "${RED}✗ Test upload failed (exit code: $CURL_EXIT)${NC}" |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
test_http_code=$(echo "$test_response" | tail -n1 | tr -d '[:space:]') |
||||||
|
test_body=$(echo "$test_response" | sed '$d') |
||||||
|
|
||||||
|
if [ "$test_http_code" -ne 200 ] && [ "$test_http_code" -ne 202 ]; then |
||||||
|
echo -e "${RED}✗ Test upload failed (HTTP $test_http_code)${NC}" |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Test event uploaded successfully (HTTP $test_http_code)${NC}" |
||||||
|
echo " Waiting 5 seconds for event to be processed..." |
||||||
|
sleep 5 |
||||||
|
|
||||||
|
echo " Querying relay for test event..." |
||||||
|
RELAY_WS_HTTP="ws://${REMOTE_HOST}:7777/" |
||||||
|
RELAY_WS_HTTPS="wss://${REMOTE_HOST}:7777/" |
||||||
|
RELAY_WS="" |
||||||
|
EVENT_FOUND=0 |
||||||
|
|
||||||
|
# Try to query using websocat or Python |
||||||
|
for RELAY_WS in "$RELAY_WS_HTTP" "$RELAY_WS_HTTPS"; do |
||||||
|
echo " Trying ${RELAY_WS}..." |
||||||
|
|
||||||
|
if command -v websocat >/dev/null 2>&1; then |
||||||
|
QUERY_MSG="[\"REQ\",\"test-query-$(date +%s)\",{\"ids\":[\"${TEST_EVENT_ID}\"]}]" |
||||||
|
QUERY_RESULT=$(timeout 10 bash -c "echo '$QUERY_MSG' | websocat '$RELAY_WS' 2>&1" | head -20) |
||||||
|
|
||||||
|
if echo "$QUERY_RESULT" | grep -q "\"id\":\"${TEST_EVENT_ID}\""; then |
||||||
|
EVENT_FOUND=1 |
||||||
|
break |
||||||
|
fi |
||||||
|
elif command -v python3 >/dev/null 2>&1; then |
||||||
|
echo " Using Python websockets to query..." |
||||||
|
QUERY_RESULT=$(python3 <<PYEOF |
||||||
|
import json |
||||||
|
import sys |
||||||
|
import asyncio |
||||||
|
import ssl |
||||||
|
try: |
||||||
|
import websockets |
||||||
|
except ImportError: |
||||||
|
print("ERROR: websockets library not installed", file=sys.stderr) |
||||||
|
sys.exit(1) |
||||||
|
|
||||||
|
async def query_event(relay_url, event_id): |
||||||
|
try: |
||||||
|
ssl_context = None |
||||||
|
if relay_url.startswith('wss://'): |
||||||
|
ssl_context = ssl.create_default_context() |
||||||
|
ssl_context.check_hostname = False |
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE |
||||||
|
|
||||||
|
async with websockets.connect(relay_url, timeout=10, ssl=ssl_context) as ws: |
||||||
|
# Read initial messages (AUTH, etc.) but don't return True for them |
||||||
|
try: |
||||||
|
initial_response = await asyncio.wait_for(ws.recv(), timeout=3.0) |
||||||
|
# Just consume it, don't check it |
||||||
|
except asyncio.TimeoutError: |
||||||
|
pass |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
|
||||||
|
# Send REQ for the specific event ID |
||||||
|
req_msg = ["REQ", f"test-query-{int(__import__('time').time())}", {"ids": [event_id]}] |
||||||
|
await ws.send(json.dumps(req_msg)) |
||||||
|
|
||||||
|
# Wait for responses - we need to find the actual EVENT with matching ID |
||||||
|
# Keep reading until we get EOSE or timeout |
||||||
|
found_event = False |
||||||
|
try: |
||||||
|
while True: |
||||||
|
response = await asyncio.wait_for(ws.recv(), timeout=5.0) |
||||||
|
data = json.loads(response) |
||||||
|
if isinstance(data, list) and len(data) > 0: |
||||||
|
if data[0] == "EVENT" and len(data) > 2: |
||||||
|
event_data = data[2] |
||||||
|
if isinstance(event_data, dict) and event_data.get("id") == event_id: |
||||||
|
found_event = True |
||||||
|
break |
||||||
|
elif data[0] == "EOSE": |
||||||
|
# End of stored events - stop waiting |
||||||
|
break |
||||||
|
except asyncio.TimeoutError: |
||||||
|
pass |
||||||
|
except Exception: |
||||||
|
pass |
||||||
|
|
||||||
|
return found_event |
||||||
|
except Exception: |
||||||
|
return False |
||||||
|
|
||||||
|
relay_url = "${RELAY_WS}" |
||||||
|
event_id = "${TEST_EVENT_ID}" |
||||||
|
result = asyncio.run(query_event(relay_url, event_id)) |
||||||
|
if result: |
||||||
|
sys.exit(0) |
||||||
|
else: |
||||||
|
sys.exit(1) |
||||||
|
PYEOF |
||||||
|
) |
||||||
|
QUERY_EXIT=$? |
||||||
|
|
||||||
|
if [ $QUERY_EXIT -eq 0 ]; then |
||||||
|
EVENT_FOUND=1 |
||||||
|
break |
||||||
|
fi |
||||||
|
else |
||||||
|
echo -e "${YELLOW} Warning: Neither websocat nor Python websockets available${NC}" |
||||||
|
break |
||||||
|
fi |
||||||
|
done |
||||||
|
|
||||||
|
# Clean up test files |
||||||
|
rm -f "$TEST_EVENT_FILE" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f /tmp/test_event.jsonl" 2>/dev/null || true |
||||||
|
|
||||||
|
if [ "$EVENT_FOUND" -eq 1 ]; then |
||||||
|
echo -e "${GREEN}✓ Test event found on relay${NC}" |
||||||
|
echo -e "${GREEN}✓ Pre-flight test PASSED - Import is working${NC}" |
||||||
|
else |
||||||
|
echo -e "${RED}✗ Test event NOT found on relay${NC}" |
||||||
|
echo -e "${RED}✗ Pre-flight test FAILED - Import is not working${NC}" |
||||||
|
echo -e "${YELLOW} Fix configuration issues before proceeding${NC}" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
echo "" |
||||||
|
echo -e "${GREEN}✓ All pre-flight checks passed. Proceeding with file copy and import...${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Process files one at a time: copy, extract event ID, import, verify, move to done |
||||||
|
echo -e "${BLUE}=== Processing Files One at a Time ===${NC}" |
||||||
|
echo -e "${CYAN}Method: Using HTTP API import (synchronous, no container restart needed)${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
success_count=0 |
||||||
|
fail_count=0 |
||||||
|
current=0 |
||||||
|
DONE_DIR="${REMOTE_TEMP_DIR}/done" |
||||||
|
|
||||||
|
# Ensure temp and done directories exist on remote |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p '${REMOTE_TEMP_DIR}' '${DONE_DIR}'" 2>/dev/null || true |
||||||
|
|
||||||
|
# Process each local file one at a time |
||||||
|
for local_file in "${jsonl_files[@]}"; do |
||||||
|
if [ ! -f "$local_file" ]; then |
||||||
|
continue |
||||||
|
fi |
||||||
|
|
||||||
|
current=$((current + 1)) |
||||||
|
filename=$(basename "$local_file") |
||||||
|
file_size=$(du -h "$local_file" | cut -f1) |
||||||
|
remote_file="${REMOTE_TEMP_DIR}/${filename}" |
||||||
|
|
||||||
|
echo "" |
||||||
|
echo -e "${BLUE}=== [$current/$file_count] Processing: $filename (${file_size}) ===${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Step 1: Copy file to remote server |
||||||
|
echo -e "${CYAN}[1/5] Copying file to remote server...${NC}" |
||||||
|
if ! scp "$local_file" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_TEMP_DIR}/" 2>&1; then |
||||||
|
echo -e "${RED}✗ Failed to copy: $filename${NC}" |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}" |
||||||
|
continue |
||||||
|
fi |
||||||
|
echo -e "${GREEN}✓ Copied: $filename${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Step 2: Extract event ID from the remote file |
||||||
|
echo -e "${CYAN}[2/5] Extracting event ID from file...${NC}" |
||||||
|
file_event_id="" |
||||||
|
|
||||||
|
# Extract first event ID from the file (look for "id":"..." pattern) |
||||||
|
# Try multiple patterns to handle different JSON formats |
||||||
|
file_event_id=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "head -1 '${remote_file}' 2>/dev/null | grep -oE '\"id\"[[:space:]]*:[[:space:]]*\"[a-f0-9]{64}\"' | head -1 | sed -E 's/\"id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\"/\1/'" || echo "") |
||||||
|
|
||||||
|
# If that didn't work, try simpler pattern |
||||||
|
if [ -z "$file_event_id" ]; then |
||||||
|
file_event_id=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "head -1 '${remote_file}' 2>/dev/null | grep -o '\"id\":\"[a-f0-9]\{64\}\"' | head -1 | sed 's/\"id\":\"\([^\"]*\)\"/\1/'" || echo "") |
||||||
|
fi |
||||||
|
|
||||||
|
# If still no ID, try to find any 64-char hex string that looks like an event ID |
||||||
|
if [ -z "$file_event_id" ]; then |
||||||
|
file_event_id=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "head -1 '${remote_file}' 2>/dev/null | grep -oE '[a-f0-9]{64}' | head -1" || echo "") |
||||||
|
fi |
||||||
|
|
||||||
|
if [ -n "$file_event_id" ]; then |
||||||
|
echo -e "${GREEN}✓ Extracted event ID: ${file_event_id:0:16}...${NC}" |
||||||
|
else |
||||||
|
echo -e "${YELLOW}⚠ Could not extract event ID (will skip verification)${NC}" |
||||||
|
fi |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Step 3: Import the file via HTTP API |
||||||
|
echo -e "${CYAN}[3/5] Importing file via HTTP API...${NC}" |
||||||
|
|
||||||
|
# Check file size first |
||||||
|
file_size_bytes=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "stat -c%s '${remote_file}' 2>/dev/null" || echo "0") |
||||||
|
file_size_mb=$(echo "scale=2; $file_size_bytes / 1024 / 1024" | bc 2>/dev/null || echo "0") |
||||||
|
echo " File size: ${file_size_mb} MB" |
||||||
|
|
||||||
|
import_success=false |
||||||
|
|
||||||
|
# Copy file into container for HTTP API import |
||||||
|
echo " Copying file into container..." |
||||||
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker cp '${remote_file}' ${DOCKER_CONTAINER}:/tmp/" 2>&1; then |
||||||
|
echo -e "${RED}✗ Failed to copy file into container${NC}" |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}" |
||||||
|
continue |
||||||
|
fi |
||||||
|
|
||||||
|
# Fix ownership and permissions |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u root ${DOCKER_CONTAINER} chown orly:orly /tmp/${filename} && docker exec -u root ${DOCKER_CONTAINER} chmod 644 /tmp/${filename}" 2>/dev/null || true |
||||||
|
|
||||||
|
# Calculate timeout based on file size |
||||||
|
timeout_seconds=$((file_size_bytes / 1024 / 1024 * 30)) # ~30 seconds per MB |
||||||
|
if [ $timeout_seconds -lt 300 ]; then |
||||||
|
timeout_seconds=300 # Minimum 5 minutes |
||||||
|
elif [ $timeout_seconds -gt 7200 ]; then |
||||||
|
timeout_seconds=7200 # Maximum 2 hours |
||||||
|
fi |
||||||
|
echo " Using timeout: ${timeout_seconds}s for ${file_size_mb} MB file" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Start streaming container logs in background to show import progress |
||||||
|
# Use prominent log prefix so it's easy to find in logs |
||||||
|
LOG_PREFIX="[IMPORT ${filename}]" |
||||||
|
echo -e "${CYAN} Starting import via HTTP API (synchronous mode)...${NC}" |
||||||
|
echo -e "${CYAN} Streaming container logs with prefix: ${LOG_PREFIX}${NC}" |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Start log streaming in background |
||||||
|
LOG_TAIL_PID="" |
||||||
|
(ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker logs -f --tail 0 ${DOCKER_CONTAINER} 2>&1" | while IFS= read -r line; do |
||||||
|
# Show all lines with [HTTP API IMPORT] prefix (our new prominent logging) |
||||||
|
# Also show other import-related lines for context |
||||||
|
if echo "$line" | grep -qiE "\[HTTP API IMPORT\]|import.*progress|import.*complete|import.*failed|import.*error"; then |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} $line${NC}" >&2 |
||||||
|
fi |
||||||
|
done) & |
||||||
|
LOG_TAIL_PID=$! |
||||||
|
sleep 1 # Give log stream a moment to start |
||||||
|
|
||||||
|
# Get NOSTR_PRIVATE_KEY for authentication |
||||||
|
container_has_key=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} sh -c 'echo \$NOSTR_PRIVATE_KEY'" 2>/dev/null | grep -v '^$' || echo "") |
||||||
|
|
||||||
|
REMOTE_IMPORT_RESPONSE="/tmp/orly-import-response-$(date +%s).txt" |
||||||
|
|
||||||
|
# Show start time |
||||||
|
UPLOAD_START=$(date +%s) |
||||||
|
echo " [$(date +%H:%M:%S)] Starting HTTP API import..." |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Import started at $(date)${NC}" |
||||||
|
|
||||||
|
# Run import via HTTP API (synchronous - waits for completion) |
||||||
|
if [ -n "$container_has_key" ]; then |
||||||
|
echo " Using NOSTR_PRIVATE_KEY from container (localhost authentication)" |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout ${timeout_seconds} bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time $((timeout_seconds - 5)) --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/${filename}\" \"http://127.0.0.1:7777/api/import\" > ${REMOTE_IMPORT_RESPONSE} 2>&1'" 2>&1 |
||||||
|
CURL_EXIT=$? |
||||||
|
elif [ -n "$NOSTR_PRIVATE_KEY" ]; then |
||||||
|
echo " Passing NOSTR_PRIVATE_KEY to container for localhost authentication" |
||||||
|
escaped_key=$(echo "$NOSTR_PRIVATE_KEY" | sed "s/'/'\\\\''/g") |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout ${timeout_seconds} bash -c 'docker exec -u orly ${DOCKER_CONTAINER} sh -c \"export NOSTR_PRIVATE_KEY=\\\"$escaped_key\\\" && curl --max-time $((timeout_seconds - 5)) --connect-timeout 5 -s -w \\\"\\\\n%{http_code}\\\" -X POST -F \\\"file=@/tmp/${filename}\\\" \\\"http://127.0.0.1:7777/api/import\\\"\" > ${REMOTE_IMPORT_RESPONSE} 2>&1'" 2>&1 |
||||||
|
CURL_EXIT=$? |
||||||
|
else |
||||||
|
echo -e "${YELLOW} Warning: No NOSTR_PRIVATE_KEY available${NC}" |
||||||
|
echo " Trying without authentication (may fail if ACL requires auth)..." |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "timeout ${timeout_seconds} bash -c 'docker exec -u orly ${DOCKER_CONTAINER} curl --max-time $((timeout_seconds - 5)) --connect-timeout 5 -s -w \"\\n%{http_code}\" -X POST -F \"file=@/tmp/${filename}\" \"http://127.0.0.1:7777/api/import\" > ${REMOTE_IMPORT_RESPONSE} 2>&1'" 2>&1 |
||||||
|
CURL_EXIT=$? |
||||||
|
fi |
||||||
|
|
||||||
|
UPLOAD_END=$(date +%s) |
||||||
|
UPLOAD_DURATION=$((UPLOAD_END - UPLOAD_START)) |
||||||
|
echo " [$(date +%H:%M:%S)] HTTP request completed in ${UPLOAD_DURATION}s" |
||||||
|
|
||||||
|
# Stop log streaming |
||||||
|
if [ -n "$LOG_TAIL_PID" ] && kill -0 "$LOG_TAIL_PID" 2>/dev/null; then |
||||||
|
kill "$LOG_TAIL_PID" 2>/dev/null || true |
||||||
|
wait "$LOG_TAIL_PID" 2>/dev/null || true |
||||||
|
fi |
||||||
|
|
||||||
|
# Get response |
||||||
|
import_response=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_IMPORT_RESPONSE} 2>/dev/null" 2>/dev/null || echo "") |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f ${REMOTE_IMPORT_RESPONSE}" 2>/dev/null || true |
||||||
|
|
||||||
|
# Clean up file from container |
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -u orly ${DOCKER_CONTAINER} rm -f '/tmp/${filename}'" 2>/dev/null || true |
||||||
|
|
||||||
|
# Check if curl timed out |
||||||
|
if [ $CURL_EXIT -eq 124 ]; then |
||||||
|
echo -e "${RED}✗ Import timed out after ${timeout_seconds} seconds${NC}" |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Import timed out${NC}" |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}" |
||||||
|
continue |
||||||
|
elif [ $CURL_EXIT -ne 0 ]; then |
||||||
|
echo -e "${RED}✗ Import failed (exit code: $CURL_EXIT)${NC}" |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Import failed with exit code $CURL_EXIT${NC}" |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${YELLOW} Skipping this file and continuing to next...${NC}" |
||||||
|
continue |
||||||
|
fi |
||||||
|
|
||||||
|
# Extract HTTP status code and body |
||||||
|
import_http_code=$(echo "$import_response" | tail -n1 | tr -d '[:space:]') |
||||||
|
import_body=$(echo "$import_response" | sed '$d') |
||||||
|
|
||||||
|
# Check if import was successful |
||||||
|
if [ -z "$import_http_code" ] || ! [[ "$import_http_code" =~ ^[0-9]+$ ]]; then |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${RED}✗ Failed to import: $filename (invalid HTTP response)${NC}" |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Invalid HTTP response${NC}" |
||||||
|
elif [ "$import_http_code" -eq 200 ]; then |
||||||
|
if echo "$import_body" | grep -qi '"success":\s*true\|"message".*completed'; then |
||||||
|
echo -e "${GREEN}✓ Imported: $filename (HTTP $import_http_code)${NC}" |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Import completed successfully${NC}" |
||||||
|
import_success=true |
||||||
|
success_count=$((success_count + 1)) |
||||||
|
else |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${RED}✗ Failed to import: $filename (HTTP $import_http_code, but no success in response)${NC}" |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Import response indicates failure${NC}" |
||||||
|
fi |
||||||
|
else |
||||||
|
fail_count=$((fail_count + 1)) |
||||||
|
echo -e "${RED}✗ Failed to import: $filename (HTTP $import_http_code)${NC}" |
||||||
|
echo -e "${CYAN} ${LOG_PREFIX} Import failed with HTTP $import_http_code${NC}" |
||||||
|
if [ -n "$import_body" ]; then |
||||||
|
echo " Response: $import_body" |
||||||
|
fi |
||||||
|
fi |
||||||
|
|
||||||
|
# Step 4: Verify import |
||||||
|
if [ "$import_success" = true ]; then |
||||||
|
echo "" |
||||||
|
echo -e "${CYAN}[4/5] Verifying import...${NC}" |
||||||
|
if [ -n "$file_event_id" ]; then |
||||||
|
echo " Verifying imported event exists on relay..." |
||||||
|
sleep 3 # Give relay a moment to process the imported events |
||||||
|
if query_event_on_relay "$file_event_id"; then |
||||||
|
echo -e "${GREEN}✓ Verified: Event ${file_event_id:0:16}... from ${filename} found on relay${NC}" |
||||||
|
else |
||||||
|
echo -e "${YELLOW}⚠ Warning: Event ${file_event_id:0:16}... from ${filename} not yet found on relay (may need more time)${NC}" |
||||||
|
fi |
||||||
|
else |
||||||
|
echo -e "${YELLOW}⚠ Skipping verification (no event ID extracted)${NC}" |
||||||
|
fi |
||||||
|
else |
||||||
|
echo "" |
||||||
|
echo -e "${CYAN}[4/5] Skipping verification (import failed)${NC}" |
||||||
|
fi |
||||||
|
echo "" |
||||||
|
|
||||||
|
# Step 5: Move to done folder (only if import was successful) |
||||||
|
if [ "$import_success" = true ]; then |
||||||
|
echo -e "${CYAN}[5/5] Moving file to done folder...${NC}" |
||||||
|
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "test -f '${remote_file}'" 2>/dev/null; then |
||||||
|
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "mv '${remote_file}' '${DONE_DIR}/${filename}'" 2>/dev/null; then |
||||||
|
echo -e "${GREEN}✓ Moved: ${filename} to ${DONE_DIR}${NC}" |
||||||
|
else |
||||||
|
echo -e "${YELLOW}⚠ Warning: Could not move ${filename} to done folder${NC}" |
||||||
|
fi |
||||||
|
else |
||||||
|
echo -e "${YELLOW}⚠ Warning: File ${filename} not found on remote (may have been moved already)${NC}" |
||||||
|
fi |
||||||
|
else |
||||||
|
echo -e "${CYAN}[5/5] Skipping move to done (import failed - file remains in ${REMOTE_TEMP_DIR})${NC}" |
||||||
|
fi |
||||||
|
echo "" |
||||||
|
done |
||||||
|
|
||||||
|
# Summary |
||||||
|
echo "" |
||||||
|
echo -e "${BLUE}=== Import Summary ===${NC}" |
||||||
|
echo -e "${GREEN}Files succeeded: $success_count${NC}" |
||||||
|
if [ "$fail_count" -gt 0 ]; then |
||||||
|
echo -e "${RED}Files failed: $fail_count${NC}" |
||||||
|
fi |
||||||
|
echo "" |
||||||
|
|
||||||
|
if [ "$fail_count" -eq 0 ]; then |
||||||
|
echo -e "${GREEN}All files imported successfully!${NC}" |
||||||
|
echo -e "${GREEN}All successful files have been moved to ${DONE_DIR}${NC}" |
||||||
|
exit 0 |
||||||
|
else |
||||||
|
echo -e "${YELLOW}Some imports failed. Check the errors above.${NC}" |
||||||
|
echo -e "${YELLOW}Failed files remain in ${REMOTE_TEMP_DIR} for retry${NC}" |
||||||
|
echo -e "${GREEN}Successful files have been moved to ${DONE_DIR}${NC}" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
Loading…
Reference in new issue