67 Commits

Author SHA1 Message Date
eduard256 671da40930 Add Umbrel install section to README 2026-04-24 11:26:13 +00:00
eduard256 e0158ea81a Add Podman section to README with required capabilities 2026-04-24 11:26:13 +00:00
eduard256 593f2fc14f Sync README install command 2026-04-16 19:00:45 +00:00
eduard256 0a41dac8e4 Sync install.sh fix 2026-04-16 18:58:46 +00:00
eduard256 1f8414079a Replace install.sh with new modular navigator 2026-04-16 18:57:35 +00:00
eduard256 43a097586a Sync scripts from develop 2026-04-16 18:54:08 +00:00
eduard256 fb4797f6b3 Sync scripts from develop 2026-04-16 18:47:55 +00:00
eduard256 4308039e08 Sync scripts from develop 2026-04-16 18:00:56 +00:00
eduard256 df32774b50 Remove test script 2026-04-16 17:51:33 +00:00
eduard256 100149241c Sync all scripts to main 2026-04-16 17:51:19 +00:00
eduard256 f6c80a2c72 Update install.sh with system detection display 2026-04-16 17:06:23 +00:00
eduard256 c86f1b936e Add modular installer scripts
Worker scripts with JSON event streaming:
- detect.sh: system/Docker/Frigate/go2rtc detection
- prepare.sh: Docker and Compose installation
- strix.sh: deploy Strix standalone
- strix-frigate.sh: deploy Strix + Frigate with HW autodetect
- proxmox-lxc-create.sh: create Ubuntu LXC on Proxmox
- install.sh: animated frontend with owl display
2026-04-16 16:56:17 +00:00
eduard256 ffe77cb9c4 Merge develop into main for v2.1.0 release 2026-04-08 11:31:03 +00:00
eduard256 29e03ce85a Release v2.1.0 2026-04-08 11:30:58 +00:00
eduard256 e47c0f7ce6 Add top-1000 checkbox to ONVIF page, classify JPEG streams as alternative
- Add checked-by-default checkbox to also test popular stream patterns
- Move JPEG-only streams (no H264/H265) to Alternative group in test results
2026-04-08 11:27:08 +00:00
eduard256 0fb7356a5e Add ONVIF stream handler for tester
- Add testOnvif(): resolves all profiles via ONVIF client, tests
  each RTSP stream, returns two Results per profile (onvif + rtsp)
  with shared screenshot
- Route onvif:// URLs in worker.go alongside homekit://
- Classify onvif:// streams as recommended in test.html
- Harden create.html against undefined/null URL values
2026-04-08 11:00:32 +00:00
eduard256 ce4b777e98 Add ONVIF camera page and probe routing
- Add onvif.html: credentials form, Discover Streams button,
  fallback to Standard Discovery and HomeKit Pairing
- Update index.html routing: onvif type -> onvif.html with all
  probe params (onvif_url, onvif_port, onvif_name, onvif_hardware,
  mdns_* for HomeKit fallback)
2026-04-08 10:50:05 +00:00
eduard256 5be8d4aa00 Add ONVIF probe detector via unicast WS-Discovery
- Add ProbeONVIF() prober: sends unicast WS-Discovery to ip:3702,
  parses XAddrs, Name, Hardware from response (no auth needed)
- Add ONVIFResult struct to probe models
- Register ONVIF detector with highest priority (before HomeKit)
- Fix homekit.html back-wrapper max-width to match design system
2026-04-08 10:31:46 +00:00
eduard256 1291e6a5b6 Add frontend_design_strix skill for UI page creation
Design guide with principles, layout patterns, component usage,
navigation rules, and checklist. References homekit.html as the
design gold standard and design-system.html for components.
2026-04-08 09:49:25 +00:00
eduard256 699ddda39b Update design system with centered layout, PIN input, floating back button
Add true-center layout pattern, back-wrapper for floating navigation,
PIN digit input component with all states, and centered page demo
with HomeKit logo example. Document PIN input JS pattern.
2026-04-08 09:39:36 +00:00
eduard256 89c5d83a6f Refine HomeKit page: add Apple HomeKit logo, centered layout, back button
Replace text-only header with official HomeKit house icon and
"Apple HomeKit" label. Pin input centered on screen, back button
aligned to container edge. Remove device info table and decorative
elements for a cleaner look matching the rest of the frontend.
2026-04-08 09:30:31 +00:00
eduard256 8398832960 Redesign HomeKit page, add design system reference
Rebuild homekit.html with centered layout, cleaner PIN input,
and consistent styling matching the rest of the frontend.
Add www/design-system.html as a living component reference
for all UI elements used across the Strix frontend.
2026-04-08 08:54:40 +00:00
eduard256 a16799fa8d Add Docker Compose files for Strix, Frigate, and go2rtc setups
Add three docker-compose variants: standalone Strix,
Strix + Frigate, and Strix + go2rtc. Update README with
Docker Compose install instructions linking to the files.
2026-04-05 14:47:54 +00:00
eduard256 0652e53bc7 Update README: add HomeKit protocol and StrixAHKCamFake reference 2026-04-05 14:41:19 +00:00
eduard256 528ec8e00b Add HomeKit stream testing via HAP snapshot
- Add worker_homekit.go with direct hap.Dial + GetImage flow
- Bypass SRTP/Producer pipeline for homekit:// URLs
- Route homekit:// streams to dedicated handler in worker.go
2026-04-05 12:58:26 +00:00
eduard256 a9820abc37 Add HomeKit camera pairing support
- Add POST /api/homekit/pair endpoint that calls hap.Pair() from go2rtc
- Rewrite homekit.html with PIN input UI (XXX-XX-XXX format)
- Auto-advance between digit fields, paste support, error/success states
- On successful pairing, redirect to create.html with homekit:// URL
- Pass mdns_port and mdns_paired from probe to homekit.html
- Detect HomeKit cameras regardless of pairing status
2026-04-05 12:43:06 +00:00
eduard256 e2e24c7578 Switch mDNS probe to multicast, use mDNS for reachability
Unicast mDNS queries (direct to IP:5353) are ignored by some HomeKit
devices. Switch to multicast (224.0.0.251:5353) and filter responses
by sender IP. Also consider mDNS response as reachability signal.

Split probe timeouts: 100ms for ports/DNS/HTTP, 120ms total to give
mDNS extra time. HomeKit responds in ~0.2ms via multicast.
2026-04-05 12:06:11 +00:00
eduard256 f084135701 Remove ICMP ping from probe, add HomeKit port 51826
ICMP requires root or CAP_NET_RAW which is not available in
unprivileged containers. Probe now relies solely on port scanning
for reachability detection, which works without any special
permissions. Add port 51826 (HomeKit) to both default and
database-loaded port lists.
2026-04-05 11:54:24 +00:00
eduard256 4e9ffd1440 Show LAN IP instead of localhost in install summary
Detect local network IP address using ip route / hostname -I / ifconfig
fallback chain and display it in the post-install summary box so users
can immediately open Strix from other devices on the network.
2026-04-05 10:45:46 +00:00
eduard256 83659c9a82 Replace double dashes with single dashes 2026-04-05 10:36:48 +00:00
eduard256 e0ccef8683 Replace double dashes with single dashes 2026-04-05 10:36:47 +00:00
eduard256 166feceab9 Add StrixCamFake link to DEVELOPERS.md 2026-04-05 10:36:11 +00:00
eduard256 66f9131cff Add StrixCamFake link to DEVELOPERS.md 2026-04-05 10:36:10 +00:00
eduard256 8cf3195b51 Add StrixCamFake link to README 2026-04-05 10:35:51 +00:00
eduard256 600141d11b Add StrixCamFake link to README 2026-04-05 10:35:50 +00:00
eduard256 608d4989ff Link feature bullets to corresponding screenshots 2026-04-05 10:28:55 +00:00
eduard256 5fb1efe599 Link feature bullets to corresponding screenshots 2026-04-05 10:28:54 +00:00
eduard256 2ab8106b01 Add supported cameras link to quick links 2026-04-05 10:25:42 +00:00
eduard256 acc456f3f5 Add supported cameras link to quick links 2026-04-05 10:25:41 +00:00
eduard256 22baefd57f Add camera database browse and contribute links 2026-04-05 10:23:33 +00:00
eduard256 f06d60f6ff Add camera database browse and contribute links 2026-04-05 10:23:32 +00:00
eduard256 6044df6ee4 Add horizontal rule before demo GIF 2026-04-05 10:20:39 +00:00
eduard256 56c02f6b72 Add horizontal rule before demo GIF 2026-04-05 10:20:38 +00:00
eduard256 43632fb8c2 Add live demo, video, and API docs links after banner 2026-04-05 10:19:33 +00:00
eduard256 55a4a62752 Add live demo, video, and API docs links after banner 2026-04-05 10:19:32 +00:00
eduard256 4c1fab86b1 Add separator between logo and title 2026-04-05 10:18:26 +00:00
eduard256 1c50564548 Add separator between logo and title 2026-04-05 10:18:25 +00:00
eduard256 1efe3cc9ba Add STRIX text next to logo in header 2026-04-05 10:17:40 +00:00
eduard256 21c96d6548 Add STRIX text next to logo in header 2026-04-05 10:17:39 +00:00
eduard256 4ea3485c9b Move API reference to DEVELOPERS.md 2026-04-05 10:15:47 +00:00
eduard256 dd8966a8d7 Move API reference to DEVELOPERS.md 2026-04-05 10:15:46 +00:00
eduard256 74eed5ede9 Redesign README header with centered layout and feature list 2026-04-05 10:09:26 +00:00
eduard256 96354f018f Redesign README header with centered layout and feature list 2026-04-05 10:09:25 +00:00
eduard256 75947be26b Remove title text from README header 2026-04-05 10:03:45 +00:00
eduard256 e4a28fe61a Remove title text from README header 2026-04-05 10:03:44 +00:00
eduard256 4cb00ec85f Left-align logo and badges in README 2026-04-05 10:03:15 +00:00
eduard256 2fc9be2d9f Left-align logo and badges in README 2026-04-05 10:03:14 +00:00
eduard256 0cf9f7d44e Remove logo caption from README 2026-04-05 10:02:39 +00:00
eduard256 39da8d2d50 Remove logo caption from README 2026-04-05 10:02:38 +00:00
eduard256 8c1a6b1b0e Add MIT license and license badge 2026-04-05 10:02:03 +00:00
eduard256 258f3712c2 Add MIT license and license badge 2026-04-05 10:02:02 +00:00
eduard256 2db7ae6f25 Move badges above icon in README 2026-04-05 10:00:31 +00:00
eduard256 bb740a04bc Move badges above icon in README 2026-04-05 10:00:30 +00:00
eduard256 09bd2ce220 Use PNG icon in README 2026-04-05 09:00:15 +00:00
eduard256 8a4201936a Use PNG icon in README 2026-04-05 09:00:10 +00:00
eduard256 0a30496991 Add README for v2.0.0 2026-04-05 08:58:55 +00:00
eduard256 eb6719237d Add README for v2.0.0 2026-04-05 08:58:23 +00:00
34 changed files with 7548 additions and 1049 deletions
@@ -0,0 +1,211 @@
---
name: frontend_design_strix
description: Create or redesign frontend pages for Strix. Use when building new HTML pages, redesigning existing ones, or working on any UI task in the www/ directory. Covers design principles, layout patterns, and component usage.
disable-model-invocation: true
---
# Strix Frontend Design
You are creating or modifying a frontend page for Strix. Your goal is to produce a page that looks **identical in quality** to the existing pages, especially `www/homekit.html` which is the design reference.
## Before you start
Read these files completely:
1. **`www/design-system.html`** -- All CSS variables, every component, JS patterns. This is your component library.
2. **`www/homekit.html`** -- The design reference. This page is the gold standard. Study its structure, spacing, how little text it uses, how the back button is positioned.
3. **`www/index.html`** -- The entry point. Understand the probe flow and how data is passed between pages via URL params.
If you need to understand backend APIs or the probe system, read:
- `www/standard.html` -- how probe data flows into a configuration page
- `www/test.html` -- how polling and real-time updates work
- `www/config.html` -- complex two-column layout with live preview
## Design Philosophy
### Radical minimalism
Every element on screen must earn its place. If something doesn't help the user complete their task, remove it.
- **10% text, 90% meaning.** A label that says "Pairing Code" with an info-icon is better than a paragraph explaining what a pairing code is.
- **Hide details behind info-icons.** Long explanations go into tooltips (the `(i)` icon pattern). The user who needs the explanation can hover. The user who doesn't is not bothered.
- **No decorative elements without function.** No ornamental icons, no badges that don't convey information, no cards-as-decoration.
- **One action per screen.** Each page should have one primary thing the user does. Everything else is secondary.
### How we think about design decisions
When building homekit.html, we went through this process:
1. **Started with all the data** -- device info table, long descriptions, badges, decorative icons
2. **Asked "does the user need this?"** for every element
3. **Removed everything that wasn't essential** -- the device info table (IP, MAC, vendor) was removed because the user doesn't need it to enter a PIN code
4. **Moved explanations into tooltips** -- "This camera supports Apple HomeKit. Enter the 8-digit pairing code printed on your camera or included in the manual" became just a label "Pairing Code" with a tooltip
5. **Removed format hints** -- "Format: XXX-XX-XXX" was removed because the input fields themselves make the format obvious
6. **Made the primary action obvious** -- big button, full width, impossible to miss
Apply this same thinking to every page you create.
### Visual rules
- Dark theme with purple accent -- never deviate from the color palette in `:root`
- All icons are inline SVG -- never use emoji, never use icon fonts, never use external icon libraries
- Fonts: system font stack for UI, monospace for technical values (URLs, IPs, codes)
- Borders are subtle: `rgba(139, 92, 246, 0.15)` -- barely visible purple tint
- Glow effects on focus and hover, never on static elements (except logos)
- Animations are fast (150ms) and subtle -- translateY(-2px) on hover, fadeIn on page load
- No rounded corners larger than 8px (except special cases like toggle switches)
## Layout Patterns
### Pages after probe (like homekit.html) -- TRUE CENTER
This is the most common case for new pages. Content is vertically centered on screen.
```
.screen {
min-height: 100vh;
display: flex;
align-items: center; /* TRUE CENTER -- not flex-start */
justify-content: center;
}
.container { max-width: 480px; width: 100%; }
```
**Back button** is positioned OUTSIDE the container, wider than content, using `.back-wrapper`:
```
.back-wrapper {
position: absolute; top: 1.5rem;
left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px; /* wider than container */
padding: 0 1.5rem;
z-index: 10;
}
```
This is MANDATORY for all centered layout pages. The back button must NOT be inside the centered container.
### Entry page (like index.html) -- TOP CENTER
Content is near the top with `margin-top: 8vh`. Used for the main entry point only.
### Content pages (like standard.html, create.html) -- STANDARD
Back button at top, then title, then content flowing down. `max-width: 600px`, no vertical centering.
### Data-heavy pages (like test.html) -- WIDE
`max-width: 1200px` with card grids.
### Two-column (like config.html) -- SPLIT
Settings left, live preview right. Collapses to tabs on mobile.
## Hero Section Pattern
For centered pages, the hero contains a logo/icon + short title:
```html
<div class="hero">
<svg class="logo-icon">...</svg> <!-- 48-72px, with glow filter -->
<h1 class="title">Short Name</h1> <!-- 1.25rem, white, font-weight 600 -->
</div>
```
- The icon should be recognizable and relevant (Strix owl for main, HomeKit house for HomeKit)
- The title is SHORT -- one or two words max
- No subtitles unless absolutely necessary
- Glow effect on the icon via `filter: drop-shadow()`
## Component Usage
All components are documented with live examples in `www/design-system.html`. Key ones:
- **Buttons**: `.btn .btn-primary .btn-large` for primary action (full width), `.btn-outline` for secondary
- **Inputs**: `.input` with `.label` and optional `.info-icon` with `.tooltip`
- **Toast**: Every page needs `<div id="toast" class="toast hidden"></div>` and the `showToast()` function
- **Error box**: `.error-box` with `.visible` class toggled
- **Info icon + tooltip**: For hiding explanations -- always prefer this over visible text
## Navigation -- CRITICAL
### Always pass ALL known data forward
When navigating to another page, pass every piece of data you have. This is non-negotiable. Future pages may need any of these values.
```javascript
function navigateNext() {
var p = new URLSearchParams();
p.set('primary_data', value);
// Pass through EVERYTHING known:
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (model) p.set('model', model);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (ports) p.set('ports', ports);
if (user) p.set('user', user);
if (channel) p.set('channel', channel);
// ... any other params from probe
window.location.href = 'next.html?' + p.toString();
}
```
### Page init always reads all params
```javascript
var params = new URLSearchParams(location.search);
var ip = params.get('ip') || '';
var mac = params.get('mac') || '';
var vendor = params.get('vendor') || '';
// ... read ALL possible params even if this page doesn't use them
// They need to be available for passing to the next page
```
## JavaScript Rules
- Use `var`, not `let`/`const` -- ES5 compatible
- Build DOM with `document.createElement`, not innerHTML
- Use `async function` + `fetch()` for API calls
- Always handle errors: check `!r.ok`, catch exceptions, show toast
- Debounce input handlers if they trigger API calls (300ms)
- Use `addEventListener`, never inline event handlers in HTML
## API Pattern
```javascript
async function doSomething() {
try {
var r = await fetch('api/endpoint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!r.ok) {
var text = await r.text();
showToast(text || 'Error ' + r.status);
return;
}
var data = await r.json();
// success...
} catch (e) {
showToast('Connection error: ' + e.message);
}
}
```
## Checklist before finishing
- [ ] Page uses correct layout pattern for its type
- [ ] Back button positioned correctly (`.back-wrapper` for centered, inline for standard)
- [ ] All CSS variables from `:root` -- no hardcoded colors
- [ ] No unnecessary text -- everything possible hidden behind info-icons
- [ ] All known URL params are read at init and passed forward on navigation
- [ ] Toast element present, showToast function included
- [ ] Error states handled (API errors, validation)
- [ ] Mobile responsive (test at 375px width)
- [ ] No emoji anywhere
- [ ] All icons are inline SVG
- [ ] Primary action is obvious and full-width
- [ ] Page looks like it belongs with homekit.html and index.html
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## [2.1.0] - 2026-04-08
### Added
- ONVIF protocol support: auto-discovery via unicast WS-Discovery, stream resolution through ONVIF profiles
- ONVIF probe detector: detects ONVIF cameras during network probe (4-7ms response time, no auth required)
- ONVIF camera page (onvif.html): credentials form with option to also test popular stream patterns
- ONVIF stream handler: resolves all camera profiles, tests each via RTSP, returns paired results (onvif:// + rtsp://) with shared screenshots
- Design system reference (design-system.html) with all UI components documented
### Changed
- ONVIF has highest probe priority (above HomeKit and Standard)
- JPEG-only streams (no H264/H265) are classified as Alternative in test results
- HomeKit page redesigned: Apple HomeKit logo, centered layout, floating back button
- Hardened create.html against undefined/null URL values in query parameters
## [2.0.0] - 2025-04-05
### Added
+370
View File
@@ -0,0 +1,370 @@
# Strix for Developers
Strix is a single static binary with embedded web UI and SQLite camera database. No config files, no external dependencies (except optional `ffmpeg` for H264/H265 screenshot conversion). Designed to run alongside your project the same way [go2rtc](https://github.com/AlexxIT/go2rtc) does.
For development and testing without real cameras, use [StrixCamFake](https://github.com/eduard256/StrixCamFake) - IP camera emulator with RTSP, HTTP, RTMP, Bubble and more.
## Binary
Download from [GitHub Releases](https://github.com/eduard256/Strix/releases). Two platforms: `linux/amd64` and `linux/arm64`.
```bash
chmod +x strix-linux-amd64
./strix-linux-amd64
```
The binary needs `cameras.db` in the working directory. Download it from [StrixCamDB](https://github.com/eduard256/StrixCamDB/releases):
```bash
curl -fsSL https://github.com/eduard256/StrixCamDB/releases/latest/download/cameras.db -o cameras.db
./strix-linux-amd64
```
## Docker
```bash
docker run -d --name strix --network host eduard256/strix:latest
```
Database is already embedded in the image.
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `STRIX_LISTEN` | `:4567` | HTTP listen address |
| `STRIX_DB_PATH` | `cameras.db` | Path to SQLite database |
| `STRIX_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error` |
| `STRIX_FRIGATE_URL` | auto-discovery | Frigate URL, e.g. `http://localhost:5000` |
| `STRIX_GO2RTC_URL` | auto-discovery | go2rtc URL, e.g. `http://localhost:1984` |
## Integration Flow
Typical automation flow using the API:
```
1. Probe device GET /api/probe?ip=192.168.1.100
2. Search database GET /api/search?q=hikvision
3. Build stream URLs GET /api/streams?ids=b:hikvision&ip=192.168.1.100&user=admin&pass=12345
4. Test streams POST /api/test {sources: {streams: [...]}}
5. Poll results GET /api/test?id=xxx
6. Generate config POST /api/generate {mainStream: "rtsp://...", subStream: "rtsp://..."}
```
All endpoints return JSON. CORS is enabled. No authentication.
---
## API Reference
### System
#### `GET /api`
```json
{"version": "2.0.0", "platform": "amd64"}
```
#### `GET /api/health`
```json
{"version": "2.0.0", "uptime": "1h30m0s"}
```
#### `GET /api/log`
Returns in-memory log in `application/jsonlines` format. Passwords are masked automatically.
#### `DELETE /api/log`
Clears in-memory log. Returns `204`.
---
### Search
#### `GET /api/search?q={query}`
Search camera database by brand, model, or preset name. Empty `q` returns all presets + first brands (limit 50).
```bash
curl "localhost:4567/api/search?q=hikvision"
```
```json
{
"results": [
{"type": "brand", "id": "b:hikvision", "name": "Hikvision"},
{"type": "model", "id": "m:hikvision:DS-2CD2032", "name": "Hikvision: DS-2CD2032"}
]
}
```
Result types:
| Type | ID format | Description |
|------|-----------|-------------|
| `preset` | `p:{preset_id}` | Curated URL pattern sets (e.g. "ONVIF", "Popular RTSP") |
| `brand` | `b:{brand_id}` | All URL patterns for a brand |
| `model` | `m:{brand_id}:{model}` | URL patterns for a specific model |
Multi-word queries match independently: `hikvision DS-2CD` matches brand "Hikvision" AND model containing "DS-2CD".
#### `GET /api/streams`
Build full stream URLs from database patterns with credentials and placeholders substituted.
| Param | Required | Description |
|-------|----------|-------------|
| `ids` | yes | Comma-separated IDs from search results |
| `ip` | yes | Camera IP address |
| `user` | no | Username (URL-encoded automatically) |
| `pass` | no | Password (URL-encoded automatically) |
| `channel` | no | Channel number, default `0` |
| `ports` | no | Comma-separated port filter (only return URLs matching these ports) |
```bash
curl "localhost:4567/api/streams?ids=b:hikvision&ip=192.168.1.100&user=admin&pass=12345"
```
```json
{
"streams": [
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/101",
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/102",
"http://admin:12345@192.168.1.100/ISAPI/Streaming/channels/101/picture"
]
}
```
Maximum 20,000 URLs per request. URLs are deduplicated.
---
### Testing
#### `POST /api/test`
Create a test session. 20 parallel workers connect to each URL, extract codecs, capture screenshots.
```bash
curl -X POST localhost:4567/api/test -d '{
"sources": {
"streams": [
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/101",
"rtsp://admin:12345@192.168.1.100/Streaming/Channels/102"
]
}
}'
```
```json
{"session_id": "a1b2c3d4e5f6g7h8"}
```
#### `GET /api/test`
List all active and completed sessions.
```json
{
"sessions": [
{
"session_id": "a1b2c3d4",
"status": "running",
"total": 604,
"tested": 341,
"alive": 191,
"with_screenshot": 191
}
]
}
```
#### `GET /api/test?id={session_id}`
Get session details with full results. Poll this endpoint to track progress.
```json
{
"session_id": "a1b2c3d4",
"status": "done",
"total": 604,
"tested": 604,
"alive": 375,
"with_screenshot": 375,
"results": [
{
"source": "rtsp://admin:***@192.168.1.100/Streaming/Channels/101",
"codecs": ["H264", "PCMA"],
"width": 1920,
"height": 1080,
"latency_ms": 45,
"screenshot": "api/test/screenshot?id=a1b2c3d4&i=0"
}
]
}
```
- `status`: `running` or `done`
- `codecs`: detected media codecs (H264, H265, PCMA, PCMU, OPUS, etc.)
- `width`, `height`: resolution extracted from JPEG screenshot
- `screenshot`: relative URL to fetch the JPEG image
- Sessions expire 30 minutes after completion
#### `DELETE /api/test?id={session_id}`
Cancel a running session and delete it.
```json
{"status": "deleted"}
```
#### `GET /api/test/screenshot?id={session_id}&i={index}`
Returns raw JPEG image. `Content-Type: image/jpeg`.
---
### Config Generation
#### `POST /api/generate`
Generate Frigate config from stream URLs.
```bash
curl -X POST localhost:4567/api/generate -d '{
"mainStream": "rtsp://admin:12345@192.168.1.100/Streaming/Channels/101",
"subStream": "rtsp://admin:12345@192.168.1.100/Streaming/Channels/102",
"name": "front_door",
"objects": ["person", "car"]
}'
```
```json
{
"config": "mqtt:\n enabled: false\n\nrecord:\n enabled: true\n\ngo2rtc:\n streams:\n ...",
"added": [1, 2, 3, 4, 5]
}
```
- `config`: complete Frigate YAML
- `added`: 1-based line numbers of new lines (for highlighting in UI)
**Merge into existing config** - pass `existingConfig` field:
```json
{
"mainStream": "rtsp://...",
"existingConfig": "go2rtc:\n streams:\n existing_cam:\n - rtsp://...\n\ncameras:\n existing_cam:\n ..."
}
```
Strix finds the right insertion points in go2rtc streams and cameras sections. Camera and stream names are deduplicated automatically.
<details>
<summary>Full request schema</summary>
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mainStream` | string | **yes** | Main stream URL |
| `subStream` | string | no | Sub stream URL for detect role |
| `name` | string | no | Camera name (auto-generated from IP if empty) |
| `existingConfig` | string | no | Existing Frigate YAML to merge into |
| `objects` | string[] | no | Objects to track (default: `["person"]`) |
| `go2rtc` | object | no | `{mainStreamName, subStreamName, mainStreamSource, subStreamSource}` |
| `frigate` | object | no | `{mainStreamPath, subStreamPath, mainStreamInputArgs, subStreamInputArgs}` |
| `detect` | object | no | `{enabled, fps, width, height}` |
| `record` | object | no | `{enabled, retain_days, mode, alerts_days, detections_days, pre_capture, post_capture}` |
| `motion` | object | no | `{enabled, threshold, contour_area}` |
| `snapshots` | object | no | `{enabled}` |
| `audio` | object | no | `{enabled, filters[]}` |
| `ffmpeg` | object | no | `{hwaccel, gpu}` |
| `live` | object | no | `{height, quality}` |
| `birdseye` | object | no | `{enabled, mode}` |
| `onvif` | object | no | `{host, port, user, password, autotracking, required_zones[]}` |
| `ptz` | object | no | `{enabled, presets{}}` |
| `notifications` | object | no | `{enabled}` |
| `ui` | object | no | `{order, dashboard}` |
</details>
---
### Probe
#### `GET /api/probe?ip={ip}`
Probe a network device. Runs 6 checks in parallel within 100ms: port scan, ICMP ping, ARP + OUI vendor lookup, reverse DNS, mDNS/HomeKit query, HTTP probe.
```bash
curl "localhost:4567/api/probe?ip=192.168.1.100"
```
```json
{
"ip": "192.168.1.100",
"reachable": true,
"latency_ms": 2.5,
"type": "standard",
"probes": {
"ping": {"latency_ms": 2.5},
"ports": {"open": [80, 554, 8080]},
"dns": {"hostname": "ipcam.local"},
"arp": {"mac": "C0:56:E3:AA:BB:CC", "vendor": "Hikvision"},
"mdns": null,
"http": {"port": 80, "status_code": 401, "server": "Hikvision-Webs"}
}
}
```
- `type`: `standard`, `homekit`, or `unreachable`
- `ports.open`: scanned from 189 ports known in the camera database
- `arp.vendor`: looked up from OUI table in SQLite database
- HomeKit cameras return `mdns` with `name`, `model`, `category` (`camera` or `doorbell`), `device_id`, `paired`, `port`
- ICMP ping requires `CAP_NET_RAW` capability. Falls back to port scan only.
---
### Frigate
#### `GET /api/frigate/config`
Get current Frigate config. Frigate is discovered automatically by probing known addresses (`localhost:5000`, `ccab4aaf-frigate:5000`) or via `STRIX_FRIGATE_URL`.
```json
{"connected": true, "url": "http://localhost:5000", "config": "mqtt:\n enabled: false\n ..."}
```
```json
{"connected": false, "config": ""}
```
#### `POST /api/frigate/config/save?save_option={option}`
Save config to Frigate. Request body is plain text (YAML config).
| Option | Description |
|--------|-------------|
| `saveonly` | Save config without restart (default) |
| `restart` | Save config and restart Frigate |
---
### go2rtc
#### `PUT /api/go2rtc/streams?name={name}&src={source}`
Add a stream to go2rtc. Proxied to local go2rtc instance (discovered automatically or via `STRIX_GO2RTC_URL`).
```bash
curl -X PUT "localhost:4567/api/go2rtc/streams?name=front_door&src=rtsp://admin:12345@192.168.1.100/Streaming/Channels/101"
```
```json
{"success": true}
```
```json
{"success": false, "error": "go2rtc not found"}
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 eduard256
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+201
View File
@@ -0,0 +1,201 @@
<h1 align="center">
<a href="https://github.com/eduard256/Strix">
<img src="https://github.com/eduard256/Strix/releases/download/v2.0.0/icon-192.png" width="64" alt="Strix" valign="middle">
</a>
&nbsp;|&nbsp;
STRIX
</h1>
<p align="center">
<a href="https://github.com/eduard256/strix/stargazers"><img src="https://img.shields.io/github/stars/eduard256/strix?style=flat-square&logo=github" alt="GitHub Stars"></a>
<a href="https://hub.docker.com/r/eduard256/strix"><img src="https://img.shields.io/docker/pulls/eduard256/strix?style=flat-square&logo=docker&logoColor=white&label=pulls" alt="Docker Pulls"></a>
<a href="https://github.com/eduard256/Strix/releases"><img src="https://img.shields.io/github/downloads/eduard256/Strix/total?color=blue&style=flat-square&logo=github" alt="GitHub Downloads"></a>
<a href="https://github.com/eduard256/Strix/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License"></a>
</p>
Camera stream discovery and Frigate config generator.
- 3,600+ camera brands with 100,000+ [URL patterns](#streams) in SQLite database
- automatic device [probing](#probe) in 100ms: ports, ARP/OUI, mDNS/HomeKit, HTTP
- 20 parallel workers [test every URL](#testing) with live screenshots
- supports [RTSP, HTTP, RTMP, Bubble, DVRIP](#supported-protocols) and more
- ready [Frigate config](#config-generation) with smart merge into existing setup
- auto-discovery of Frigate and [go2rtc](https://github.com/AlexxIT/go2rtc) on local network
- zero-dependency static [binary](#binary) for Linux amd64/arm64
- can be used as [standalone app](#binary), [Docker](#docker), or [Home Assistant add-on](#home-assistant-add-on)
---
<a href="https://youtu.be/JgVWsl4NApE">
<img src="https://github.com/eduard256/Strix/releases/download/v2.0.0/demo.gif" width="100%">
</a>
<p align="center">
<a href="https://gostrix.github.io/demo.html"><b>Live Demo</b></a>
&nbsp;&bull;&nbsp;
<a href="https://gostrix.github.io/"><b>Supported Cameras</b></a>
&nbsp;&bull;&nbsp;
<a href="https://youtu.be/JgVWsl4NApE"><b>Video</b></a>
&nbsp;&bull;&nbsp;
<a href="DEVELOPERS.md"><b>API Docs</b></a>
</p>
## Install
Any Linux or Proxmox, one command:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/install.sh)
```
Run as root (or with `sudo`). Interactive installer detects your system (Linux / Proxmox) and guides you through setup.
Open `http://YOUR_IP:4567`
## How it works
<a id="probe"></a>
Enter camera IP. Strix probes the device - open ports, MAC vendor, mDNS, HTTP server.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/01-enter-ip.png)
<a id="search"></a>
Search camera model in database. Enter credentials if needed.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/02-camera-config.png)
<a id="streams"></a>
Strix builds all possible stream URLs from database patterns.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/03-stream-urls.png)
<a id="testing"></a>
20 parallel workers test every URL. Live screenshots, codecs, resolution, latency.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/04-testing.png)
Pick main and sub streams from results.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/05-results.png)
<a id="config-generation"></a>
Generate ready Frigate config. Copy, download, or save directly to Frigate.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/06-frigate-config.png)
Camera works in Frigate. Done.
![](https://github.com/eduard256/Strix/releases/download/v2.0.0/07-frigate-result.png)
## Other install methods
### Docker
```bash
docker run -d --name strix --network host --restart unless-stopped eduard256/strix:latest
```
### Docker Compose
Strix only:
```bash
curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml
docker compose up -d
```
Strix + [Frigate](https://github.com/blakeblackshear/frigate):
```bash
curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.frigate.yml
docker compose -f docker-compose.frigate.yml up -d
```
Strix + [go2rtc](https://github.com/AlexxIT/go2rtc):
```bash
curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.go2rtc.yml
docker compose -f docker-compose.go2rtc.yml up -d
```
### Podman
Podman drops `NET_RAW` and `NET_ADMIN` by default, which Strix needs for network scanning. Add them explicitly:
```bash
podman run -d \
--name strix \
--network host \
--cap-add=NET_RAW \
--cap-add=NET_ADMIN \
--restart unless-stopped \
eduard256/strix:latest
```
Or run with `--privileged` if you prefer.
### Home Assistant Add-on
1. **Settings** > **Add-ons** > **Add-on Store**
2. Menu (top right) > **Repositories** > add `https://github.com/eduard256/hassio-strix`
3. Install **Strix**, enable **Start on boot** and **Show in sidebar**
### Umbrel
<a href="https://apps.umbrel.com/app/strix">
<img src="https://apps.umbrel.com/api/app/strix/badge-light.svg" alt="Install on Umbrel" height="60">
</a>
Install in one click from the [Umbrel App Store](https://apps.umbrel.com/app/strix).
### Binary
Download from [GitHub Releases](https://github.com/eduard256/Strix/releases). No dependencies except `ffmpeg` for screenshot conversion.
```bash
chmod +x strix-linux-amd64
STRIX_LISTEN=:4567 ./strix-linux-amd64
```
## Supported protocols
| Protocol | Port | Description |
|----------|------|-------------|
| RTSP | 554 | Most IP cameras |
| RTSPS | 322 | RTSP over TLS |
| HTTP/HTTPS | 80/443 | MJPEG, JPEG snapshots, HLS, MPEG-TS |
| RTMP | 1935 | Some Chinese NVRs |
| Bubble | 80 | XMeye/NetSurveillance cameras |
| DVRIP | 34567 | Sofia protocol DVR/NVR |
| HomeKit | 51826 | Apple HomeKit cameras via HAP |
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `STRIX_LISTEN` | `:4567` | HTTP listen address |
| `STRIX_DB_PATH` | `cameras.db` | Path to SQLite camera database |
| `STRIX_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, `trace` |
| `STRIX_FRIGATE_URL` | auto-discovery | Frigate URL, e.g. `http://localhost:5000` |
| `STRIX_GO2RTC_URL` | auto-discovery | go2rtc URL, e.g. `http://localhost:1984` |
## Camera database
SQLite database with 3,600+ brands and 100,000+ URL patterns. Maintained separately in [StrixCamDB](https://github.com/eduard256/StrixCamDB). Database is embedded in Docker image and bundled with binary releases.
[Browse supported cameras](https://gostrix.github.io/) - search by brand or model to check if your camera is in the database.
Three entity types:
- **Presets** - curated sets of popular URL patterns (e.g. "ONVIF", "Popular RTSP")
- **Brands** - all URL patterns for a brand (e.g. "Hikvision", "Dahua")
- **Models** - URL patterns for a specific model within a brand
Camera not in the database? [Add it here](https://gostrix.github.io/#/contribute).
**Developers:** integrate [Strix HTTP API](DEVELOPERS.md) into your smart home platform.
**Testing:** [StrixCamFake](https://github.com/eduard256/StrixCamFake) - IP camera emulator for development and testing. [StrixAHKCamFake](https://github.com/eduard256/StrixAHKCamFake) - Apple HomeKit camera emulator.
+42
View File
@@ -0,0 +1,42 @@
# Strix + Frigate
# Usage: docker compose -f docker-compose.frigate.yml up -d
services:
strix:
container_name: strix
image: eduard256/strix:latest
network_mode: host
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
STRIX_FRIGATE_URL: "http://localhost:8971"
# STRIX_LOG_LEVEL: debug
depends_on:
- frigate
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:stable
privileged: true
restart: unless-stopped
stop_grace_period: 30s
shm_size: "512mb"
# devices:
# - /dev/bus/usb:/dev/bus/usb # USB Coral
# - /dev/apex_0:/dev/apex_0 # PCIe Coral
# - /dev/dri/renderD128:/dev/dri/renderD128 # Intel/AMD GPU
volumes:
- /etc/localtime:/etc/localtime:ro
- ./frigate/config:/config
- ./frigate/storage:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
ports:
- "8971:8971"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
environment:
FRIGATE_RTSP_PASSWORD: "password"
+27
View File
@@ -0,0 +1,27 @@
# Strix + go2rtc
# Usage: docker compose -f docker-compose.go2rtc.yml up -d
services:
strix:
container_name: strix
image: eduard256/strix:latest
network_mode: host
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
STRIX_GO2RTC_URL: "http://localhost:1984"
# STRIX_LOG_LEVEL: debug
depends_on:
- go2rtc
go2rtc:
container_name: go2rtc
image: alexxit/go2rtc
restart: unless-stopped
volumes:
- ./go2rtc:/config
ports:
- "1984:1984"
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
+12
View File
@@ -0,0 +1,12 @@
# Strix standalone
# Usage: docker compose up -d
services:
strix:
container_name: strix
image: eduard256/strix:latest
network_mode: host
restart: unless-stopped
environment:
STRIX_LISTEN: ":4567"
# STRIX_LOG_LEVEL: debug
+5
View File
@@ -15,13 +15,18 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 // indirect
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f // indirect
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
+10
View File
@@ -25,6 +25,8 @@ github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
@@ -33,6 +35,10 @@ github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w=
github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -47,6 +53,10 @@ github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f h1:1R9KdKjCNSd7F8iGTxI
github.com/sigurn/crc8 v0.0.0-20220107193325-2243fe600f9f/go.mod h1:vQhwQ4meQEDfahT5kd61wLAF5AAeh5ZPLVI4JJ/tYo8=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 h1:aeN+ghOV0b2VCmKKO3gqnDQ8mLbpABZgRR2FVYx4ouI=
github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9/go.mod h1:roo6cZ/uqpwKMuvPG0YmzI5+AmUiMWfjCBZpGXqbTxE=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+405 -768
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
package homekit
import (
"encoding/json"
"fmt"
"net/http"
"github.com/AlexxIT/go2rtc/pkg/hap"
"github.com/eduard256/strix/internal/api"
"github.com/eduard256/strix/internal/app"
"github.com/rs/zerolog"
)
var log zerolog.Logger
func Init() {
log = app.GetLogger("homekit")
api.HandleFunc("api/homekit/pair", apiPair)
}
func apiPair(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IP string `json:"ip"`
Port int `json:"port"`
DeviceID string `json:"device_id"`
PIN string `json:"pin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.IP == "" || req.Port == 0 || req.DeviceID == "" || req.PIN == "" {
http.Error(w, "ip, port, device_id and pin required", http.StatusBadRequest)
return
}
// ex. "homekit://10.0.10.52:45959?device_id=90:8C:0F:F2:EC:F3&pin=12345678"
rawURL := fmt.Sprintf("homekit://%s:%d?device_id=%s&pin=%s", req.IP, req.Port, req.DeviceID, req.PIN)
log.Debug().Str("ip", req.IP).Int("port", req.Port).Str("device_id", req.DeviceID).Msg("[homekit] pair")
conn, err := hap.Pair(rawURL)
if err != nil {
log.Warn().Err(err).Str("ip", req.IP).Msg("[homekit] pair failed")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer conn.Close()
url := conn.URL()
log.Info().Str("ip", req.IP).Str("device_id", req.DeviceID).Msg("[homekit] paired")
api.ResponseJSON(w, map[string]string{"url": url})
}
+25 -32
View File
@@ -16,13 +16,11 @@ import (
_ "modernc.org/sqlite"
)
const probeTimeout = 100 * time.Millisecond
const probeTimeout = 120 * time.Millisecond
var log zerolog.Logger
var db *sql.DB
var ports []int
var hasICMP bool
var detectors []func(*probe.Response) string
func Init() {
@@ -35,17 +33,17 @@ func Init() {
}
ports = loadPorts()
hasICMP = probe.CanICMP()
if hasICMP {
log.Info().Msg("[probe] ICMP available")
} else {
log.Info().Msg("[probe] ICMP not available, using port scan only")
}
// ONVIF detector (highest priority -- auto-discovers all streams)
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.ONVIF != nil {
return "onvif"
}
return ""
})
// HomeKit detector
detectors = append(detectors, func(r *probe.Response) string {
if r.Probes.MDNS != nil && !r.Probes.MDNS.Paired {
if r.Probes.MDNS != nil {
if r.Probes.MDNS.Category == "camera" || r.Probes.MDNS.Category == "doorbell" {
return "homekit"
}
@@ -88,14 +86,17 @@ func runProbe(parent context.Context, ip string) *probe.Response {
}()
}
fastCtx, fastCancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer fastCancel()
run(func() {
r, _ := probe.ScanPorts(ctx, ip, ports)
r, _ := probe.ScanPorts(fastCtx, ip, ports)
mu.Lock()
resp.Probes.Ports = r
mu.Unlock()
})
run(func() {
r, _ := probe.ReverseDNS(ctx, ip)
r, _ := probe.ReverseDNS(fastCtx, ip)
mu.Lock()
resp.Probes.DNS = r
mu.Unlock()
@@ -117,32 +118,23 @@ func runProbe(parent context.Context, ip string) *probe.Response {
mu.Unlock()
})
run(func() {
r, _ := probe.ProbeHTTP(ctx, ip, nil)
r, _ := probe.ProbeHTTP(fastCtx, ip, nil)
mu.Lock()
resp.Probes.HTTP = r
mu.Unlock()
})
if hasICMP {
run(func() {
r, _ := probe.Ping(ctx, ip)
mu.Lock()
resp.Probes.Ping = r
mu.Unlock()
})
}
run(func() {
r, _ := probe.ProbeONVIF(fastCtx, ip)
mu.Lock()
resp.Probes.ONVIF = r
mu.Unlock()
})
wg.Wait()
// determine reachable
resp.Reachable = resp.Probes.Ports != nil && len(resp.Probes.Ports.Open) > 0
if !resp.Reachable && resp.Probes.Ping != nil {
resp.Reachable = true
}
if resp.Reachable && resp.Probes.Ping != nil {
resp.LatencyMs = resp.Probes.Ping.LatencyMs
}
resp.Reachable = (resp.Probes.Ports != nil && len(resp.Probes.Ports.Open) > 0) ||
resp.Probes.MDNS != nil
// determine type
resp.Type = "standard"
@@ -184,10 +176,11 @@ func loadPorts() []int {
return defaultPorts()
}
result = append(result, 51826)
log.Info().Int("count", len(result)).Msg("[probe] loaded ports from db")
return result
}
func defaultPorts() []int {
return []int{554, 80, 8080, 443, 8554, 5544, 10554, 1935, 81, 88, 8090, 8001, 8081, 7070, 7447, 34567}
return []int{554, 80, 8080, 443, 8554, 5544, 10554, 1935, 81, 88, 8090, 8001, 8081, 7070, 7447, 34567, 51826}
}
+2
View File
@@ -6,6 +6,7 @@ import (
"github.com/eduard256/strix/internal/frigate"
"github.com/eduard256/strix/internal/generate"
"github.com/eduard256/strix/internal/go2rtc"
"github.com/eduard256/strix/internal/homekit"
"github.com/eduard256/strix/internal/probe"
"github.com/eduard256/strix/internal/search"
"github.com/eduard256/strix/internal/test"
@@ -33,6 +34,7 @@ func main() {
{"generate", generate.Init},
{"frigate", frigate.Init},
{"go2rtc", go2rtc.Init},
{"homekit", homekit.Init},
}
for _, m := range modules {
+25 -15
View File
@@ -22,8 +22,11 @@ const (
categoryDoorbell = "18"
)
// QueryHAP sends unicast mDNS query to ip:5353 for HomeKit service.
// Returns nil if device is not a HomeKit camera/doorbell.
var multicastAddr = &net.UDPAddr{IP: net.IP{224, 0, 0, 251}, Port: 5353}
// QueryHAP sends multicast mDNS query for HomeKit service and waits
// for a response from the specified ip. Returns nil if device is not
// a HomeKit camera/doorbell.
func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
msg := &dns.Msg{
Question: []dns.Question{
@@ -36,7 +39,7 @@ func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
return nil, err
}
conn, err := net.ListenPacket("udp4", ":0")
conn, err := net.ListenMulticastUDP("udp4", nil, multicastAddr)
if err != nil {
return nil, err
}
@@ -44,27 +47,34 @@ func QueryHAP(ctx context.Context, ip string) (*MDNSResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
deadline = time.Now().Add(time.Second)
}
_ = conn.SetDeadline(deadline)
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 5353}
if _, err = conn.WriteTo(query, addr); err != nil {
if _, err = conn.WriteTo(query, multicastAddr); err != nil {
return nil, err
}
targetIP := net.ParseIP(ip)
buf := make([]byte, 1500)
n, _, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil // timeout = not a HomeKit device
}
var resp dns.Msg
if err = resp.Unpack(buf[:n]); err != nil {
return nil, nil
}
for {
n, from, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil // timeout
}
return parseHAPResponse(&resp)
if !from.(*net.UDPAddr).IP.Equal(targetIP) {
continue
}
var resp dns.Msg
if err = resp.Unpack(buf[:n]); err != nil {
continue
}
return parseHAPResponse(&resp)
}
}
// internals
+9 -7
View File
@@ -3,23 +3,18 @@ package probe
type Response struct {
IP string `json:"ip"`
Reachable bool `json:"reachable"`
LatencyMs float64 `json:"latency_ms,omitempty"`
Type string `json:"type"` // "unreachable", "standard", "homekit"
Type string `json:"type"` // "unreachable", "standard", "homekit"
Error string `json:"error,omitempty"`
Probes Probes `json:"probes"`
}
type Probes struct {
Ping *PingResult `json:"ping"`
Ports *PortsResult `json:"ports"`
DNS *DNSResult `json:"dns"`
ARP *ARPResult `json:"arp"`
MDNS *MDNSResult `json:"mdns"`
HTTP *HTTPResult `json:"http"`
}
type PingResult struct {
LatencyMs float64 `json:"latency_ms"`
ONVIF *ONVIFResult `json:"onvif"`
}
type PortsResult struct {
@@ -49,3 +44,10 @@ type HTTPResult struct {
StatusCode int `json:"status_code"`
Server string `json:"server"`
}
type ONVIFResult struct {
URL string `json:"url"`
Port int `json:"port"`
Name string `json:"name,omitempty"`
Hardware string `json:"hardware,omitempty"`
}
+126
View File
@@ -0,0 +1,126 @@
package probe
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"net/url"
"regexp"
"strings"
"time"
)
// ProbeONVIF sends unicast WS-Discovery probe to ip:3702.
// Returns nil, nil if the device does not support ONVIF.
func ProbeONVIF(ctx context.Context, ip string) (*ONVIFResult, error) {
conn, err := net.ListenPacket("udp4", ":0")
if err != nil {
return nil, err
}
defer conn.Close()
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
}
_ = conn.SetDeadline(deadline)
// WS-Discovery Probe message
// https://www.onvif.org/wp-content/uploads/2016/12/ONVIF_Feature_Discovery_Specification_16.07.pdf
msg := `<?xml version="1.0" ?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">
<s:Header xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing">
<a:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>
<a:MessageID>urn:uuid:` + randUUID() + `</a:MessageID>
<a:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>
</s:Header>
<s:Body>
<d:Probe xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery">
<d:Types />
<d:Scopes />
</d:Probe>
</s:Body>
</s:Envelope>`
addr := &net.UDPAddr{IP: net.ParseIP(ip), Port: 3702}
if _, err = conn.WriteTo([]byte(msg), addr); err != nil {
return nil, err
}
buf := make([]byte, 8192)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
return nil, nil // timeout -- device doesn't support ONVIF
}
body := string(buf[:n])
if !strings.Contains(body, "onvif") {
continue
}
xaddrs := findXMLTag(body, "XAddrs")
if xaddrs == "" {
continue
}
// fix buggy cameras reporting 0.0.0.0
// ex. <wsdd:XAddrs>http://0.0.0.0:8080/onvif/device_service</wsdd:XAddrs>
if s, ok := strings.CutPrefix(xaddrs, "http://0.0.0.0"); ok {
xaddrs = "http://" + ip + s
}
port := 80
if u, err := url.Parse(xaddrs); err == nil && u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
scopes := findXMLTag(body, "Scopes")
return &ONVIFResult{
URL: xaddrs,
Port: port,
Name: findScope(scopes, "onvif://www.onvif.org/name/"),
Hardware: findScope(scopes, "onvif://www.onvif.org/hardware/"),
}, nil
}
}
// internals
var reXMLTag = map[string]*regexp.Regexp{}
func findXMLTag(s, tag string) string {
re, ok := reXMLTag[tag]
if !ok {
re = regexp.MustCompile(`(?s)<(?:\w+:)?` + tag + `\b[^>]*>([^<]+)`)
reXMLTag[tag] = re
}
m := re.FindStringSubmatch(s)
if len(m) != 2 {
return ""
}
return m[1]
}
func findScope(s, prefix string) string {
i := strings.Index(s, prefix)
if i < 0 {
return ""
}
s = s[i+len(prefix):]
if j := strings.IndexByte(s, ' '); j >= 0 {
s = s[:j]
}
s, _ = url.QueryUnescape(s)
return s
}
func randUUID() string {
b := make([]byte, 16)
rand.Read(b)
s := hex.EncodeToString(b)
return s[:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:]
}
-39
View File
@@ -1,39 +0,0 @@
package probe
import (
"context"
"net"
"time"
)
func CanICMP() bool {
conn, err := net.DialTimeout("ip4:icmp", "127.0.0.1", 100*time.Millisecond)
if err != nil {
return false
}
conn.Close()
return true
}
func Ping(ctx context.Context, ip string) (*PingResult, error) {
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(100 * time.Millisecond)
}
timeout := time.Until(deadline)
if timeout <= 0 {
return nil, context.DeadlineExceeded
}
start := time.Now()
conn, err := net.DialTimeout("ip4:icmp", ip, timeout)
if err != nil {
return nil, err
}
conn.Close()
return &PingResult{
LatencyMs: float64(time.Since(start).Microseconds()) / 1000.0,
}, nil
}
+104
View File
@@ -0,0 +1,104 @@
package tester
import (
"fmt"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
"github.com/AlexxIT/go2rtc/pkg/onvif"
)
// testOnvif resolves all ONVIF profiles, tests each via RTSP,
// and adds two Results per profile (onvif:// + rtsp://).
// ex. "onvif://admin:pass@10.0.20.111" or "onvif://admin:pass@10.0.20.119:2020"
func testOnvif(s *Session, rawURL string) {
client, err := onvif.NewClient(rawURL)
if err != nil {
return
}
tokens, err := client.GetProfilesTokens()
if err != nil {
return
}
for _, token := range tokens {
profileURL := rawURL + "?subtype=" + token
pc, err := onvif.NewClient(profileURL)
if err != nil {
continue
}
rtspURI, err := pc.GetURI()
if err != nil {
continue
}
testOnvifProfile(s, profileURL, rtspURI)
}
}
// testOnvifProfile tests a single RTSP stream and adds two Results (onvif + rtsp)
func testOnvifProfile(s *Session, onvifURL, rtspURL string) {
start := time.Now()
prod, err := rtspHandler(rtspURL)
if err != nil {
return
}
defer func() { _ = prod.Stop() }()
latency := time.Since(start).Milliseconds()
var codecs []string
for _, media := range prod.GetMedias() {
if media.Direction != core.DirectionRecvonly {
continue
}
for _, codec := range media.Codecs {
codecs = append(codecs, codec.Name)
}
}
// capture screenshot
var screenshotPath string
var width, height int
if raw, codecName := getScreenshot(prod); raw != nil {
var jpeg []byte
switch codecName {
case core.CodecH264, core.CodecH265:
jpeg = toJPEG(raw)
default:
jpeg = raw
}
if jpeg != nil {
idx := s.AddScreenshot(jpeg)
screenshotPath = fmt.Sprintf("api/test/screenshot?id=%s&i=%d", s.ID, idx)
width, height = jpegSize(jpeg)
}
}
// add onvif:// result
s.AddResult(&Result{
Source: onvifURL,
Screenshot: screenshotPath,
Codecs: codecs,
Width: width,
Height: height,
LatencyMs: latency,
})
// add rtsp:// result (same screenshot, same codecs)
s.AddResult(&Result{
Source: rtspURL,
Screenshot: screenshotPath,
Codecs: codecs,
Width: width,
Height: height,
LatencyMs: latency,
})
}
+11
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"os/exec"
"strings"
"time"
"github.com/AlexxIT/go2rtc/pkg/core"
@@ -50,6 +51,16 @@ func RunWorkers(s *Session, urls []string) {
func testURL(s *Session, rawURL string) {
defer s.AddTested()
if strings.HasPrefix(rawURL, "homekit://") {
testHomeKit(s, rawURL)
return
}
if strings.HasPrefix(rawURL, "onvif://") {
testOnvif(s, rawURL)
return
}
handler := GetHandler(rawURL)
if handler == nil {
return
+40
View File
@@ -0,0 +1,40 @@
package tester
import (
"fmt"
"time"
"github.com/AlexxIT/go2rtc/pkg/hap"
)
// testHomeKit -- snapshot via HAP GetImage, bypasses SRTP/Producer flow
func testHomeKit(s *Session, rawURL string) {
start := time.Now()
conn, err := hap.Dial(rawURL)
if err != nil {
return
}
defer conn.Close()
jpeg, err := conn.GetImage(1920, 1080)
if err != nil {
return
}
latency := time.Since(start).Milliseconds()
r := &Result{
Source: rawURL,
Codecs: []string{"JPEG"},
LatencyMs: latency,
}
if len(jpeg) > 0 {
idx := s.AddScreenshot(jpeg)
r.Screenshot = fmt.Sprintf("api/test/screenshot?id=%s&i=%d", s.ID, idx)
r.Width, r.Height = jpegSize(jpeg)
}
s.AddResult(r)
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- detect.sh (worker)
#
# Detects system environment: OS type, Docker, Compose, Frigate, go2rtc.
# Fast, silent, returns JSON events to stdout.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, error, done
# - Exit code: 0 always (detection never "fails", it just reports what it finds)
#
# Usage:
# bash scripts/detect.sh
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
# ---------------------------------------------------------------------------
# 1. System type
# ---------------------------------------------------------------------------
detect_system() {
emit "check" "Detecting system"
if command -v pveversion &>/dev/null; then
local pve_ver
pve_ver=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' || echo "unknown")
emit "ok" "Proxmox VE ${pve_ver}" "{\"type\":\"proxmox\",\"pve_version\":\"${pve_ver}\"}"
elif [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
local mac_ver
mac_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
emit "ok" "macOS ${mac_ver} (${arch})" "{\"type\":\"macos\",\"version\":\"${mac_ver}\",\"arch\":\"${arch}\"}"
else
local os_name="Linux"
local os_id="unknown"
local os_ver="unknown"
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
if [[ -f /etc/os-release ]]; then
. /etc/os-release
os_name="${PRETTY_NAME:-Linux}"
os_id="${ID:-unknown}"
os_ver="${VERSION_ID:-unknown}"
fi
emit "ok" "${os_name} (${arch})" "{\"type\":\"linux\",\"id\":\"${os_id}\",\"version\":\"${os_ver}\",\"arch\":\"${arch}\"}"
fi
}
# ---------------------------------------------------------------------------
# 2. Docker
# ---------------------------------------------------------------------------
detect_docker() {
emit "check" "Checking Docker"
if command -v docker &>/dev/null; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Docker ${ver}" "{\"version\":\"${ver}\"}"
else
emit "miss" "Docker not installed"
fi
}
# ---------------------------------------------------------------------------
# 3. Docker Compose
# ---------------------------------------------------------------------------
detect_compose() {
emit "check" "Checking Docker Compose"
if docker compose version &>/dev/null 2>&1; then
local ver
ver=$(docker compose version --short 2>/dev/null || echo "unknown")
emit "ok" "Compose ${ver}" "{\"version\":\"${ver}\",\"type\":\"plugin\"}"
elif command -v docker-compose &>/dev/null; then
local ver
ver=$(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Compose ${ver}" "{\"version\":\"${ver}\",\"type\":\"standalone\"}"
else
emit "miss" "Docker Compose not installed"
fi
}
# ---------------------------------------------------------------------------
# 4. Frigate
# ---------------------------------------------------------------------------
detect_frigate() {
emit "check" "Checking Frigate"
if command -v curl &>/dev/null; then
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then
emit "ok" "Frigate on port 5000" "{\"url\":\"http://localhost:5000\",\"port\":5000}"
return
fi
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:8971/api/config" &>/dev/null; then
emit "ok" "Frigate on port 8971" "{\"url\":\"http://localhost:8971\",\"port\":8971}"
return
fi
fi
emit "miss" "Frigate not found"
}
# ---------------------------------------------------------------------------
# 5. go2rtc
# ---------------------------------------------------------------------------
detect_go2rtc() {
emit "check" "Checking go2rtc"
if command -v curl &>/dev/null; then
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:1984/api" &>/dev/null; then
emit "ok" "go2rtc on port 1984" "{\"url\":\"http://localhost:1984\",\"port\":1984}"
return
fi
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:11984/api" &>/dev/null; then
emit "ok" "go2rtc on port 11984" "{\"url\":\"http://localhost:11984\",\"port\":11984}"
return
fi
fi
emit "miss" "go2rtc not found"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
detect_system
detect_docker
detect_compose
detect_frigate
detect_go2rtc
printf '{"type":"done","ok":true}\n'
}
main
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- linux.sh (navigator for plain Linux / macOS)
# =============================================================================
set -o pipefail
BACKTITLE="Strix Installer | Linux mode"
WT_H=16
WT_W=60
command -v whiptail &>/dev/null || { echo "whiptail required (install: apt install whiptail | dnf install newt)"; exit 1; }
# Dark theme for whiptail
export NEWT_COLORS='
root=,black
window=,black
border=white,black
textbox=white,black
button=black,white
actbutton=white,magenta
compactbutton=white,black
listbox=white,black
actlistbox=white,magenta
title=magenta,black
roottext=white,black
emptyscale=,black
fullscale=,magenta
helpline=white,black
'
# Parameters
INSTALL_MODE=""
FRIGATE_URL=""
GO2RTC_URL=""
STRIX_PORT="4567"
LOG_LEVEL=""
STRIX_TAG="latest"
# ---------------------------------------------------------------------------
# Simple flow
# ---------------------------------------------------------------------------
simple_flow() {
local step=1
while true; do
case $step in
1) # Mode
INSTALL_MODE=$(whiptail --backtitle "$BACKTITLE" --title " Install Mode " \
--menu "" $WT_H $WT_W 3 \
"1" "Strix only" \
"2" "Strix + Frigate" \
"3" "Advanced setup" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
case "$INSTALL_MODE" in
1) INSTALL_MODE="strix"; step=2 ;;
2) INSTALL_MODE="strix-frigate"; step=3 ;;
3) advanced_flow; return ;;
esac
;;
2) # Frigate URL (strix only)
FRIGATE_URL=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):\n\nExample: http://192.168.1.100:5000" \
$WT_H $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$FRIGATE_URL" == "http://" || "$FRIGATE_URL" == "https://" ]] && FRIGATE_URL=""
step=3
;;
3) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" $WT_H $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Advanced flow
# ---------------------------------------------------------------------------
advanced_flow() {
local step=1
INSTALL_MODE="${INSTALL_MODE:-strix}"
while true; do
case $step in
1) # Mode
local choice
choice=$(whiptail --backtitle "$BACKTITLE" --title " Mode " \
--menu "" $WT_H $WT_W 2 \
"strix" "Strix only" \
"strix-frigate" "Strix + Frigate" \
--default-item "$INSTALL_MODE" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
INSTALL_MODE="$choice"; step=2 ;;
2) # Port
local val
val=$(whiptail --backtitle "$BACKTITLE" --title " Port " \
--inputbox "Strix port:" 9 $WT_W "$STRIX_PORT" \
3>&1 1>&2 2>&3) || { step=1; continue; }
STRIX_PORT="${val:-4567}"; step=3 ;;
3) # Frigate
val=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):" 9 $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
FRIGATE_URL="$val"; step=4 ;;
4) # go2rtc
val=$(whiptail --backtitle "$BACKTITLE" --title " go2rtc " \
--inputbox "go2rtc URL (empty to skip):" 9 $WT_W "${GO2RTC_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
GO2RTC_URL="$val"; step=5 ;;
5) # Log level
val=$(whiptail --backtitle "$BACKTITLE" --title " Log Level " \
--menu "" 14 $WT_W 5 \
"" "default (info)" \
"debug" "debug" \
"info" "info" \
"warn" "warn" \
"error" "error" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LOG_LEVEL="$val"; step=6 ;;
6) # Tag
val=$(whiptail --backtitle "$BACKTITLE" --title " Image Tag " \
--inputbox "Strix image tag:" 9 $WT_W "$STRIX_TAG" \
3>&1 1>&2 2>&3) || { step=1; continue; }
STRIX_TAG="${val:-latest}"; step=7 ;;
7) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\nTag: ${STRIX_TAG}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
[[ -n "$GO2RTC_URL" ]] && s+="go2rtc: ${GO2RTC_URL}\n"
[[ -n "$LOG_LEVEL" ]] && s+="Log: ${LOG_LEVEL}\n"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" $WT_H $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
C_RESET="\033[0m"
C_BOLD="\033[1m"
C_DIM="\033[2m"
C_GREEN="\033[32m"
C_RED="\033[31m"
C_YELLOW="\033[33m"
C_CYAN="\033[36m"
C_WHITE="\033[97m"
C_MAGENTA="\033[35m"
# ---------------------------------------------------------------------------
# Worker runner: streams JSON events as status lines
# ---------------------------------------------------------------------------
SCRIPTS_BASE="https://raw.githubusercontent.com/eduard256/Strix/main/scripts"
download_worker() {
local name="$1"
local dest="/tmp/strix-${name}"
curl -fsSL "${SCRIPTS_BASE}/${name}" -o "$dest" 2>/dev/null
echo "$dest"
}
print_events() {
while IFS= read -r line; do
type=""; msg=""
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
}
json_field() {
echo "$1" | grep -oP "\"$2\"\s*:\s*\"\K[^\"]*" | head -1
}
# ---------------------------------------------------------------------------
# LAN IP detection
# ---------------------------------------------------------------------------
detect_lan_ip() {
local ip=""
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1)
[[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -z "$ip" ]] && ip=$(ifconfig 2>/dev/null | grep -oP 'inet \K[0-9.]+' | grep -v '127.0.0.1' | head -1)
[[ -z "$ip" ]] && ip="localhost"
echo "$ip"
}
# ---------------------------------------------------------------------------
# Final URLs
# ---------------------------------------------------------------------------
show_urls() {
local ip="$1"
local port="$2"
local mode="$3"
echo ""
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD} Installation Complete${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo ""
echo -e " ${C_WHITE}${C_BOLD}Strix:${C_RESET} ${C_CYAN}http://${ip}:${port}${C_RESET}"
if [[ "$mode" == "strix-frigate" ]]; then
echo -e " ${C_WHITE}${C_BOLD}Frigate:${C_RESET} ${C_CYAN}http://${ip}:8971${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}Frigate API:${C_RESET} ${C_CYAN}http://${ip}:5000${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}go2rtc:${C_RESET} ${C_CYAN}http://${ip}:1984${C_RESET}"
fi
echo ""
echo -e " ${C_DIM}Press Enter to exit${C_RESET}"
read -r
}
# ---------------------------------------------------------------------------
# Check root / docker -- bail early if not sudo and docker missing
# ---------------------------------------------------------------------------
check_sudo_required() {
if [[ "$(id -u)" -eq 0 ]]; then
return 0 # already root
fi
if command -v docker &>/dev/null; then
return 0 # docker present, maybe root not strictly needed
fi
clear
echo ""
echo -e " ${C_RED}${C_BOLD}Root privileges required${C_RESET}"
echo ""
echo -e " Docker is not installed. Installing it needs root."
echo -e " Please re-run the installer with ${C_BOLD}sudo${C_RESET}:"
echo ""
echo -e " ${C_CYAN}${C_BOLD}curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/scripts/install.sh | sudo bash${C_RESET}"
echo ""
exit 1
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
simple_flow
clear
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}STRIX INSTALLER${C_RESET} ${C_DIM}(Linux)${C_RESET}"
echo -e " ${C_DIM}Mode: ${INSTALL_MODE} | Port: ${STRIX_PORT}${C_RESET}"
echo ""
check_sudo_required
# Step 1: Check Docker / install via prepare.sh
if ! command -v docker &>/dev/null || ! docker compose version &>/dev/null; then
echo -e " ${C_MAGENTA}${C_BOLD}--- Installing Docker ---${C_RESET}"
echo ""
prepare_script=$(download_worker "prepare.sh")
bash "$prepare_script" 2>/dev/null | print_events
rm -f "$prepare_script"
echo ""
fi
# Step 2: Deploy
if [[ "$INSTALL_MODE" == "strix-frigate" ]]; then
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix + Frigate ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix-frigate.sh")
deploy_args="--port $STRIX_PORT --tag $STRIX_TAG"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
[[ -n "$LOG_LEVEL" ]] && deploy_args="$deploy_args --log-level $LOG_LEVEL"
deploy_output=$(bash "$deploy_script" $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | print_events
rm -f "$deploy_script"
else
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix.sh")
deploy_args="--port $STRIX_PORT --tag $STRIX_TAG"
[[ -n "$FRIGATE_URL" ]] && deploy_args="$deploy_args --frigate-url $FRIGATE_URL"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
[[ -n "$LOG_LEVEL" ]] && deploy_args="$deploy_args --log-level $LOG_LEVEL"
deploy_output=$(bash "$deploy_script" $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | print_events
rm -f "$deploy_script"
fi
# Final URLs
deploy_ok=$(echo "$deploy_done" | grep -oP '"ok"\s*:\s*\K[a-z]+' | head -1)
if [[ "$deploy_ok" == "true" ]]; then
lan_ip=$(detect_lan_ip)
show_urls "$lan_ip" "$STRIX_PORT" "$INSTALL_MODE"
else
echo ""
echo -e " ${C_RED}${C_BOLD}Deployment failed.${C_RESET}"
echo ""
fi
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- prepare.sh (worker)
#
# Silent backend worker that prepares the system for Strix deployment.
# Detects OS, installs Docker and Docker Compose if missing.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Field "msg" is always human-readable.
# - Field "data" is optional, carries machine-readable details.
# - Last line is always: {"type":"done","ok":true} or {"type":"done","ok":false,"error":"..."}
# - All internal command output goes to /dev/null or stderr (never stdout).
# - Exit code: 0 = success, 1 = failure.
#
# Usage:
# bash scripts/prepare.sh
# result=$(bash scripts/prepare.sh)
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# JSON helpers (no jq dependency)
# ---------------------------------------------------------------------------
# Emit a JSON event line to stdout.
# Usage: emit "type" "msg" '{"key":"val"}'
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
# Escape double quotes in msg
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
# Emit final done event and exit.
emit_done() {
local ok="$1"
local error="${2:-}"
if [[ "$ok" == "true" ]]; then
printf '{"type":"done","ok":true}\n'
exit 0
else
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
fi
}
# ---------------------------------------------------------------------------
# OS detection
# ---------------------------------------------------------------------------
detect_os() {
emit "check" "Detecting operating system"
local kernel
kernel=$(uname -s 2>/dev/null || echo "unknown")
case "$kernel" in
Linux)
local os_id="unknown"
local os_ver="unknown"
local os_name="Unknown Linux"
if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
os_id="${ID:-unknown}"
os_ver="${VERSION_ID:-unknown}"
os_name="${PRETTY_NAME:-${ID} ${VERSION_ID}}"
fi
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
local arch_label="$arch"
case "$arch" in
x86_64) arch_label="amd64" ;;
aarch64) arch_label="arm64" ;;
armv7l) arch_label="armv7" ;;
esac
OS_TYPE="linux"
OS_ID="$os_id"
OS_VER="$os_ver"
OS_NAME="$os_name"
OS_ARCH="$arch_label"
emit "ok" "${os_name} (${arch_label})" \
"{\"os\":\"linux\",\"id\":\"${os_id}\",\"ver\":\"${os_ver}\",\"arch\":\"${arch_label}\"}"
;;
Darwin)
local mac_ver
mac_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown")
local arch
arch=$(uname -m 2>/dev/null || echo "unknown")
local arch_label="$arch"
case "$arch" in
x86_64) arch_label="amd64" ;;
arm64) arch_label="arm64" ;;
esac
OS_TYPE="mac"
OS_ID="macos"
OS_VER="$mac_ver"
OS_NAME="macOS ${mac_ver}"
OS_ARCH="$arch_label"
emit "ok" "macOS ${mac_ver} (${arch_label})" \
"{\"os\":\"mac\",\"id\":\"macos\",\"ver\":\"${mac_ver}\",\"arch\":\"${arch_label}\"}"
;;
*)
emit "error" "Unsupported OS: ${kernel}" \
"{\"kernel\":\"${kernel}\"}"
emit_done "false" "Unsupported operating system: ${kernel}"
;;
esac
}
# ---------------------------------------------------------------------------
# Root check (Linux only)
# ---------------------------------------------------------------------------
check_root() {
if [[ "$OS_TYPE" == "mac" ]]; then
return
fi
emit "check" "Checking root privileges"
if [[ "$(id -u)" -eq 0 ]]; then
emit "ok" "Running as root"
else
emit "error" "Root privileges required. Run with sudo."
emit_done "false" "Not running as root"
fi
}
# ---------------------------------------------------------------------------
# curl (required for Docker install and compose download)
# ---------------------------------------------------------------------------
ensure_curl() {
emit "check" "Checking curl"
if command -v curl &>/dev/null; then
emit "ok" "curl available"
return 0
fi
emit "miss" "curl not found"
emit "install" "Installing curl"
local pkg_mgr="unknown"
if command -v apt-get &>/dev/null; then
pkg_mgr="apt"
emit "check" "Updating apt package lists"
if ! apt-get update -qq &>/dev/null; then
emit "error" "apt-get update failed"
emit_done "false" "Failed to update package lists"
fi
emit "ok" "Package lists updated"
emit "install" "Installing curl via apt"
apt-get install -y -qq curl &>/dev/null
elif command -v yum &>/dev/null; then
pkg_mgr="yum"
emit "install" "Installing curl via yum"
yum install -y -q curl &>/dev/null
elif command -v dnf &>/dev/null; then
pkg_mgr="dnf"
emit "install" "Installing curl via dnf"
dnf install -y -q curl &>/dev/null
elif command -v apk &>/dev/null; then
pkg_mgr="apk"
emit "install" "Installing curl via apk"
apk add --no-cache curl &>/dev/null
elif command -v pacman &>/dev/null; then
pkg_mgr="pacman"
emit "install" "Installing curl via pacman"
pacman -Sy --noconfirm curl &>/dev/null
elif command -v zypper &>/dev/null; then
pkg_mgr="zypper"
emit "install" "Installing curl via zypper"
zypper install -y curl &>/dev/null
else
emit "error" "No supported package manager found" "{\"tried\":\"apt,yum,dnf,apk,pacman,zypper\"}"
emit_done "false" "Cannot install curl: no supported package manager"
fi
if command -v curl &>/dev/null; then
emit "ok" "curl installed via ${pkg_mgr}"
return 0
fi
emit "error" "curl installation failed via ${pkg_mgr}"
emit_done "false" "curl installation failed"
}
# ---------------------------------------------------------------------------
# Docker
# ---------------------------------------------------------------------------
check_docker() {
emit "check" "Checking Docker"
if command -v docker &>/dev/null; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Docker ${ver}" "{\"version\":\"${ver}\"}"
return 0
fi
emit "miss" "Docker not found"
return 1
}
install_docker_linux() {
emit "install" "Downloading Docker install script from get.docker.com"
local tmp_script="/tmp/get-docker.sh"
if ! curl -fsSL https://get.docker.com -o "$tmp_script" 2>/dev/null; then
emit "error" "Failed to download get.docker.com"
emit_done "false" "Docker download failed"
fi
emit "ok" "Docker install script downloaded"
emit "install" "Running Docker install script (this may take a minute)"
if sh "$tmp_script" &>/dev/null; then
rm -f "$tmp_script"
emit "ok" "Docker install script completed"
else
rm -f "$tmp_script"
emit "error" "Docker install script failed"
emit_done "false" "Docker installation failed"
fi
# Enable and start via systemd
if command -v systemctl &>/dev/null; then
emit "check" "Enabling Docker service"
systemctl enable docker &>/dev/null || true
systemctl start docker &>/dev/null || true
if systemctl is-active docker &>/dev/null; then
emit "ok" "Docker service started"
else
emit "error" "Docker service failed to start"
emit_done "false" "Docker service failed to start"
fi
fi
# Verify docker binary works
if command -v docker &>/dev/null; then
local ver
ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
emit "ok" "Docker ${ver} installed" "{\"version\":\"${ver}\"}"
return 0
fi
emit "error" "Docker binary not found after install"
emit_done "false" "Docker installation failed"
}
install_docker_mac() {
emit "check" "Checking Docker Desktop for Mac"
# Docker Desktop should already be installed on Mac.
# We can't silently install it -- it requires GUI interaction.
emit "error" "Docker not found. Install Docker Desktop from https://docker.com/products/docker-desktop"
emit_done "false" "Docker Desktop not installed on Mac"
}
# ---------------------------------------------------------------------------
# Docker Compose
# ---------------------------------------------------------------------------
check_compose() {
emit "check" "Checking Docker Compose"
# Plugin (v2): docker compose
if docker compose version &>/dev/null; then
local ver
ver=$(docker compose version --short 2>/dev/null || echo "unknown")
COMPOSE_CMD="docker compose"
emit "ok" "Docker Compose ${ver} (plugin)" "{\"version\":\"${ver}\",\"type\":\"plugin\"}"
return 0
fi
# Standalone: docker-compose
if command -v docker-compose &>/dev/null; then
local ver
ver=$(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown")
COMPOSE_CMD="docker-compose"
emit "ok" "Docker Compose ${ver} (standalone)" "{\"version\":\"${ver}\",\"type\":\"standalone\"}"
return 0
fi
emit "miss" "Docker Compose not found"
return 1
}
install_compose_linux() {
emit "install" "Installing Docker Compose plugin"
local installed=false
# Try package manager first
if command -v apt-get &>/dev/null; then
apt-get update -qq &>/dev/null && apt-get install -y -qq docker-compose-plugin &>/dev/null && installed=true
elif command -v yum &>/dev/null; then
yum install -y -q docker-compose-plugin &>/dev/null && installed=true
elif command -v dnf &>/dev/null; then
dnf install -y -q docker-compose-plugin &>/dev/null && installed=true
fi
# Fallback: download binary
if [[ "$installed" == false ]]; then
emit "install" "Downloading Docker Compose binary"
local compose_ver="v2.29.1"
local compose_arch
case "$OS_ARCH" in
amd64) compose_arch="x86_64" ;;
arm64) compose_arch="aarch64" ;;
*) compose_arch="$(uname -m)" ;;
esac
mkdir -p /usr/local/lib/docker/cli-plugins &>/dev/null
if curl -fsSL "https://github.com/docker/compose/releases/download/${compose_ver}/docker-compose-linux-${compose_arch}" \
-o /usr/local/lib/docker/cli-plugins/docker-compose &>/dev/null; then
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
installed=true
fi
fi
# Verify
if [[ "$installed" == true ]] && docker compose version &>/dev/null; then
local ver
ver=$(docker compose version --short 2>/dev/null || echo "unknown")
COMPOSE_CMD="docker compose"
emit "ok" "Docker Compose ${ver} installed" "{\"version\":\"${ver}\"}"
return 0
fi
emit "error" "Docker Compose installation failed"
emit_done "false" "Docker Compose installation failed"
}
install_compose_mac() {
# On Mac, Docker Compose comes with Docker Desktop
emit "error" "Docker Compose not found. It should be included with Docker Desktop."
emit_done "false" "Docker Compose missing on Mac"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Detect OS
detect_os
# 2. Root check
check_root
# 3. curl (needed for Docker install, always present on Mac)
if [[ "$OS_TYPE" == "linux" ]]; then
ensure_curl
fi
# 4. Docker
if ! check_docker; then
case "$OS_TYPE" in
linux) install_docker_linux ;;
mac) install_docker_mac ;;
esac
fi
# 5. Docker Compose
if ! check_compose; then
case "$OS_TYPE" in
linux) install_compose_linux ;;
mac) install_compose_mac ;;
esac
fi
# 6. All good
emit_done "true"
}
main
+606
View File
@@ -0,0 +1,606 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- proxmox-lxc-create.sh (worker)
#
# Creates an unprivileged Ubuntu LXC container on Proxmox with Docker support.
# Runs ON the Proxmox host. Uses only official CLI tools (pct, pveam, pvesm).
# Does NOT install anything inside the container -- just creates and starts it.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Last line: {"type":"done","ok":true,"data":{...}} or {"type":"done","ok":false,"error":"..."}
# - Exit code: 0 = success, 1 = failure.
#
# Parameters (all optional):
# --id ID Container ID (default: auto, next free)
# --hostname NAME Hostname (default: strix)
# --memory MB RAM in MB (default: 2048)
# --swap MB Swap in MB (default: 512)
# --disk GB Disk size in GB (default: 32)
# --cores N CPU cores (default: 2)
# --storage NAME Storage for container disk (default: auto)
# --bridge NAME Network bridge (default: auto, first vmbr*)
# --ip CIDR IP address, e.g. 10.0.99.110/24 (default: dhcp)
# --gateway IP Gateway (required if --ip is static)
# --password PASS Root password (default: auto-generated)
#
# Usage:
# bash scripts/proxmox-lxc-create.sh
# bash scripts/proxmox-lxc-create.sh --hostname strix --memory 4096 --cores 4
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
CT_ID=""
CT_HOSTNAME="strix"
CT_MEMORY="2048"
CT_SWAP="512"
CT_DISK="32"
CT_CORES="2"
CT_STORAGE=""
CT_BRIDGE=""
CT_IP="dhcp"
CT_GATEWAY=""
CT_PASSWORD=""
TEMPLATE_STORAGE=""
TEMPLATE=""
# ---------------------------------------------------------------------------
# Parse CLI arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--id) CT_ID="$2"; shift 2 ;;
--hostname) CT_HOSTNAME="$2"; shift 2 ;;
--memory) CT_MEMORY="$2"; shift 2 ;;
--swap) CT_SWAP="$2"; shift 2 ;;
--disk) CT_DISK="$2"; shift 2 ;;
--cores) CT_CORES="$2"; shift 2 ;;
--storage) CT_STORAGE="$2"; shift 2 ;;
--bridge) CT_BRIDGE="$2"; shift 2 ;;
--ip) CT_IP="$2"; shift 2 ;;
--gateway) CT_GATEWAY="$2"; shift 2 ;;
--password) CT_PASSWORD="$2"; shift 2 ;;
*) shift ;;
esac
done
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
emit_done_ok() {
local data="$1"
printf '{"type":"done","ok":true,"data":%s}\n' "$data"
exit 0
}
emit_done_fail() {
local error="$1"
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
}
# Cleanup on failure: destroy container if it was partially created
cleanup_on_fail() {
local id="$1"
local msg="$2"
if pct status "$id" &>/dev/null; then
pct stop "$id" &>/dev/null || true
pct destroy "$id" --purge &>/dev/null || true
emit "ok" "Rolled back: container ${id} destroyed"
fi
emit "error" "$msg"
emit_done_fail "$msg"
}
# ---------------------------------------------------------------------------
# 1. Verify Proxmox environment
# ---------------------------------------------------------------------------
check_proxmox() {
emit "check" "Verifying Proxmox environment"
if ! command -v pct &>/dev/null; then
emit "error" "pct not found -- this script must run on a Proxmox host"
emit_done_fail "Not a Proxmox host"
fi
if ! command -v pveam &>/dev/null; then
emit "error" "pveam not found -- this script must run on a Proxmox host"
emit_done_fail "Not a Proxmox host"
fi
local pve_ver
pve_ver=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' || echo "unknown")
emit "ok" "Proxmox VE ${pve_ver}" "{\"pve_version\":\"${pve_ver}\"}"
}
# ---------------------------------------------------------------------------
# 2. Auto-detect container ID
# ---------------------------------------------------------------------------
resolve_ct_id() {
emit "check" "Resolving container ID"
if [[ -n "$CT_ID" ]]; then
# Verify it's free
if pct status "$CT_ID" &>/dev/null || qm status "$CT_ID" &>/dev/null; then
emit "error" "Container/VM ID ${CT_ID} is already in use"
emit_done_fail "CT ID ${CT_ID} already in use"
fi
emit "ok" "Using specified ID: ${CT_ID}"
else
CT_ID=$(pvesh get /cluster/nextid 2>/dev/null || echo "")
if [[ -z "$CT_ID" ]]; then
emit "error" "Failed to get next free container ID"
emit_done_fail "Cannot get next free CT ID"
fi
# Double-check it's actually free
if pct status "$CT_ID" &>/dev/null || qm status "$CT_ID" &>/dev/null; then
CT_ID=$((CT_ID + 1))
fi
emit "ok" "Auto-assigned ID: ${CT_ID}" "{\"id\":\"${CT_ID}\"}"
fi
}
# ---------------------------------------------------------------------------
# 3. Auto-detect storage
# ---------------------------------------------------------------------------
resolve_storage() {
# Container storage (rootdir)
emit "check" "Resolving container storage"
if [[ -n "$CT_STORAGE" ]]; then
if ! pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$CT_STORAGE"; then
emit "error" "Storage '${CT_STORAGE}' not found"
emit_done_fail "Storage ${CT_STORAGE} not found"
fi
emit "ok" "Using specified storage: ${CT_STORAGE}"
else
# Find first storage that supports rootdir content
CT_STORAGE=$(pvesm status -content rootdir 2>/dev/null | awk 'NR>1 && $2=="active"{print $1; exit}')
if [[ -z "$CT_STORAGE" ]]; then
# Fallback: try local-lvm, then local
if pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "local-lvm"; then
CT_STORAGE="local-lvm"
else
CT_STORAGE="local"
fi
fi
emit "ok" "Auto-detected storage: ${CT_STORAGE}" "{\"storage\":\"${CT_STORAGE}\"}"
fi
# Template storage (vztmpl)
emit "check" "Resolving template storage"
TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | awk 'NR>1 && $2=="active"{print $1; exit}')
if [[ -z "$TEMPLATE_STORAGE" ]]; then
TEMPLATE_STORAGE="local"
fi
emit "ok" "Template storage: ${TEMPLATE_STORAGE}" "{\"template_storage\":\"${TEMPLATE_STORAGE}\"}"
}
# ---------------------------------------------------------------------------
# 4. Check free space
# ---------------------------------------------------------------------------
check_free_space() {
emit "check" "Checking free space on ${CT_STORAGE}"
local avail_kb
avail_kb=$(pvesm status 2>/dev/null | awk -v s="$CT_STORAGE" '$1==s{print $6}')
if [[ -n "$avail_kb" ]]; then
local avail_gb=$((avail_kb / 1024 / 1024))
local required_gb=$CT_DISK
if [[ "$avail_gb" -lt "$required_gb" ]]; then
emit "error" "Not enough space: ${avail_gb}GB available, ${required_gb}GB required"
emit_done_fail "Not enough disk space on ${CT_STORAGE}"
fi
emit "ok" "${avail_gb}GB available, ${required_gb}GB required"
else
emit "ok" "Could not determine free space, proceeding"
fi
}
# ---------------------------------------------------------------------------
# 5. Auto-detect network bridge
# ---------------------------------------------------------------------------
resolve_bridge() {
emit "check" "Resolving network bridge"
if [[ -n "$CT_BRIDGE" ]]; then
emit "ok" "Using specified bridge: ${CT_BRIDGE}"
return
fi
# Find first vmbr* interface
CT_BRIDGE=$(ip link show 2>/dev/null | grep -oP 'vmbr\d+' | head -1)
if [[ -z "$CT_BRIDGE" ]]; then
CT_BRIDGE="vmbr0"
emit "ok" "Defaulting to bridge: vmbr0"
else
emit "ok" "Auto-detected bridge: ${CT_BRIDGE}" "{\"bridge\":\"${CT_BRIDGE}\"}"
fi
}
# ---------------------------------------------------------------------------
# 6. Generate password
# ---------------------------------------------------------------------------
resolve_password() {
if [[ -n "$CT_PASSWORD" ]]; then
return
fi
emit "check" "Generating root password"
CT_PASSWORD=$(openssl rand -base64 12 2>/dev/null | tr -d '/+=' | head -c 16)
if [[ -z "$CT_PASSWORD" ]]; then
# Fallback if openssl not available
CT_PASSWORD=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 16)
fi
emit "ok" "Root password generated"
}
# ---------------------------------------------------------------------------
# 7. Download Ubuntu template
# ---------------------------------------------------------------------------
download_template() {
emit "check" "Searching for Ubuntu template"
# Check if already downloaded locally
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null \
| awk '$1 ~ /ubuntu-24\.04.*-standard_/ {print $1}' \
| sed 's|.*/||' \
| sort -V \
| tail -1)
if [[ -n "$TEMPLATE" ]]; then
emit "ok" "Template found locally: ${TEMPLATE}"
return
fi
# Not local, try online
emit "miss" "No local Ubuntu 24.04 template"
emit "install" "Updating template catalog"
if command -v timeout &>/dev/null; then
timeout 30 pveam update &>/dev/null || true
else
pveam update &>/dev/null || true
fi
# Search for Ubuntu 24.04
TEMPLATE=$(pveam available --section system 2>/dev/null \
| awk '$2 ~ /ubuntu-24\.04.*-standard_/ {print $2}' \
| sort -V \
| tail -1)
# Fallback to 22.04
if [[ -z "$TEMPLATE" ]]; then
emit "miss" "Ubuntu 24.04 not available, trying 22.04"
TEMPLATE=$(pveam available --section system 2>/dev/null \
| awk '$2 ~ /ubuntu-22\.04.*-standard_/ {print $2}' \
| sort -V \
| tail -1)
fi
if [[ -z "$TEMPLATE" ]]; then
emit "error" "No Ubuntu template found"
emit_done_fail "No Ubuntu template available"
fi
emit "install" "Downloading template: ${TEMPLATE}"
local attempt
for attempt in 1 2 3; do
if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" &>/dev/null; then
emit "ok" "Template downloaded: ${TEMPLATE}"
return
fi
if [[ "$attempt" -lt 3 ]]; then
emit "check" "Download failed, retrying (${attempt}/3)"
sleep $((attempt * 5))
fi
done
emit "error" "Template download failed after 3 attempts"
emit_done_fail "Template download failed"
}
# ---------------------------------------------------------------------------
# 8. Ensure subuid/subgid (required for unprivileged containers)
# ---------------------------------------------------------------------------
fix_subuid_subgid() {
emit "check" "Checking subuid/subgid mappings"
local changed=false
if ! grep -q "root:100000:65536" /etc/subuid 2>/dev/null; then
echo "root:100000:65536" >> /etc/subuid
changed=true
fi
if ! grep -q "root:100000:65536" /etc/subgid 2>/dev/null; then
echo "root:100000:65536" >> /etc/subgid
changed=true
fi
if [[ "$changed" == true ]]; then
emit "ok" "subuid/subgid mappings added"
else
emit "ok" "subuid/subgid mappings present"
fi
}
# ---------------------------------------------------------------------------
# 9. Create container
# ---------------------------------------------------------------------------
create_container() {
emit "install" "Creating LXC container ${CT_ID}"
# Build network string
local net_string="name=eth0,bridge=${CT_BRIDGE}"
if [[ "$CT_IP" == "dhcp" ]]; then
net_string="${net_string},ip=dhcp,ip6=dhcp"
else
net_string="${net_string},ip=${CT_IP}"
[[ -n "$CT_GATEWAY" ]] && net_string="${net_string},gw=${CT_GATEWAY}"
fi
local pct_cmd=(
pct create "$CT_ID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}"
-hostname "$CT_HOSTNAME"
-cores "$CT_CORES"
-memory "$CT_MEMORY"
-swap "$CT_SWAP"
-rootfs "${CT_STORAGE}:${CT_DISK}"
-net0 "$net_string"
-features "nesting=1,keyctl=1"
-unprivileged 1
-onboot 1
-password "$CT_PASSWORD"
)
if "${pct_cmd[@]}" &>/dev/null; then
emit "ok" "Container ${CT_ID} created"
else
# Retry once -- could be race condition on ID
if pct status "$CT_ID" &>/dev/null; then
emit "error" "Container ID ${CT_ID} was claimed by another process"
CT_ID=$((CT_ID + 1))
pct_cmd[2]="$CT_ID"
if "${pct_cmd[@]}" &>/dev/null; then
emit "ok" "Container ${CT_ID} created (reassigned ID)"
else
emit "error" "Container creation failed"
emit_done_fail "pct create failed"
fi
else
emit "error" "Container creation failed"
emit_done_fail "pct create failed"
fi
fi
}
# ---------------------------------------------------------------------------
# 10. Start container
# ---------------------------------------------------------------------------
start_container() {
emit "install" "Starting container ${CT_ID}"
if pct start "$CT_ID" &>/dev/null; then
emit "ok" "Container ${CT_ID} started"
else
cleanup_on_fail "$CT_ID" "Failed to start container ${CT_ID}"
fi
}
# ---------------------------------------------------------------------------
# 11. Setup autologin for Proxmox console
# ---------------------------------------------------------------------------
setup_autologin() {
emit "check" "Configuring console autologin"
# Wait a moment for systemd to initialize inside the container
sleep 2
pct exec "$CT_ID" -- bash -c '
mkdir -p /etc/systemd/system/container-getty@1.service.d
cat > /etc/systemd/system/container-getty@1.service.d/override.conf <<AUTOLOGIN
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 \$TERM
AUTOLOGIN
systemctl daemon-reload
systemctl restart container-getty@1.service
' &>/dev/null
if [[ $? -eq 0 ]]; then
emit "ok" "Console autologin enabled"
else
# Non-fatal -- container works fine without it
emit "ok" "Console autologin skipped (non-critical)"
fi
}
# ---------------------------------------------------------------------------
# 12. Select fastest apt mirror and update
# ---------------------------------------------------------------------------
setup_apt_mirror() {
emit "check" "Selecting fastest apt mirror"
# Wait for network inside container first
local net_ready=false
for (( i = 1; i <= 15; i++ )); do
if pct exec "$CT_ID" -- ping -c 1 -W 2 archive.ubuntu.com &>/dev/null; then
net_ready=true
break
fi
sleep 1
done
if [[ "$net_ready" == false ]]; then
emit "ok" "Network not ready, skipping mirror selection"
return
fi
# Ping mirrors in parallel, pick fastest
local best_mirror="archive.ubuntu.com"
local best_time=9999
local mirrors=(
"archive.ubuntu.com"
"mirror.yandex.ru"
"de.archive.ubuntu.com"
"nl.archive.ubuntu.com"
"us.archive.ubuntu.com"
"mirror.linux-ia64.org"
)
local tmpdir
tmpdir=$(pct exec "$CT_ID" -- mktemp -d 2>/dev/null || echo "/tmp/mirror-test")
# Launch all pings in parallel inside the container
pct exec "$CT_ID" -- bash -c "
mkdir -p ${tmpdir}
for m in ${mirrors[*]}; do
(ping -c 1 -W 2 \$m 2>/dev/null | grep -oP 'time=\K[0-9.]+' > ${tmpdir}/\$m || echo 9999 > ${tmpdir}/\$m) &
done
wait
" &>/dev/null
# Read results
for m in "${mirrors[@]}"; do
local ms
ms=$(pct exec "$CT_ID" -- cat "${tmpdir}/${m}" 2>/dev/null | head -1)
ms="${ms:-9999}"
# Compare as integers (strip decimal)
local ms_int="${ms%%.*}"
ms_int="${ms_int:-9999}"
if [[ "$ms_int" -lt "$best_time" ]]; then
best_time="$ms_int"
best_mirror="$m"
fi
done
# Cleanup
pct exec "$CT_ID" -- rm -rf "$tmpdir" &>/dev/null
emit "ok" "Fastest mirror: ${best_mirror} (${best_time}ms)" "{\"mirror\":\"${best_mirror}\",\"latency_ms\":${best_time}}"
# Apply mirror if different from default
if [[ "$best_mirror" != "archive.ubuntu.com" ]]; then
emit "install" "Configuring apt mirror: ${best_mirror}"
pct exec "$CT_ID" -- bash -c "
sed -i 's|http://archive.ubuntu.com|http://${best_mirror}|g' /etc/apt/sources.list
" &>/dev/null
emit "ok" "Apt mirror set to ${best_mirror}"
fi
# Run apt update
emit "install" "Updating package lists"
if pct exec "$CT_ID" -- bash -c "apt-get update -qq" &>/dev/null; then
emit "ok" "Package lists updated"
else
emit "ok" "Package lists update had warnings (non-critical)"
fi
}
# ---------------------------------------------------------------------------
# 13. Wait for network and get IP
# ---------------------------------------------------------------------------
wait_for_network() {
emit "check" "Waiting for network"
local ip=""
local retries=30
for (( i = 1; i <= retries; i++ )); do
ip=$(pct exec "$CT_ID" -- ip -4 -o addr show dev eth0 2>/dev/null \
| awk '{print $4}' \
| cut -d/ -f1 \
| head -1)
if [[ -n "$ip" && "$ip" != "127.0.0.1" ]]; then
emit "ok" "Container IP: ${ip}" "{\"ip\":\"${ip}\"}"
CT_ACTUAL_IP="$ip"
return
fi
sleep 1
done
# Fallback: no IP but container is running
CT_ACTUAL_IP="unknown"
emit "ok" "Container running but IP not detected (check network manually)"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Verify we're on Proxmox
check_proxmox
# 2. Container ID
resolve_ct_id
# 3. Storage
resolve_storage
# 4. Free space
check_free_space
# 5. Network bridge
resolve_bridge
# 6. Password
resolve_password
# 7. Template
download_template
# 8. subuid/subgid
fix_subuid_subgid
# 9. Create
create_container
# 10. Start
start_container
# 11. Autologin
setup_autologin
# 12. Apt mirror + update
setup_apt_mirror
# 13. Network
wait_for_network
# 14. Done
emit_done_ok "{\"id\":\"${CT_ID}\",\"hostname\":\"${CT_HOSTNAME}\",\"ip\":\"${CT_ACTUAL_IP}\",\"password\":\"${CT_PASSWORD}\",\"memory\":\"${CT_MEMORY}\",\"swap\":\"${CT_SWAP}\",\"disk\":\"${CT_DISK}\",\"cores\":\"${CT_CORES}\",\"storage\":\"${CT_STORAGE}\",\"bridge\":\"${CT_BRIDGE}\",\"template\":\"${TEMPLATE}\"}"
}
main
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- proxmox.sh (navigator for Proxmox)
# =============================================================================
set -o pipefail
BACKTITLE="Strix Installer | Proxmox mode"
WT_H=16
WT_W=60
command -v whiptail &>/dev/null || { echo "whiptail required"; exit 1; }
# Dark theme for whiptail
export NEWT_COLORS='
root=,black
window=,black
border=white,black
textbox=white,black
button=black,white
actbutton=white,magenta
compactbutton=white,black
listbox=white,black
actlistbox=white,magenta
title=magenta,black
roottext=white,black
emptyscale=,black
fullscale=,magenta
helpline=white,black
'
# Parameters
INSTALL_MODE=""
FRIGATE_URL=""
GO2RTC_URL=""
STRIX_PORT="4567"
LXC_HOSTNAME="strix"
LXC_MEMORY="2048"
LXC_CORES="2"
LXC_DISK="32"
LXC_SWAP="512"
LXC_IP="dhcp"
LXC_GATEWAY=""
LXC_BRIDGE=""
LXC_STORAGE=""
# ---------------------------------------------------------------------------
# Simple flow
# ---------------------------------------------------------------------------
simple_flow() {
local step=1
while true; do
case $step in
1) # Mode
INSTALL_MODE=$(whiptail --backtitle "$BACKTITLE" --title " Install Mode " \
--menu "" $WT_H $WT_W 3 \
"1" "Strix only" \
"2" "Strix + Frigate" \
"3" "Advanced setup" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
case "$INSTALL_MODE" in
1) INSTALL_MODE="strix"; step=2 ;;
2) INSTALL_MODE="strix-frigate"; step=3 ;;
3) advanced_flow; return ;;
esac
;;
2) # Frigate URL (strix only)
FRIGATE_URL=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):\n\nExample: http://192.168.1.100:5000" \
$WT_H $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$FRIGATE_URL" == "http://" || "$FRIGATE_URL" == "https://" ]] && FRIGATE_URL=""
step=3
;;
3) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
s+="\nLXC: auto (${LXC_HOSTNAME}, ${LXC_MEMORY}MB, ${LXC_CORES}cpu, ${LXC_DISK}GB)"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" $WT_H $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Advanced flow
# ---------------------------------------------------------------------------
advanced_flow() {
local step=1
INSTALL_MODE="${INSTALL_MODE:-strix}"
while true; do
case $step in
1) # Mode
local choice
choice=$(whiptail --backtitle "$BACKTITLE" --title " Mode " \
--menu "" $WT_H $WT_W 2 \
"strix" "Strix only" \
"strix-frigate" "Strix + Frigate" \
--default-item "$INSTALL_MODE" \
3>&1 1>&2 2>&3) || { clear; exit 0; }
INSTALL_MODE="$choice"; step=2 ;;
2) # Port
local val
val=$(whiptail --backtitle "$BACKTITLE" --title " Port " \
--inputbox "Strix port:" 9 $WT_W "$STRIX_PORT" \
3>&1 1>&2 2>&3) || { step=1; continue; }
STRIX_PORT="${val:-4567}"; step=3 ;;
3) # Frigate
val=$(whiptail --backtitle "$BACKTITLE" --title " Frigate " \
--inputbox "Frigate URL (empty to skip):" 9 $WT_W "${FRIGATE_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
FRIGATE_URL="$val"; step=4 ;;
4) # go2rtc
val=$(whiptail --backtitle "$BACKTITLE" --title " go2rtc " \
--inputbox "go2rtc URL (empty to skip):" 9 $WT_W "${GO2RTC_URL:-http://}" \
3>&1 1>&2 2>&3) || { step=1; continue; }
[[ "$val" == "http://" || "$val" == "https://" ]] && val=""
GO2RTC_URL="$val"; step=5 ;;
5) # Hostname
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Hostname " \
--inputbox "Hostname:" 9 $WT_W "$LXC_HOSTNAME" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_HOSTNAME="${val:-strix}"; step=6 ;;
6) # RAM
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC RAM " \
--inputbox "RAM (MB):" 9 $WT_W "$LXC_MEMORY" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_MEMORY="${val:-2048}"; step=7 ;;
7) # CPU
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC CPU " \
--inputbox "CPU cores:" 9 $WT_W "$LXC_CORES" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_CORES="${val:-2}"; step=8 ;;
8) # Disk
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Disk " \
--inputbox "Disk (GB):" 9 $WT_W "$LXC_DISK" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_DISK="${val:-32}"; step=9 ;;
9) # Swap
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Swap " \
--inputbox "Swap (MB):" 9 $WT_W "$LXC_SWAP" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_SWAP="${val:-512}"; step=10 ;;
10) # IP
val=$(whiptail --backtitle "$BACKTITLE" --title " LXC Network " \
--inputbox "IP (dhcp or CIDR e.g. 10.0.20.110/24):" 9 $WT_W "$LXC_IP" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_IP="${val:-dhcp}"
[[ "$LXC_IP" != "dhcp" ]] && step=11 || step=12
;;
11) # Gateway
val=$(whiptail --backtitle "$BACKTITLE" --title " Gateway " \
--inputbox "Gateway:" 9 $WT_W "$LXC_GATEWAY" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_GATEWAY="$val"; step=12 ;;
12) # Bridge
val=$(whiptail --backtitle "$BACKTITLE" --title " Bridge " \
--inputbox "Network bridge (empty=auto):" 9 $WT_W "$LXC_BRIDGE" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_BRIDGE="$val"; step=13 ;;
13) # Storage
val=$(whiptail --backtitle "$BACKTITLE" --title " Storage " \
--inputbox "Storage (empty=auto):" 9 $WT_W "$LXC_STORAGE" \
3>&1 1>&2 2>&3) || { step=1; continue; }
LXC_STORAGE="$val"; step=14 ;;
14) # Confirm
local s="Mode: ${INSTALL_MODE}\nPort: ${STRIX_PORT}\n"
[[ -n "$FRIGATE_URL" ]] && s+="Frigate: ${FRIGATE_URL}\n"
[[ -n "$GO2RTC_URL" ]] && s+="go2rtc: ${GO2RTC_URL}\n"
s+="\nLXC:\n"
s+=" ${LXC_HOSTNAME} | ${LXC_MEMORY}MB | ${LXC_CORES}cpu | ${LXC_DISK}GB\n"
s+=" IP: ${LXC_IP}"
[[ -n "$LXC_GATEWAY" ]] && s+=" gw ${LXC_GATEWAY}"
s+="\n"
[[ -n "$LXC_BRIDGE" ]] && s+=" Bridge: ${LXC_BRIDGE}\n" || s+=" Bridge: auto\n"
[[ -n "$LXC_STORAGE" ]] && s+=" Storage: ${LXC_STORAGE}\n" || s+=" Storage: auto\n"
whiptail --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "$s" 18 $WT_W || { step=1; continue; }
break
;;
esac
done
}
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
C_RESET="\033[0m"
C_BOLD="\033[1m"
C_DIM="\033[2m"
C_GREEN="\033[32m"
C_RED="\033[31m"
C_YELLOW="\033[33m"
C_CYAN="\033[36m"
C_WHITE="\033[97m"
C_MAGENTA="\033[35m"
# ---------------------------------------------------------------------------
# Worker runner: streams JSON events and prints status lines
# ---------------------------------------------------------------------------
SCRIPTS_BASE="https://raw.githubusercontent.com/eduard256/Strix/main/scripts"
# Download a worker script to /tmp
download_worker() {
local name="$1"
local dest="/tmp/strix-${name}"
curl -fsSL "${SCRIPTS_BASE}/${name}" -o "$dest" 2>/dev/null
echo "$dest"
}
# Run a worker and display its JSON events as status lines
run_worker() {
local script="$1"
shift
local label="$1"
shift
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- ${label} ---${C_RESET}"
echo ""
bash "$script" "$@" 2>/dev/null | while IFS= read -r line; do
type=""; msg=""
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
done) ;; # handled after loop
esac
done
echo ""
}
# Extract a field from JSON done line
json_field() {
echo "$1" | grep -oP "\"$2\"\s*:\s*\"\K[^\"]*" | head -1
}
# ---------------------------------------------------------------------------
# Show final URLs
# ---------------------------------------------------------------------------
show_urls() {
local ip="$1"
local port="$2"
local mode="$3"
echo ""
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD} Installation Complete${C_RESET}"
echo -e " ${C_GREEN}${C_BOLD}====================================${C_RESET}"
echo ""
echo -e " ${C_WHITE}${C_BOLD}Strix:${C_RESET} ${C_CYAN}http://${ip}:${port}${C_RESET}"
if [[ "$mode" == "strix-frigate" ]]; then
echo -e " ${C_WHITE}${C_BOLD}Frigate:${C_RESET} ${C_CYAN}http://${ip}:8971${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}Frigate API:${C_RESET} ${C_CYAN}http://${ip}:5000${C_RESET}"
echo -e " ${C_WHITE}${C_BOLD}go2rtc:${C_RESET} ${C_CYAN}http://${ip}:1984${C_RESET}"
fi
echo ""
echo -e " ${C_DIM}Press Enter to exit${C_RESET}"
read -r
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
simple_flow
clear
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}STRIX INSTALLER${C_RESET}"
echo -e " ${C_DIM}Mode: ${INSTALL_MODE} | Port: ${STRIX_PORT}${C_RESET}"
echo ""
# Step 1: Create LXC container
echo -e " ${C_MAGENTA}${C_BOLD}--- Creating LXC Container ---${C_RESET}"
echo ""
lxc_script=$(download_worker "proxmox-lxc-create.sh")
lxc_args=""
[[ -n "$LXC_HOSTNAME" ]] && lxc_args="$lxc_args --hostname $LXC_HOSTNAME"
[[ -n "$LXC_MEMORY" ]] && lxc_args="$lxc_args --memory $LXC_MEMORY"
[[ -n "$LXC_CORES" ]] && lxc_args="$lxc_args --cores $LXC_CORES"
[[ -n "$LXC_DISK" ]] && lxc_args="$lxc_args --disk $LXC_DISK"
[[ -n "$LXC_SWAP" ]] && lxc_args="$lxc_args --swap $LXC_SWAP"
[[ -n "$LXC_BRIDGE" ]] && lxc_args="$lxc_args --bridge $LXC_BRIDGE"
[[ -n "$LXC_STORAGE" ]] && lxc_args="$lxc_args --storage $LXC_STORAGE"
[[ "$LXC_IP" != "dhcp" && -n "$LXC_IP" ]] && lxc_args="$lxc_args --ip $LXC_IP"
[[ -n "$LXC_GATEWAY" ]] && lxc_args="$lxc_args --gateway $LXC_GATEWAY"
# Run LXC creation and capture full output
lxc_output=$(bash "$lxc_script" $lxc_args 2>/dev/null)
lxc_done=$(echo "$lxc_output" | grep '"type":"done"')
# Display LXC creation events
echo "$lxc_output" | while IFS= read -r line; do
type=""; msg=""
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
# Check if LXC creation succeeded
lxc_ok=$(echo "$lxc_done" | grep -oP '"ok"\s*:\s*\K[a-z]+' | head -1)
if [[ "$lxc_ok" != "true" ]]; then
echo ""
echo -e " ${C_RED}${C_BOLD}LXC creation failed. Aborting.${C_RESET}"
rm -f "$lxc_script"
exit 1
fi
# Extract LXC data
CT_ID=$(json_field "$lxc_done" "id")
CT_IP=$(json_field "$lxc_done" "ip")
CT_PASS=$(json_field "$lxc_done" "password")
echo ""
echo -e " ${C_GREEN}${C_BOLD}LXC ${CT_ID} ready${C_RESET} -- IP: ${C_WHITE}${CT_IP}${C_RESET}"
# Step 2: Run prepare.sh inside LXC (install Docker)
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- Installing Docker ---${C_RESET}"
echo ""
prepare_script=$(download_worker "prepare.sh")
pct push "$CT_ID" "$prepare_script" /tmp/prepare.sh &>/dev/null
pct exec "$CT_ID" -- bash /tmp/prepare.sh 2>/dev/null | while IFS= read -r line; do
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
# Step 3: Deploy Strix (or Strix + Frigate)
if [[ "$INSTALL_MODE" == "strix-frigate" ]]; then
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix + Frigate ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix-frigate.sh")
pct push "$CT_ID" "$deploy_script" /tmp/deploy.sh &>/dev/null
deploy_args="--port $STRIX_PORT"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
deploy_output=$(pct exec "$CT_ID" -- bash /tmp/deploy.sh $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | while IFS= read -r line; do
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
else
echo ""
echo -e " ${C_MAGENTA}${C_BOLD}--- Deploying Strix ---${C_RESET}"
echo ""
deploy_script=$(download_worker "strix.sh")
pct push "$CT_ID" "$deploy_script" /tmp/deploy.sh &>/dev/null
deploy_args="--port $STRIX_PORT"
[[ -n "$FRIGATE_URL" ]] && deploy_args="$deploy_args --frigate-url $FRIGATE_URL"
[[ -n "$GO2RTC_URL" ]] && deploy_args="$deploy_args --go2rtc-url $GO2RTC_URL"
deploy_output=$(pct exec "$CT_ID" -- bash /tmp/deploy.sh $deploy_args 2>/dev/null)
deploy_done=$(echo "$deploy_output" | grep '"type":"done"')
echo "$deploy_output" | while IFS= read -r line; do
type=$(echo "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' | head -1)
msg=$(echo "$line" | grep -oP '"msg"\s*:\s*"\K[^"]+' | head -1)
case "$type" in
check) echo -e " ${C_CYAN}[..]${C_RESET} ${msg}" ;;
ok) echo -e " ${C_GREEN}[OK]${C_RESET} ${msg}" ;;
miss) echo -e " ${C_YELLOW}[--]${C_RESET} ${msg}" ;;
install) echo -e " ${C_CYAN}[>>]${C_RESET} ${msg}" ;;
error) echo -e " ${C_RED}[XX]${C_RESET} ${msg}" ;;
esac
done
fi
# Show final URLs
deploy_ok=$(echo "$deploy_done" | grep -oP '"ok"\s*:\s*\K[a-z]+' | head -1)
if [[ "$deploy_ok" == "true" ]]; then
show_urls "$CT_IP" "$STRIX_PORT" "$INSTALL_MODE"
else
echo ""
echo -e " ${C_RED}${C_BOLD}Deployment failed.${C_RESET}"
echo -e " ${C_DIM}LXC ${CT_ID} (${CT_IP}) is still running. Check logs inside.${C_RESET}"
echo ""
fi
# Cleanup
rm -f "$lxc_script" "$prepare_script" "$deploy_script" 2>/dev/null
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- strix-frigate.sh (worker)
#
# Deploys Strix + Frigate together via Docker Compose.
# Generates docker-compose.yml dynamically (devices depend on hardware),
# creates .env, pulls images, starts containers, runs healthchecks.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Last line is always: {"type":"done","ok":true,...} or {"type":"done","ok":false,"error":"..."}
# - Exit code: 0 = success, 1 = failure.
#
# Parameters (all optional):
# --port PORT Strix listen port (default: 4567)
# --tag TAG Strix image tag (default: latest)
# --log-level LEVEL Log level: debug, info, warn, error, trace
# --go2rtc-url URL External go2rtc URL
# --shm-size SIZE Frigate shm_size (default: 512mb)
# --frigate-tag TAG Frigate image tag (default: stable)
# --dir DIR Working directory (default: /opt/strix)
#
# Usage:
# bash scripts/strix-frigate.sh
# bash scripts/strix-frigate.sh --port 4567 --frigate-tag stable-tensorrt
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
STRIX_PORT="4567"
STRIX_TAG="latest"
STRIX_LOG_LEVEL=""
STRIX_GO2RTC_URL=""
FRIGATE_SHM="512mb"
FRIGATE_TAG="stable"
STRIX_DIR="/opt/strix"
STRIX_IMAGE="eduard256/strix"
FRIGATE_IMAGE="ghcr.io/blakeblackshear/frigate"
# Detected devices (populated by detect_devices)
DEVICES=()
DEVICE_NAMES=()
# ---------------------------------------------------------------------------
# Parse CLI arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--port) STRIX_PORT="$2"; shift 2 ;;
--tag) STRIX_TAG="$2"; shift 2 ;;
--log-level) STRIX_LOG_LEVEL="$2"; shift 2 ;;
--go2rtc-url) STRIX_GO2RTC_URL="$2"; shift 2 ;;
--shm-size) FRIGATE_SHM="$2"; shift 2 ;;
--frigate-tag) FRIGATE_TAG="$2"; shift 2 ;;
--dir) STRIX_DIR="$2"; shift 2 ;;
*) shift ;;
esac
done
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
emit_done_ok() {
# Accepts raw JSON data string
local data="$1"
printf '{"type":"done","ok":true,"data":%s}\n' "$data"
exit 0
}
emit_done_fail() {
local error="$1"
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
}
# ---------------------------------------------------------------------------
# Detect LAN IP
# ---------------------------------------------------------------------------
detect_lan_ip() {
local ip=""
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1)
[[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -z "$ip" ]] && ip="localhost"
echo "$ip"
}
# ---------------------------------------------------------------------------
# 1. Working directory
# ---------------------------------------------------------------------------
setup_dir() {
emit "check" "Checking working directory ${STRIX_DIR}"
if [[ -d "$STRIX_DIR" ]]; then
emit "ok" "Directory exists: ${STRIX_DIR}"
else
emit "install" "Creating directory ${STRIX_DIR}"
if mkdir -p "$STRIX_DIR" 2>/dev/null; then
emit "ok" "Directory created: ${STRIX_DIR}"
else
emit "error" "Failed to create directory ${STRIX_DIR}"
emit_done_fail "Cannot create ${STRIX_DIR}"
fi
fi
# Frigate subdirectories
emit "check" "Checking Frigate directories"
mkdir -p "${STRIX_DIR}/frigate/config" 2>/dev/null
mkdir -p "${STRIX_DIR}/frigate/storage" 2>/dev/null
if [[ -d "${STRIX_DIR}/frigate/config" ]] && [[ -d "${STRIX_DIR}/frigate/storage" ]]; then
emit "ok" "Frigate directories ready"
else
emit "error" "Failed to create Frigate directories"
emit_done_fail "Cannot create Frigate directories"
fi
}
# ---------------------------------------------------------------------------
# 2. Detect hardware devices
# ---------------------------------------------------------------------------
detect_devices() {
emit "check" "Detecting hardware accelerators"
local found=0
# USB Coral
emit "check" "Checking for USB Coral"
if command -v lsusb &>/dev/null && lsusb 2>/dev/null | grep -qE "1a6e:089a|18d1:9302"; then
DEVICES+=("/dev/bus/usb:/dev/bus/usb")
DEVICE_NAMES+=("usb_coral")
emit "ok" "USB Coral detected" "{\"device\":\"usb_coral\",\"path\":\"/dev/bus/usb\"}"
found=$((found + 1))
else
emit "miss" "USB Coral not found"
fi
# PCIe Coral
emit "check" "Checking for PCIe Coral"
if [[ -e /dev/apex_0 ]]; then
DEVICES+=("/dev/apex_0:/dev/apex_0")
DEVICE_NAMES+=("pcie_coral")
emit "ok" "PCIe Coral detected" "{\"device\":\"pcie_coral\",\"path\":\"/dev/apex_0\"}"
found=$((found + 1))
else
emit "miss" "PCIe Coral not found"
fi
# Intel / AMD GPU
emit "check" "Checking for Intel/AMD GPU"
if [[ -e /dev/dri/renderD128 ]]; then
DEVICES+=("/dev/dri:/dev/dri")
DEVICE_NAMES+=("gpu")
emit "ok" "GPU detected (Intel/AMD)" "{\"device\":\"gpu\",\"path\":\"/dev/dri\"}"
found=$((found + 1))
else
emit "miss" "Intel/AMD GPU not found"
fi
# Intel NPU
emit "check" "Checking for Intel NPU"
if [[ -e /dev/accel ]]; then
DEVICES+=("/dev/accel:/dev/accel")
DEVICE_NAMES+=("intel_npu")
emit "ok" "Intel NPU detected" "{\"device\":\"intel_npu\",\"path\":\"/dev/accel\"}"
found=$((found + 1))
else
emit "miss" "Intel NPU not found"
fi
# Raspberry Pi 4 video
emit "check" "Checking for Raspberry Pi video device"
if [[ -e /dev/video11 ]]; then
DEVICES+=("/dev/video11:/dev/video11")
DEVICE_NAMES+=("rpi_video")
emit "ok" "Raspberry Pi video device detected" "{\"device\":\"rpi_video\",\"path\":\"/dev/video11\"}"
found=$((found + 1))
else
emit "miss" "Raspberry Pi video device not found"
fi
if [[ "$found" -eq 0 ]]; then
emit "ok" "No hardware accelerators found, using CPU only"
else
emit "ok" "${found} hardware accelerator(s) detected"
fi
}
# ---------------------------------------------------------------------------
# 3. Generate docker-compose.yml
# ---------------------------------------------------------------------------
generate_compose() {
emit "check" "Generating docker-compose.yml"
# Build devices section
local devices_block=""
if [[ ${#DEVICES[@]} -gt 0 ]]; then
devices_block=" devices:"
for dev in "${DEVICES[@]}"; do
devices_block="${devices_block}
- ${dev}"
done
fi
# Build compose file
cat > "${STRIX_DIR}/docker-compose.yml" <<EOF
# Strix + Frigate
# Generated by strix-frigate.sh
services:
strix:
container_name: strix
image: ${STRIX_IMAGE}:\${STRIX_TAG:-latest}
network_mode: host
restart: unless-stopped
env_file: .env
depends_on:
frigate:
condition: service_started
frigate:
container_name: frigate
image: ${FRIGATE_IMAGE}:${FRIGATE_TAG}
privileged: true
network_mode: host
restart: unless-stopped
stop_grace_period: 30s
shm_size: "${FRIGATE_SHM}"
${devices_block}
volumes:
- /etc/localtime:/etc/localtime:ro
- ./frigate/config:/config
- ./frigate/storage:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
environment:
FRIGATE_RTSP_PASSWORD: "password"
EOF
emit "ok" "docker-compose.yml generated" "{\"frigate_tag\":\"${FRIGATE_TAG}\",\"shm_size\":\"${FRIGATE_SHM}\"}"
}
# ---------------------------------------------------------------------------
# 4. Generate .env
# ---------------------------------------------------------------------------
generate_env() {
emit "check" "Generating .env configuration"
cat > "${STRIX_DIR}/.env" <<EOF
# Strix configuration -- generated by strix-frigate.sh
STRIX_TAG=${STRIX_TAG}
STRIX_LISTEN=:${STRIX_PORT}
STRIX_FRIGATE_URL=http://localhost:5000
EOF
emit "ok" "Frigate URL: http://localhost:5000 (internal API)"
if [[ -n "$STRIX_GO2RTC_URL" ]]; then
echo "STRIX_GO2RTC_URL=${STRIX_GO2RTC_URL}" >> "${STRIX_DIR}/.env"
emit "ok" "go2rtc URL: ${STRIX_GO2RTC_URL}"
fi
if [[ -n "$STRIX_LOG_LEVEL" ]]; then
echo "STRIX_LOG_LEVEL=${STRIX_LOG_LEVEL}" >> "${STRIX_DIR}/.env"
emit "ok" "Log level: ${STRIX_LOG_LEVEL}"
fi
emit "ok" ".env generated (port ${STRIX_PORT})"
}
# ---------------------------------------------------------------------------
# 5. Pull images
# ---------------------------------------------------------------------------
pull_images() {
emit "check" "Pulling Frigate image ${FRIGATE_IMAGE}:${FRIGATE_TAG} (this may take a while)"
if docker pull "${FRIGATE_IMAGE}:${FRIGATE_TAG}" &>/dev/null; then
emit "ok" "Frigate image pulled: ${FRIGATE_TAG}"
else
emit "error" "Failed to pull Frigate image ${FRIGATE_IMAGE}:${FRIGATE_TAG}"
emit_done_fail "Frigate image pull failed"
fi
emit "check" "Pulling Strix image ${STRIX_IMAGE}:${STRIX_TAG}"
if docker pull "${STRIX_IMAGE}:${STRIX_TAG}" &>/dev/null; then
emit "ok" "Strix image pulled: ${STRIX_TAG}"
else
emit "error" "Failed to pull Strix image ${STRIX_IMAGE}:${STRIX_TAG}"
emit_done_fail "Strix image pull failed"
fi
}
# ---------------------------------------------------------------------------
# 6. Start containers
# ---------------------------------------------------------------------------
start_containers() {
local running_frigate=false
local running_strix=false
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^frigate$' && running_frigate=true
docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^strix$' && running_strix=true
if [[ "$running_frigate" == true ]] || [[ "$running_strix" == true ]]; then
emit "check" "Existing containers found, recreating"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" up -d --force-recreate &>/dev/null; then
emit "ok" "Containers recreated"
else
emit "error" "Failed to recreate containers"
emit_done_fail "Container recreate failed"
fi
else
emit "install" "Starting Frigate and Strix containers"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" up -d &>/dev/null; then
emit "ok" "Containers started"
else
emit "error" "Failed to start containers"
emit_done_fail "Container start failed"
fi
fi
}
# ---------------------------------------------------------------------------
# 7. Healthchecks
# ---------------------------------------------------------------------------
healthcheck_frigate() {
emit "check" "Waiting for Frigate to respond on port 5000"
local retries=30
for (( i = 1; i <= retries; i++ )); do
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then
emit "ok" "Frigate is running on port 5000"
return 0
fi
sleep 2
done
emit "error" "Frigate healthcheck failed after ${retries} attempts"
emit_done_fail "Frigate healthcheck failed"
}
healthcheck_strix() {
emit "check" "Waiting for Strix to respond on port ${STRIX_PORT}"
local retries=15
for (( i = 1; i <= retries; i++ )); do
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${STRIX_PORT}/api/health" &>/dev/null; then
local version
version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")
emit "ok" "Strix v${version} is running on port ${STRIX_PORT}" "{\"version\":\"${version}\"}"
return 0
fi
sleep 1
done
emit "error" "Strix healthcheck failed after ${retries} attempts"
emit_done_fail "Strix healthcheck failed"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Working directory
setup_dir
# 2. Detect hardware
detect_devices
# 3. Generate compose (with detected devices)
generate_compose
# 4. Generate .env
generate_env
# 5. Pull images
pull_images
# 6. Start containers
start_containers
# 7. Healthchecks
healthcheck_frigate
healthcheck_strix
# 8. Done -- all URLs
local lan_ip
lan_ip=$(detect_lan_ip)
local strix_version
strix_version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")
# Build device names JSON array
local devices_json="["
local first=true
for name in "${DEVICE_NAMES[@]}"; do
[[ "$first" == true ]] && first=false || devices_json="${devices_json},"
devices_json="${devices_json}\"${name}\""
done
devices_json="${devices_json}]"
emit_done_ok "{\"ip\":\"${lan_ip}\",\"strix_url\":\"http://${lan_ip}:${STRIX_PORT}\",\"strix_version\":\"${strix_version}\",\"frigate_url\":\"http://${lan_ip}:8971\",\"frigate_internal\":\"http://${lan_ip}:5000\",\"go2rtc_url\":\"http://${lan_ip}:1984\",\"frigate_tag\":\"${FRIGATE_TAG}\",\"port\":\"${STRIX_PORT}\",\"devices\":${devices_json}}"
}
main
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env bash
# =============================================================================
# Strix -- strix.sh (worker)
#
# Deploys Strix container via Docker Compose.
# Downloads docker-compose.yml from GitHub (if not already present),
# generates .env from parameters, pulls image, starts container, healthchecks.
#
# Protocol:
# - Every action is reported as a single-line JSON to stdout.
# - Types: check, ok, miss, install, error, done
# - Field "msg" is always human-readable.
# - Field "data" is optional, carries machine-readable details.
# - Last line is always: {"type":"done","ok":true,...} or {"type":"done","ok":false,"error":"..."}
# - Exit code: 0 = success, 1 = failure.
#
# Parameters (all optional):
# --port PORT Strix listen port (default: 4567)
# --frigate-url URL Frigate URL, e.g. http://192.168.1.50:5000
# --go2rtc-url URL go2rtc URL, e.g. http://192.168.1.50:1984
# --log-level LEVEL Log level: debug, info, warn, error, trace (default: info)
# --tag TAG Docker image tag (default: latest)
# --dir DIR Working directory (default: /opt/strix)
#
# Usage:
# bash scripts/strix.sh
# bash scripts/strix.sh --port 4567 --frigate-url http://192.168.1.50:5000
# =============================================================================
set -uo pipefail
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
STRIX_PORT="4567"
STRIX_FRIGATE_URL=""
STRIX_GO2RTC_URL=""
STRIX_LOG_LEVEL=""
STRIX_TAG="latest"
STRIX_DIR="/opt/strix"
COMPOSE_URL="https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml"
IMAGE="eduard256/strix"
# ---------------------------------------------------------------------------
# Parse CLI arguments
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--port) STRIX_PORT="$2"; shift 2 ;;
--frigate-url) STRIX_FRIGATE_URL="$2"; shift 2 ;;
--go2rtc-url) STRIX_GO2RTC_URL="$2"; shift 2 ;;
--log-level) STRIX_LOG_LEVEL="$2"; shift 2 ;;
--tag) STRIX_TAG="$2"; shift 2 ;;
--dir) STRIX_DIR="$2"; shift 2 ;;
*) shift ;;
esac
done
# ---------------------------------------------------------------------------
# JSON helpers (same protocol as prepare.sh)
# ---------------------------------------------------------------------------
emit() {
local type="$1"
local msg="$2"
local data="${3:-}"
msg="${msg//\\/\\\\}"
msg="${msg//\"/\\\"}"
if [[ -n "$data" ]]; then
printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data"
else
printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg"
fi
}
emit_done() {
local ok="$1"
shift
if [[ "$ok" == "true" ]]; then
# Remaining args are key:value pairs for data
local data="{"
local first=true
while [[ $# -ge 2 ]]; do
local key="$1" val="$2"; shift 2
val="${val//\\/\\\\}"
val="${val//\"/\\\"}"
[[ "$first" == true ]] && first=false || data="${data},"
data="${data}\"${key}\":\"${val}\""
done
data="${data}}"
printf '{"type":"done","ok":true,"data":%s}\n' "$data"
exit 0
else
local error="${1:-unknown}"
error="${error//\\/\\\\}"
error="${error//\"/\\\"}"
printf '{"type":"done","ok":false,"error":"%s"}\n' "$error"
exit 1
fi
}
# ---------------------------------------------------------------------------
# Detect LAN IP
# ---------------------------------------------------------------------------
detect_lan_ip() {
local ip=""
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1)
[[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
[[ -z "$ip" ]] && ip="localhost"
echo "$ip"
}
# ---------------------------------------------------------------------------
# Working directory
# ---------------------------------------------------------------------------
setup_dir() {
emit "check" "Checking working directory ${STRIX_DIR}"
if [[ -d "$STRIX_DIR" ]]; then
emit "ok" "Directory exists: ${STRIX_DIR}"
else
emit "install" "Creating directory ${STRIX_DIR}"
if mkdir -p "$STRIX_DIR" 2>/dev/null; then
emit "ok" "Directory created: ${STRIX_DIR}"
else
emit "error" "Failed to create directory ${STRIX_DIR}"
emit_done "false" "Cannot create ${STRIX_DIR}"
fi
fi
}
# ---------------------------------------------------------------------------
# Download docker-compose.yml
# ---------------------------------------------------------------------------
download_compose() {
emit "check" "Checking docker-compose.yml"
if [[ -f "${STRIX_DIR}/docker-compose.yml" ]]; then
emit "ok" "docker-compose.yml already exists"
return
fi
emit "install" "Downloading docker-compose.yml from GitHub"
if curl -fsSL "$COMPOSE_URL" -o "${STRIX_DIR}/docker-compose.yml" 2>/dev/null; then
emit "ok" "docker-compose.yml downloaded"
else
emit "error" "Failed to download docker-compose.yml"
emit_done "false" "docker-compose.yml download failed"
fi
}
# ---------------------------------------------------------------------------
# Generate .env
# ---------------------------------------------------------------------------
generate_env() {
emit "check" "Generating .env configuration"
cat > "${STRIX_DIR}/.env" <<EOF
# Strix configuration -- generated by strix.sh
STRIX_LISTEN=:${STRIX_PORT}
EOF
if [[ -n "$STRIX_FRIGATE_URL" ]]; then
echo "STRIX_FRIGATE_URL=${STRIX_FRIGATE_URL}" >> "${STRIX_DIR}/.env"
emit "ok" "Frigate URL: ${STRIX_FRIGATE_URL}" "{\"frigate_url\":\"${STRIX_FRIGATE_URL}\"}"
fi
if [[ -n "$STRIX_GO2RTC_URL" ]]; then
echo "STRIX_GO2RTC_URL=${STRIX_GO2RTC_URL}" >> "${STRIX_DIR}/.env"
emit "ok" "go2rtc URL: ${STRIX_GO2RTC_URL}" "{\"go2rtc_url\":\"${STRIX_GO2RTC_URL}\"}"
fi
if [[ -n "$STRIX_LOG_LEVEL" ]]; then
echo "STRIX_LOG_LEVEL=${STRIX_LOG_LEVEL}" >> "${STRIX_DIR}/.env"
emit "ok" "Log level: ${STRIX_LOG_LEVEL}"
fi
emit "ok" ".env generated (port ${STRIX_PORT})" "{\"port\":\"${STRIX_PORT}\"}"
}
# ---------------------------------------------------------------------------
# Pull image
# ---------------------------------------------------------------------------
pull_image() {
emit "check" "Pulling image ${IMAGE}:${STRIX_TAG}"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" pull &>/dev/null; then
emit "ok" "Image pulled: ${IMAGE}:${STRIX_TAG}"
else
emit "error" "Failed to pull image ${IMAGE}:${STRIX_TAG}"
emit_done "false" "Image pull failed"
fi
}
# ---------------------------------------------------------------------------
# Start container
# ---------------------------------------------------------------------------
start_container() {
# Check if strix container is already running
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^strix$'; then
emit "check" "Strix container is running, recreating"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d --force-recreate &>/dev/null; then
emit "ok" "Container recreated"
else
emit "error" "Failed to recreate container"
emit_done "false" "Container recreate failed"
fi
else
emit "install" "Starting Strix container"
if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d &>/dev/null; then
emit "ok" "Container started"
else
emit "error" "Failed to start container"
emit_done "false" "Container start failed"
fi
fi
}
# ---------------------------------------------------------------------------
# Healthcheck
# ---------------------------------------------------------------------------
healthcheck() {
emit "check" "Waiting for Strix to respond"
local retries=15
local i
for (( i = 1; i <= retries; i++ )); do
if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${STRIX_PORT}/api/health" &>/dev/null; then
local version
version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")
emit "ok" "Strix v${version} is running on port ${STRIX_PORT}" "{\"version\":\"${version}\",\"port\":\"${STRIX_PORT}\"}"
return 0
fi
sleep 1
done
emit "error" "Healthcheck failed after ${retries} attempts"
emit_done "false" "Healthcheck failed"
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# 1. Working directory
setup_dir
# 2. Download compose file (if not present)
download_compose
# 3. Generate .env from parameters
generate_env
# 4. Pull image
pull_image
# 5. Start / recreate container
start_container
# 6. Healthcheck
healthcheck
# 7. Done -- include URL for navigator
local lan_ip
lan_ip=$(detect_lan_ip)
local url="http://${lan_ip}:${STRIX_PORT}"
emit_done "true" "url" "$url" "version" "$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")" "port" "$STRIX_PORT" "ip" "$lan_ip"
}
main
+6 -5
View File
@@ -328,8 +328,9 @@
// Pre-populate custom streams from "url" query parameter (supports multiple)
params.getAll('url').forEach(function(u) {
if (!u || typeof u !== 'string') return;
u = u.trim();
if (u && u.indexOf('://') !== -1 && customStreams.indexOf(u) === -1) {
if (u && u !== 'undefined' && u !== 'null' && u.indexOf('://') !== -1 && customStreams.indexOf(u) === -1) {
customStreams.push(u);
}
});
@@ -395,7 +396,7 @@
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
addInput.value = pendingInput;
addInput.value = pendingInput || '';
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
@@ -404,7 +405,7 @@
function addCustom() {
var v = addInput.value.trim();
if (!v) return;
if (!v || v === 'undefined' || v === 'null') return;
if (v.indexOf('://') === -1) {
showToast('URL must include protocol (rtsp://, http://, bubble://, ...)');
return;
@@ -592,7 +593,7 @@
addInput.type = 'text';
addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...';
addInput.spellcheck = false;
addInput.value = pendingInput;
addInput.value = pendingInput || '';
var addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.type = 'button';
@@ -600,7 +601,7 @@
function addCustom() {
var v = addInput.value.trim();
if (!v) return;
if (!v || v === 'undefined' || v === 'null') return;
if (v.indexOf('://') === -1) { showToast('URL must include protocol (rtsp://, http://, bubble://, ...)'); return; }
if (customStreams.indexOf(v) !== -1 || dbStreams.indexOf(v) !== -1) { showToast('This URL is already in the list'); return; }
customStreams.push(v);
File diff suppressed because it is too large Load Diff
+411 -178
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - HomeKit Device</title>
<title>Strix - HomeKit Pairing</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -21,6 +21,8 @@
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--error: #ef4444;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
@@ -40,15 +42,31 @@
}
.screen {
min-height: 100vh; padding: 1.5rem;
display: flex; align-items: flex-start; justify-content: center;
min-height: 100vh;
padding: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn var(--transition-base);
}
.container { max-width: 520px; width: 100%; margin-top: 6vh; }
.container { max-width: 480px; width: 100%; }
@media (min-width: 768px) {
.screen { padding: 3rem 1.5rem; }
.container { max-width: 540px; }
}
.back-wrapper {
position: absolute; top: 1.5rem;
left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px;
padding: 0 1.5rem;
z-index: 10;
}
@media (min-width: 768px) {
.back-wrapper { max-width: 660px; }
}
.btn-back {
@@ -56,203 +74,255 @@
background: none; border: none;
color: var(--text-secondary); font-size: 0.875rem;
font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0; margin-bottom: 2rem;
padding: 0.5rem 0;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.hero { text-align: center; margin-bottom: 2.5rem; }
.card-icon {
width: 48px; height: 48px;
margin: 0 auto 1.25rem;
color: var(--purple-light);
}
.card-title {
.title {
font-size: 1.25rem; font-weight: 600;
margin-bottom: 0.5rem;
letter-spacing: 0.03em;
color: var(--text-primary);
}
.card-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: rgba(139, 92, 246, 0.15);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 6px;
font-size: 0.75rem; font-weight: 600;
color: var(--purple-light);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 1.5rem;
.homekit-logo {
width: 72px; height: 72px;
margin: 0 auto;
filter: drop-shadow(0 4px 16px rgba(255, 171, 31, 0.3));
}
.card-text {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1.25rem;
text-align: left;
}
.form-group { margin-bottom: 1.5rem; }
.card-text strong { color: var(--text-primary); }
.device-info {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
text-align: left;
}
.device-row {
display: flex; justify-content: space-between;
padding: 0.375rem 0;
font-size: 0.8125rem;
}
.device-row:not(:last-child) {
border-bottom: 1px solid rgba(139, 92, 246, 0.07);
}
.device-label { color: var(--text-tertiary); }
.device-value { color: var(--text-primary); font-family: var(--font-mono); font-size: 0.75rem; }
.contact-links {
display: flex; flex-direction: column; gap: 0.5rem;
margin-bottom: 1.5rem;
text-align: left;
}
.contact-link {
.label {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--purple-light);
text-decoration: none;
font-size: 0.875rem;
transition: all var(--transition-fast);
font-size: 0.875rem; font-weight: 500;
color: var(--text-secondary); margin-bottom: 0.75rem;
}
.contact-link:hover {
border-color: var(--purple-primary);
background: rgba(139, 92, 246, 0.08);
/* Info icon + tooltip */
.info-icon {
position: relative; display: inline-flex;
align-items: center; justify-content: center;
width: 16px; height: 16px; cursor: help;
color: var(--text-tertiary); transition: color var(--transition-fast);
}
.contact-link svg { width: 18px; height: 18px; flex-shrink: 0; }
.info-icon:hover { color: var(--purple-primary); }
.info-icon svg { width: 16px; height: 16px; }
.divider {
display: flex; align-items: center; gap: 1rem;
margin: 1.5rem 0;
.tooltip {
position: absolute; top: calc(100% + 8px); left: 50%;
transform: translateX(-50%);
background: var(--bg-elevated);
border: 1px solid var(--purple-primary);
border-radius: 8px; padding: 1rem;
width: 320px; max-width: 90vw;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--purple-glow);
z-index: 1000; opacity: 0; visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
pointer-events: none;
}
.tooltip::after {
content: ''; position: absolute; bottom: 100%; left: 50%;
transform: translateX(-50%);
border: 6px solid transparent; border-bottom-color: var(--purple-primary);
}
.info-icon:hover .tooltip { opacity: 1; visibility: visible; }
.tooltip-title { font-weight: 600; color: var(--purple-primary); margin-bottom: 0.5rem; font-size: 0.875rem; }
.tooltip-text { font-size: 0.75rem; line-height: 1.5; color: var(--text-secondary); }
/* PIN input */
.pin-row {
display: flex; align-items: center; justify-content: center;
gap: 0;
}
.pin-group { display: flex; gap: 0.375rem; }
.pin-separator {
font-size: 1.5rem; font-weight: 300;
color: var(--text-tertiary);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 0 0.5rem;
line-height: 1;
user-select: none;
}
.divider::before, .divider::after {
content: ''; flex: 1; height: 1px;
background: var(--border-color);
.pin-digit {
width: 57px; height: 69px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 1.375rem; font-weight: 600;
text-align: center;
outline: none;
transition: all var(--transition-fast);
caret-color: var(--purple-primary);
-moz-appearance: textfield;
}
.pin-digit::-webkit-inner-spin-button,
.pin-digit::-webkit-outer-spin-button {
-webkit-appearance: none; margin: 0;
}
.pin-digit:focus {
border-color: var(--purple-primary);
box-shadow: 0 0 0 3px var(--purple-glow);
}
.pin-digit.filled {
border-color: var(--purple-light);
background: rgba(139, 92, 246, 0.06);
}
.pin-digit.error {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
animation: shake 0.4s ease;
}
.pin-digit.success {
border-color: var(--success);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
@media (max-width: 400px) {
.pin-digit { width: 36px; height: 48px; font-size: 1.125rem; }
.pin-separator { padding: 0 0.25rem; font-size: 1.25rem; }
.pin-group { gap: 0.25rem; }
}
/* Error */
.error-box {
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: var(--error);
font-size: 0.875rem;
margin-bottom: 1.5rem;
display: none;
animation: fadeIn var(--transition-fast);
}
.error-box.visible { display: block; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 0.9375rem; font-weight: 600; font-family: var(--font-primary);
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
border: none; outline: none; width: 100%;
border: none; outline: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--purple-primary), var(--purple-dark));
color: white; box-shadow: 0 4px 12px var(--purple-glow);
}
.btn-primary:hover {
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--purple-glow-strong);
}
.btn-primary:active { transform: translateY(0); }
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-large { width: 100%; padding: 1.5rem; font-size: 1.125rem; }
.btn-outline {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border-color);
border: 1px solid var(--border-color); width: 100%;
margin-top: 0.75rem;
}
.btn-outline:hover { border-color: var(--purple-primary); color: var(--purple-light); }
/* Pairing spinner */
.pairing-spinner {
width: 20px; height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
</style>
</head>
<body>
<div class="back-wrapper">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Back
</button>
</div>
<div class="screen">
<div class="container">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<div class="hero">
<svg class="homekit-logo" viewBox="100 120 824 780" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="#FA9012" d="M883.2,413.1l-70.4-55.6l0,0V231.1c0-8.6-3.4-11-9.5-11h-64.4c-7,0-11.3,1.4-11.3,11v59.1l0,0 C634.5,216.7,533.6,137,529.8,134c-7.6-6-12.3-7.6-17.8-7.6c-5.4,0-10.1,1.6-17.8,7.6c-7.6,6-343.2,271.1-353.4,279.1 c-12.4,9.8-8.9,23.9,4.9,23.9h65.5v355.6c0,23,9.2,32.2,31.1,32.2h539.4c21.9,0,31.1-9.2,31.1-32.2V436.9h65.5 C892.1,436.9,895.6,422.9,883.2,413.1z M757.6,742.6c0,15.9-8.2,26.9-24.8,26.9H291.1c-16.6,0-24.8-11-24.8-26.9V410.3 c0-19.3,8.4-31.6,18.1-39.2l212.4-167.7c5.6-4.4,10.4-6.3,15.1-6.3s9.5,1.9,15.1,6.4l212.4,167.7c9.6,7.6,18.1,19.9,18.1,39.2 V742.6z"/>
<path fill="#FFAB1F" d="M739.6,371.1L527.1,203.3c-5.6-4.4-10.6-6.3-15.1-6.3c-4.6,0-9.5,1.9-15.1,6.4L284.4,371.1 c-9.6,7.6-18.1,19.9-18.1,39.2v332.3c0,15.9,8.2,26.9,24.8,26.9h441.7c16.6,0,24.8-11,24.8-26.9V410.3 C757.6,391,749.2,378.7,739.6,371.1z M702.6,692.7c0,14.8-8.4,21.7-20.7,21.7H342.2c-12.3,0-20.7-6.9-20.7-21.7V433.2 c0-14.4,3.4-22.6,13.6-30.7c5.8-4.6,160.3-126.6,164.4-129.8c4.1-3.3,8.5-4.9,12.5-4.9c4,0,8.4,1.7,12.5,4.9 c4.1,3.3,158.6,125.3,164.4,129.8c10.2,8.1,13.6,16.4,13.6,30.7L702.6,692.7z"/>
<path fill="#FFBE41" d="M688.9,402.5c-5.8-4.5-160.3-126.6-164.4-129.8c-4.1-3.3-8.5-4.9-12.5-4.9c-4,0-8.4,1.7-12.5,4.9 c-4.1,3.3-158.6,125.3-164.4,129.8c-10.2,8.1-13.6,16.4-13.6,30.7v259.5c0,14.8,8.4,21.7,20.7,21.7h339.7 c12.3,0,20.7-6.9,20.7-21.7V433.2C702.5,418.9,699.1,410.6,688.9,402.5z M647.4,642.8c0,11.9-6.6,16.5-15.6,16.5H392.2 c-9,0-15.6-4.6-15.6-16.5V456.2c0-8.3,0-14.9,9.1-22.2c6-4.8,113.2-89.4,116.4-91.9s6.4-3.8,9.9-3.8c3.6,0.1,7.1,1.5,9.9,3.8 c3.2,2.5,110.4,87.1,116.4,91.9c9.1,7.3,9.1,13.9,9.1,22.2L647.4,642.8z"/>
<path fill="#FFD260" d="M638.3,434c-6-4.8-113.2-89.4-116.4-91.9c-2.8-2.4-6.3-3.7-9.9-3.8c-3.5,0-6.7,1.3-9.9,3.8 S391.6,429.2,385.7,434c-9.1,7.3-9.1,13.9-9.1,22.2v186.6c0,11.9,6.6,16.5,15.6,16.5h239.5c9,0,15.6-4.6,15.6-16.5V456.2 C647.4,447.8,647.4,441.2,638.3,434z M592.3,593c0,9.2-4.6,11.2-11,11.2H442.8c-6.4,0-11-2.1-11-11.2V479.1 c0-6.4,2.9-12.6,7.8-16.6c2.8-2.3,63-49.4,65.1-51.1c4.2-3.5,10.4-3.5,14.6,0c2.2,1.7,62.3,48.8,65.1,51.1 c5,4.1,7.9,10.2,7.8,16.6L592.3,593z"/>
<path fill="#FFE780" d="M512,604.1h69.2c6.4,0,11-2.1,11-11.2V479.1c0-6.4-2.9-12.6-7.8-16.6c-2.8-2.3-63-49.4-65.1-51.1 c-4.2-3.5-10.4-3.5-14.6,0c-2.1,1.7-62.3,48.8-65.1,51.1c-5,4.1-7.9,10.2-7.8,16.6v113.8c0,9.2,4.6,11.2,11,11.2L512,604.1z"/>
</svg>
Back
</button>
<div class="card">
<svg class="card-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<h2 class="card-title">HomeKit Device Detected</h2>
<div class="card-badge">Apple HomeKit</div>
<div id="device-info" class="device-info"></div>
<p class="card-text">
We are working on adding <strong>Apple HomeKit camera support</strong> to Strix, but we don't have HomeKit cameras available for testing.
</p>
<p class="card-text">
The device at this IP supports HomeKit protocol. If you'd like to help us add support for HomeKit cameras, please reach out. Your contribution would be greatly appreciated.
</p>
<div class="contact-links">
<a class="contact-link" href="mailto:ceo@webaweba.com">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 4l-10 8L2 4"/>
</svg>
ceo@webaweba.com
</a>
<a class="contact-link" href="https://github.com/eduard256/Strix/issues/new/choose" target="_blank">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
Create GitHub Issue
</a>
</div>
<div class="divider">or</div>
<button class="btn btn-primary" id="btn-standard">
Try Standard Discovery
</button>
<button class="btn btn-outline" id="btn-skip">
Back to Home
</button>
<h1 class="title">Apple HomeKit</h1>
</div>
<div class="form-group">
<label class="label">
Pairing Code
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">HomeKit Pairing Code</div>
<p class="tooltip-text">The 8-digit code is usually printed on a label on the camera itself, on the packaging, or in the setup manual. It looks like XXX-XX-XXX.</p>
</div>
</span>
</label>
<div class="pin-row" id="pin-row">
<div class="pin-group" id="group-0"></div>
<span class="pin-separator">-</span>
<div class="pin-group" id="group-1"></div>
<span class="pin-separator">-</span>
<div class="pin-group" id="group-2"></div>
</div>
</div>
<div class="error-box" id="error-box"></div>
<button class="btn btn-primary btn-large" id="btn-pair" disabled>Pair Device</button>
<button class="btn-outline" id="btn-standard">Skip, use Standard Discovery</button>
</div>
</div>
<script>
var params = new URLSearchParams(location.search);
// all probe data passed through
var ip = params.get('ip') || '';
var ports = params.get('ports') || '';
var mac = params.get('mac') || '';
@@ -263,49 +333,209 @@
var mdnsName = params.get('mdns_name') || '';
var mdnsModel = params.get('mdns_model') || '';
var mdnsCategory = params.get('mdns_category') || '';
var mdnsPort = params.get('mdns_port') || '';
var mdnsPaired = params.get('mdns_paired') || '';
var mdnsDeviceId = params.get('mdns_device_id') || '';
// render device info
var infoDiv = document.getElementById('device-info');
var rows = [];
if (ip) rows.push(['IP Address', ip]);
if (mdnsName) rows.push(['Device Name', mdnsName]);
if (mdnsModel) rows.push(['Model', mdnsModel]);
if (mdnsCategory) rows.push(['Category', mdnsCategory]);
if (vendor) rows.push(['Vendor', vendor]);
if (mac) rows.push(['MAC', mac]);
if (hostname) rows.push(['Hostname', hostname]);
if (latency) rows.push(['Latency', latency + 'ms']);
if (ports) rows.push(['Open Ports', ports]);
// PIN input -- 8 digits: 3-2-3
var pinGroups = [3, 2, 3];
var inputs = [];
rows.forEach(function(r) {
var row = document.createElement('div');
row.className = 'device-row';
var label = document.createElement('span');
label.className = 'device-label';
label.textContent = r[0];
var value = document.createElement('span');
value.className = 'device-value';
value.textContent = r[1];
row.appendChild(label);
row.appendChild(value);
infoDiv.appendChild(row);
pinGroups.forEach(function(count, gi) {
var group = document.getElementById('group-' + gi);
for (var i = 0; i < count; i++) {
var input = document.createElement('input');
input.type = 'text';
input.inputMode = 'numeric';
input.pattern = '[0-9]';
input.maxLength = 1;
input.className = 'pin-digit';
input.autocomplete = 'off';
group.appendChild(input);
inputs.push(input);
}
});
if (rows.length === 0) infoDiv.style.display = 'none';
inputs.forEach(function(input, idx) {
input.addEventListener('input', function(e) {
var v = input.value.replace(/\D/g, '');
input.value = v ? v[0] : '';
// back
hideError();
if (v && idx < inputs.length - 1) {
inputs[idx + 1].focus();
}
updateState();
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Backspace') {
if (input.value === '' && idx > 0) {
inputs[idx - 1].focus();
inputs[idx - 1].value = '';
updateState();
e.preventDefault();
}
}
if (e.key === 'ArrowLeft' && idx > 0) {
inputs[idx - 1].focus();
e.preventDefault();
}
if (e.key === 'ArrowRight' && idx < inputs.length - 1) {
inputs[idx + 1].focus();
e.preventDefault();
}
if (e.key === 'Enter') {
doPair();
}
});
input.addEventListener('focus', function() {
input.select();
});
// paste support -- distribute digits across fields
input.addEventListener('paste', function(e) {
e.preventDefault();
var text = (e.clipboardData || window.clipboardData).getData('text');
var digits = text.replace(/\D/g, '');
for (var i = 0; i < digits.length && idx + i < inputs.length; i++) {
inputs[idx + i].value = digits[i];
}
var next = Math.min(idx + digits.length, inputs.length - 1);
inputs[next].focus();
updateState();
});
});
function getPin() {
var pin = '';
for (var i = 0; i < inputs.length; i++) {
pin += inputs[i].value;
}
return pin;
}
function updateState() {
var pin = getPin();
document.getElementById('btn-pair').disabled = pin.length !== 8;
inputs.forEach(function(input) {
if (input.value) {
input.classList.add('filled');
} else {
input.classList.remove('filled');
}
input.classList.remove('error');
input.classList.remove('success');
});
}
function showError(msg) {
var el = document.getElementById('error-box');
el.textContent = msg;
el.classList.add('visible');
inputs.forEach(function(input) { input.classList.add('error'); });
}
function hideError() {
document.getElementById('error-box').classList.remove('visible');
}
function showSuccess() {
inputs.forEach(function(input) {
input.classList.remove('error');
input.classList.add('success');
});
}
// pair
var btnPair = document.getElementById('btn-pair');
btnPair.addEventListener('click', doPair);
async function doPair() {
var pin = getPin();
if (pin.length !== 8) return;
var port = parseInt(mdnsPort, 10);
if (!ip || !port || !mdnsDeviceId) {
showError('Missing device information. Go back and re-probe.');
return;
}
hideError();
btnPair.disabled = true;
var origText = btnPair.textContent;
btnPair.textContent = '';
var spinner = document.createElement('div');
spinner.className = 'pairing-spinner';
btnPair.appendChild(spinner);
var label = document.createTextNode(' Pairing...');
btnPair.appendChild(label);
inputs.forEach(function(input) { input.disabled = true; });
try {
var r = await fetch('api/homekit/pair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ip: ip,
port: port,
device_id: mdnsDeviceId,
pin: pin
})
});
if (!r.ok) {
var text = await r.text();
inputs.forEach(function(input) { input.disabled = false; });
btnPair.textContent = origText;
updateState();
showError(text || 'Pairing failed (HTTP ' + r.status + ')');
inputs[0].focus();
return;
}
var data = await r.json();
showSuccess();
btnPair.textContent = 'Paired!';
// redirect to create.html with the homekit URL
setTimeout(function() {
var p = new URLSearchParams();
p.set('url', data.url);
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (mdnsName) p.set('model', mdnsName);
if (hostname) p.set('hostname', hostname);
window.location.href = 'create.html?' + p.toString();
}, 800);
} catch (e) {
inputs.forEach(function(input) { input.disabled = false; });
btnPair.textContent = origText;
updateState();
showError('Connection error: ' + e.message);
}
}
// navigation
document.getElementById('btn-back').addEventListener('click', function() {
window.location.href = 'index.html';
});
document.getElementById('btn-skip').addEventListener('click', function() {
window.location.href = 'index.html';
});
// try standard discovery -- pass all params to standard.html
document.getElementById('btn-standard').addEventListener('click', function() {
var p = new URLSearchParams();
if (ip) p.set('ip', ip);
@@ -317,6 +547,9 @@
if (latency) p.set('latency', latency);
window.location.href = 'standard.html?' + p.toString();
});
// autofocus first input
inputs[0].focus();
</script>
</body>
+49 -2
View File
@@ -317,8 +317,8 @@
const data = await r.json();
if (data.type === 'standard' || (data.reachable && data.type !== 'homekit')) {
navigateStandard(ip, data);
if (data.type === 'onvif') {
navigateOnvif(ip, data);
return;
}
@@ -327,6 +327,11 @@
return;
}
if (data.type === 'standard' || data.reachable) {
navigateStandard(ip, data);
return;
}
if (data.type === 'unreachable') {
showUnreachable(ip);
return;
@@ -340,6 +345,46 @@
}
}
function navigateOnvif(ip, data) {
var p = new URLSearchParams();
p.set('ip', ip);
var probes = data.probes || {};
if (probes.ports && probes.ports.open && probes.ports.open.length) {
p.set('ports', probes.ports.open.join(','));
}
if (probes.arp) {
if (probes.arp.mac) p.set('mac', probes.arp.mac);
if (probes.arp.vendor) p.set('vendor', probes.arp.vendor);
}
if (probes.http && probes.http.server) {
p.set('server', probes.http.server);
}
if (probes.dns && probes.dns.hostname) {
p.set('hostname', probes.dns.hostname);
}
if (probes.ping && probes.ping.latency_ms) {
p.set('latency', Math.round(probes.ping.latency_ms));
}
if (probes.onvif) {
if (probes.onvif.url) p.set('onvif_url', probes.onvif.url);
if (probes.onvif.port) p.set('onvif_port', probes.onvif.port);
if (probes.onvif.name) p.set('onvif_name', probes.onvif.name);
if (probes.onvif.hardware) p.set('onvif_hardware', probes.onvif.hardware);
}
if (probes.mdns) {
if (probes.mdns.name) p.set('mdns_name', probes.mdns.name);
if (probes.mdns.model) p.set('mdns_model', probes.mdns.model);
if (probes.mdns.category) p.set('mdns_category', probes.mdns.category);
if (probes.mdns.device_id) p.set('mdns_device_id', probes.mdns.device_id);
if (probes.mdns.port) p.set('mdns_port', probes.mdns.port);
p.set('mdns_paired', probes.mdns.paired ? '1' : '0');
}
window.location.href = 'onvif.html?' + p.toString();
}
function navigateStandard(ip, data) {
var p = new URLSearchParams();
p.set('ip', ip);
@@ -393,6 +438,8 @@
if (probes.mdns.model) p.set('mdns_model', probes.mdns.model);
if (probes.mdns.category) p.set('mdns_category', probes.mdns.category);
if (probes.mdns.device_id) p.set('mdns_device_id', probes.mdns.device_id);
if (probes.mdns.port) p.set('mdns_port', probes.mdns.port);
p.set('mdns_paired', probes.mdns.paired ? '1' : '0');
}
window.location.href = 'homekit.html?' + p.toString();
+469
View File
@@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0a0f">
<title>Strix - ONVIF Camera</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #1a1a24;
--bg-tertiary: #24242f;
--bg-elevated: #2a2a38;
--purple-primary: #8b5cf6;
--purple-light: #a78bfa;
--purple-dark: #7c3aed;
--purple-glow: rgba(139, 92, 246, 0.3);
--purple-glow-strong: rgba(139, 92, 246, 0.5);
--text-primary: #e0e0e8;
--text-secondary: #a0a0b0;
--text-tertiary: #606070;
--success: #10b981;
--error: #ef4444;
--border-color: rgba(139, 92, 246, 0.15);
--font-primary: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body {
font-family: var(--font-primary);
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
.screen {
min-height: 100vh;
padding: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn var(--transition-base);
}
.container { max-width: 480px; width: 100%; }
@media (min-width: 768px) {
.screen { padding: 3rem 1.5rem; }
.container { max-width: 540px; }
}
.back-wrapper {
position: absolute; top: 1.5rem;
left: 50%; transform: translateX(-50%);
width: 100%; max-width: 600px;
padding: 0 1.5rem;
z-index: 10;
}
@media (min-width: 768px) {
.back-wrapper { max-width: 660px; }
}
.btn-back {
display: inline-flex; align-items: center; gap: 0.5rem;
background: none; border: none;
color: var(--text-secondary); font-size: 0.875rem;
font-family: var(--font-primary); cursor: pointer;
padding: 0.5rem 0;
transition: color var(--transition-fast);
}
.btn-back:hover { color: var(--purple-primary); }
.hero { text-align: center; margin-bottom: 2.5rem; }
.onvif-word {
font-size: 2.5rem; font-weight: 700;
letter-spacing: 0.08em;
margin-bottom: 1rem;
}
.onvif-o {
background: linear-gradient(180deg, #00a0e9 0%, #1a237e 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.onvif-nvif {
color: var(--text-primary);
}
.title {
font-size: 1.25rem; font-weight: 600;
letter-spacing: 0.03em;
color: var(--text-primary);
}
.subtitle {
font-size: 0.8125rem; color: var(--text-tertiary);
margin-top: 0.375rem;
font-family: var(--font-mono);
}
.subtitle .highlight {
color: var(--purple-light);
}
/* Form */
.form-group { margin-bottom: 1.5rem; }
.label {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.875rem; font-weight: 500;
color: var(--text-secondary); margin-bottom: 0.5rem;
}
.info-icon {
position: relative; display: inline-flex;
align-items: center; justify-content: center;
width: 16px; height: 16px; cursor: help;
color: var(--text-tertiary); transition: color var(--transition-fast);
}
.info-icon:hover { color: var(--purple-primary); }
.info-icon svg { width: 16px; height: 16px; }
.tooltip {
position: absolute; top: calc(100% + 8px); left: 50%;
transform: translateX(-50%);
background: var(--bg-elevated);
border: 1px solid var(--purple-primary);
border-radius: 8px; padding: 1rem;
width: 320px; max-width: 90vw;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px var(--purple-glow);
z-index: 1000; opacity: 0; visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast);
pointer-events: none;
}
.tooltip::after {
content: ''; position: absolute; bottom: 100%; left: 50%;
transform: translateX(-50%);
border: 6px solid transparent; border-bottom-color: var(--purple-primary);
}
.info-icon:hover .tooltip { opacity: 1; visibility: visible; }
.tooltip-title { font-weight: 600; color: var(--purple-primary); margin-bottom: 0.5rem; font-size: 0.875rem; }
.tooltip-text { font-size: 0.75rem; line-height: 1.5; color: var(--text-secondary); }
.input {
width: 100%; padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 1rem; font-family: var(--font-primary);
transition: all var(--transition-fast);
outline: none;
}
.input:focus {
border-color: var(--purple-primary);
box-shadow: 0 0 0 3px var(--purple-glow);
}
.input::placeholder { color: var(--text-tertiary); }
.input-password { position: relative; }
.input-password .input { padding-right: 3rem; }
.btn-toggle-pass {
position: absolute; right: 0.75rem; top: 50%; transform: translateY(-50%);
background: none; border: none; padding: 0.5rem; cursor: pointer;
color: var(--text-tertiary); display: flex;
transition: color var(--transition-fast);
}
.btn-toggle-pass:hover { color: var(--purple-primary); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
border: none; outline: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--purple-primary), var(--purple-dark));
color: white; box-shadow: 0 4px 12px var(--purple-glow);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px var(--purple-glow-strong);
}
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-large { width: 100%; padding: 1.5rem; font-size: 1.125rem; }
.btn-outline {
display: inline-flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 1rem 1.5rem; border-radius: 8px;
font-size: 1rem; font-weight: 600; font-family: var(--font-primary);
cursor: pointer; transition: all var(--transition-fast);
background: transparent; color: var(--text-secondary);
border: 1px solid var(--border-color); width: 100%;
margin-top: 0.75rem;
}
.btn-outline:hover { border-color: var(--purple-primary); color: var(--purple-light); }
/* Checkbox */
.checkbox-row {
margin-bottom: 1.5rem;
}
.checkbox-label {
display: flex; align-items: center; gap: 0.625rem;
font-size: 0.875rem; color: var(--text-secondary);
cursor: pointer; user-select: none;
}
.checkbox-label input { display: none; }
.checkbox-custom {
width: 18px; height: 18px; flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
transition: all var(--transition-fast);
position: relative;
}
.checkbox-label input:checked + .checkbox-custom {
background: var(--purple-primary);
border-color: var(--purple-primary);
}
.checkbox-label input:checked + .checkbox-custom::after {
content: '';
position: absolute; top: 2px; left: 5px;
width: 5px; height: 9px;
border: solid white; border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
</head>
<body>
<div class="back-wrapper">
<button class="btn-back" id="btn-back">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M12 4L6 10l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Back
</button>
</div>
<div class="screen">
<div class="container">
<div class="hero">
<div class="onvif-word"><span class="onvif-o">O</span><span class="onvif-nvif">nvif</span></div>
<h1 class="title" id="title">ONVIF Camera</h1>
<p class="subtitle" id="subtitle"></p>
</div>
<div class="form-group">
<label class="label">
Username
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">ONVIF Username</div>
<p class="tooltip-text">The username for your camera's ONVIF service. Usually the same as the web interface login. Common defaults: admin, root.</p>
</div>
</span>
</label>
<input type="text" id="f-user" class="input" value="admin" autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label class="label">
Password
<span class="info-icon">
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 7v4M8 5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<div class="tooltip">
<div class="tooltip-title">ONVIF Password</div>
<p class="tooltip-text">The password for your camera's ONVIF service. Some cameras allow ONVIF access without a password -- leave empty to try.</p>
</div>
</span>
</label>
<div class="input-password">
<input type="password" id="f-pass" class="input" placeholder="Camera password" autocomplete="off">
<button class="btn-toggle-pass" id="btn-toggle-pass" type="button">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<div class="checkbox-row" id="checkbox-row">
<label class="checkbox-label">
<input type="checkbox" id="cb-top1000" checked>
<span class="checkbox-custom"></span>
Also test popular stream patterns
</label>
</div>
<button class="btn btn-primary btn-large" id="btn-discover">Discover Streams</button>
<button class="btn-outline" id="btn-standard">Skip, use Standard Discovery</button>
<button class="btn-outline" id="btn-homekit" style="display:none">Try HomeKit Pairing</button>
</div>
</div>
<script>
var params = new URLSearchParams(location.search);
var ip = params.get('ip') || '';
var ports = params.get('ports') || '';
var mac = params.get('mac') || '';
var vendor = params.get('vendor') || '';
var server = params.get('server') || '';
var hostname = params.get('hostname') || '';
var latency = params.get('latency') || '';
var onvifUrl = params.get('onvif_url') || '';
var onvifPort = params.get('onvif_port') || '';
var onvifName = params.get('onvif_name') || '';
var onvifHardware = params.get('onvif_hardware') || '';
var mdnsName = params.get('mdns_name') || '';
var mdnsModel = params.get('mdns_model') || '';
var mdnsCategory = params.get('mdns_category') || '';
var mdnsPort = params.get('mdns_port') || '';
var mdnsPaired = params.get('mdns_paired') || '';
var mdnsDeviceId = params.get('mdns_device_id') || '';
// title from ONVIF name
if (onvifName) {
document.getElementById('title').textContent = onvifName;
}
// subtitle with IP and hardware
var subtitleEl = document.getElementById('subtitle');
var parts = [];
if (ip) parts.push(ip);
if (onvifHardware && onvifHardware !== onvifName) parts.push(onvifHardware);
if (parts.length) {
while (subtitleEl.firstChild) subtitleEl.removeChild(subtitleEl.firstChild);
parts.forEach(function(text, i) {
if (i > 0) {
var sep = document.createElement('span');
sep.textContent = ' / ';
subtitleEl.appendChild(sep);
}
var span = document.createElement('span');
span.className = 'highlight';
span.textContent = text;
subtitleEl.appendChild(span);
});
}
// show HomeKit button only if mDNS data present
if (mdnsName && mdnsDeviceId) {
document.getElementById('btn-homekit').style.display = '';
}
// password toggle
document.getElementById('btn-toggle-pass').addEventListener('click', function() {
var input = document.getElementById('f-pass');
input.type = input.type === 'password' ? 'text' : 'password';
});
// discover streams -> build onvif:// URL and go to create.html
document.getElementById('btn-discover').addEventListener('click', function() {
var user = document.getElementById('f-user').value.trim();
var pass = document.getElementById('f-pass').value;
var auth = '';
if (user || pass) {
auth = encodeURIComponent(user) + ':' + encodeURIComponent(pass) + '@';
}
var host = ip;
var port = parseInt(onvifPort, 10) || 80;
if (port !== 80) {
host = ip + ':' + port;
}
var onvifStreamUrl = 'onvif://' + auth + host;
var p = new URLSearchParams();
p.set('url', onvifStreamUrl);
if (ip) p.set('ip', ip);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (onvifName) p.set('model', onvifName);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (ports) p.set('ports', ports);
if (document.getElementById('cb-top1000').checked) {
p.set('ids', 'p:top-1000');
p.set('user', user);
if (pass) p.set('pass', pass);
}
window.location.href = 'create.html?' + p.toString();
});
// standard discovery
document.getElementById('btn-standard').addEventListener('click', function() {
var p = new URLSearchParams();
if (ip) p.set('ip', ip);
if (ports) p.set('ports', ports);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (latency) p.set('latency', latency);
window.location.href = 'standard.html?' + p.toString();
});
// homekit pairing
document.getElementById('btn-homekit').addEventListener('click', function() {
var p = new URLSearchParams();
if (ip) p.set('ip', ip);
if (ports) p.set('ports', ports);
if (mac) p.set('mac', mac);
if (vendor) p.set('vendor', vendor);
if (server) p.set('server', server);
if (hostname) p.set('hostname', hostname);
if (latency) p.set('latency', latency);
if (mdnsName) p.set('mdns_name', mdnsName);
if (mdnsModel) p.set('mdns_model', mdnsModel);
if (mdnsCategory) p.set('mdns_category', mdnsCategory);
if (mdnsPort) p.set('mdns_port', mdnsPort);
if (mdnsPaired) p.set('mdns_paired', mdnsPaired);
if (mdnsDeviceId) p.set('mdns_device_id', mdnsDeviceId);
window.location.href = 'homekit.html?' + p.toString();
});
// back
document.getElementById('btn-back').addEventListener('click', function() {
window.location.href = 'index.html';
});
// autofocus password field (user is prefilled)
document.getElementById('f-pass').focus();
</script>
</body>
</html>
+5 -3
View File
@@ -423,11 +423,13 @@
function classifyResult(r) {
var scheme = r.source.split('://')[0] || '';
var isRtsp = scheme === 'rtsp' || scheme === 'rtsps';
var isRecommended = scheme === 'rtsp' || scheme === 'rtsps' || scheme === 'onvif';
var isJpegOnly = r.codecs && r.codecs.length > 0 && r.codecs.indexOf('H264') === -1 && r.codecs.indexOf('H265') === -1;
var isHD = r.width >= 1280;
if (isRtsp && isHD) return 'rec-main';
if (isRtsp) return 'rec-sub';
if (isJpegOnly) return 'alt';
if (isRecommended && isHD) return 'rec-main';
if (isRecommended) return 'rec-sub';
return 'alt';
}