Compare commits
165 Commits
Author | SHA1 | Date |
---|---|---|
Scott Lamb | 0422593ec6 | |
Scott Lamb | adf73a2da1 | |
Scott Lamb | c20c644747 | |
Scott Lamb | 6c227ec0f5 | |
Scott Lamb | e6c7b800fe | |
Scott Lamb | 1ae61b4c64 | |
Scott Lamb | eb97e618fd | |
Scott Lamb | 93a9ad9af3 | |
Scott Lamb | 9acb095a5d | |
Scott Lamb | 8b5f2b4b0d | |
Scott Lamb | a65994ba71 | |
Scott Lamb | ef98f60241 | |
Scott Lamb | 7f4b04ee8a | |
michioxd | 9ede361b25 | |
michioxd | 8036aa40b7 | |
michioxd | a787703a31 | |
michioxd | 3f4cee7ead | |
michioxd | c67a5ffba5 | |
michioxd | 305deaa1e7 | |
michioxd | 29cafc2f82 | |
michioxd | 60c6247ef9 | |
michioxd | 317b8e9484 | |
michioxd | 91e02eba7a | |
michioxd | 5b5822900d | |
michioxd | b46d3acabb | |
michioxd | 6e81b27d1a | |
Scott Lamb | dbf6c2f476 | |
Scott Lamb | eef18372cc | |
Scott Lamb | 1f7c4c184a | |
Scott Lamb | f385215d6e | |
dependabot[bot] | f3da22fc5c | |
dependabot[bot] | 65b3d54466 | |
dependabot[bot] | 7beff8e1c9 | |
Scott Lamb | 9592fe24e8 | |
Scott Lamb | b47310644d | |
Scott Lamb | 6f472256ab | |
Scott Lamb | d1c033b46d | |
Scott Lamb | 223da03e36 | |
Scott Lamb | 4d4d786cde | |
Scott Lamb | 86816e862a | |
Scott Lamb | 2bcee02ea6 | |
Scott Lamb | 77720a09e3 | |
Scott Lamb | 38eba846f8 | |
Scott Lamb | 2da459dae2 | |
Scott Lamb | cca430b701 | |
Scott Lamb | 7d12e8033d | |
Scott Lamb | e9a25322b5 | |
Scott Lamb | 3911334fee | |
Scott Lamb | 24880a5c2d | |
Scott Lamb | 79af39f35e | |
Scott Lamb | 14d1879ccd | |
Scott Lamb | 3de62eb70d | |
Scott Lamb | f493ad94eb | |
Scott Lamb | 672647730d | |
Scott Lamb | 1013c35791 | |
Scott Lamb | c5f5bd39ee | |
Scott Lamb | 882596c7f6 | |
Scott Lamb | 8efea4526d | |
Scott Lamb | 5b1f7807f9 | |
Scott Lamb | 1f7806108c | |
Leandro Silva | 5e00217784 | |
Leandro Silva | 5ea5d27908 | |
Scott Lamb | 4a0cb6e62d | |
Scott Lamb | a2d243d3a4 | |
Scott Lamb | 89ee2d0269 | |
Scott Lamb | ee98bf5236 | |
Scott Lamb | faf0201b52 | |
Scott Lamb | a76483a912 | |
Scott Lamb | ef62ebfc6c | |
Scott Lamb | 33e6a18e63 | |
Scott Lamb | 1944f95974 | |
Scott Lamb | b4eb573ca2 | |
Scott Lamb | e103a709a0 | |
Scott Lamb | 9820bf8883 | |
Scott Lamb | 2a8c1bb632 | |
Scott Lamb | faba358925 | |
Scott Lamb | cff832e646 | |
Scott Lamb | 02ac1a5570 | |
Scott Lamb | 0f019b6fb3 | |
Scott Lamb | b9db9c11cc | |
Scott Lamb | 9d07d24bc7 | |
Scott Lamb | 64ca096ff3 | |
Scott Lamb | 6a5b751bd6 | |
Leandro Silva | ed7ab5dddf | |
Scott Lamb | 4ad627b997 | |
Scott Lamb | baa2ee6118 | |
Scott Lamb | aa60bc991c | |
Scott Lamb | 028243532a | |
Scott Lamb | ebcdd76084 | |
Skye | db2e0f1d39 | |
Skye | 81ea7d8a87 | |
Skye | 1fde947f36 | |
Skye | c2d226d58e | |
Skye | be53509325 | |
Skye | 10b61ddc5e | |
Skye | 3d40a39b93 | |
Skye | 930decc766 | |
Scott Lamb | 05562dae5b | |
Scott Lamb | e4ecd0d853 | |
Scott Lamb | 53414ed903 | |
Skye | 6acf9ad67f | |
Skye | 5a567da652 | |
Scott Lamb | 438de38202 | |
Scott Lamb | 0ffda11d4b | |
Tim Small | ad48cf2e10 | |
Scott Lamb | 2b27797f42 | |
Scott Lamb | 015dfef9c9 | |
Scott Lamb | 64d161d0a7 | |
Scott Lamb | 321c95a88c | |
Scott Lamb | f7718edc7f | |
Scott Lamb | b1a46cfb25 | |
Scott Lamb | e21f795e93 | |
dependabot[bot] | 23c1b9404b | |
Scott Lamb | 182f6f8a1b | |
Scott Lamb | a9430464b6 | |
Scott Lamb | 159e426943 | |
Scott Lamb | 284a59b05e | |
Scott Lamb | 3965cbc547 | |
Scott Lamb | 6a49bffff2 | |
Scott Lamb | 098b54c9f9 | |
Scott Lamb | fbb5e6b266 | |
Scott Lamb | f827c0647a | |
Scott Lamb | 9060dbfe14 | |
Scott Lamb | 50fa5ce6fe | |
Scott Lamb | 6ed23e90e8 | |
Scott Lamb | 58e19265ef | |
Scott Lamb | dc9c62e8bb | |
Scott Lamb | 2667dd68cb | |
Scott Lamb | dac0f44ed8 | |
Scott Lamb | 8c4e69f772 | |
Scott Lamb | 5248ebc51f | |
Scott Lamb | a4bc7f5218 | |
Scott Lamb | abcb26b281 | |
Scott Lamb | e0940979e4 | |
Scott Lamb | 689765ea97 | |
Scott Lamb | cc34a1aef5 | |
Scott Lamb | 7fe2284cec | |
Scott Lamb | dfa949815b | |
Scott Lamb | 42fe054d46 | |
Scott Lamb | a6bdf0bd80 | |
Scott Lamb | 88d7165c3e | |
Scott Lamb | 163eaa4cf9 | |
Scott Lamb | 6c90077ff1 | |
Scott Lamb | c02fc6f439 | |
Scott Lamb | 3ab30a318f | |
Scott Lamb | dffec68b2f | |
Scott Lamb | be4e11c506 | |
Scott Lamb | 918bb05d40 | |
Scott Lamb | 96e6cbfd5f | |
Scott Lamb | d509d3ff40 | |
Scott Lamb | 1ad14007a5 | |
Scott Lamb | a614a8f559 | |
Scott Lamb | be9c2e5815 | |
Scott Lamb | 5738410bdc | |
Scott Lamb | a5824b8633 | |
Scott Lamb | 8b50a45ab0 | |
Scott Lamb | 8d716bf4dd | |
Scott Lamb | ae502200c0 | |
Scott Lamb | d8ff02ab8b | |
Scott Lamb | 0866b23991 | |
Scott Lamb | b03eceb21a | |
Scott Lamb | 25346b82bc | |
Dima S | 2b92f06152 | |
Scott Lamb | 14f70ff4ce | |
K | 0d2cda5c18 |
|
@ -24,8 +24,7 @@ A clear and concise description of what you expected to happen.
|
|||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Server (please complete the following information):**
|
||||
- If using Docker: `docker ps` + `docker images`
|
||||
- If building from git: `git describe --dirty` + `moonfire-nvr --version`
|
||||
- `moonfire-nvr --version`
|
||||
- Attach a [log file](https://github.com/scottlamb/moonfire-nvr/blob/master/guide/troubleshooting.md#viewing-moonfire-nvrs-logs). Run with the `RUST_BACKTRACE=1` environment variable set if possible.
|
||||
|
||||
**Camera (please complete the following information):**
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
name: CI
|
||||
on: [push, pull_request]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
MOONFIRE_COLOR: always
|
||||
|
@ -11,16 +15,21 @@ jobs:
|
|||
name: Rust ${{ matrix.rust }}
|
||||
strategy:
|
||||
matrix:
|
||||
rust: [ "stable", "1.60", "nightly" ]
|
||||
rust: [ "stable", "1.70", "nightly" ]
|
||||
include:
|
||||
- rust: nightly
|
||||
extra_args: "--features nightly --benches"
|
||||
- rust: stable
|
||||
extra_components: rustfmt
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# `git describe` output gets baked into the binary for `moonfire-nvr --version`.
|
||||
# Fetch all revs so it can see tag history.
|
||||
fetch-depth: 0
|
||||
filter: 'tree:0'
|
||||
- name: Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
|
@ -28,9 +37,13 @@ jobs:
|
|||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
server/target
|
||||
key: ${{ matrix.rust }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
- name: Install dependencies
|
||||
run: sudo apt-get update && sudo apt-get install libavcodec-dev libavformat-dev libavutil-dev libncurses-dev libsqlite3-dev pkgconf
|
||||
key: cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
cargo-${{ matrix.rust }}-
|
||||
cargo-
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
|
@ -39,7 +52,9 @@ jobs:
|
|||
override: true
|
||||
components: ${{ matrix.extra_components }}
|
||||
- name: Test
|
||||
run: cd server && cargo test ${{ matrix.extra_args }} --all
|
||||
run: |
|
||||
cd server
|
||||
cargo test --features=rusqlite/bundled ${{ matrix.extra_args }} --all
|
||||
continue-on-error: ${{ matrix.rust == 'nightly' }}
|
||||
- name: Check formatting
|
||||
if: matrix.rust == 'stable'
|
||||
|
@ -48,22 +63,24 @@ jobs:
|
|||
name: Node ${{ matrix.node }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: [ "12", "14", "16" ]
|
||||
runs-on: ubuntu-20.04
|
||||
node: [ "18", "20", "21" ]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: cd ui && npm ci
|
||||
- run: cd ui && npm run build
|
||||
- run: cd ui && npm run test
|
||||
- run: cd ui && npm run lint
|
||||
- run: cd ui && npm run check-format
|
||||
# Install pnpm then use pnpm instead npm
|
||||
- run: npm i -g pnpm
|
||||
- run: cd ui && pnpm i --frozen-lockfile
|
||||
- run: cd ui && pnpm run build
|
||||
- run: cd ui && pnpm run test
|
||||
- run: cd ui && pnpm run lint
|
||||
- run: cd ui && pnpm run check-format
|
||||
license:
|
||||
name: Check copyright/license headers
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- run: find . -type f -print0 | xargs -0 .github/workflows/check-license.py
|
||||
|
|
|
@ -1,19 +1,146 @@
|
|||
name: Release
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
DOCKER_TAG: "ghcr.io/${{ github.repository }}:${{ github.ref_name }}"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v[0-9]+.*
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
base:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: taiki-e/create-gh-release-action@v1
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
# (Optional) Path to changelog.
|
||||
changelog: CHANGELOG.md
|
||||
tool: parse-changelog
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
VERSION_MINUS_V=${GITHUB_REF_NAME/#v/}
|
||||
parse-changelog CHANGELOG.md $VERSION_MINUS_V > CHANGELOG-$GITHUB_REF_NAME.md
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm i -g pnpm
|
||||
- run: cd ui && pnpm i --frozen-lockfile
|
||||
- run: cd ui && pnpm run build
|
||||
- run: cd ui && pnpm run test
|
||||
# Upload the UI and changelog as *job* artifacts (not *release* artifacts), used below.
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: moonfire-nvr-ui-${{ github.ref_name }}
|
||||
path: ui/dist
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: CHANGELOG-${{ github.ref_name }}
|
||||
path: CHANGELOG-${{ github.ref_name }}.md
|
||||
if-no-files-found: error
|
||||
|
||||
cross:
|
||||
needs: base # for bundled ui
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Note: keep these arches in sync with `Upload Docker Manifest` list.
|
||||
- arch: x86_64 # as in `uname -m` on Linux.
|
||||
rust_target: x86_64-unknown-linux-musl # as in <https://doc.rust-lang.org/rustc/platform-support.html>
|
||||
docker_platform: linux/amd64 # as in <https://docs.docker.com/build/building/multi-platform/>
|
||||
- arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-musl
|
||||
docker_platform: linux/arm64
|
||||
- arch: armv7l
|
||||
rust_target: armv7-unknown-linux-musleabihf
|
||||
docker_platform: linux/arm/v7
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Download UI
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: moonfire-nvr-ui-${{ github.ref_name }}
|
||||
path: ui/dist
|
||||
|
||||
# actions-rust-cross doesn't actually use cross for x86_64.
|
||||
# Install the needed musl-tools in the host.
|
||||
- name: Install musl-tools
|
||||
run: sudo apt-get --option=APT::Acquire::Retries=3 update && sudo apt-get --option=APT::Acquire::Retries=3 install musl-tools
|
||||
if: matrix.rust_target == 'x86_64-unknown-linux-musl'
|
||||
- name: Build
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
env:
|
||||
# (Required) GitHub token for creating GitHub Releases.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UI_BUILD_DIR: ../ui/dist
|
||||
|
||||
# cross doesn't install `git` within its Docker container, so plumb
|
||||
# the version through rather than try `git describe` from `build.rs`.
|
||||
VERSION: ${{ github.ref_name }}
|
||||
with:
|
||||
working-directory: server
|
||||
target: ${{ matrix.rust_target }}
|
||||
command: build
|
||||
args: --release --features bundled
|
||||
- name: Upload Docker Artifact
|
||||
run: |
|
||||
tag="${DOCKER_TAG}-${{ matrix.arch }}"
|
||||
mkdir output
|
||||
ln ./server/target/${{ matrix.rust_target }}/release/moonfire-nvr output/
|
||||
echo ${{secrets.GITHUB_TOKEN}} | docker login --username ${{github.actor}} --password-stdin ghcr.io
|
||||
docker build --platform ${{ matrix.docker_platform }} --push --tag "${tag}" --file - output <<EOF
|
||||
FROM scratch
|
||||
LABEL org.opencontainers.image.title="Moonfire NVR"
|
||||
LABEL org.opencontainers.image.description="security camera network video recorder"
|
||||
LABEL org.opencontainers.image.source="https://github.com/${{ github.repository }}"
|
||||
LABEL org.opencontainers.image.version="${{ github.ref_name }}"
|
||||
COPY --chown=root:root --chmod=755 ./moonfire-nvr /usr/local/bin/moonfire-nvr
|
||||
ENTRYPOINT ["/usr/local/bin/moonfire-nvr"]
|
||||
EOF
|
||||
# Upload as a *job* artifact (not *release* artifact), used below.
|
||||
- name: Upload Job Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: moonfire-nvr-${{ github.ref_name }}-${{ matrix.arch }}
|
||||
path: output/moonfire-nvr
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: [ base, cross ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: artifacts
|
||||
- name: ls before rearranging
|
||||
run: find . -ls
|
||||
- name: Rearrange
|
||||
run: |
|
||||
(cd artifacts/moonfire-nvr-ui-${GITHUB_REF_NAME} && zip -r ../../moonfire-nvr-ui-${GITHUB_REF_NAME}.zip .)
|
||||
(cd artifacts; for i in moonfire-nvr-*/moonfire-nvr; do mv $i "../$(dirname $i)"; done)
|
||||
- name: ls after rearranging
|
||||
run: find . -ls
|
||||
- name: Upload Docker Manifest (ghcr.io)
|
||||
run: |
|
||||
echo ${{secrets.GITHUB_TOKEN}} | docker login --username ${{github.actor}} --password-stdin ghcr.io
|
||||
|
||||
# Note: keep these arches in sync with `cross` job matrix.
|
||||
docker manifest create "$DOCKER_TAG" "${DOCKER_TAG}"-{x86_64,aarch64,armv7l}
|
||||
docker manifest push "$DOCKER_TAG"
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body_path: artifacts/CHANGELOG-${{ github.ref_name }}/CHANGELOG-${{ github.ref_name }}.md
|
||||
files: |
|
||||
moonfire-nvr-*
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
.DS_Store
|
||||
*.swp
|
||||
/release-*
|
||||
/server/target
|
||||
.idea
|
||||
|
|
|
@ -4,13 +4,14 @@
|
|||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"matklad.rust-analyzer",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"yzhang.markdown-all-in-one"
|
||||
"esbenp.prettier-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"vitest.explorer"
|
||||
],
|
||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
||||
"unwantedRecommendations": [
|
||||
"rust-lang.rust"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,11 +35,7 @@
|
|||
}
|
||||
],
|
||||
|
||||
// It seems like rust-analyzer is supposed to be able to format
|
||||
// Rust files, but with "matklad.rust-analyzer" here, VS Code says
|
||||
// "There is no formatter for 'rust' files installed."
|
||||
"editor.defaultFormatter": "matklad.rust-analyzer"
|
||||
//"editor.defaultFormatter": null
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
},
|
||||
"markdown.extension.list.indentationSize": "inherit",
|
||||
"markdown.extension.toc.unorderedList.marker": "*",
|
||||
|
@ -47,5 +43,7 @@
|
|||
// Specify the path to the workspace version of TypeScript. Note this only
|
||||
// takes effect when workspace version is selected in the UI.
|
||||
// https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-the-workspace-version-of-typescript
|
||||
"typescript.tsdk": "./ui/node_modules/typescript/lib"
|
||||
"typescript.tsdk": "./ui/node_modules/typescript/lib",
|
||||
"cmake.configureOnOpen": false,
|
||||
"vitest.enable": true
|
||||
}
|
||||
|
|
2
AUTHORS
2
AUTHORS
|
@ -1,2 +1,4 @@
|
|||
Scott Lamb <slamb@slamb.org>
|
||||
Dolf Starreveld <dolf@starreveld.com>
|
||||
Sky1e <me@skye-c.at>
|
||||
michioxd <michio.haiyaku@gmail.com>
|
||||
|
|
147
CHANGELOG.md
147
CHANGELOG.md
|
@ -1,12 +1,125 @@
|
|||
# Moonfire NVR change log
|
||||
|
||||
Below are some highlights in each release. For a full description of all
|
||||
changes, see Git history.
|
||||
changes, see Git history. Each release is tagged in git.
|
||||
|
||||
Each release is tagged in Git and on the Docker repository
|
||||
[`scottlamb/moonfire-nvr`](https://hub.docker.com/r/scottlamb/moonfire-nvr).
|
||||
Backwards-incompatible database schema changes happen on on major version
|
||||
upgrades, e.g. `v0.6.x` -> `v0.7.x`. The config file format and
|
||||
[API](ref/api.md) currently have no stability guarantees, so they may change
|
||||
even on minor releases, e.g. `v0.7.5` -> `v0.7.6`.
|
||||
|
||||
## 0.7.5 (2022-05-09)
|
||||
## unreleased
|
||||
|
||||
* in UI's list view, add a tooltip on the end time which shows why the
|
||||
recording ended.
|
||||
|
||||
## v0.7.16 (2024-05-30)
|
||||
|
||||
* further changes to improve Reolink camera compatibility.
|
||||
|
||||
## v0.7.15 (2024-05-26)
|
||||
|
||||
* update Retina to 0.4.8, improving compatibility with some Reolink cameras.
|
||||
See [retina#102](https://github.com/scottlamb/retina/issues/102).
|
||||
|
||||
## v0.7.14 (2024-04-16)
|
||||
|
||||
* Many UI improvements in [#315](https://github.com/scottlamb/moonfire-nvr/pull/315)
|
||||
from [@michioxd](https://github.com/michioxd). See the PR description for
|
||||
full details, including screenshots.
|
||||
* dark/light modes
|
||||
* redesigned login dialog
|
||||
* live view: new dual camera layout, more descriptive layout names,
|
||||
full screen option, re-open with last layout and camera selection
|
||||
* list view: filter button becomes outlined when enabled
|
||||
* Fix [#286](https://github.com/scottlamb/moonfire-nvr/issues/286):
|
||||
live view now works on Firefox! Formerly, it'd fail with messages such as
|
||||
`Security Error: Content at https://mydomain.com/ may not load data from blob:https://mydomain.com/44abc5dc-750d-48d1-817d-2e6a52445592`.
|
||||
|
||||
|
||||
## v0.7.13 (2024-02-12)
|
||||
|
||||
* seamlessly merge together recordings which have imperceptible changes in
|
||||
their `VideoSampleEntry`. Improves
|
||||
[#302](https://github.com/scottlamb/moonfire-nvr/issues/302).
|
||||
|
||||
## v0.7.12 (2024-01-08)
|
||||
|
||||
* update to Retina 0.4.7, supporting RTSP servers that do not set
|
||||
a `Content-Type` in their `DESCRIBE` responses
|
||||
|
||||
## v0.7.11 (2023-12-29)
|
||||
|
||||
* upgrade some Rust dependencies. Most notably, Retina 0.4.6 improves camera
|
||||
compatibility.
|
||||
* update frontend build and tests from no-longer-maintained create-react-app
|
||||
to vite and vitest.
|
||||
|
||||
## v0.7.10 (2023-11-28)
|
||||
|
||||
* build docker images again
|
||||
* upgrade date/time pickers to `@mui/x-date-pickers` v6 beta, improving
|
||||
time entry. Fixes
|
||||
[#256](https://github.com/scottlamb/moonfire-nvr/issues/256).
|
||||
|
||||
## v0.7.9 (2023-10-21)
|
||||
|
||||
* `systemd` integration on Linux
|
||||
* notify `systemd` on starting/stopping. To take advantage of this, you'll
|
||||
need to modify your `/etc/systemd/moonfire-nvr.service`. See
|
||||
[`guide/install.md`](guide/install.md).
|
||||
* socket activation. See [`ref/config.md`](ref/config.md).
|
||||
|
||||
## v0.7.8 (2023-10-18)
|
||||
|
||||
* release as self-contained Linux binaries (for `x86_64`, `aarch64`, and
|
||||
`armv8` architectures) rather than Docker images. This minimizes hassle and
|
||||
total download size. Along the way, we switched libc from `glibc` to `musl`.
|
||||
Please report any problems with the build or instructions!
|
||||
|
||||
## v0.7.7 (2023-08-03)
|
||||
|
||||
* fix [#289](https://github.com/scottlamb/moonfire-nvr/issues/289): crash on
|
||||
pressing the `Add` button in the sample file directory dialog
|
||||
* log to `stderr` again, fixing a regression with the `tracing` change in 0.7.6.
|
||||
* experimental (off by default) support for bundling UI files into the
|
||||
executable.
|
||||
|
||||
## v0.7.6 (2023-07-08)
|
||||
|
||||
* new log formats using `tracing`. This will allow richer context information.
|
||||
* bump minimum Rust version to 1.70.
|
||||
* expect camelCase in `moonfire-nvr.toml` file, for consistency with the JSON
|
||||
API. You'll need to adjust your config file when upgrading.
|
||||
* use Retina 0.4.5.
|
||||
* This version is newly compatible with rtsp-simple-server v0.19.3 and some
|
||||
TP-Link cameras. Fixes [#238](https://github.com/scottlamb/moonfire-nvr/issues/238).
|
||||
* Fixes problems connecting to cameras that use RTP extensions.
|
||||
* Fixes problems with Longse cameras
|
||||
[scottlamb/retina#77](https://github.com/scottlamb/retina/pull/77).
|
||||
* expanded API interface for examining and updating users:
|
||||
* `admin_users` permission for operating on arbitrary users.
|
||||
* `GET /users/` endpoint to list users
|
||||
* `POST /users/` endpoint to add a user
|
||||
* `GET /users/<id>` endpoint to examine a user in detail
|
||||
* expanded `PATCH /users/<id>` endpoint, including password and
|
||||
permissions.
|
||||
* `DELETE /users/<id>` endpoint to delete a user
|
||||
* improved API documentation in [`ref/api.md`](ref/api.md).
|
||||
* first draft of a web UI for user administration. Rough edges expected!
|
||||
* `moonfire-nvr login --permissions` now accepts the JSON format documented
|
||||
in `ref/api.md`, not an undocumented plaintext protobuf format.
|
||||
* fix [#257](https://github.com/scottlamb/moonfire-nvr/issues/257):
|
||||
Live View: select None Not Possible After Selecting a Camera.
|
||||
* get rid of live view's dreaded `ws close: 1006` error altogether. The live
|
||||
view WebSocket protocol now conveys errors in a way that allows the
|
||||
Javscript UI to see them.
|
||||
* fix [#282](https://github.com/scottlamb/moonfire-nvr/issues/282):
|
||||
sessions' last use information wasn't getting persisted.
|
||||
* improvements to `moonfire-nvr config`,
|
||||
thanks to [@sky1e](https://github.com/sky1e).
|
||||
|
||||
## v0.7.5 (2022-05-09)
|
||||
|
||||
* [#219](https://github.com/scottlamb/moonfire-nvr/issues/219): fix
|
||||
live stream failing with `ws close: 1006` on URLs with port numbers.
|
||||
|
@ -16,7 +129,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
Retina 0.3.10, improving compatibility with OMNY M5S2A 2812 cameras that
|
||||
send invalid `rtptime` values.
|
||||
|
||||
## 0.7.4 (2022-04-13)
|
||||
## v0.7.4 (2022-04-13)
|
||||
|
||||
* upgrade to Retina 0.3.9, improving camera interop and diagnostics.
|
||||
Fixes [#213](https://github.com/scottlamb/moonfire-nvr/issues/213),
|
||||
|
@ -27,13 +140,13 @@ Each release is tagged in Git and on the Docker repository
|
|||
* [#206](https://github.com/scottlamb/moonfire-nvr/issues/206#issuecomment-1086442543):
|
||||
fix `teardown Sender shouldn't be dropped: RecvError(())` errors on shutdown.
|
||||
|
||||
## 0.7.3 (2022-03-22)
|
||||
## v0.7.3 (2022-03-22)
|
||||
|
||||
* security fix: check the `Origin` header on live stream WebSocket requests
|
||||
to avoid cross-site WebSocket hijacking (CSWSH).
|
||||
* RTSP connections always use the Retina library rather than FFmpeg.
|
||||
|
||||
## 0.7.2 (2022-03-16)
|
||||
## v0.7.2 (2022-03-16)
|
||||
|
||||
* introduce a configuration file `/etc/moonfire-nvr.toml`; you will need
|
||||
to create one when upgrading.
|
||||
|
@ -50,13 +163,13 @@ Each release is tagged in Git and on the Docker repository
|
|||
* progress on [#70](https://github.com/scottlamb/moonfire-nvr/issues/184):
|
||||
shrink the binary from 154 MiB to 70 MiB by reducing debugging information.
|
||||
|
||||
## 0.7.1 (2021-10-27)
|
||||
## v0.7.1 (2021-10-27)
|
||||
|
||||
* bugfix: editing a camera from `nvr config` would erroneously clear the
|
||||
sample file directory associated with its streams.
|
||||
* RTSP transport (TCP or UDP) can be set per-stream from `nvr config`.
|
||||
|
||||
## 0.7.0 (2021-10-27)
|
||||
## v0.7.0 (2021-10-27)
|
||||
|
||||
* [schema version 7](guide/schema.md#version-7)
|
||||
* Changes to the [API](guide/api.md):
|
||||
|
@ -83,7 +196,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
currently may be either absent or the string `record`.
|
||||
* Added `POST /api/users/<id>` for altering a user's UI preferences.
|
||||
|
||||
## 0.6.7 (2021-10-20)
|
||||
## v0.6.7 (2021-10-20)
|
||||
|
||||
* trim whitespace when detecting time zone by reading `/etc/timezone`.
|
||||
* (Retina 0.3.2) better `TEARDOWN` handling with the default
|
||||
|
@ -95,7 +208,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
`--rtsp-library=retina` (see
|
||||
[scottlamb/retina#25](https://github.com/scottlamb/retina/25)).
|
||||
|
||||
## 0.6.6 (2021-09-23)
|
||||
## v0.6.6 (2021-09-23)
|
||||
|
||||
* fix [#146](https://github.com/scottlamb/moonfire-nvr/issues/146): "init
|
||||
segment fetch error" when browsers have cached data from `v0.6.4` and
|
||||
|
@ -120,7 +233,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
impatient to get fast results with ctrl-C when running interactively, rather
|
||||
than having to use `SIGKILL` from another terminal.
|
||||
|
||||
## 0.6.5 (2021-08-13)
|
||||
## v0.6.5 (2021-08-13)
|
||||
|
||||
* UI: improve video aspect ratio handling. Live streams formerly worked
|
||||
around a Firefox pixel aspect ratio bug by forcing all videos to 16:9, which
|
||||
|
@ -134,7 +247,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
`GET_PARAMETERS` as a RTSP keepalive. GW Security cameras would ignored
|
||||
the latter, causing Moonfire NVR to drop the connection every minute.
|
||||
|
||||
## 0.6.4 (2021-06-28)
|
||||
## v0.6.4 (2021-06-28)
|
||||
|
||||
* Default to a new pure-Rust RTSP library, `retina`. If you hit problems, you
|
||||
can switch back via `--rtsp-library=ffmpeg`. Please report a bug if this
|
||||
|
@ -142,7 +255,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
* Correct the pixel aspect ratio of 9:16 sub streams (eg a standard 16x9
|
||||
camera rotated 90 degrees) in the same way as 16:9 sub streams.
|
||||
|
||||
## 0.6.3 (2021-03-31)
|
||||
## v0.6.3 (2021-03-31)
|
||||
|
||||
* New user interface! Besides a more modern appearance, it has better
|
||||
error handling and an experimental live view UI.
|
||||
|
@ -152,7 +265,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
not calculated properly there might be unexpected gaps or overlaps in
|
||||
playback.
|
||||
|
||||
## 0.6.2 (2021-03-12)
|
||||
## v0.6.2 (2021-03-12)
|
||||
|
||||
* Fix panics when a stream's PTS has extreme jumps
|
||||
([#113](https://github.com/scottlamb/moonfire-nvr/issues/113))
|
||||
|
@ -162,7 +275,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
`moonfire-nvr check --delete-orphan-rows` command from actually deleting
|
||||
rows.
|
||||
|
||||
## 0.6.1 (2021-02-16)
|
||||
## v0.6.1 (2021-02-16)
|
||||
|
||||
* Improve the server's error messages on the console and in logs.
|
||||
* Switch the UI build from the `yarn` package manager to `npm`.
|
||||
|
@ -174,7 +287,7 @@ Each release is tagged in Git and on the Docker repository
|
|||
* Fix mangled favicons
|
||||
([#105](https://github.com/scottlamb/moonfire-nvr/issues/105))
|
||||
|
||||
## 0.6.0 (2021-01-22)
|
||||
## v0.6.0 (2021-01-22)
|
||||
|
||||
This is the first tagged version and first Docker image release. I chose the
|
||||
version number 0.6.0 to match the current schema version 6.
|
||||
|
|
13
README.md
13
README.md
|
@ -35,10 +35,9 @@ There's no support yet for motion detection, no https/TLS support (you'll
|
|||
need a proxy server, as described [here](guide/secure.md)), and only a
|
||||
console-based (rather than web-based) configuration UI.
|
||||
|
||||
Moonfire NVR is currently at version 0.7.5. Until version 1.0, there will be no
|
||||
compatibility guarantees: configuration and storage formats may change from
|
||||
version to version. There is an [upgrade procedure](guide/schema.md) but it is
|
||||
not for the faint of heart.
|
||||
Moonfire NVR is pre-1.0, with will be no compatibility guarantees:
|
||||
configuration and storage formats may change from version to version. There is
|
||||
an [upgrade procedure](guide/schema.md) but it is not for the faint of heart.
|
||||
|
||||
I hope to add features such as video analytics. In time, we can build
|
||||
a full-featured hobbyist-oriented multi-camera NVR that requires nothing but
|
||||
|
@ -66,11 +65,15 @@ could use to make this possible:
|
|||
with [GPL-3.0-linking-exception](https://spdx.org/licenses/GPL-3.0-linking-exception.html)
|
||||
for OpenSSL.
|
||||
* [Change log](CHANGELOG.md) / release notes.
|
||||
* [Guides](guide/)
|
||||
* [Guides](guide/), including:
|
||||
* [Installing](guide/install.md)
|
||||
* [Building from source](guide/build.md)
|
||||
* [Securing Moonfire NVR and exposing it to the Internet](guide/secure.md)
|
||||
* [UI Development](guide/developing-ui.md)
|
||||
* [Troubleshooting](guide/troubleshooting.md)
|
||||
* [References](ref/), including:
|
||||
* [Configuration file](ref/config.md)
|
||||
* [JSON API](ref/api.md)
|
||||
* [Design documents](design/)
|
||||
* [Wiki](https://github.com/scottlamb/moonfire-nvr/wiki) has hardware
|
||||
recommendations, notes on several camera models, etc. Please add more!
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
Design documents and [Architectural Decision Records](https://adr.github.io/)
|
||||
for Moonfire NVR. Meant for developers.
|
|
@ -33,8 +33,9 @@ Source: https://www.best-microcontroller-projects.com/ppm.html
|
|||
*recording:* the video from a (typically 1-minute) portion of an RTSP session.
|
||||
RTSP sessions are divided into recordings as a detail of the
|
||||
storage schema. See [schema.md](schema.md) for details. This concept is exposed
|
||||
to the frontend code through the API; see [api.md](api.md). It's not exposed in
|
||||
the user interface; videos are reconstructed from segments automatically.
|
||||
to the frontend code through the API; see [../ref/api.md](../ref/api.md). It's
|
||||
not exposed in the user interface; videos are reconstructed from segments
|
||||
automatically.
|
||||
|
||||
*run:* all the recordings from a single RTSP session. These are all from the
|
||||
same *stream* and could be reassembled into a single video with no gaps. If the
|
||||
|
@ -51,7 +52,8 @@ sample files for one or more streams. Typically there is one directory per disk.
|
|||
*segment:* part or all of a recording. An API request might ask for a video of
|
||||
recordings 1–4 starting 80 seconds in. If each recording is exactly 60 seconds,
|
||||
this would correspond to three segments: recording 2 from 20 seconds in to
|
||||
the end, all of recording 3, and all of recording 4. See [api.md](api.md).
|
||||
the end, all of recording 3, and all of recording 4. See
|
||||
[../ref/api.md](../ref/api.md).
|
||||
|
||||
*session:* a set of authenticated Moonfire NVR requests defined by the use of a
|
||||
given credential (`s` cookie). Each user may have many credentials and thus
|
||||
|
@ -60,9 +62,10 @@ nothing to do with RTSP sessions; those more closely match a *run*.
|
|||
|
||||
*signal:* a timeseries with an enum value. Signals might represent a camera's
|
||||
motion detection or day/night status. They could also represent an external
|
||||
input such as a burglar alarm system's zone status. See [api.md](api.md).
|
||||
Note signals are still under development and not yet exposed in Moonfire NVR's
|
||||
UI. See [#28](https://github.com/scottlamb/moonfire-nvr/issues/28) for more
|
||||
input such as a burglar alarm system's zone status. See
|
||||
[../ref/api.md](../ref/api.md). Note signals are still under development and
|
||||
not yet exposed in Moonfire NVR's UI. See
|
||||
[#28](https://github.com/scottlamb/moonfire-nvr/issues/28) for more
|
||||
information.
|
||||
|
||||
*stream:* the "main" or "sub" stream from a given camera. Moonfire NVR expects
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
# Moonfire NVR Signals
|
||||
|
||||
Status: **draft**.
|
||||
|
||||
"Signals" are what Moonfire NVR uses to describe non-video timeseries data
|
||||
such as "was motion detected?" or "what mode was my burglar alarm in?" They are
|
||||
intended to be displayed in the UI with the video scrub bar to aid in finding
|
||||
a relevant portion of video.
|
||||
|
||||
## Objective
|
||||
|
||||
Goals:
|
||||
|
||||
* represent simple results of on-camera and on-NVR motion detection, e.g.:
|
||||
`true`, `false`, or `unknown`.
|
||||
* represent external signals such as burglar alarm state, e.g.:
|
||||
`off`, `stay`, `away`, `alarm`, or `unknown`.
|
||||
|
||||
Non-goals:
|
||||
|
||||
* provide meaningful data when the NVR has inaccurate system time.
|
||||
* support internal state necessary for on-NVR motion detection. (This will
|
||||
be considered separately.)
|
||||
* support fine-grained outputs such as "what are the bounding boxes of all
|
||||
detected faces?", "what cells have motion?", audio volume, or audio
|
||||
spectograms.
|
||||
|
||||
## Overview
|
||||
|
||||
hmm, two ideas:
|
||||
|
||||
* just use timestamps everywhere. allow adding/updating historical data.
|
||||
* only allow updating the current open. initially, just support setting
|
||||
current time. then support extending from a previous request. no ability
|
||||
to fill in while NVR is down.
|
|
@ -1,75 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.2.1
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
# See documentation here:
|
||||
# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/syntax.md
|
||||
|
||||
# "dev-common" is the portion of "dev" (see below) which isn't specific to the
|
||||
# target arch. It's sufficient for building the non-arch-specific webpack.
|
||||
FROM --platform=$BUILDPLATFORM ubuntu:20.04 AS dev-common
|
||||
LABEL maintainer="slamb@slamb.org"
|
||||
ARG BUILD_UID=1000
|
||||
ARG BUILD_GID=1000
|
||||
ARG INVALIDATE_CACHE_DEV_COMMON=
|
||||
ENV LC_ALL=C.UTF-8
|
||||
COPY docker/dev-common.bash /
|
||||
RUN --mount=type=cache,id=var-cache-apt,target=/var/cache/apt,sharing=locked \
|
||||
/dev-common.bash
|
||||
CMD [ "/bin/bash", "--login" ]
|
||||
|
||||
# "dev" is a full development environment, suitable for shelling into or
|
||||
# using with the VS Code container plugin.
|
||||
FROM --platform=$BUILDPLATFORM dev-common AS dev
|
||||
LABEL maintainer="slamb@slamb.org"
|
||||
ARG BUILDARCH
|
||||
ARG TARGETARCH
|
||||
ARG INVALIDATE_CACHE_DEV=
|
||||
COPY docker/dev.bash /
|
||||
RUN --mount=type=cache,id=var-cache-apt,target=/var/cache/apt,sharing=locked \
|
||||
/dev.bash
|
||||
USER moonfire-nvr:moonfire-nvr
|
||||
WORKDIR /var/lib/moonfire-nvr
|
||||
|
||||
# Build the UI with node_modules and ui-dist outside the src dir.
|
||||
FROM --platform=$BUILDPLATFORM dev-common AS build-ui
|
||||
ARG INVALIDATE_CACHE_BUILD_UI=
|
||||
LABEL maintainer="slamb@slamb.org"
|
||||
WORKDIR /var/lib/moonfire-nvr/src/ui
|
||||
COPY docker/build-ui.bash /
|
||||
COPY ui /var/lib/moonfire-nvr/src/ui
|
||||
RUN --mount=type=tmpfs,target=/var/lib/moonfire-nvr/src/ui/node_modules \
|
||||
/build-ui.bash
|
||||
|
||||
# Build the Rust components. Note that dev.sh set up an environment variable
|
||||
# in .buildrc that similarly changes the target dir path.
|
||||
FROM --platform=$BUILDPLATFORM dev AS build-server
|
||||
LABEL maintainer="slamb@slamb.org"
|
||||
ARG INVALIDATE_CACHE_BUILD_SERVER=
|
||||
COPY docker/build-server.bash /
|
||||
RUN --mount=type=cache,id=target,target=/var/lib/moonfire-nvr/target,sharing=locked,mode=777 \
|
||||
--mount=type=cache,id=cargo,target=/cargo-cache,sharing=locked,mode=777 \
|
||||
--mount=type=bind,source=server,target=/var/lib/moonfire-nvr/src/server,readonly \
|
||||
/build-server.bash
|
||||
|
||||
# Deployment environment, now in the target platform.
|
||||
FROM --platform=$TARGETPLATFORM ubuntu:20.04 AS deploy
|
||||
LABEL maintainer="slamb@slamb.org"
|
||||
ARG INVALIDATE_CACHE_BUILD_DEPLOY=
|
||||
ENV LC_ALL=C.UTF-8
|
||||
COPY docker/deploy.bash /
|
||||
RUN --mount=type=cache,id=var-cache-apt,target=/var/cache/apt,sharing=locked \
|
||||
/deploy.bash
|
||||
COPY --from=dev-common /docker-build-debug/dev-common/ /docker-build-debug/dev-common/
|
||||
COPY --from=dev /docker-build-debug/dev/ /docker-build-debug/dev/
|
||||
COPY --from=build-server /docker-build-debug/build-server/ /docker-build-debug/build-server/
|
||||
COPY --from=build-server /usr/local/bin/moonfire-nvr /usr/local/bin/moonfire-nvr
|
||||
COPY --from=build-ui /docker-build-debug/build-ui /docker-build-debug/build-ui
|
||||
COPY --from=build-ui /var/lib/moonfire-nvr/src/ui/build /usr/local/lib/moonfire-nvr/ui
|
||||
|
||||
# The install instructions say to use --user in the docker run commandline.
|
||||
# Specify a non-root user just in case someone forgets.
|
||||
USER 10000:10000
|
||||
WORKDIR /var/lib/moonfire-nvr
|
||||
ENTRYPOINT [ "/usr/local/bin/moonfire-nvr" ]
|
|
@ -1,37 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
# Build the "build-server" target. See Dockerfile.
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
mkdir /docker-build-debug/build-server
|
||||
exec > >(tee -i /docker-build-debug/build-server/output) 2>&1
|
||||
date
|
||||
uname -a
|
||||
ls -laFR /cargo-cache > /docker-build-debug/build-server/cargo-cache-before
|
||||
ls -laFR /var/lib/moonfire-nvr/target \
|
||||
> /docker-build-debug/build-server/target-before
|
||||
|
||||
source ~/.buildrc
|
||||
|
||||
# The "mode" argument to cache mounts doesn't seem to work reliably
|
||||
# (as of Docker version 20.10.5, build 55c4c88, using a docker-container
|
||||
# builder), thus the chmod command.
|
||||
sudo chmod 777 /cargo-cache /var/lib/moonfire-nvr/target
|
||||
mkdir -p /cargo-cache/{git,registry}
|
||||
ln -s /cargo-cache/{git,registry} ~/.cargo
|
||||
|
||||
cd src/server
|
||||
time cargo test
|
||||
time cargo build --profile=release-lto
|
||||
sudo install -m 755 ~/moonfire-nvr /usr/local/bin/moonfire-nvr
|
||||
|
||||
ls -laFR /cargo-cache > /docker-build-debug/build-server/cargo-cache-after
|
||||
ls -laFR /var/lib/moonfire-nvr/target \
|
||||
> /docker-build-debug/build-server/target-after
|
||||
date
|
|
@ -1,24 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
# Build the "build-ui" target. See Dockerfile.
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
mkdir /docker-build-debug/build-ui
|
||||
exec > >(tee -i /docker-build-debug/build-ui/output) 2>&1
|
||||
|
||||
date
|
||||
uname -a
|
||||
node --version
|
||||
npm --version
|
||||
time npm ci
|
||||
time npm run build
|
||||
|
||||
ls -laFR /var/lib/moonfire-nvr/src/ui/node_modules \
|
||||
> /docker-build-debug/build-ui/node_modules-after
|
||||
date
|
|
@ -1,34 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
# Build the "deploy" target. See Dockerfile.
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
mkdir -p /docker-build-debug/deploy
|
||||
exec > >(tee -i /docker-build-debug/deploy/output) 2>&1
|
||||
ls -laFR /var/cache/apt \
|
||||
> /docker-build-debug/deploy/var-cache-apt-before
|
||||
|
||||
date
|
||||
uname -a
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
time apt-get update
|
||||
time apt-get install --assume-yes --no-install-recommends \
|
||||
libncurses6 \
|
||||
libncursesw6 \
|
||||
locales \
|
||||
sudo \
|
||||
sqlite3 \
|
||||
tzdata \
|
||||
vim-nox
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
ln -s moonfire-nvr /usr/local/bin/nvr
|
||||
|
||||
ls -laFR /var/cache/apt \
|
||||
> /docker-build-debug/deploy/var-cache-apt-after
|
||||
date
|
|
@ -1,82 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
# Build the "dev" target. See Dockerfile.
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
mkdir --mode=1777 /docker-build-debug
|
||||
mkdir /docker-build-debug/dev-common
|
||||
exec > >(tee -i /docker-build-debug/dev-common/output) 2>&1
|
||||
|
||||
date
|
||||
uname -a
|
||||
ls -laFR /var/cache/apt > /docker-build-debug/dev-common/var-cache-apt-before
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# This file cleans apt caches after every invocation. Instead, we use a
|
||||
# buildkit cachemount to avoid putting them in the image while still allowing
|
||||
# some reuse.
|
||||
rm /etc/apt/apt.conf.d/docker-clean
|
||||
|
||||
packages=()
|
||||
|
||||
# Install all packages necessary for building (and some for testing/debugging).
|
||||
packages+=(
|
||||
build-essential
|
||||
curl
|
||||
pkgconf
|
||||
locales
|
||||
sudo
|
||||
sqlite3
|
||||
tzdata
|
||||
vim-nox
|
||||
)
|
||||
time apt-get update
|
||||
time apt-get install --assume-yes --no-install-recommends "${packages[@]}"
|
||||
|
||||
# Install a more recent nodejs/npm than in the universe repository.
|
||||
time curl -sL https://deb.nodesource.com/setup_14.x | bash -
|
||||
time apt-get install nodejs
|
||||
|
||||
# Create the user. On the dev environment, allow sudo.
|
||||
groupadd \
|
||||
--gid="${BUILD_GID}" \
|
||||
moonfire-nvr
|
||||
useradd \
|
||||
--no-log-init \
|
||||
--home-dir=/var/lib/moonfire-nvr \
|
||||
--uid="${BUILD_UID}" \
|
||||
--gid=moonfire-nvr \
|
||||
--shell=/bin/bash \
|
||||
--create-home \
|
||||
moonfire-nvr
|
||||
echo 'moonfire-nvr ALL=(ALL) NOPASSWD: ALL' >>/etc/sudoers
|
||||
|
||||
# Install Rust. Note curl was already installed for yarn above.
|
||||
time su moonfire-nvr -lc "
|
||||
curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs |
|
||||
sh -s - -y"
|
||||
|
||||
# Put configuration for the Rust build into a new ".buildrc" which is used
|
||||
# both (1) interactively from ~/.bashrc when logging into the dev container
|
||||
# and (2) from a build-server RUN command. In particular, the latter can't
|
||||
# use ~/.bashrc because that script immediately exits when run from a
|
||||
# non-interactive shell.
|
||||
echo 'source $HOME/.buildrc' >> /var/lib/moonfire-nvr/.bashrc
|
||||
cat >> /var/lib/moonfire-nvr/.buildrc <<EOF
|
||||
source \$HOME/.cargo/env
|
||||
|
||||
# Set the target directory to be outside the src bind mount.
|
||||
# https://doc.rust-lang.org/cargo/reference/config.html#buildtarget-dir
|
||||
export CARGO_BUILD_TARGET_DIR=/var/lib/moonfire-nvr/target
|
||||
EOF
|
||||
chown moonfire-nvr:moonfire-nvr /var/lib/moonfire-nvr/.buildrc
|
||||
|
||||
ls -laFR /var/cache/apt > /docker-build-debug/dev-common/var-cache-apt-after
|
||||
date
|
119
docker/dev.bash
119
docker/dev.bash
|
@ -1,119 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
# Build the "dev" target. See Dockerfile.
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
mkdir /docker-build-debug/dev
|
||||
exec > >(tee -i /docker-build-debug/dev/output) 2>&1
|
||||
|
||||
date
|
||||
uname -a
|
||||
ls -laFR /var/cache/apt > /docker-build-debug/dev/var-cache-apt-before
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
packages=()
|
||||
|
||||
if [[ "${BUILDARCH}" != "${TARGETARCH}" ]]; then
|
||||
# Set up cross compilation.
|
||||
case "${TARGETARCH}" in
|
||||
arm64)
|
||||
dpkg_arch=arm64
|
||||
gcc_target=aarch64-linux-gnu
|
||||
rust_target=aarch64-unknown-linux-gnu
|
||||
target_is_port=1
|
||||
;;
|
||||
arm)
|
||||
dpkg_arch=armhf
|
||||
gcc_target=arm-linux-gnueabihf
|
||||
rust_target=arm-unknown-linux-gnueabihf
|
||||
target_is_port=1
|
||||
;;
|
||||
amd64)
|
||||
dpkg_arch=amd64
|
||||
gcc_target=x86_64-linux-gnu
|
||||
rust_target=x86_64-unknown-linux-gnu
|
||||
target_is_port=0
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported cross-compile target ${TARGETARCH}." >&2
|
||||
exit 1
|
||||
esac
|
||||
apt_target_suffix=":${dpkg_arch}"
|
||||
case "${BUILDARCH}" in
|
||||
amd64|386) host_is_port=0 ;;
|
||||
*) host_is_port=1 ;;
|
||||
esac
|
||||
|
||||
time dpkg --add-architecture "${dpkg_arch}"
|
||||
|
||||
if [[ $target_is_port -ne $host_is_port ]]; then
|
||||
# Ubuntu stores non-x86 architectures at a different URL, so futz the
|
||||
# sources file to allow installing both host and target.
|
||||
# See https://github.com/rust-embedded/cross/blob/master/docker/common.sh
|
||||
perl -pi.bak -e '
|
||||
s{^deb (http://.*.ubuntu.com/ubuntu/) (.*)}
|
||||
{deb [arch=amd64,i386] \1 \2\ndeb [arch-=amd64,i386] http://ports.ubuntu.com/ubuntu-ports \2};
|
||||
s{^deb (http://ports.ubuntu.com/ubuntu-ports/) (.*)}
|
||||
{deb [arch=amd64,i386] http://archive.ubuntu.com/ubuntu \2\ndeb [arch-=amd64,i386] \1 \2}' \
|
||||
/etc/apt/sources.list
|
||||
cat /etc/apt/sources.list
|
||||
fi
|
||||
|
||||
packages+=(
|
||||
g++-${gcc_target/_/-}
|
||||
libc6-dev-${dpkg_arch}-cross
|
||||
pkg-config-${gcc_target}
|
||||
qemu-user
|
||||
)
|
||||
fi
|
||||
|
||||
time apt-get update
|
||||
|
||||
# Install the packages for the target architecture.
|
||||
packages+=(
|
||||
libncurses-dev"${apt_target_suffix}"
|
||||
libsqlite3-dev"${apt_target_suffix}"
|
||||
)
|
||||
time apt-get update
|
||||
time apt-get install --assume-yes --no-install-recommends "${packages[@]}"
|
||||
|
||||
# Set environment variables for cross-compiling.
|
||||
# Also set up a symlink that points to the release binary, because the
|
||||
# release binary's location varies when cross-compiling, as described here:
|
||||
# https://doc.rust-lang.org/cargo/guide/build-cache.html
|
||||
if [[ -n "${rust_target}" ]]; then
|
||||
su moonfire-nvr -lc "rustup target install ${rust_target} &&
|
||||
ln -s target/${rust_target}/release/moonfire-nvr ."
|
||||
underscore_rust_target="${rust_target//-/_}"
|
||||
uppercase_underscore_rust_target="${underscore_rust_target^^}"
|
||||
cat >> /var/lib/moonfire-nvr/.buildrc <<EOF
|
||||
|
||||
# https://doc.rust-lang.org/cargo/reference/config.html
|
||||
export CARGO_BUILD_TARGET=${rust_target}
|
||||
export CARGO_TARGET_${uppercase_underscore_rust_target}_LINKER=${gcc_target}-gcc
|
||||
|
||||
# https://github.com/rust-lang/pkg-config-rs uses the "PKG_CONFIG"
|
||||
# variable to to select the pkg-config binary to use. As of pkg-config 0.3.19,
|
||||
# it unfortunately doesn't understand the <target>_ prefix that the README.md
|
||||
# describes for other vars. Fortunately Moonfire NVR doesn't have any host tools
|
||||
# that need pkg-config.
|
||||
export PKG_CONFIG=${gcc_target}-pkg-config
|
||||
|
||||
# https://github.com/alexcrichton/cc-rs uses these variables to decide what
|
||||
# compiler to invoke.
|
||||
export CC_${underscore_rust_target}=${gcc_target}-gcc
|
||||
export CXX_${underscore_rust_target}=${gcc_target}-g++
|
||||
EOF
|
||||
else
|
||||
su moonfire-nvr -lc "ln -s target/release/moonfire-nvr ."
|
||||
fi
|
||||
|
||||
ls -laFR /var/cache/apt > /docker-build-debug/dev/var-cache-apt-after
|
||||
date
|
|
@ -0,0 +1 @@
|
|||
Guides to using and contributing to Moonfire NVR.
|
272
guide/build.md
272
guide/build.md
|
@ -2,7 +2,7 @@
|
|||
|
||||
This document has notes for software developers on building Moonfire NVR from
|
||||
source code for development. If you just want to install precompiled
|
||||
binaries, see the [Docker installation instructions](install.md) instead.
|
||||
binaries, see the [installation instructions](install.md) instead.
|
||||
|
||||
This document doesn't spell out as many details as the installation
|
||||
instructions. Please ask on Moonfire NVR's [issue
|
||||
|
@ -11,11 +11,9 @@ tracker](https://github.com/scottlamb/moonfire-nvr/issues) or
|
|||
stuck. Please also send pull requests to improve this doc.
|
||||
|
||||
* [Downloading](#downloading)
|
||||
* [Docker builds](#docker-builds)
|
||||
* [Release procedure](#release-procedure)
|
||||
* [Non-Docker setup](#non-docker-setup)
|
||||
* [Building](#building)
|
||||
* [Running interactively straight from the working copy](#running-interactively-straight-from-the-working-copy)
|
||||
* [Running as a `systemd` service](#running-as-a-systemd-service)
|
||||
* [Release procedure](#release-procedure)
|
||||
|
||||
## Downloading
|
||||
|
||||
|
@ -28,155 +26,21 @@ $ git clone https://github.com/scottlamb/moonfire-nvr.git
|
|||
$ cd moonfire-nvr
|
||||
```
|
||||
|
||||
## Docker builds
|
||||
## Building
|
||||
|
||||
This command should prepare a deployment image for your local machine:
|
||||
|
||||
```console
|
||||
$ sudo docker buildx build --load --tag=moonfire-nvr -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Common errors</summary>
|
||||
|
||||
* `docker: 'buildx' is not a docker command.`: You shouldn't see this with
|
||||
Docker 20.10. With Docker version 19.03 you'll need to prepend
|
||||
`DOCKER_CLI_EXPERIMENTAL=enabled` to `docker buildx build` commands. If
|
||||
your Docker version is older than 19.03, you'll need to upgrade.
|
||||
* `At least one invalid signature was encountered.`: this is likely
|
||||
due to an error in `libseccomp`, as described [in this askubuntu.com answer](https://askubuntu.com/a/1264921/1365248).
|
||||
Try running in a privileged builder. As described in [`docker buildx build` documentation](https://docs.docker.com/engine/reference/commandline/buildx_build/#allow),
|
||||
run this command once:
|
||||
```console
|
||||
$ sudo docker buildx create --use --name insecure-builder --buildkitd-flags '--allow-insecure-entitlement security.insecure'
|
||||
```
|
||||
then add `--allow security.insecure` to your `docker buildx build` commandlines.
|
||||
</details>
|
||||
|
||||
If you want to iterate on code changes, doing a full Docker build from
|
||||
scratch every time will be painfully slow. You will likely find it more
|
||||
helpful to use the `dev` target. This is a self-contained developer environment
|
||||
which you can use from its shell via `docker run` or via something like
|
||||
Visual Studio Code's Docker plugin.
|
||||
|
||||
```console
|
||||
$ sudo docker buildx build \
|
||||
--load --tag=moonfire-dev --target=dev -f docker/Dockerfile .
|
||||
...
|
||||
$ sudo docker run \
|
||||
--rm --interactive=true --tty \
|
||||
--mount=type=bind,source=$(pwd),destination=/var/lib/moonfire-nvr/src \
|
||||
moonfire-dev
|
||||
```
|
||||
|
||||
The development image overrides cargo's output directory to
|
||||
`/var/lib/moonfire-nvr/target`. (See `~moonfire-nvr/.buildrc`.) This avoids
|
||||
using a bind filesystem for build products, which can be slow on macOS. It
|
||||
also means that if you sometimes compile directly on the host and sometimes
|
||||
within Docker, they don't trip over each other's target directories.
|
||||
|
||||
You can also cross-compile to a different architecture. Adding a
|
||||
`--platform=linux/arm64/v8,linux/arm/v7,linux/amd64` argument will compile
|
||||
Moonfire NVR for all supported platforms. (Note: this has been used
|
||||
successfully for building on x86-64 and compiling to arm but not the
|
||||
reverse.) For the `dev` target, this prepares a build which executes on your
|
||||
local architecture and is capable of building a binary for your desired target
|
||||
architecture.
|
||||
|
||||
On the author's macOS machine with Docker desktop 3.0.4, building for
|
||||
multiple platforms at once will initially fail with the following error:
|
||||
|
||||
```console
|
||||
$ sudo docker buildx build ... --platform=linux/arm64/v8,linux/arm/v7,linux/amd64
|
||||
[+] Building 0.0s (0/0)
|
||||
error: multiple platforms feature is currently not supported for docker driver. Please switch to a different driver (eg. "docker buildx create --use")
|
||||
```
|
||||
|
||||
Running `docker buildx create --use` once solves this problem, with a couple
|
||||
caveats:
|
||||
|
||||
* you'll need to specify an additional `--load` argument to make builds
|
||||
available to run locally.
|
||||
* the `--load` argument only works for one platform at a time. With multiple
|
||||
platforms, it gives an error like the following:
|
||||
```
|
||||
error: failed to solve: rpc error: code = Unknown desc = docker exporter does not currently support exporting manifest lists
|
||||
```
|
||||
[A comment on docker/buildx issue
|
||||
#59](https://github.com/docker/buildx/issues/59#issuecomment-667548900)
|
||||
suggests a workaround of building all three then using caching to quickly
|
||||
load the one of immediate interest:
|
||||
```
|
||||
$ sudo docker buildx build --platform=linux/arm64/v8,linux/arm/v7,linux/amd64 ...
|
||||
$ sudo docker buildx build --load --platform=arm64/v8 ...
|
||||
```
|
||||
|
||||
On Linux hosts (as opposed to when using Docker Desktop on macOS/Windows),
|
||||
you'll likely see errors like the ones below. The solution is to [install
|
||||
emulators](https://github.com/tonistiigi/binfmt#installing-emulators).
|
||||
|
||||
```
|
||||
Error while loading /usr/sbin/dpkg-split: No such file or directory
|
||||
Error while loading /usr/sbin/dpkg-deb: No such file or directory
|
||||
```
|
||||
|
||||
Moonfire NVR's `Dockerfile` has some built-in debugging tools:
|
||||
|
||||
* Each stage saves some debug info to `/docker-build-debug/<stage>`, and
|
||||
the `deploy` stage preserves the output from previous stages. The debug
|
||||
info includes:
|
||||
* output (stdout + stderr) from the build script, running long operations
|
||||
through the `time` command.
|
||||
* `ls -laFR` of cache mounts before and after.
|
||||
* Each stage accepts a `INVALIDATE_CACHE_<stage>` argument. You can use eg
|
||||
`--build-arg=INVALIDATE_CACHE_BUILD_SERVER=$(date +%s)` to force the
|
||||
`build-server` stage to be rebuilt rather than use cached Docker layers.
|
||||
|
||||
### Release procedure
|
||||
|
||||
Releases are currently a bit manual. From a completely clean git work tree,
|
||||
|
||||
1. manually verify the current commit is pushed to github's master branch and
|
||||
has a green checkmark indicating CI passed.
|
||||
2. update versions:
|
||||
* update `server/Cargo.toml` version by hand; run `cargo test --workspace`
|
||||
to update `Cargo.lock`.
|
||||
* ensure `README.md` and `CHANGELOG.md` refer to the new version.
|
||||
3. run commands:
|
||||
```bash
|
||||
VERSION=x.y.z
|
||||
git commit -am "prepare version ${VERSION}"
|
||||
git tag -a "v${VERSION}" -m "version ${VERSION}"
|
||||
./release.bash
|
||||
git push
|
||||
git push origin "v${VERSION}"
|
||||
```
|
||||
|
||||
The `release.bash` script needs [`jq`](https://stedolan.github.io/jq/)
|
||||
installed to work.
|
||||
|
||||
## Non-Docker setup
|
||||
|
||||
You may prefer building without Docker on the host. Moonfire NVR should run
|
||||
natively on any Unix-like system. It's been tested on Linux and macOS.
|
||||
(In theory [Windows Subsystem for
|
||||
Moonfire NVR should run natively on any Unix-like system. It's been tested on
|
||||
Linux, macOS, and FreeBSD. (In theory [Windows Subsystem for
|
||||
Linux](https://docs.microsoft.com/en-us/windows/wsl/about) should also work.
|
||||
Please speak up if you try it.)
|
||||
|
||||
On macOS systems native builds may be noticeably faster than using Docker's
|
||||
Linux VM and filesystem overlay.
|
||||
|
||||
To build the server, you will need the following C libraries installed:
|
||||
|
||||
* [SQLite3](https://www.sqlite.org/), at least version 3.8.2.
|
||||
(You can skip this if you compile with `--features=bundled` and
|
||||
don't mind the `moonfire-nvr sql` command not working.)
|
||||
|
||||
* [`ncursesw`](https://www.gnu.org/software/ncurses/), the UTF-8 version of
|
||||
the `ncurses` library.
|
||||
To build the server, you will need [SQLite3](https://www.sqlite.org/). You
|
||||
can skip this if compiling with `--features=rusqlite/bundled` and don't
|
||||
mind the `moonfire-nvr sql` command not working.
|
||||
|
||||
To build the UI, you'll need a [nodejs](https://nodejs.org/en/download/) release
|
||||
in "Maintenance LTS" or "Active LTS" status: currently v12 or v14.
|
||||
in "Maintenance", "LTS", or "Current" status on the
|
||||
[Release Schedule](https://github.com/nodejs/release#release-schedule):
|
||||
currently v18, v20, or v21.
|
||||
|
||||
On recent Ubuntu or Raspbian Linux, the following command will install
|
||||
most non-Rust dependencies:
|
||||
|
@ -184,18 +48,15 @@ most non-Rust dependencies:
|
|||
```console
|
||||
$ sudo apt-get install \
|
||||
build-essential \
|
||||
libavcodec-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libncurses-dev \
|
||||
libsqlite3-dev \
|
||||
pkgconf \
|
||||
sqlite3 \
|
||||
tzdata
|
||||
```
|
||||
|
||||
Ubuntu 20.04 (the latest LTS as of this writing) bundles node 10, which has
|
||||
reached end-of-life (see [node.js: releases](https://nodejs.org/en/about/releases/)).
|
||||
Ubuntu 20.04 LTS (still popular, supported by Ubuntu until April 2025) bundles
|
||||
node 10, which has reached end-of-life (see
|
||||
[node.js: releases](https://nodejs.org/en/about/releases/)).
|
||||
So rather than install the `nodejs` and `npm` packages from the built-in
|
||||
repository, see [Installing Node.js via package
|
||||
manager](https://nodejs.org/en/download/package-manager/#debian-and-ubuntu-based-linux-distributions).
|
||||
|
@ -207,7 +68,7 @@ following command:
|
|||
$ brew install node
|
||||
```
|
||||
|
||||
Next, you need Rust 1.60+ and Cargo. The easiest way to install them is by
|
||||
Next, you need Rust 1.65+ and Cargo. The easiest way to install them is by
|
||||
following the instructions at [rustup.rs](https://www.rustup.rs/). Avoid
|
||||
your Linux distribution's Rust packages, which tend to be too old.
|
||||
(At least on Debian-based systems; Arch and Gentoo might be okay.)
|
||||
|
@ -223,17 +84,24 @@ $ sudo install -m 755 target/release/moonfire-nvr /usr/local/bin
|
|||
$ cd ..
|
||||
```
|
||||
|
||||
You can build the UI via `npm` and find it in the `ui/build` directory:
|
||||
You can build the UI via `pnpm` and find it in the `ui/build` directory:
|
||||
|
||||
```console
|
||||
$ cd ui
|
||||
$ npm install
|
||||
$ npm run build
|
||||
$ pnpm install
|
||||
$ pnpm run build
|
||||
$ sudo mkdir /usr/local/lib/moonfire-nvr
|
||||
$ cd ..
|
||||
$ sudo rsync --recursive --delete --chmod=D755,F644 ui/build/ /usr/local/lib/moonfire-nvr/ui
|
||||
$ sudo rsync --recursive --delete --chmod=D755,F644 ui/dist/ /usr/local/lib/moonfire-nvr/ui
|
||||
```
|
||||
|
||||
For more information about using `pnpm`, check out the [Developing UI Guide](./developing-ui.md#requirements).
|
||||
|
||||
If you wish to bundle the UI into the binary, you can build the UI first and then pass
|
||||
`--features=bundled-ui` when building the server. See also the
|
||||
[release workflow](../.github/workflows/release.yml) which statically links SQLite and
|
||||
(musl-based) libc for a zero-dependencies binary.
|
||||
|
||||
### Running interactively straight from the working copy
|
||||
|
||||
The author finds it convenient for local development to set up symlinks so that
|
||||
|
@ -241,9 +109,9 @@ the binaries in the working copy will run via just `nvr`:
|
|||
|
||||
```console
|
||||
$ sudo mkdir /usr/local/lib/moonfire-nvr
|
||||
$ sudo ln -s `pwd`/ui/build /usr/local/lib/moonfire-nvr/ui
|
||||
$ sudo ln -s `pwd`/ui/dist /usr/local/lib/moonfire-nvr/ui
|
||||
$ sudo mkdir /var/lib/moonfire-nvr
|
||||
$ sudo chown $USER:$USER /var/lib/moonfire-nvr
|
||||
$ sudo chown $USER: /var/lib/moonfire-nvr
|
||||
$ ln -s `pwd`/server/target/release/moonfire-nvr $HOME/bin/moonfire-nvr
|
||||
$ ln -s moonfire-nvr $HOME/bin/nvr
|
||||
$ nvr init
|
||||
|
@ -255,70 +123,24 @@ $ nvr run
|
|||
with `cargo build` rather than `cargo build --release`, for a faster build
|
||||
cycle and slower performance.)
|
||||
|
||||
Note this `nvr` is a little different than the `nvr` shell script you create
|
||||
when following the [install instructions](install.md). With that shell wrapper,
|
||||
`nvr run` will create and run a detached Docker container with some extra
|
||||
arguments specified in the script. This `nvr run` will directly run from the
|
||||
terminal, with no extra arguments, until you abort with Ctrl-C. Likewise,
|
||||
some of the shell script's subcommands that wrap Docker (`start`, `stop`, and
|
||||
`logs`) have no parallel with this `nvr`.
|
||||
## Release procedure
|
||||
|
||||
### Running as a `systemd` service
|
||||
Releases are currently a bit manual. From a completely clean git work tree,
|
||||
|
||||
If you want to deploy a non-Docker build on Linux, you may want to use
|
||||
`systemd`. Create `/etc/systemd/system/moonfire-nvr.service`:
|
||||
1. manually verify the current commit is pushed to github's master branch and
|
||||
has a green checkmark indicating CI passed.
|
||||
2. update versions:
|
||||
* update `server/Cargo.toml` version by hand; run `cargo test --workspace`
|
||||
to update `Cargo.lock`.
|
||||
* ensure `README.md` and `CHANGELOG.md` refer to the new version.
|
||||
3. run commands:
|
||||
```bash
|
||||
VERSION=x.y.z
|
||||
git commit -am "prepare version ${VERSION}"
|
||||
git tag -a "v${VERSION}" -m "version ${VERSION}"
|
||||
git push origin "v${VERSION}"
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Moonfire NVR
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/moonfire-nvr run
|
||||
Environment=TZ=:/etc/localtime
|
||||
Environment=MOONFIRE_FORMAT=google-systemd
|
||||
Environment=MOONFIRE_LOG=info
|
||||
Environment=RUST_BACKTRACE=1
|
||||
Type=simple
|
||||
User=moonfire-nvr
|
||||
Restart=on-failure
|
||||
CPUAccounting=true
|
||||
MemoryAccounting=true
|
||||
BlockIOAccounting=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
You'll also need a `/etc/moonfire-nvr.toml`:
|
||||
|
||||
```toml
|
||||
[[binds]]
|
||||
ipv4 = "0.0.0.0:8080"
|
||||
allow_unauthenticated_permissions = { view_video = true }
|
||||
|
||||
[[binds]]
|
||||
unix = "/var/lib/moonfire-nvr/sock"
|
||||
own_uid_is_privileged = true
|
||||
```
|
||||
|
||||
Note this configuration is insecure. You can change that via replacing the
|
||||
`allow_unauthenticated_permissions` here as described in [Securing Moonfire NVR
|
||||
and exposing it to the Internet](secure.md).
|
||||
|
||||
Some handy commands:
|
||||
|
||||
```console
|
||||
$ sudo systemctl daemon-reload # reload configuration files
|
||||
$ sudo systemctl start moonfire-nvr # start the service now
|
||||
$ sudo systemctl stop moonfire-nvr # stop the service now (but don't wait for it finish stopping)
|
||||
$ sudo systemctl status moonfire-nvr # show if the service is running and the last few log lines
|
||||
$ sudo systemctl enable moonfire-nvr # start the service on boot
|
||||
$ sudo systemctl disable moonfire-nvr # don't start the service on boot
|
||||
$ sudo journalctl --unit=moonfire-nvr --since='-5 min' --follow # look at recent logs and await more
|
||||
```
|
||||
|
||||
See the [systemd](http://www.freedesktop.org/wiki/Software/systemd/)
|
||||
documentation for more information. The [manual
|
||||
pages](http://www.freedesktop.org/software/systemd/man/) for `systemd.service`
|
||||
and `systemctl` may be of particular interest.
|
||||
The rest should happen automatically—the tag push will fire off a GitHub
|
||||
Actions workflow which creates a release, cross-compiles statically compiled
|
||||
binaries for three different platforms, and uploads them to the release.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Working on UI development <!-- omit in toc -->
|
||||
# Developing the UI <!-- omit in toc -->
|
||||
|
||||
* [Getting started](#getting-started)
|
||||
* [Overriding defaults](#overriding-defaults)
|
||||
|
@ -6,7 +6,7 @@
|
|||
|
||||
The UI is presented from a single HTML page (index.html) and any number
|
||||
of Javascript files, css files, images, etc. These are "packed" together
|
||||
using [webpack](https://webpack.js.org).
|
||||
using [vite](https://vitejs.dev/).
|
||||
|
||||
For ongoing development it is possible to have the UI running in a web
|
||||
browser using "hot loading". This means that as you make changes to source
|
||||
|
@ -20,18 +20,26 @@ and more effort is expended on packing and minimizing the components of
|
|||
the application as represented in the various "bundles". Read more about
|
||||
this in the webpack documentation.
|
||||
|
||||
## Requirements
|
||||
|
||||
* Node.js v18+
|
||||
* `pnpm` installed
|
||||
|
||||
This guide below will use [`pnpm`](https://pnpm.io/) as package manager instead
|
||||
`npm`. So we highly recommended you to use `pnpm` in this project.
|
||||
|
||||
## Getting started
|
||||
|
||||
Checkout the branch you want to work on and type
|
||||
|
||||
```
|
||||
$ cd ui
|
||||
$ npm run start
|
||||
```bash
|
||||
cd ui
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
This will pack and prepare a development setup. By default the development
|
||||
server that serves up the web page(s) will listen on
|
||||
[http://localhost:3000/](http://localhost:3000/) so you can direct your browser
|
||||
[http://localhost:5173/](http://localhost:5173/) so you can direct your browser
|
||||
there. It assumes the Moonfire NVR server is running at
|
||||
[http://localhost:8080/](http://localhost:8080/) and will proxy API requests
|
||||
there.
|
||||
|
@ -45,29 +53,25 @@ process, but some will show up in the browser console, or both.
|
|||
|
||||
## Overriding defaults
|
||||
|
||||
The UI is setup with [Create React App](https://create-react-app.dev/).
|
||||
`npm run start` will honor any of the environment variables described in their
|
||||
[Advanced Configuration](https://create-react-app.dev/docs/advanced-configuration/),
|
||||
as well as Moonfire NVR's custom `PROXY_TARGET` variable. Quick reference:
|
||||
Currently there's only one supported environment variable override defined in
|
||||
`ui/vite.config.ts`:
|
||||
|
||||
| variable | description | default |
|
||||
| :------------- | :----------------------------------------------------------------------- | :----------------------- |
|
||||
| `PROXY_TARGET` | base URL of the backing Moonfire NVR server (see `ui/src/setupProxy.js`) | `http://localhost:8080/` |
|
||||
| `PORT` | port to listen on | 3000 |
|
||||
| `HOST` | host/IP to listen on (or `0.0.0.0` for all) | `0.0.0.0` |
|
||||
| variable | description | default |
|
||||
| :------------- | :------------------------------------------ | :----------------------- |
|
||||
| `PROXY_TARGET` | base URL of the backing Moonfire NVR server | `http://localhost:8080/` |
|
||||
|
||||
Thus one could connect to a remote Moonfire NVR by specifying its URL as
|
||||
follows:
|
||||
|
||||
```
|
||||
$ PROXY_TARGET=https://nvr.example.com/ npm run start
|
||||
```bash
|
||||
PROXY_TARGET=https://nvr.example.com/ npm run dev
|
||||
```
|
||||
|
||||
This allows you to test a new UI against your stable, production Moonfire NVR
|
||||
installation with real data.
|
||||
|
||||
You can also set environment variables in `.env` files, as described in
|
||||
[Adding Custom Environment Variables](https://create-react-app.dev/docs/adding-custom-environment-variables/).
|
||||
[vitejs.dev: Env Variables and Modes](https://vitejs.dev/guide/env-and-mode).
|
||||
|
||||
## A note on `https`
|
||||
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 124 KiB |
315
guide/install.md
315
guide/install.md
|
@ -1,34 +1,148 @@
|
|||
# Installing Moonfire NVR <!-- omit in toc -->
|
||||
|
||||
* [Downloading, installing, and configuring Moonfire NVR with Docker](#downloading-installing-and-configuring-moonfire-nvr-with-docker)
|
||||
* [Downloading, installing, and configuring Moonfire NVR](#downloading-installing-and-configuring-moonfire-nvr)
|
||||
* [Dedicated hard drive setup](#dedicated-hard-drive-setup)
|
||||
* [Completing configuration through the UI](#completing-configuration-through-the-ui)
|
||||
* [Starting it up](#starting-it-up)
|
||||
|
||||
## Downloading, installing, and configuring Moonfire NVR with Docker
|
||||
## Downloading, installing, and configuring Moonfire NVR
|
||||
|
||||
This document describes how to download, install, and configure Moonfire NVR
|
||||
via the prebuilt Docker images available for x86-64, arm64, and arm. If you
|
||||
via the prebuilt Linux binaries available for x86-64, arm64, and arm. If you
|
||||
instead want to build Moonfire NVR yourself, see the [Build
|
||||
instructions](build.md).
|
||||
|
||||
First, make sure you are viewing instructions that match the release you intend
|
||||
<table><tr><td><details>
|
||||
<summary>Go to the instructions for your exact Moonfire version</summary>
|
||||
|
||||
Make sure you are viewing instructions that match the release you intend
|
||||
to install. When viewing this page on Github, look for a pull-down in the upper
|
||||
left, and pick the latest tagged version:
|
||||
left, and pick the [latest tagged version](https://github.com/scottlamb/moonfire-nvr/releases/latest):
|
||||
|
||||
![Selecting a version of install instructions](install-version.png)
|
||||
<img src="install-version.png" height=367 alt="Selecting a version of install instructions">
|
||||
|
||||
Next, install [Docker](https://www.docker.com/) if you haven't already,
|
||||
and verify `sudo docker run --rm hello-world` works.
|
||||
</details></td></tr></table>
|
||||
|
||||
<details>
|
||||
<summary><tt>sudo</tt> or not?</summary>
|
||||
|
||||
If you prefer to save typing by not prefixing all `docker` and `nvr` commands
|
||||
with `sudo`, see [Docker docs: Manage Docker as a non-root
|
||||
user](https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user).
|
||||
Note `docker` access is equivalent to root access security-wise.
|
||||
</details>
|
||||
Download the binary for your platform from the matching GitHub release.
|
||||
Install it as `/usr/local/bin/moonfire-nvr` and ensure it is executable, e.g.
|
||||
for version `v0.7.14`:
|
||||
|
||||
```console
|
||||
$ VERSION=v0.7.14
|
||||
$ ARCH=$(uname -m)
|
||||
$ curl -OL "https://github.com/scottlamb/moonfire-nvr/releases/download/$VERSION/moonfire-nvr-$VERSION-$ARCH"
|
||||
$ sudo install -m 755 "moonfire-nvr-$VERSION-$ARCH" /usr/local/bin/moonfire-nvr
|
||||
```
|
||||
|
||||
<table><tr><td><details>
|
||||
<summary>Docker</summary>
|
||||
|
||||
The procedure above, in which Moonfire runs directly on the host, is strongly
|
||||
recommended.
|
||||
|
||||
* The single binary installed in `/usr/local/bin` has zero dependencies.
|
||||
It is statically linked and bundles the UI. It just works. There's no
|
||||
complex distribution-specific install procedures or danger of conflicting
|
||||
version requirements between Moonfire and other software. These are the same
|
||||
problems most people use Docker to solve.
|
||||
* Moonfire's recommended install method used to involve Docker. In our
|
||||
experience, complexity around Docker commands, filesystem/process namespace
|
||||
mappings, broken seccomp defaults that do not allow standard system calls
|
||||
like `clock_gettime`, etc. has been a major frustration for folks installing
|
||||
Moonfire. Now that we have the zero-dependencies binary, we recommend
|
||||
sidestepping all of this and have rewritten the docs accordingly.
|
||||
|
||||
…but, you may still prefer Docker for familiarity or other reasons. If so, you
|
||||
can install the [`ghcr.io/scottlamb/moonfire-nvr`](https://github.com/scottlamb/moonfire-nvr/pkgs/container/moonfire-nvr) Docker images instead. We'll
|
||||
assume you know your way around your preferred tools and can adapt the
|
||||
instructions to the workflow you use with Docker. You may find the following
|
||||
Docker compose snippet useful:
|
||||
|
||||
```yaml
|
||||
version: 3
|
||||
services:
|
||||
moonfire-nvr:
|
||||
# The `vX.Y.Z` images will work on any architecture (x86-64, arm, or
|
||||
# aarch64); just pick the correct version.
|
||||
image: ghcr.io/scottlamb/moonfire-nvr:v0.7.11
|
||||
command: run
|
||||
|
||||
volumes:
|
||||
# Pass through `/var/lib/moonfire-nvr` from the host.
|
||||
- "/var/lib/moonfire-nvr:/var/lib/moonfire-nvr"
|
||||
|
||||
# Pass through `/etc/moonfire-nvr.toml` from the host.
|
||||
# Be sure to create `/etc/moonfire-nvr.toml` first (see below).
|
||||
# Docker will "helpfully" create a directory by this name otherwise.
|
||||
- "/etc/moonfire-nvr.toml:/etc/moonfire-nvr.toml:ro"
|
||||
|
||||
# Pass through `/var/tmp` from the host.
|
||||
# SQLite expects to be able to create temporary files in this dir, which
|
||||
# is not created in Moonfire's minimal Docker image.
|
||||
# See: <https://www.sqlite.org/tempfiles.html>
|
||||
- "/var/tmp:/var/tmp"
|
||||
|
||||
# Add additional mount lines here for each sample file directory
|
||||
# outside of /var/lib/moonfire-nvr, e.g.:
|
||||
# - "/media/nvr:/media/nvr"
|
||||
|
||||
# The Docker image doesn't include the time zone database; you must mount
|
||||
# it from the host for Moonfire to support local time.
|
||||
- "/usr/share/zoneinfo:/usr/share/zoneinfo:ro"
|
||||
|
||||
# Edit this to match your `moonfire-nvr` user.
|
||||
# Note that Docker will not honor names from the host here, even if
|
||||
# `/etc/passwd` is passed through.
|
||||
# - Be sure to run the `useradd` command below first.
|
||||
# - Then run `echo $(id -u moonfire-nvr):$(id -g moonfire-nvr)` to see
|
||||
# what should be filled in here.
|
||||
user: UID:GID
|
||||
|
||||
# Uncomment this if Moonfire fails with `clock_gettime failed` (likely on
|
||||
# older 32-bit hosts). <https://github.com/moby/moby/issues/40734>
|
||||
# security_opt:
|
||||
# - seccomp:unconfined
|
||||
|
||||
environment:
|
||||
# Edit zone below to taste. The `:` is functional.
|
||||
TZ: ":America/Los_Angeles"
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
# docker's default log driver won't rotate logs properly, and will throw
|
||||
# away logs when you destroy and recreate the container. Using journald
|
||||
# solves these problems.
|
||||
# <https://docs.docker.com/config/containers/logging/configure/>
|
||||
logging:
|
||||
driver: journald
|
||||
options:
|
||||
tag: moonfire-nvr
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "8080:8080/tcp"
|
||||
```
|
||||
|
||||
Command reference:
|
||||
|
||||
<table>
|
||||
|
||||
<tr><th colspan="2">Initialize the database</th></tr>
|
||||
<tr><th>Non-Docker</th><td><code>sudo -u moonfire-nvr moonfire-nvr init</code></td></tr>
|
||||
<tr><th>Docker</th><td><code>sudo docker compose run --rm moonfire-nvr init</code></td></tr>
|
||||
|
||||
<tr><th colspan="2">Run interactive configuration</th></tr>
|
||||
<tr><th>Non-Docker</th><td><code>sudo -u moonfire-nvr moonfire-nvr config 2>debug-log</code></td></tr>
|
||||
<tr><th>Docker</th><td><code>sudo docker compose run --rm moonfire-nvr config 2>debug-log</code></td></tr>
|
||||
|
||||
<tr><th colspan="2">Enable and start the server</th></tr>
|
||||
<tr><th>Non-Docker<td><code>sudo systemctl enable --now moonfire-nvr</code></td></tr>
|
||||
<tr><th>Docker</th><td><code>sudo docker compose up --detach moonfire-nvr</code></td></tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details></td></tr></table>
|
||||
|
||||
Next, you'll need to set up your filesystem and the Moonfire NVR user.
|
||||
|
||||
|
@ -49,108 +163,29 @@ On most Linux systems, you can create the user as follows:
|
|||
$ sudo useradd --user-group --create-home --home /var/lib/moonfire-nvr moonfire-nvr
|
||||
```
|
||||
|
||||
and create a script called `nvr` to run Moonfire NVR as the intended host user.
|
||||
This script supports running Moonfire NVR's various administrative commands interactively
|
||||
and managing a long-lived Docker container for its web interface.
|
||||
|
||||
As you set up this script, adjust the `tz` variable as appropriate for your
|
||||
time zone.
|
||||
|
||||
Use your favorite editor to create `/etc/moonfire-nvr.toml` and
|
||||
`/usr/local/bin/nvr`, starting from the configurations below:
|
||||
Use your favorite editor to create `/etc/moonfire-nvr.toml`,
|
||||
starting from the configurations below:
|
||||
|
||||
```console
|
||||
$ sudo nano /etc/moonfire-nvr.toml
|
||||
(see below for contents)
|
||||
$ sudo nano /usr/local/bin/nvr
|
||||
(see below for contents)
|
||||
$ sudo chmod a+rx /usr/local/bin/nvr
|
||||
```
|
||||
|
||||
`/etc/moonfire-nvr.toml`:
|
||||
`/etc/moonfire-nvr.toml` (see [ref/config.md](../ref/config.md) for more explanation):
|
||||
```toml
|
||||
[[binds]]
|
||||
ipv4 = "0.0.0.0:8080"
|
||||
allow_unauthenticated_permissions = { view_video = true }
|
||||
allowUnauthenticatedPermissions = { viewVideo = true }
|
||||
|
||||
[[binds]]
|
||||
unix = "/var/lib/moonfire-nvr/sock"
|
||||
own_uid_is_privileged = true
|
||||
ownUidIsPrivileged = true
|
||||
```
|
||||
|
||||
`/usr/local/bin/nvr`:
|
||||
```bash
|
||||
#!/bin/bash -e
|
||||
|
||||
# Set your timezone here.
|
||||
tz="America/Los_Angeles"
|
||||
|
||||
image_name="scottlamb/moonfire-nvr:v0.7.5"
|
||||
container_name="moonfire-nvr"
|
||||
common_docker_run_args=(
|
||||
--mount=type=bind,source=/var/lib/moonfire-nvr,destination=/var/lib/moonfire-nvr
|
||||
--mount=type=bind,source=/etc/moonfire-nvr.toml,destination=/etc/moonfire-nvr.toml
|
||||
|
||||
# Add additional mount lines here for each sample file directory
|
||||
# outside of /var/lib/moonfire-nvr, e.g.:
|
||||
# --mount=type=bind,source=/media/nvr/sample,destination=/media/nvr/sample
|
||||
|
||||
--user="$(id -u moonfire-nvr):$(id -g moonfire-nvr)"
|
||||
|
||||
# This avoids errors with broken seccomp on older 32-bit hosts.
|
||||
# https://github.com/moby/moby/issues/40734
|
||||
--security-opt=seccomp:unconfined
|
||||
|
||||
# This is the simplest way of configuring networking, although
|
||||
# you can use e.g. --publish=8080:8080 in the run) case below if you
|
||||
# prefer.
|
||||
--network=host
|
||||
|
||||
# docker's default log driver won't rotate logs properly, and will throw
|
||||
# away logs when you destroy and recreate the container. Using journald
|
||||
# solves these problems.
|
||||
# https://docs.docker.com/config/containers/logging/configure/
|
||||
--log-driver=journald
|
||||
--log-opt="tag=moonfire-nvr"
|
||||
|
||||
--env=RUST_BACKTRACE=1
|
||||
--env=TZ=":${tz}"
|
||||
)
|
||||
|
||||
case "$1" in
|
||||
run)
|
||||
shift
|
||||
exec docker run \
|
||||
--detach=true \
|
||||
--restart=unless-stopped \
|
||||
"${common_docker_run_args[@]}" \
|
||||
--name="${container_name}" \
|
||||
"${image_name}" \
|
||||
run \
|
||||
"$@"
|
||||
;;
|
||||
start|stop|logs|rm)
|
||||
exec docker "$@" "${container_name}"
|
||||
;;
|
||||
pull)
|
||||
exec docker pull "${image_name}"
|
||||
;;
|
||||
*)
|
||||
exec docker run \
|
||||
--interactive=true \
|
||||
--tty \
|
||||
--rm \
|
||||
"${common_docker_run_args[@]}" \
|
||||
"${image_name}" \
|
||||
"$@"
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
then try it out by initializing the database:
|
||||
Then initialize the database:
|
||||
|
||||
```console
|
||||
$ sudo nvr init
|
||||
$ sudo -u moonfire-nvr moonfire-nvr init
|
||||
```
|
||||
|
||||
This will create a directory `/var/lib/moonfire-nvr/db` with a SQLite3 database
|
||||
|
@ -189,10 +224,6 @@ system will boot successfully even when the hard drive is unavailable (such as
|
|||
when your external USB storage is unmounted). This can be helpful when
|
||||
recovering from problems.
|
||||
|
||||
Add a new `--mount` line to your Docker wrapper script `/usr/local/bin/nvr`
|
||||
to expose the new sample directory `/media/nvr/sample` to the Docker container,
|
||||
right where a comment mentions "Additional mount lines".
|
||||
|
||||
### Completing configuration through the UI
|
||||
|
||||
Once your system is set up, it's time to initialize an empty database
|
||||
|
@ -200,17 +231,18 @@ and add the cameras and sample directories. You can do this
|
|||
by using the `moonfire-nvr` binary's text-based configuration tool.
|
||||
|
||||
```console
|
||||
$ sudo nvr config 2>debug-log
|
||||
$ sudo -u moonfire-nvr moonfire-nvr config 2>debug-log
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Did it return without doing anything?</summary>
|
||||
<table><tr><td><details>
|
||||
<summary>Did it return without doing anything?</summary>
|
||||
|
||||
If `nvr config` returns you to the console prompt right away, look in the
|
||||
`debug-log` file for why. One common reason is that you have Moonfire NVR
|
||||
running; you'll need to shut it down first. Try `nvr stop` before `nvr config`
|
||||
and `nvr start` afterward.
|
||||
</details>
|
||||
If `moonfire-nvr config` returns you to the console prompt right away, look in
|
||||
the `debug-log` file for why. One common reason is that you have Moonfire NVR
|
||||
running; you'll need to shut it down first. If you are running a systemd
|
||||
service as described below, try `sudo systemctl stop moonfire-nvr` before
|
||||
editing the config and `sudo systemctl start moonfire-nvr` after.
|
||||
</details></td></tr></table>
|
||||
|
||||
In the user interface,
|
||||
|
||||
|
@ -277,15 +309,60 @@ starting it in this configuration to try it out, particularly if the machine
|
|||
it's running on is behind a home router's firewall. You might not; in that case
|
||||
read through [secure the system](secure.md) first.
|
||||
|
||||
This command will start a detached Docker container for the web interface.
|
||||
It will automatically restart when your system does.
|
||||
Assuming you want to proceed, you can launch Moonfire NVR through `systemd`.
|
||||
Create `/etc/systemd/system/moonfire-nvr.service`:
|
||||
|
||||
```console
|
||||
$ sudo nvr run
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Moonfire NVR
|
||||
After=network-online.target
|
||||
|
||||
# If you use an external hard drive, uncomment this with a reference to the
|
||||
# mount point as written in `/etc/fstab`.
|
||||
# RequiresMountsFor=/media/nvr
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/moonfire-nvr run
|
||||
Environment=TZ=:/etc/localtime
|
||||
Environment=MOONFIRE_FORMAT=systemd
|
||||
Environment=MOONFIRE_LOG=info
|
||||
Environment=RUST_BACKTRACE=1
|
||||
Type=notify
|
||||
# large installations take a while to scan the sample file dirs
|
||||
TimeoutStartSec=300
|
||||
User=moonfire-nvr
|
||||
Restart=on-failure
|
||||
CPUAccounting=true
|
||||
MemoryAccounting=true
|
||||
BlockIOAccounting=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
You can temporarily disable the service via `nvr stop` and restart it later via
|
||||
`nvr start`. You'll need to do this before and after using `nvr config`.
|
||||
Then start it up as follows:
|
||||
|
||||
```console
|
||||
$ sudo systemctl daemon-reload # read in the new config file
|
||||
$ sudo systemctl enable --now moonfire-nvr # start the service now and on boot
|
||||
```
|
||||
|
||||
Some handy commands:
|
||||
|
||||
```console
|
||||
$ sudo systemctl daemon-reload # reload configuration files
|
||||
$ sudo systemctl start moonfire-nvr # start the service now without enabling on boot
|
||||
$ sudo systemctl stop moonfire-nvr # stop the service now (but don't wait for it finish stopping)
|
||||
$ sudo systemctl status moonfire-nvr # show if the service is running and the last few log lines
|
||||
$ sudo systemctl enable moonfire-nvr # start the service on boot
|
||||
$ sudo systemctl disable moonfire-nvr # don't start the service on boot
|
||||
$ sudo journalctl --unit=moonfire-nvr --since='-5 min' --follow # look at recent logs and await more
|
||||
```
|
||||
|
||||
See the [systemd](http://www.freedesktop.org/wiki/Software/systemd/)
|
||||
documentation for more information. The [manual
|
||||
pages](http://www.freedesktop.org/software/systemd/man/) for `systemd.service`
|
||||
and `systemctl` may be of particular interest.
|
||||
|
||||
The HTTP interface is accessible on port 8080; if your web browser is running
|
||||
on the same machine, you can access it at
|
||||
|
|
|
@ -62,11 +62,11 @@ SQLite database:
|
|||
no longer in the dangerous mode.
|
||||
|
||||
Next ensure Moonfire NVR is not running and does not automatically restart if
|
||||
the system is rebooted during the upgrade. If you followed the Docker
|
||||
the system is rebooted during the upgrade. If you followed the standard
|
||||
instructions, you can do this as follows:
|
||||
|
||||
```console
|
||||
$ sudo nvr stop
|
||||
$ sudo systemctl disable --now moonfire-nvr
|
||||
```
|
||||
|
||||
Then back up your SQLite database. If you are using the default path, you can
|
||||
|
@ -92,34 +92,27 @@ manual for write-ahead logging](https://www.sqlite.org/wal.html):
|
|||
|
||||
Run the upgrade procedure using the new software binary.
|
||||
|
||||
```console
|
||||
$ sudo nvr pull # updates the docker image to the latest binary
|
||||
$ sudo nvr upgrade # runs the upgrade
|
||||
```
|
||||
|
||||
As a rule of thumb, on a Raspberry Pi 4 with a 1 GiB database, an upgrade might
|
||||
take about four minutes for each schema version and for the final vacuum.
|
||||
|
||||
Next, you can run the system in read-only mode, although you'll find this only
|
||||
works in the "insecure" setup. (Authorization requires writing the database.)
|
||||
To just run directly within the console until you hit ctrl-C, use the following
|
||||
command:
|
||||
|
||||
```console
|
||||
$ sudo nvr rm
|
||||
$ sudo nvr run --read-only
|
||||
$ sudo -u moonfire-nvr moonfire-nvr run --read-only
|
||||
```
|
||||
|
||||
Go to the web interface and ensure the system is operating correctly. If
|
||||
you detect a problem now, you can copy the old database back over the new one
|
||||
and edit your `nvr` script to use the corresponding older Docker image. If
|
||||
you detect a problem after enabling read-write operation, a restore will be
|
||||
more complicated.
|
||||
and go back to the prior release. If you detect a problem after enabling
|
||||
read-write operation, a restore will be more complicated.
|
||||
|
||||
Once you're satisfied, restart the system in read-write mode:
|
||||
Once you're satisfied, ctrl-C and start the system in read-write mode:
|
||||
|
||||
```console
|
||||
$ sudo nvr stop
|
||||
$ sudo nvr rm
|
||||
$ sudo nvr run
|
||||
$ sudo systemctl enable --now moonfire-nvr
|
||||
```
|
||||
|
||||
Hopefully your system is functioning correctly. If not, there are two options
|
||||
|
@ -137,7 +130,8 @@ for restore; neither are easy:
|
|||
* undo the changes by hand. There's no documentation on this; you'll need
|
||||
to read the code and come up with a reverse transformation.
|
||||
|
||||
The `nvr check` command will show you what problems exist on your system.
|
||||
The `sudo -u moonfire-nvr moonfire-nvr check` command will show you what
|
||||
problems exist on your system.
|
||||
|
||||
### Unversioned to version 0
|
||||
|
||||
|
|
|
@ -99,7 +99,7 @@ proxy through a webserver that does. If Moonfire NVR will be sharing an
|
|||
`https` port with anything else, you'll need to set up the webserver to proxy
|
||||
to all of these interfaces as well.
|
||||
|
||||
I use [nginx](https://https://nginx.com/) as the proxy server. Some folks may
|
||||
I use [nginx](https://nginx.com/) as the proxy server. Some folks may
|
||||
prefer [Apache httpd](https://httpd.apache.org/) or some other webserver.
|
||||
Anything will work. I include snippets of a `nginx` config below, so stick
|
||||
with that if you're not comfortable adapting it to some other server.
|
||||
|
@ -161,40 +161,40 @@ your browser. See [How to secure Nginx with Let's Encrypt on Ubuntu
|
|||
|
||||
## 6. Reconfigure Moonfire NVR
|
||||
|
||||
If you follow the recommended Docker setup, your `/etc/moonfire-nvr.json`
|
||||
will contain this line:
|
||||
If you follow the recommended setup, your `/etc/moonfire-nvr.toml` will contain
|
||||
this line:
|
||||
|
||||
```toml
|
||||
allow_unauthenticated_permissions = { view_video = true }
|
||||
allowUnauthenticatedPermissions = { viewVideo = true }
|
||||
```
|
||||
|
||||
Replace it with the following:
|
||||
|
||||
```toml
|
||||
trust_forward_headers = true
|
||||
trustForwardHeaders = true
|
||||
```
|
||||
|
||||
This change has two effects:
|
||||
|
||||
* No `allow_unauthenticated_permissions` means that web users must
|
||||
* No `allowUnauthenticatedPermissions` means that web users must
|
||||
authenticate.
|
||||
* `trust_forward_headers` means that Moonfire NVR will look for `X-Real-IP`
|
||||
* `trustForwardHeaders` means that Moonfire NVR will look for `X-Real-IP`
|
||||
and `X-Forwarded-Proto` headers as added by the webserver configuration
|
||||
in the next section.
|
||||
|
||||
If the webserver is running on the same machine as Moonfire NVR, you might
|
||||
also change `--publish=8080:8080` to `--publish=127.0.0.1:8080:8080` in your
|
||||
`/usr/local/bin/nvr` script, preventing other machines on the network from
|
||||
impersonating the proxy, effectively allowing them to lie about the client's IP
|
||||
and protocol.
|
||||
See also [ref/config.md](../ref/config.md) for more about the configuration file.
|
||||
|
||||
To make this take effect, you'll need to stop the running Docker container,
|
||||
delete it, and create/run a new one:
|
||||
If the webserver is running on the same machine as Moonfire NVR, you might
|
||||
also change the `ipv4 = "0.0.0.0:8080"` line in `/etc/moonfire-nvr/toml` to
|
||||
`ipv4 = "127.0.0.1:8080"`, so that only the local host can directly connect to
|
||||
Moonfire NVR. If other machines can connect directly, they can impersonate
|
||||
the proxy, which would effectively allow them to lie about the client's IP and
|
||||
protocol.
|
||||
|
||||
To make this take effect, you'll need to restart Moonfire NVR:
|
||||
|
||||
```console
|
||||
$ sudo nvr stop
|
||||
$ sudo nvr rm
|
||||
$ sudo nvr run
|
||||
$ sudo systemctl restart moonfire-nvr
|
||||
```
|
||||
|
||||
## 7. Configure the webserver
|
||||
|
|
|
@ -10,16 +10,18 @@ need more help.
|
|||
* [Slow operations](#slow-operations)
|
||||
* [Camera stream errors](#camera-stream-errors)
|
||||
* [Problems](#problems)
|
||||
* [Server errors](#server-errors)
|
||||
* [Docker setup](#docker-setup)
|
||||
* [`"/etc/moonfire-nvr.toml" is a directory`](#etcmoonfire-nvrtoml-is-a-directory)
|
||||
* [`Error response from daemon: unable to find user UID: no matching entries in passwd file`](#error-response-from-daemon-unable-to-find-user-uid-no-matching-entries-in-passwd-file)
|
||||
* [`clock_gettime failed: EPERM: Operation not permitted`](#clock_gettime-failed-eperm-operation-not-permitted)
|
||||
* [`VFS is unable to determine a suitable directory for temporary files`](#vfs-is-unable-to-determine-a-suitable-directory-for-temporary-files)
|
||||
* [Server errors](#server-errors)
|
||||
* [`Error: pts not monotonically increasing; got 26615520 then 26539470`](#error-pts-not-monotonically-increasing-got-26615520-then-26539470)
|
||||
* [Out of disk space](#out-of-disk-space)
|
||||
* [Database or filesystem corruption errors](#database-or-filesystem-corruption-errors)
|
||||
* [Incorrect timestamps](#incorrect-timestamps)
|
||||
* [Configuration interface problems](#configuration-interface-problems)
|
||||
* [`moonfire-nvr config` displays garbage](#moonfire-nvr-config-displays-garbage)
|
||||
* [Browser user interface problems](#browser-user-interface-problems)
|
||||
* [Live stream always fails with `ws close: 1006`](#live-stream-always-fails-with-ws-close-1006)
|
||||
* [Errors in kernel logs](#errors-in-kernel-logs)
|
||||
* [UAS errors](#uas-errors)
|
||||
* [Filesystem errors](#filesystem-errors)
|
||||
|
@ -31,14 +33,17 @@ While Moonfire NVR is running, logs will be written to stderr.
|
|||
* When running the configuration UI, you typically should redirect stderr
|
||||
to a text file to avoid poor interaction between the interactive stdout
|
||||
output and the logging. If you use the recommended
|
||||
`nvr config 2>debug-log` command, output will be in the `debug-log` file.
|
||||
* When running detached through Docker, Docker saves the logs for you.
|
||||
Try `nvr logs` or `docker logs moonfire-nvr`.
|
||||
`moonfire-nvr config 2>debug-log` command, output will be in the
|
||||
`debug-log` file.
|
||||
* When running through systemd, stderr will be redirected to the journal.
|
||||
Try `sudo journalctl --unit moonfire-nvr` to view the logs. You also
|
||||
likely want to set `MOONFIRE_FORMAT=google-systemd` to format logs as
|
||||
likely want to set `MOONFIRE_FORMAT=systemd` to format logs as
|
||||
expected by systemd.
|
||||
|
||||
*Note:* Moonfire's log format has recently changed significantly. You may
|
||||
encounter the older format in the issue tracker or (despite best efforts)
|
||||
documentation that hasn't been updated.
|
||||
|
||||
Logging options are controlled by environment variables:
|
||||
|
||||
* `MOONFIRE_LOG` controls the log level. Its format is similar to the
|
||||
|
@ -47,80 +52,48 @@ Logging options are controlled by environment variables:
|
|||
`MOONFIRE_LOG=info` is the default.
|
||||
`MOONFIRE_LOG=info,moonfire_nvr=debug` gives more detailed logging of the
|
||||
`moonfire_nvr` crate itself.
|
||||
* `MOONFIRE_FORMAT` selects the output format. The two options currently
|
||||
accepted are `google` (the default, like the Google
|
||||
[glog](https://github.com/google/glog) package) and `google-systemd` (a
|
||||
variation for better systemd compatibility).
|
||||
* `MOONFIRE_COLOR` controls color coding when using the `google` format.
|
||||
It accepts `always`, `never`, or `auto`. `auto` means to color code if
|
||||
stderr is a terminal.
|
||||
* `MOONFIRE_FORMAT` selects an output format. It defaults to an output meant
|
||||
for human consumption. It can be overridden to either of the following:
|
||||
* `systemd` uses [sd-daemon logging prefixes](https://man7.org/linux/man-pages/man3/sd-daemon.3.html))
|
||||
* `json` outputs one JSON-formatted log message per line, for machine
|
||||
consumption.
|
||||
* Errors include a backtrace if `RUST_BACKTRACE=1` is set.
|
||||
|
||||
If you use Docker, set these via Docker's `--env` argument.
|
||||
|
||||
With the default `MOONFIRE_FORMAT=google`, log lines are in the following
|
||||
format:
|
||||
With `MOONFIRE_FORMAT` left unset, log events look as follows:
|
||||
|
||||
```text
|
||||
I20210308 21:31:24.255 main moonfire_nvr] Success.
|
||||
LYYYYmmdd HH:MM:SS.FFF TTTT PPPPPPPPPPPP] ...
|
||||
L = level:
|
||||
E = error; when color mode is on, the message will be bright red.
|
||||
W = warn; " " " " " " " " " " yellow.
|
||||
I = info
|
||||
D = debug
|
||||
T = trace
|
||||
YYYY = year
|
||||
mm = month
|
||||
dd = day
|
||||
HH = hour (using a 24-hour clock)
|
||||
MM = minute
|
||||
SS = second
|
||||
FFF = fractional portion of the second
|
||||
TTTT = thread name (if set) or tid (otherwise)
|
||||
PPPP = log target (usually a module path)
|
||||
... = message body
|
||||
2023-02-15T22:45:06.999329 INFO s-courtyard-sub streamer{stream="courtyard-sub"}: moonfire_nvr::streamer: opening input url=rtsp://192.168.5.112/cam/realmonitor?channel=1&subtype=1&unicast=true&proto=Onvif
|
||||
```
|
||||
|
||||
This example contains the following elements:
|
||||
|
||||
* the timestamp (`2023-02-15T22:45:06.9999329`) in the system's local zone.
|
||||
* the log level (`INFO`) is one of `TRACE`, `DEBUG`, `INFO`, `WARN`, or
|
||||
`ERROR`.
|
||||
* the thread name (`s-courtyard-sub`), see explanation below.
|
||||
* the "spans" (`streamer{stream="courtyard-sub"}`), which contain
|
||||
context information for a group of messages. In this case there is a single
|
||||
span `streamer` with a single field `stream`. There can be multiple
|
||||
spans; they are listed starting from the root. Each may have fields.
|
||||
* the target (`moonfire_nvr::streamer`), which generally corresponds to a Rust
|
||||
module name.
|
||||
* the log message (`opening input`), a human-readable string
|
||||
* event fields (`url=...`)
|
||||
|
||||
Moonfire NVR names a few important thread types as follows:
|
||||
|
||||
* `main`: during `moonfire-nvr run`, the main thread does initial setup then
|
||||
just waits for the other threads. In other subcommands, it does everything.
|
||||
* `s-CAMERA-TYPE` (one per stream, where `TYPE` is `main`, `sub`, or `ext`):
|
||||
these threads write video to disk.
|
||||
* `sync-PATH` (one per sample file directory): These threads call `fsync` to
|
||||
* `sync-DIR_ID` (one per sample file directory): These threads call `fsync` to
|
||||
* commit sample files to disk, delete old sample files, and flush the
|
||||
database.
|
||||
* `r-PATH` (one per sample file directory): These threads read sample files
|
||||
* `r-DIR_ID` (one per sample file directory): These threads read sample files
|
||||
from disk for serving `.mp4` files.
|
||||
* `tokio-runtime-worker` (one per core, unless overridden with
|
||||
`--worker-threads`): these threads handle HTTP requests and read video
|
||||
data from cameras via RTSP.
|
||||
* `logger`: this thread writes the log buffer to `stderr`. Logging is
|
||||
asynchronous; other threads don't wait for log messages to be written
|
||||
unless the log buffer is full.
|
||||
|
||||
You can use the following command to teach [`lnav`](http://lnav.org/) Moonfire
|
||||
NVR's log format:
|
||||
|
||||
```console
|
||||
$ lnav -i misc/moonfire_log.json
|
||||
```
|
||||
|
||||
`lnav` versions prior to 0.9.0 print a (harmless) warning message on startup:
|
||||
|
||||
```console
|
||||
$ lnav -i git/moonfire-nvr/misc/moonfire_log.json
|
||||
warning:git/moonfire-nvr/misc/moonfire_log.json:line 2
|
||||
warning: unexpected path --
|
||||
warning: /$schema
|
||||
warning: accepted paths --
|
||||
warning: /(?<format_name>\w+)/ -- The definition of a log file format.
|
||||
info: installed: /home/slamb/.lnav/formats/installed/moonfire_log.json
|
||||
```
|
||||
|
||||
You can avoid this by removing the `$schema` line from `moonfire_log.json`
|
||||
and rerunning the `lnav -i` command.
|
||||
|
||||
Below are some interesting log lines you may encounter.
|
||||
|
||||
|
@ -130,16 +103,16 @@ During normal operation, Moonfire NVR will periodically flush changes to its
|
|||
SQLite3 database. Every flush is logged, as in the following info message:
|
||||
|
||||
```
|
||||
I20210308 23:14:18.388 sync-/media/14tb/sample moonfire_db::db] Flush 3810 (why: 120 sec after start of 1 minute 14 seconds courtyard-main recording 3/1842086):
|
||||
2021-03-08T23:14:18.388000 sync-2 syncer{path=/media/14tb/sample}:flush{flush_count=2 reason="120 sec after start of 1 minute 14 seconds courtyard-main recording 3/1842086"}: moonfire_db::db: flush complete:
|
||||
/media/6tb/sample: added 98M 864K 842B in 8 recordings (4/1839795, 7/1503516, 6/1853939, 1/1838087, 2/1852096, 12/1516945, 8/1514942, 10/1506111), deleted 111M 435K 587B in 5 (4/1801170, 4/1801171, 6/1799708, 1/1801528, 2/1815572), GCed 9 recordings (6/1799707, 7/1376577, 4/1801168, 1/1801527, 4/1801167, 4/1801169, 10/1243252, 2/1815571, 12/1418785).
|
||||
/media/14tb/sample: added 8M 364K 643B in 3 recordings (3/1842086, 9/1505359, 11/1516695), deleted 0B in 0 (), GCed 0 recordings ().
|
||||
```
|
||||
|
||||
This log message is packed with debugging information:
|
||||
|
||||
* the date and time: `20210308 23:14:18.388`.
|
||||
* the name of the thread that prompted the flush: `sync-/media/14tb/sample`.
|
||||
* a sequence number: `3810`. This is handy for checking how often Moonfire NVR
|
||||
* the date and time: `2021-03-08T23:14:18.388`.
|
||||
* the name of the thread that prompted the flush: `sync-2`.
|
||||
* a flush count: `3810`. This is handy for checking how often Moonfire NVR
|
||||
is flushing.
|
||||
* a reason for the flush: `120 sec after start of 1 minute 14 seconds courtyard-main recording 3/1842086`.
|
||||
This was a regular periodic flush at the `flush_if_sec` for the stream,
|
||||
|
@ -180,9 +153,7 @@ file a bug if you see one. It's helpful to set the `RUST_BACKTRACE`
|
|||
environment variable to include more information.
|
||||
|
||||
```
|
||||
E20210304 11:09:29.230 main s-peck_west-main] panic at 'src/moonfire-nvr/server/db/writer.rs:750:54': should always be an unindexed sample
|
||||
|
||||
(set environment variable RUST_BACKTRACE=1 to see backtraces)"
|
||||
2021-03-04T11:09:29.230291 ERROR s-peck_west-main streamer{stream="peck_west-main"}: panic: should always be an unindexed sample location=src/moonfire-nvr/server/db/writer.rs:750:54 backtrace=...
|
||||
```
|
||||
|
||||
In this case, a stream thread (one starting with `s-`) panicked. That stream
|
||||
|
@ -197,11 +168,11 @@ It's normal to see these warnings on startup and occasionally while running.
|
|||
Frequent occurrences may indicate a performance problem.
|
||||
|
||||
```
|
||||
W20201129 12:01:21.128 s-driveway-main moonfire_base::clock] opening rtsp://admin:redacted@192.168.5.108/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif took PT2.070715796S!
|
||||
W20201129 12:32:15.870 s-west_side-sub moonfire_base::clock] getting next packet took PT10.158121387S!
|
||||
W20201228 12:09:29.050 s-back_east-sub moonfire_base::clock] database lock acquisition took PT8.122452
|
||||
W20201228 21:22:32.012 main moonfire_base::clock] database operation took PT39.526386958S!
|
||||
W20201228 21:27:11.402 s-driveway-sub moonfire_base::clock] writing 37 bytes took PT20.701894190S!
|
||||
2020-11-29T12:01:21.128725 WARN s-driveway-main streamer{stream="driveway-main"}: moonfire_base::clock: opening rtsp://admin:redacted@192.168.5.108/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif took PT2.070715796S!
|
||||
2020-11-29T12:32:15.870658 WARN s-west_side-sub streamer{stream="west_side-sub"}: moonfire_base::clock: getting next packet took PT10.158121387S!
|
||||
2020-12-28T12:09:29.050464 WARN s-back_east-sub streamer{stream="s-back_east-sub"}: moonfire_base::clock: database lock acquisition took PT8.122452
|
||||
2020-12-28T21:22:32.012811 WARN main moonfire_base::clock: database operation took PT39.526386958S!
|
||||
2020-12-28T21:27:11.402259 WARN s-driveway-sub streamer{stream="s-driveway-sub"}: moonfire_base::clock: writing 37 bytes took PT20.701894190S!
|
||||
```
|
||||
|
||||
### Camera stream errors
|
||||
|
@ -213,31 +184,72 @@ quickly enough. In the latter case, you'll likely see a
|
|||
`getting next packet took PT...S!` message as described above.
|
||||
|
||||
```
|
||||
W20210309 00:28:55.527 s-courtyard-sub moonfire_nvr::streamer] courtyard-sub: sleeping for PT1S after error: Stream ended
|
||||
2021-03-09T00:28:55.527078 WARN s-courtyard-sub streamer{stream="courtyard-sub"}: moonfire_nvr::streamer: sleeping for PT1S after error: Stream ended
|
||||
(set environment variable RUST_BACKTRACE=1 to see backtraces)
|
||||
```
|
||||
|
||||
## Problems
|
||||
|
||||
### Server errors
|
||||
### Docker setup
|
||||
|
||||
If you are using the Docker compose snippet mentioned in the
|
||||
[install instructions](install.md), you might run into a few unique problems.
|
||||
|
||||
#### `"/etc/moonfire-nvr.toml" is a directory`
|
||||
|
||||
If you try running the Docker container with its
|
||||
`/etc/moonfire-nvr.toml:/etc/moonfire-nvr.toml:ro` mount before creating the
|
||||
config file, Docker will "helpfully" create it as a directory. Shut down
|
||||
the Docker container, remove the directory, create the config file,
|
||||
and try again.
|
||||
|
||||
#### `Error response from daemon: unable to find user UID: no matching entries in passwd file`
|
||||
|
||||
If Docker produces this error, look at this section of the docker compose setup:
|
||||
|
||||
```yaml
|
||||
# Edit this to match your `moonfire-nvr` user.
|
||||
# Note that Docker will not honor names from the host here, even if
|
||||
# `/etc/passwd` is passed through.
|
||||
# - Be sure to run the `useradd` command below first.
|
||||
# - Then run `echo $(id -u moonfire-nvr):$(id -g moonfire-nvr)` to see
|
||||
# what should be filled in here.
|
||||
user: UID:GID
|
||||
```
|
||||
|
||||
#### `clock_gettime failed: EPERM: Operation not permitted`
|
||||
|
||||
If commands fail with an error like the following, you're likely running
|
||||
Docker with an overly restrictive `seccomp` setup. [This stackoverflow
|
||||
answer](https://askubuntu.com/questions/1263284/apt-update-throws-signature-error-in-ubuntu-20-04-container-on-arm/1264921#1264921) describes the
|
||||
problem in more detail. The simplest solution is to add
|
||||
`--security-opt=seccomp:unconfined` to your Docker commandline.
|
||||
If you are using the recommended `/usr/local/bin/nvr` wrapper script,
|
||||
add this option to the `common_docker_run_args` section.
|
||||
problem in more detail. The simplest solution is to uncomment
|
||||
the `- seccomp: unconfined` line in your Docker compose file.
|
||||
|
||||
```console
|
||||
$ sudo docker run --rm -it moonfire-nvr:latest
|
||||
$ sudo docker compose run --rm moonfire-nvr --version
|
||||
clock_gettime failed: EPERM: Operation not permitted
|
||||
|
||||
This indicates a broken environment. See the troubleshooting guide.
|
||||
```
|
||||
|
||||
#### `VFS is unable to determine a suitable directory for temporary files`
|
||||
|
||||
Moonfire NVR's database internally uses SQLite, which creates
|
||||
[various temporary files](https://www.sqlite.org/tempfiles.html). If it can't
|
||||
find a path that exists and is writable by the current user, it will produce
|
||||
errors such as the following:
|
||||
|
||||
```
|
||||
2023-12-29T16:16:47.795330 WARN sync-1 syncer{path=/media/nvr/sample}: moonfire_db::writer: flush failure on save for reason 120 sec after start of 59 seconds driveway-sub recording 10/1222348; will retry after PT60S: UNAVAILABLE
|
||||
caused by: disk I/O error
|
||||
caused by: Error code 6410: VFS is unable to determine a suitable directory for temporary files
|
||||
```
|
||||
|
||||
The simplest solution is to pass `/var/tmp` through from the host to the Docker
|
||||
container in your Docker compose file.
|
||||
|
||||
### Server errors
|
||||
|
||||
#### `Error: pts not monotonically increasing; got 26615520 then 26539470`
|
||||
|
||||
If your streams cut out and you see error messages like this one in Moonfire
|
||||
|
@ -253,7 +265,7 @@ If Moonfire NVR runs out of disk space on a sample file directory, recording
|
|||
will be stuck and you'll see log messages like the following:
|
||||
|
||||
```
|
||||
W20210401 11:21:07.365 s-driveway-main moonfire_base::clock] sleeping for PT1S after error: No space left on device (os error 28)
|
||||
2021-04-01T11:21:07.365 WARN s-driveway-main streamer{stream="s-driveway-main"}: moonfire_base::clock: sleeping for PT1S after error: No space left on device (os error 28)
|
||||
```
|
||||
|
||||
If something else used more disk space on the filesystem than planned, just
|
||||
|
@ -352,40 +364,10 @@ mechanism to fix old timestamps after the fact. Ideas and help welcome; see
|
|||
|
||||
#### `moonfire-nvr config` displays garbage
|
||||
|
||||
This happens if you're not using the premade Docker containers and have
|
||||
configured your machine is configured to a non-UTF-8 locale, due to
|
||||
This may happen if your machine is configured to a non-UTF-8 locale, due to
|
||||
gyscos/Cursive#13. As a workaround, try setting the environment variable
|
||||
`LC_ALL=C.UTF-8`.
|
||||
|
||||
### Browser user interface problems
|
||||
|
||||
#### Live stream always fails with `ws close: 1006`
|
||||
|
||||
Moonfire NVR's UI uses a
|
||||
[WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
|
||||
connection to the server for the live view. If you see an alert in the lower
|
||||
left corner of a live stream area that says `ws close: 1006`, this means that
|
||||
the WebSocket connection failed. Unfortunately this is all the UI knows;
|
||||
the WebSocket spec [deliberately withholds](https://html.spec.whatwg.org/multipage/web-sockets.html#closeWebSocket) additional debugging information
|
||||
for security reasons.
|
||||
|
||||
You might be able to learn more through your browser's Javascript console.
|
||||
|
||||
If you consistently see this error when other parts of the UI work properly,
|
||||
here are some things to check:
|
||||
|
||||
* If you are using Safari and haven't logged out since Moonfire NVR v0.6.3
|
||||
was released, try logging out and back in. Safari apparently doesn't send
|
||||
[`SameSite=Strict`
|
||||
cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#strict)
|
||||
on WebSocket requests. Since v0.6.3, Moonfire NVR uses `SameSite=Lax`
|
||||
instead.
|
||||
* If you are using a proxy server, check that it is properly configured for
|
||||
Websockets. In particular, if you followed the [Securing Moonfire NVR
|
||||
guide](schema.md) prior to 29 Feb 2020, look at [this
|
||||
update](https://github.com/scottlamb/moonfire-nvr/commit/92266612b5c9163eb6096c580ba751280a403648#diff-e8bdd96dda101a25a541a6629675ea46bd6eaf670c6417c76662db5397c50c19)
|
||||
to those instructions.
|
||||
|
||||
### Errors in kernel logs
|
||||
|
||||
#### UAS errors
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"$schema": "https://lnav.org/schemas/format-v1.schema.json",
|
||||
"moonfire_log": {
|
||||
"title": "Moonfire Log",
|
||||
"description": "The Moonfire NVR log format.",
|
||||
"timestamp-format": [
|
||||
"%Y%m%d %H:%M:%S.%L",
|
||||
"%m%d %H%M%S.%L"
|
||||
],
|
||||
"url": "https://github.com/scottlamb/mylog/blob/master/src/lib.rs",
|
||||
"regex": {
|
||||
"std": {
|
||||
"pattern": "^(?<level>[EWIDT])(?<timestamp>\\d{8} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}|\\d{4} \\d{6}\\.\\d{3}) +(?<thread>[^ ]+) (?<module_path>[^ \\]]+)\\] (?<body>(?:.|\\n)*)"
|
||||
}
|
||||
},
|
||||
"level-field": "level",
|
||||
"level": {
|
||||
"error": "E",
|
||||
"warning": "W",
|
||||
"info": "I",
|
||||
"debug": "D",
|
||||
"trace": "T"
|
||||
},
|
||||
"opid-field": "thread",
|
||||
"value": {
|
||||
"thread": {
|
||||
"kind": "string",
|
||||
"identifier": true
|
||||
},
|
||||
"module_path": {
|
||||
"kind": "string",
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"sample": [
|
||||
{
|
||||
"line": "I20210308 21:31:24.255 main moonfire_nvr] Success."
|
||||
},
|
||||
{
|
||||
"line": "I0308 213124.255 main moonfire_nvr] Older style."
|
||||
},
|
||||
{
|
||||
"line": "W20210303 22:20:53.081 s-west_side-main moonfire_base::clock] getting next packet took PT8.153173367S!"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
Reference documentation for Moonfire NVR.
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
Status: **current**.
|
||||
|
||||
* [Objective](#objective)
|
||||
* [Detailed design](#detailed-design)
|
||||
* [`POST /api/login`](#post-apilogin)
|
||||
* [`POST /api/logout`](#post-apilogout)
|
||||
* [Summary](#summary)
|
||||
* [Endpoints](#endpoints)
|
||||
* [Authentication](#authentication)
|
||||
* [`POST /api/login`](#post-apilogin)
|
||||
* [`POST /api/logout`](#post-apilogout)
|
||||
* [`GET /api/`](#get-api)
|
||||
* [`GET /api/cameras/<uuid>/`](#get-apicamerasuuid)
|
||||
* [`GET /api/cameras/<uuid>/<stream>/recordings`](#get-apicamerasuuidstreamrecordings)
|
||||
|
@ -21,12 +22,21 @@ Status: **current**.
|
|||
* [Request 1](#request-1)
|
||||
* [Request 2](#request-2)
|
||||
* [Request 3](#request-3)
|
||||
* [`POST /api/users/<id>`](#post-apiusersid)
|
||||
* [User management](#user-management)
|
||||
* [`GET /api/users/`](#get-apiusers)
|
||||
* [`POST /api/users/`](#post-apiusers)
|
||||
* [`GET /api/users/<id>`](#get-apiusersid)
|
||||
* [`PATCH /api/users/<id>`](#patch-apiusersid)
|
||||
* [`DELETE /api/users/<id>`](#delete-apiusersid)
|
||||
* [Types](#types)
|
||||
* [UserSubset](#usersubset)
|
||||
* [Permissions](#permissions)
|
||||
* [Cross-site request forgery (CSRF) protection](#cross-site-request-forgery-csrf-protection)
|
||||
|
||||
## Objective
|
||||
## Summary
|
||||
|
||||
Allow a JavaScript-based web interface to list cameras and view recordings.
|
||||
Support external analytics.
|
||||
A JavaScript-based web interface to list cameras and view recordings.
|
||||
Supports external analytics.
|
||||
|
||||
In the future, this is likely to be expanded:
|
||||
|
||||
|
@ -34,8 +44,6 @@ In the future, this is likely to be expanded:
|
|||
* commandline tool over a UNIX-domain socket
|
||||
(at least for bootstrapping web authentication)
|
||||
|
||||
## Detailed design
|
||||
|
||||
*Note:* italicized terms in this document are defined in the [glossary](glossary.md).
|
||||
|
||||
Currently the API is considered an internal contract between the server and the
|
||||
|
@ -50,7 +58,11 @@ developed tools.
|
|||
All requests for JSON data should be sent with the header
|
||||
`Accept: application/json` (exactly).
|
||||
|
||||
### `POST /api/login`
|
||||
## Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
#### `POST /api/login`
|
||||
|
||||
The request should have an `application/json` body containing a JSON object with
|
||||
`username` and `password` keys.
|
||||
|
@ -63,7 +75,7 @@ If authentication or authorization fails, the server will return a HTTP 403
|
|||
(forbidden) response. Currently the body will be a `text/plain` error message;
|
||||
future versions will likely be more sophisticated.
|
||||
|
||||
### `POST /api/logout`
|
||||
#### `POST /api/logout`
|
||||
|
||||
The request should have an `application/json` body containing
|
||||
a `csrf` parameter copied from the `session.csrf` of the
|
||||
|
@ -81,8 +93,7 @@ request parameters:
|
|||
should be included.
|
||||
* `cameraConfigs`: a boolean indicating if the `camera.config` and
|
||||
`camera.stream[].config` parameters described below should be included.
|
||||
This requires the `read_camera_configs` permission as described in
|
||||
`schema.proto`.
|
||||
This requires the `readCameraConfigs` permission.
|
||||
|
||||
Example request URI (with added whitespace between parameters):
|
||||
|
||||
|
@ -176,13 +187,12 @@ The `application/json` response will have a JSON object as follows:
|
|||
considered to have motion when this signal is in this state.
|
||||
* `color` (optional): a recommended color to use in UIs to represent
|
||||
this state, as in the [HTML specification](https://html.spec.whatwg.org/#colours).
|
||||
* `permissions`: the caller's current `Permissions` object (defined below).
|
||||
* `user`: an object, present only when authenticated:
|
||||
* `name`: a human-readable name
|
||||
* `id`: an integer
|
||||
* `preferences`: a JSON object
|
||||
* `session`: an object, present only if authenticated via session cookie.
|
||||
(In the future, it will be possible to instead authenticate via uid over
|
||||
a Unix domain socket.)
|
||||
* `csrf`: a cross-site request forgery token for use in `POST` requests.
|
||||
|
||||
Example response:
|
||||
|
@ -332,6 +342,7 @@ arbitrary order. Each recording object has the following properties:
|
|||
together are as described. Adjacent recordings from the same RTSP session
|
||||
may be coalesced in this fashion to reduce the amount of redundant data
|
||||
transferred.
|
||||
* `runStartId`. The id of the first recording in this run.
|
||||
* `firstUncommitted` (optional). If this range is not fully committed to the
|
||||
database, the first id that is uncommitted. This is significant because
|
||||
it's possible that after a crash and restart, this id will refer to a
|
||||
|
@ -364,6 +375,9 @@ arbitrary order. Each recording object has the following properties:
|
|||
and Moonfire NVR fills in a duration of 0. When using `/view.mp4`, it's
|
||||
not possible to append additional segments after such frames, as noted
|
||||
below.
|
||||
* `endReason`: the reason the recording ended. Absent if the recording did
|
||||
not end (`growing` is true or this was split via `split90k`) or if the
|
||||
reason was unknown (recording predates schema version 7).
|
||||
|
||||
Under the property `videoSampleEntries`, an object mapping ids to objects with
|
||||
the following properties:
|
||||
|
@ -419,7 +433,7 @@ Example response:
|
|||
|
||||
### `GET /api/cameras/<uuid>/<stream>/view.mp4`
|
||||
|
||||
Requires the `view_video` permission.
|
||||
Requires the `viewVideo` permission.
|
||||
|
||||
Returns a `.mp4` file, with an etag and support for range requests. The MIME
|
||||
type will be `video/mp4`, with a `codecs` parameter as specified in
|
||||
|
@ -558,10 +572,16 @@ Initiate a WebSocket stream for chunks of video. Expects the standard
|
|||
WebSocket headers as described in [RFC 6455][rfc-6455] and (if authentication
|
||||
is required) the `s` cookie.
|
||||
|
||||
The server will send a sequence of binary messages. Each message corresponds
|
||||
to one or more frames of video. The first message is guaranteed to start with a
|
||||
"key" (IDR) frame; others may not. The message will contain HTTP headers
|
||||
followed by by a `.mp4` media segment. The following headers will be included:
|
||||
The server will send messages as follows:
|
||||
|
||||
* text: a plaintext error message, followed by the end of stream.
|
||||
* binary: video data, repeatedly, as described below.
|
||||
* ping: every 30 seconds.
|
||||
|
||||
Each binary message corresponds to one or more frames of video. The first
|
||||
message is guaranteed to start with a "key" (IDR) frame; others may not. The
|
||||
message will contain HTTP headers followed by by a `.mp4` media segment. The
|
||||
following headers will be included:
|
||||
|
||||
* `X-Video-Sample-Entry-Id`: An id to use when fetching an initialization segment.
|
||||
* `X-Recording-Id`: the open id, a period, and the recording id of the
|
||||
|
@ -575,10 +595,8 @@ followed by by a `.mp4` media segment. The following headers will be included:
|
|||
* `X-Media-Time-Range`: the relative media start and end times of these
|
||||
frames within the recording, as a half-open interval.
|
||||
|
||||
The server will also send pings, currently at 30-second intervals.
|
||||
|
||||
The WebSocket will always open immediately but will receive messages only while the
|
||||
backing RTSP stream is connected.
|
||||
The WebSocket will always open immediately but will receive messages only while
|
||||
the backing RTSP stream is connected.
|
||||
|
||||
Example request URI:
|
||||
|
||||
|
@ -709,7 +727,7 @@ This represents the following observations:
|
|||
|
||||
### `POST /api/signals`
|
||||
|
||||
Requires the `update_signals` permission.
|
||||
Requires the `updateSignals` permission.
|
||||
|
||||
Alters the state of a signal.
|
||||
|
||||
|
@ -727,6 +745,7 @@ last ran. These will specify beginning and end times.
|
|||
The request should have an `application/json` body describing the change to
|
||||
make. It should be a JSON object with these attributes:
|
||||
|
||||
* `csrf`: a CSRF token, required when using session authentication.
|
||||
* `signalIds`: a list of signal ids to change. Must be sorted.
|
||||
* `states`: a list (one per `signalIds` entry) of states to set.
|
||||
* `start`: the starting time of the change, as a JSON object of the form
|
||||
|
@ -819,24 +838,142 @@ Response:
|
|||
}
|
||||
```
|
||||
|
||||
### `POST /api/users/<id>`
|
||||
### User management
|
||||
|
||||
Currently this request only allows updating the preferences for the
|
||||
currently-authenticated user. This is likely to change.
|
||||
#### `GET /api/users/`
|
||||
|
||||
Requires the `adminUsers` permission.
|
||||
|
||||
Lists all users. Currently there's no paging. Returns a JSON object with
|
||||
a `users` key with an array of objects, each with the following keys:
|
||||
|
||||
* `id`: a number.
|
||||
* `user`: a `UserSubset`.
|
||||
|
||||
#### `POST /api/users/`
|
||||
|
||||
Requires the `adminUsers` permission.
|
||||
|
||||
Adds a user. Expects a JSON object as follows:
|
||||
|
||||
* `csrf`: a CSRF token, required when using session authentication.
|
||||
* `user`: a `UserSubset` as defined below.
|
||||
|
||||
Returns status 204 (No Content) on success.
|
||||
|
||||
#### `GET /api/users/<id>`
|
||||
|
||||
Retrieves the user. Requires the `adminUsers` permission if the caller is
|
||||
not authenticated as the user in question.
|
||||
|
||||
Returns a HTTP status 200 on success with a JSON `UserSubset`.
|
||||
|
||||
#### `PATCH /api/users/<id>`
|
||||
|
||||
Updates the given user. Requires the `adminUsers` permission if the caller is
|
||||
not authenticated as the user in question.
|
||||
|
||||
Expects a JSON object:
|
||||
|
||||
* `update`: sets the provided fields
|
||||
* `precondition`: forces the request to fail with HTTP status 412
|
||||
(Precondition failed) if the provided fields don't have the given value.
|
||||
|
||||
Currently both objects support a single field, `preferences`, which should be
|
||||
a JSON dictionary.
|
||||
* `csrf`: a CSRF token, required when using session authentication.
|
||||
* `update`: `UserSubset`, sets the provided fields. Field-specific notes:
|
||||
* `disabled`: requires `adminUsers` permission.
|
||||
* `password`: when updating the password, the previous password must
|
||||
be supplied as a precondition, unless the caller has `adminUsers`
|
||||
permission.
|
||||
* `permissions`: requires `adminUsers` permission. Note that updating a
|
||||
user's permissions currently neither adds nor limits permissions of
|
||||
existing sessions; it only changes what is available to newly created
|
||||
sessions.
|
||||
* `username`: requires `adminUsers` permission.
|
||||
* `precondition`: `UserSubset`, forces the request to fail with HTTP status
|
||||
412 (Precondition failed) if the provided fields don't have the given
|
||||
values.
|
||||
|
||||
Returns HTTP status 204 (No Content) on success.
|
||||
|
||||
#### `DELETE /api/users/<id>`
|
||||
|
||||
Deletes the given user. Requires the `adminUsers` permission.
|
||||
|
||||
Expects a JSON object body with the following parameters:
|
||||
|
||||
* `csrf`: a CSRF token, required when using session authentication.
|
||||
|
||||
Returns HTTP status 204 (No Content) on success.
|
||||
|
||||
## Types
|
||||
|
||||
### UserSubset
|
||||
|
||||
A JSON object with any of the following parameters:
|
||||
|
||||
* `disabled`, boolean indicating if all logins from the user are rejected.
|
||||
* `password`
|
||||
* on retrieval, a placeholder string to indicate a password is set,
|
||||
or null.
|
||||
* in preconditions, may be left absent to ignore, set to null to require
|
||||
no password, or set to a plaintext string.
|
||||
* in updates, may be left absent to keep as-is, set to null to disable
|
||||
session creation, or set to a plaintext string.
|
||||
* `permissions`, a `Permissions` as described below.
|
||||
* `preferences`, a JSON object which the server stores without interpreting.
|
||||
This field is meant for user-level preferences meaningful to the UI.
|
||||
* `username`
|
||||
|
||||
### Permissions
|
||||
|
||||
A JSON object of permissions to perform various actions:
|
||||
|
||||
* `adminUsers`: bool
|
||||
* `readCameraConfigs`: bool, read camera configs including credentials
|
||||
* `updateSignals`: bool
|
||||
* `viewVideo`: bool
|
||||
|
||||
See endpoints above for more details on the contexts in which these are
|
||||
required.
|
||||
|
||||
## Cross-site request forgery (CSRF) protection
|
||||
|
||||
The API includes several standard protections against [cross-site request
|
||||
forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) attacks, in
|
||||
which a malicious third-party website convinces the user's browser to send
|
||||
requests on its behalf.
|
||||
|
||||
The following protections apply regardless of method of authentication (session,
|
||||
no authentication + `allowUnauthenticatedPermissions`, or the browser proxying
|
||||
to a Unix socket with `ownUidIsPrivileged`):
|
||||
|
||||
* The `GET` method is always "safe". Actions that have significant side
|
||||
effects require another method such as `DELETE`, `POST`, or `PUT`. This
|
||||
prevents simple hyperlinks from causing damage.
|
||||
* Mutations always require some non-default request header (e.g.
|
||||
`Content-Type: application/json`) so that a `<form method="POST">` will be
|
||||
rejected.
|
||||
* The server does *not* override the default
|
||||
[CORS](https://en.wikipedia.org/wiki/Cross-origin_resource_sharing) policy.
|
||||
Thus, cross-domain Ajax requests (via `XMLHTTPRequest` or `fetch`) should
|
||||
fail.
|
||||
* WebSocket upgrade requests are rejected if the `Origin` header is present
|
||||
and does not match `Host`. This is the sole protection against
|
||||
[Cross-Site WebSocket Hijacting (CSWSH)](https://christian-schneider.net/CrossSiteWebSocketHijacking.html).
|
||||
|
||||
The following additional protections apply only when using session
|
||||
authentication:
|
||||
|
||||
* Session cookies are set with the [`SameSite=Lax` attribute](samesite-lax),
|
||||
so that sufficiently modern web browsers will never send the session cookie
|
||||
on subrequests. (Note they still send session cookies when following links
|
||||
and on WebSocket upgrade. In these cases, we rely on the protections
|
||||
described above.)
|
||||
* Mutations use a `csrf` token, requiring the caller to prove it is able
|
||||
to read the `GET /api/` response. (This is subect to change. We may decide
|
||||
to implement these tokens in a way that doesn't require session
|
||||
authentication or decide they're entirely unnecessary.)
|
||||
|
||||
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
|
||||
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
|
||||
[rfc-6381]: https://tools.ietf.org/html/rfc6381
|
||||
[rfc-6455]: https://tools.ietf.org/html/rfc6455
|
||||
[multipart-mixed-js]: https://github.com/scottlamb/multipart-mixed-js
|
||||
[samesite-lax]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax
|
|
@ -0,0 +1,152 @@
|
|||
# Moonfire NVR Configuration File
|
||||
|
||||
Moonfire NVR has a small runtime configuration file. By default it's called
|
||||
`/etc/moonfire-nvr.toml`. You can specify a different path on the commandline,
|
||||
e.g. as follows:
|
||||
|
||||
```console
|
||||
$ moonfire-nvr run --config /path/to/config.toml
|
||||
```
|
||||
|
||||
`.toml` refers to [Tom's Obvious Minimal Language](https://toml.io/en/). This
|
||||
is a line-based config format with `[section]` boundaries and `# comment`
|
||||
lines, meant to be more easily edited by humans.
|
||||
|
||||
## Examples
|
||||
|
||||
### Starter config
|
||||
|
||||
The following is a starter config which allows connecting and viewing video with no authentication:
|
||||
|
||||
```toml
|
||||
[[binds]]
|
||||
ipv4 = "0.0.0.0:8080"
|
||||
allowUnauthenticatedPermissions = { viewVideo = true }
|
||||
|
||||
[[binds]]
|
||||
unix = "/var/lib/moonfire-nvr/sock"
|
||||
ownUidIsPrivileged = true
|
||||
```
|
||||
|
||||
### Authenticated config
|
||||
|
||||
The following is for a more secure setup with authentication and a TLS proxy
|
||||
server in front, as in [guide/secure.md](../guide/secure.md).
|
||||
|
||||
```toml
|
||||
[[binds]]
|
||||
ipv4 = "0.0.0.0:8080"
|
||||
trustForwardHeaders = true
|
||||
|
||||
[[binds]]
|
||||
unix = "/var/lib/moonfire-nvr/sock"
|
||||
ownUidIsPrivileged = true
|
||||
```
|
||||
|
||||
### `systemd` socket activation
|
||||
|
||||
`systemd` socket activation (Linux-only) expects `systemd` to create the sockets
|
||||
on behalf of Moonfire NVR. This can speed startup of services that depend on them and allow
|
||||
Moonfire to bind to privileged ports (80 or 443) without root privileges. The latter is
|
||||
expected to be more useful once
|
||||
[moonfire-nvr#27](https://github.com/scottlamb/moonfire-nvr/issues/27) is
|
||||
complete and Moonfire is suitable for direct use as an Internet-facing webserver.
|
||||
|
||||
To set this up, you'll need an additional systemd unit file for each socket and
|
||||
to reference them from `/etc/moonfire-nvr.toml`. Be sure to run `sudo systemctl
|
||||
daemon-reload` to tell `systemd` to read in the new unit files. Your
|
||||
`moonfire-nvr.service` file should also `Requires=` each socket file.
|
||||
|
||||
#### `/etc/moonfire-nvr.toml`
|
||||
|
||||
```toml
|
||||
[[binds]]
|
||||
systemd = "moonfire-nvr-tcp.socket"
|
||||
allowUnauthenticatedPermissions = { viewVideo = true }
|
||||
|
||||
[[binds]]
|
||||
systemd = "moonfire-nvr-unix.socket"
|
||||
ownUidIsPrivileged = true
|
||||
```
|
||||
|
||||
### `/etc/systemd/system/moonfire-nvr.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Requires=moonfire-nvr-tcp.socket
|
||||
Requires=moonfire-nvr-unix.socket
|
||||
# ...rest as before...
|
||||
```
|
||||
|
||||
### `/etc/systemd/system/moonfire-nvr-tcp.socket`
|
||||
|
||||
```ini
|
||||
[Socket]
|
||||
ListenStream=80
|
||||
Service=moonfire-nvr.service
|
||||
```
|
||||
|
||||
### `/etc/systemd/system/moonfire-nvr-unix.socket`
|
||||
|
||||
```ini
|
||||
[Socket]
|
||||
ListenStream=/var/lib/moonfire-nvr/sock
|
||||
Service=moonfire-nvr.service
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
At the top level, before any `[[bind]]` lines, the following
|
||||
keys are understood:
|
||||
|
||||
* `dbDir`: path to the SQLite database directory. Defaults to `/var/lib/moonfire-nvr/db`.
|
||||
* `uiDir`: UI to serve; can be a path. Defaults to the special value
|
||||
`uiDir = { bundled = true }` if a UI was built into the binary, or
|
||||
`/usr/local/lib/moonfire-nvr/ui` otherwise. Release builds have UIs
|
||||
built in; you can replicate this yourself via `--features=bundled` or `--features=bundled-ui`
|
||||
when [building the server](../guide/build.md). **Note:** it's unusual
|
||||
to override this value. For UI development, a much more pleasant
|
||||
workflow is to use a hot-reloading proxy server as described in
|
||||
[this guide](../guide/developing-ui.md).
|
||||
* `workerThreads`: number of [tokio](https://tokio.rs/) worker threads to
|
||||
use. Defaults to the number of CPUs on the system. This normally does not
|
||||
need to be changed, but reducing it may slightly lower idle CPU usage.
|
||||
|
||||
A useful config will bind at least one socket for clients to connect to. Each
|
||||
should start with a `[[binds]]` line and specify one of the following:
|
||||
|
||||
* `ipv4`: an IPv4 socket address. `0.0.0.0:8080` would allow connections from outside the machine;
|
||||
`127.0.0.1:8080` would allow connections only from the local host.
|
||||
* `ipv6`: an IPv6 socket address. `[::0]:8080` would allow connections from outside the machine;
|
||||
`[[::1]:8080` would allow connections from only the local host.
|
||||
* `unix`: a path in the local filesystem where a UNIX-domain socket can be created. Permissions on the
|
||||
enclosing directories control which users are allowed to connect to it. Web browsers typically don't
|
||||
support directly connecting to UNIX domain sockets, but other tools do, e.g.:
|
||||
* `curl --unix-socket /var/lib/moonfire-nvr/sock http://nvr/api/` will
|
||||
issue a request from the commandline. (The hostname in the URL doesn't
|
||||
matter.)
|
||||
* `ssh -L localhost:8080:/var/lib/moonfire-nvr/sock moonfire-nvr@nvr-host`
|
||||
will allow a web browser on your local machine to connect to the
|
||||
Moonfire NVR instance on `nvr-host` via https://localhost:8080/. If
|
||||
`ownUidIsPrivileged` is specified (see below), it will additionally
|
||||
have all permissions.
|
||||
* `systemd` (Linux-only): a name of a socket passed from `systemd`. See
|
||||
[`systemd.socket(5)`](https://www.freedesktop.org/software/systemd/man/latest/systemd.socket.html)
|
||||
for more information, or the example above.
|
||||
|
||||
Additional options within `[[binds]]`:
|
||||
|
||||
* `ownUidIsPrivileged` (UNIX domain sockets only): boolean. If true, a client
|
||||
running as Moonfire NVR's own uid can perform any action without additional
|
||||
authentication. Once the configuration UI is complete, this will be a handy
|
||||
way to set up the first user accounts.
|
||||
* `allowUnauthenticatedPermissions`: dictionary. Clients connecting to this
|
||||
bind will have the specified permissions, even without UID or session
|
||||
authentication. The supported permissions are as in the [`Permissions`
|
||||
section of api.md](api.md#permissions).
|
||||
* `trustForwardHeaders`: boolean. Moonfire NVR will look for `X-Real-IP` and
|
||||
`X-Forwarded-Proto` headers added by a proxy server to determine the
|
||||
client's IP address and protocol (`http` or `https`). See
|
||||
[guide/secure.md](../guide/secure.md) for more information. *Note:* when
|
||||
using this option, ensure that untrusted clients can't bypass the proxy
|
||||
server, or they will be able to disguise their true origin.
|
51
release.bash
51
release.bash
|
@ -1,51 +0,0 @@
|
|||
#!/bin/bash
|
||||
# This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
# Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
# SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
|
||||
# Pushes a release to Docker. See guides/build.md#release-procedure.
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o xtrace
|
||||
|
||||
set_latest() {
|
||||
# Our images are manifest lists (for multiple architectures).
|
||||
# "docker tag" won't copy those. The technique below is adopted from here:
|
||||
# https://github.com/docker/buildx/issues/459#issuecomment-750011371
|
||||
local image="$1"
|
||||
local hashes=($(docker manifest inspect "${image}:${version}" |
|
||||
jq --raw-output '.manifests[].digest'))
|
||||
time docker manifest rm "${image}:latest" || true
|
||||
time docker manifest create \
|
||||
"${image}:latest" \
|
||||
"${hashes[@]/#/${image}@}"
|
||||
time docker manifest push "${image}:latest"
|
||||
}
|
||||
|
||||
build_and_push() {
|
||||
local image="$1"
|
||||
local target="$2"
|
||||
time docker buildx build \
|
||||
--push \
|
||||
--tag="${image}:${version}" \
|
||||
--target="${target}" \
|
||||
--platform=linux/amd64,linux/arm64/v8,linux/arm/v7 \
|
||||
-f docker/Dockerfile .
|
||||
}
|
||||
|
||||
version="$(git describe --dirty)"
|
||||
if [[ ! "${version}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Expected a vX.Y.Z version tag, got ${version}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "git status says there's extra stuff in this directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_and_push scottlamb/moonfire-nvr deploy
|
||||
build_and_push scottlamb/moonfire-dev dev
|
||||
set_latest scottlamb/moonfire-nvr
|
||||
set_latest scottlamb/moonfire-dev
|
File diff suppressed because it is too large
Load Diff
|
@ -1,74 +1,100 @@
|
|||
[package]
|
||||
name = "moonfire-nvr"
|
||||
version = "0.7.5"
|
||||
version = "0.0.0"
|
||||
authors = ["Scott Lamb <slamb@slamb.org>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
resolver = "2"
|
||||
license-file = "../LICENSE.txt"
|
||||
rust-version = "1.60"
|
||||
rust-version = "1.70"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
|
||||
# The nightly feature is used within moonfire-nvr itself to gate the
|
||||
# benchmarks. Also pass it along to crates that can benefit from it.
|
||||
nightly = ["db/nightly", "parking_lot/nightly"]
|
||||
nightly = ["db/nightly"]
|
||||
|
||||
# The bundled feature includes bundled (aka statically linked) versions of
|
||||
# native libraries where possible.
|
||||
bundled = ["rusqlite/bundled"]
|
||||
# The bundled feature aims to make a single executable file that is deployable,
|
||||
# including statically linked libraries and embedded UI files.
|
||||
bundled = ["rusqlite/bundled", "bundled-ui"]
|
||||
|
||||
bundled-ui = []
|
||||
|
||||
[workspace]
|
||||
members = ["base", "db"]
|
||||
|
||||
[workspace.dependencies]
|
||||
base64 = "0.21.0"
|
||||
h264-reader = "0.7.0"
|
||||
itertools = "0.12.0"
|
||||
nix = "0.27.0"
|
||||
pretty-hex = "0.4.0"
|
||||
ring = "0.17.0"
|
||||
rusqlite = "0.30.0"
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
tracing-log = "0.2"
|
||||
|
||||
[dependencies]
|
||||
base = { package = "moonfire-base", path = "base" }
|
||||
base64 = "0.13.0"
|
||||
base64 = { workspace = true }
|
||||
blake3 = "1.0.0"
|
||||
bpaf = { version = "0.9.1", features = ["autocomplete", "bright-color", "derive"]}
|
||||
bytes = "1"
|
||||
byteorder = "1.0"
|
||||
clap = { version = "2.33.3", default-features = false, features = ["color", "wrap_help"] }
|
||||
cursive = "0.17.0"
|
||||
chrono = "0.4.23"
|
||||
cursive = { version = "0.20.0", default-features = false, features = ["termion-backend"] }
|
||||
db = { package = "moonfire-db", path = "db" }
|
||||
failure = "0.1.1"
|
||||
futures = "0.3"
|
||||
fnv = "1.0"
|
||||
h264-reader = "0.5.0"
|
||||
h264-reader = { workspace = true }
|
||||
http = "0.2.3"
|
||||
http-serve = { version = "0.3.1", features = ["dir"] }
|
||||
hyper = { version = "0.14.2", features = ["http1", "server", "stream", "tcp"] }
|
||||
lazy_static = "1.0"
|
||||
itertools = { workspace = true }
|
||||
libc = "0.2"
|
||||
log = { version = "0.4" }
|
||||
memchr = "2.0.2"
|
||||
mylog = { git = "https://github.com/scottlamb/mylog" }
|
||||
nix = "0.23.0"
|
||||
nix = { workspace = true, features = ["time", "user"] }
|
||||
nom = "7.0.0"
|
||||
parking_lot = "0.12.0"
|
||||
password-hash = "0.3.2"
|
||||
password-hash = "0.5.0"
|
||||
pretty-hex = { workspace = true }
|
||||
protobuf = "3.0"
|
||||
reffers = "0.7.0"
|
||||
retina = "0.3.9"
|
||||
ring = "0.16.2"
|
||||
rusqlite = "0.27.0"
|
||||
retina = "0.4.0"
|
||||
ring = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
smallvec = { version = "1.7", features = ["union"] }
|
||||
structopt = { version = "0.3.13", default-features = false }
|
||||
sync_wrapper = "0.1.0"
|
||||
time = "0.1"
|
||||
tokio = { version = "1.0", features = ["macros", "parking_lot", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio = { version = "1.24", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
|
||||
tokio-stream = "0.1.5"
|
||||
tokio-tungstenite = "0.17.1"
|
||||
toml = "0.5"
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
tokio-tungstenite = "0.20.0"
|
||||
toml = "0.8"
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
|
||||
tracing-core = "0.1.30"
|
||||
tracing-futures = { version = "0.2.5", features = ["futures-03", "std-future"] }
|
||||
tracing-log = { workspace = true }
|
||||
ulid = "1.0.0"
|
||||
url = "2.1.1"
|
||||
uuid = { version = "0.8", features = ["serde", "std", "v4"] }
|
||||
uuid = { version = "1.1.2", features = ["serde", "std", "v4"] }
|
||||
flate2 = "1.0.26"
|
||||
git-version = "0.3.5"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libsystemd = "0.7.0"
|
||||
|
||||
[build-dependencies]
|
||||
ahash = "0.8"
|
||||
blake3 = "1.0.0"
|
||||
walkdir = "2.3.3"
|
||||
|
||||
[dev-dependencies]
|
||||
mp4 = { git = "https://github.com/scottlamb/mp4-rust", branch = "moonfire" }
|
||||
num-rational = { version = "0.4.0", default-features = false, features = ["std"] }
|
||||
reqwest = { version = "0.11.0", default-features = false, features = ["json"] }
|
||||
tempfile = "3.2.0"
|
||||
tracing-test = "0.2.4"
|
||||
|
||||
[profile.dev.package.scrypt]
|
||||
# On an Intel i3-6100U @ 2.30 GHz, a single scrypt password hash takes 7.6
|
||||
|
@ -85,3 +111,11 @@ lto = true
|
|||
|
||||
[profile.bench]
|
||||
debug = 1
|
||||
|
||||
[patch.crates-io]
|
||||
# update to indexmap 2
|
||||
protobuf-codegen = { git = "https://github.com/scottlamb/rust-protobuf.git", rev = "a61e09785c957eb9a183d129b426710146bfde38" }
|
||||
protobuf-parse = { git = "https://github.com/scottlamb/rust-protobuf.git", rev = "a61e09785c957eb9a183d129b426710146bfde38" }
|
||||
|
||||
# This version uses fallible-iterator v0.3 (same one rusqlite 0.30 uses) and hasn't been released yet.
|
||||
sdp-types = { git = "https://github.com/sdroege/sdp-types", rev = "e8d0a2c4b8b1fc1ddf1c60a01dc717a2f4e2d514" }
|
|
@ -0,0 +1,14 @@
|
|||
[build.env]
|
||||
volumes = [
|
||||
# For the (optional) `bundled-ui` feature.
|
||||
"UI_BUILD_DIR",
|
||||
|
||||
# For tests which use the `America/Los_Angeles` zone.
|
||||
"ZONEINFO=/usr/share/zoneinfo",
|
||||
]
|
||||
|
||||
passthrough = [
|
||||
# Cross's default docker image doesn't install `git`, so `git_version!` doesn't work.
|
||||
# Allow passing through the version via this environment variable.
|
||||
"VERSION",
|
||||
]
|
|
@ -1,10 +1,11 @@
|
|||
[package]
|
||||
name = "moonfire-base"
|
||||
version = "0.0.1"
|
||||
version = "0.0.0"
|
||||
authors = ["Scott Lamb <slamb@slamb.org>"]
|
||||
readme = "../README.md"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
license-file = "../../LICENSE.txt"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
nightly = []
|
||||
|
@ -13,14 +14,19 @@ nightly = []
|
|||
path = "lib.rs"
|
||||
|
||||
[dependencies]
|
||||
failure = "0.1.1"
|
||||
ahash = "0.8"
|
||||
chrono = "0.4.23"
|
||||
coded = { git = "https://github.com/scottlamb/coded", rev = "2c97994974a73243d5dd12134831814f42cdb0e8"}
|
||||
futures = "0.3"
|
||||
lazy_static = "1.0"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
parking_lot = "0.12.0"
|
||||
nix = { workspace = true }
|
||||
nom = "7.0.0"
|
||||
rusqlite = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
slab = "0.4"
|
||||
time = "0.1"
|
||||
tracing = { workspace = true }
|
||||
tracing-core = "0.1.30"
|
||||
tracing-log = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
|
||||
|
|
|
@ -4,15 +4,15 @@
|
|||
|
||||
//! Clock interface and implementations for testability.
|
||||
|
||||
use failure::Error;
|
||||
use log::warn;
|
||||
use parking_lot::Mutex;
|
||||
use std::mem;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{mpsc, Arc};
|
||||
use std::thread;
|
||||
use std::time::Duration as StdDuration;
|
||||
use time::{Duration, Timespec};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::shutdown::ShutdownError;
|
||||
|
||||
/// Abstract interface to the system clocks. This is for testability.
|
||||
|
@ -54,9 +54,8 @@ where
|
|||
shutdown_rx.check()?;
|
||||
let sleep_time = Duration::seconds(1);
|
||||
warn!(
|
||||
"sleeping for {} after error: {}",
|
||||
sleep_time,
|
||||
crate::error::prettify_failure(&e)
|
||||
exception = %e.chain(),
|
||||
"sleeping for 1 s after error"
|
||||
);
|
||||
clocks.sleep(sleep_time);
|
||||
}
|
||||
|
@ -71,7 +70,13 @@ impl RealClocks {
|
|||
let mut ts = mem::MaybeUninit::uninit();
|
||||
assert_eq!(0, libc::clock_gettime(clock, ts.as_mut_ptr()));
|
||||
let ts = ts.assume_init();
|
||||
Timespec::new(ts.tv_sec as i64, ts.tv_nsec as i32)
|
||||
Timespec::new(
|
||||
// On 32-bit arm builds, `tv_sec` is an `i32` and requires conversion.
|
||||
// On other platforms, the `.into()` is a no-op.
|
||||
#[allow(clippy::useless_conversion)]
|
||||
ts.tv_sec.into(),
|
||||
ts.tv_nsec as i32,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -94,7 +99,7 @@ impl Clocks for RealClocks {
|
|||
fn sleep(&self, how_long: Duration) {
|
||||
match how_long.to_std() {
|
||||
Ok(d) => thread::sleep(d),
|
||||
Err(e) => warn!("Invalid duration {:?}: {}", how_long, e),
|
||||
Err(err) => warn!(%err, "invalid duration {:?}", how_long),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -160,15 +165,15 @@ impl SimulatedClocks {
|
|||
|
||||
impl Clocks for SimulatedClocks {
|
||||
fn realtime(&self) -> Timespec {
|
||||
self.0.boot + *self.0.uptime.lock()
|
||||
self.0.boot + *self.0.uptime.lock().unwrap()
|
||||
}
|
||||
fn monotonic(&self) -> Timespec {
|
||||
Timespec::new(0, 0) + *self.0.uptime.lock()
|
||||
Timespec::new(0, 0) + *self.0.uptime.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Advances the clock by the specified amount without actually sleeping.
|
||||
fn sleep(&self, how_long: Duration) {
|
||||
let mut l = self.0.uptime.lock();
|
||||
let mut l = self.0.uptime.lock().unwrap();
|
||||
*l = *l + how_long;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,123 +2,315 @@
|
|||
// Copyright (C) 2018 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use failure::{Backtrace, Context, Fail};
|
||||
use std::fmt::{self, Write};
|
||||
use std::backtrace::Backtrace;
|
||||
use std::error::Error as StdError;
|
||||
use std::fmt::{Debug, Display};
|
||||
//use std::num::NonZeroU16;
|
||||
|
||||
/// Returns a pretty-and-informative version of `e`.
|
||||
pub fn prettify_failure(e: &failure::Error) -> String {
|
||||
let mut msg = e.to_string();
|
||||
for cause in e.iter_causes() {
|
||||
write!(&mut msg, "\ncaused by: {}", cause).unwrap();
|
||||
}
|
||||
if e.backtrace().is_empty() {
|
||||
write!(
|
||||
&mut msg,
|
||||
"\n\n(set environment variable RUST_BACKTRACE=1 to see backtraces)"
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
write!(&mut msg, "\n\nBacktrace:\n{}", e.backtrace()).unwrap();
|
||||
}
|
||||
msg
|
||||
pub use coded::ErrorKind;
|
||||
|
||||
/// Like [`coded::ToErrKind`] but with more third-party implementations.
|
||||
///
|
||||
/// It's not possible to implement those here on that trait because of the orphan rule.
|
||||
pub trait ToErrKind {
|
||||
fn err_kind(&self) -> ErrorKind;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
inner: Context<ErrorKind>,
|
||||
impl ToErrKind for Error {
|
||||
#[inline]
|
||||
fn err_kind(&self) -> ErrorKind {
|
||||
self.0.kind
|
||||
}
|
||||
}
|
||||
|
||||
impl ToErrKind for std::io::Error {
|
||||
#[inline]
|
||||
fn err_kind(&self) -> ErrorKind {
|
||||
self.kind().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToErrKind for rusqlite::ErrorCode {
|
||||
fn err_kind(&self) -> ErrorKind {
|
||||
use rusqlite::ErrorCode;
|
||||
// https://www.sqlite.org/rescode.html
|
||||
match self {
|
||||
ErrorCode::InternalMalfunction => ErrorKind::Internal,
|
||||
ErrorCode::PermissionDenied => ErrorKind::PermissionDenied,
|
||||
ErrorCode::OperationAborted => ErrorKind::Aborted,
|
||||
|
||||
// Conflict with another database connection in a process which is accessing
|
||||
// the database, apparently without using Moonfire NVR's scheme of acquiring
|
||||
// a lock on the db directory.
|
||||
// https://www.sqlite.org/wal.html#sometimes_queries_return_sqlite_busy_in_wal_mode
|
||||
ErrorCode::DatabaseBusy => ErrorKind::Unavailable,
|
||||
|
||||
// Conflict within the same database connection. Shouldn't happen for Moonfire.
|
||||
ErrorCode::DatabaseLocked => ErrorKind::Internal,
|
||||
ErrorCode::OutOfMemory => ErrorKind::ResourceExhausted,
|
||||
ErrorCode::ReadOnly => ErrorKind::FailedPrecondition,
|
||||
ErrorCode::OperationInterrupted => ErrorKind::Aborted,
|
||||
ErrorCode::SystemIoFailure => ErrorKind::Unavailable,
|
||||
ErrorCode::DatabaseCorrupt => ErrorKind::DataLoss,
|
||||
ErrorCode::NotFound => ErrorKind::NotFound,
|
||||
ErrorCode::DiskFull => ErrorKind::ResourceExhausted,
|
||||
ErrorCode::CannotOpen => ErrorKind::Unavailable,
|
||||
|
||||
// Similar to DatabaseBusy in this implies a conflict with another conn.
|
||||
ErrorCode::FileLockingProtocolFailed => ErrorKind::Unavailable,
|
||||
|
||||
// Likewise: Moonfire NVR should never change the schema
|
||||
// mid-statement, so the most plausible explanation for
|
||||
// SchemaChange is another process.
|
||||
ErrorCode::SchemaChanged => ErrorKind::Unavailable,
|
||||
|
||||
ErrorCode::TooBig => ErrorKind::ResourceExhausted,
|
||||
ErrorCode::ConstraintViolation => ErrorKind::Internal,
|
||||
ErrorCode::TypeMismatch => ErrorKind::Internal,
|
||||
ErrorCode::ApiMisuse => ErrorKind::Internal,
|
||||
ErrorCode::NoLargeFileSupport => ErrorKind::ResourceExhausted,
|
||||
ErrorCode::AuthorizationForStatementDenied => ErrorKind::Internal,
|
||||
ErrorCode::ParameterOutOfRange => ErrorKind::Internal,
|
||||
ErrorCode::NotADatabase => ErrorKind::FailedPrecondition,
|
||||
_ => ErrorKind::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToErrKind for rusqlite::Error {
|
||||
#[inline]
|
||||
fn err_kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
rusqlite::Error::SqliteFailure(e, _) => e.code.err_kind(),
|
||||
_ => ErrorKind::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToErrKind for rusqlite::types::FromSqlError {
|
||||
fn err_kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
rusqlite::types::FromSqlError::InvalidType => ErrorKind::FailedPrecondition,
|
||||
rusqlite::types::FromSqlError::OutOfRange(_) => ErrorKind::OutOfRange,
|
||||
rusqlite::types::FromSqlError::InvalidBlobSize { .. } => ErrorKind::OutOfRange,
|
||||
/* rusqlite::types::FromSqlError::Other(_) | */ _ => ErrorKind::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToErrKind for nix::Error {
|
||||
fn err_kind(&self) -> ErrorKind {
|
||||
use nix::Error;
|
||||
match self {
|
||||
Error::EACCES | Error::EPERM => ErrorKind::PermissionDenied,
|
||||
Error::EDQUOT => ErrorKind::ResourceExhausted,
|
||||
Error::EBUSY
|
||||
| Error::EEXIST
|
||||
| Error::ENOTDIR
|
||||
| Error::EROFS
|
||||
| Error::EFBIG
|
||||
| Error::EOVERFLOW
|
||||
| Error::ENXIO
|
||||
| Error::ETXTBSY => ErrorKind::FailedPrecondition,
|
||||
Error::EINVAL | Error::ENAMETOOLONG => ErrorKind::InvalidArgument,
|
||||
Error::ELOOP => ErrorKind::FailedPrecondition,
|
||||
Error::EMLINK | Error::ENOMEM | Error::ENOSPC | Error::EMFILE | Error::ENFILE => {
|
||||
ErrorKind::ResourceExhausted
|
||||
}
|
||||
Error::EBADF | Error::EFAULT => ErrorKind::InvalidArgument,
|
||||
Error::EINTR | Error::EAGAIN => ErrorKind::Aborted,
|
||||
Error::ENOENT | Error::ENODEV => ErrorKind::NotFound,
|
||||
Error::EOPNOTSUPP => ErrorKind::Unimplemented,
|
||||
_ => ErrorKind::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Error(Box<ErrorInner>);
|
||||
|
||||
struct ErrorInner {
|
||||
kind: ErrorKind,
|
||||
msg: Option<String>,
|
||||
//http_status: Option<NonZeroU16>,
|
||||
backtrace: Option<Backtrace>,
|
||||
source: Option<Box<dyn StdError + Sync + Send>>,
|
||||
}
|
||||
|
||||
pub struct ErrorBuilder(Box<ErrorInner>);
|
||||
|
||||
impl Default for ErrorBuilder {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self(Box::new(ErrorInner {
|
||||
kind: ErrorKind::Unknown,
|
||||
msg: None,
|
||||
// http_status: None,
|
||||
backtrace: None,
|
||||
source: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ErrorKind> for ErrorBuilder {
|
||||
#[inline]
|
||||
fn from(value: ErrorKind) -> Self {
|
||||
Self::default().kind(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorBuilder {
|
||||
#[inline]
|
||||
pub fn kind(mut self, kind: ErrorKind) -> Self {
|
||||
self.0.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn map<F: Fn(ErrorKind) -> ErrorKind>(mut self, f: F) -> Self {
|
||||
self.0.kind = f(self.0.kind);
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn msg(mut self, msg: String) -> Self {
|
||||
self.0.msg = Some(msg);
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn source<S: Into<Box<dyn StdError + Send + Sync + 'static>>>(mut self, source: S) -> Self {
|
||||
self.0.source = Some(source.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn build(self) -> Error {
|
||||
Error(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cvt {
|
||||
($t:ty) => {
|
||||
impl From<$t> for ErrorBuilder {
|
||||
#[inline]
|
||||
fn from(t: $t) -> Self {
|
||||
Self::default().kind(ToErrKind::err_kind(&t)).source(t)
|
||||
}
|
||||
}
|
||||
impl From<$t> for Error {
|
||||
#[inline(always)]
|
||||
fn from(t: $t) -> Self {
|
||||
Self($crate::ErrorBuilder::from(t).0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
cvt!(rusqlite::Error);
|
||||
cvt!(rusqlite::types::FromSqlError);
|
||||
cvt!(std::io::Error);
|
||||
cvt!(nix::Error);
|
||||
|
||||
impl From<Error> for ErrorBuilder {
|
||||
#[inline]
|
||||
fn from(value: Error) -> Self {
|
||||
Self::default()
|
||||
.kind(ToErrKind::err_kind(&value))
|
||||
.source(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures a backtrace if enabled for the given error kind.
|
||||
// TODO: make this more configurable at runtime.
|
||||
fn maybe_backtrace(kind: ErrorKind) -> Option<Backtrace> {
|
||||
if matches!(kind, ErrorKind::Internal | ErrorKind::Unknown) {
|
||||
Some(Backtrace::capture())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
#[inline]
|
||||
pub fn wrap<E: StdError + Sync + Send + 'static>(kind: ErrorKind, e: E) -> Self {
|
||||
Self(Box::new(ErrorInner {
|
||||
kind,
|
||||
msg: None,
|
||||
// http_status: None,
|
||||
backtrace: maybe_backtrace(kind),
|
||||
source: Some(Box::new(e)),
|
||||
}))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn map<F: FnOnce(ErrorKind) -> ErrorKind>(mut self, f: F) -> Self {
|
||||
self.0.kind = f(self.0.kind);
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
*self.inner.get_context()
|
||||
self.0.kind
|
||||
}
|
||||
|
||||
pub fn compat(self) -> failure::Compat<Context<ErrorKind>> {
|
||||
self.inner.compat()
|
||||
#[inline]
|
||||
pub fn msg(&self) -> Option<&str> {
|
||||
self.0.msg.as_deref()
|
||||
}
|
||||
|
||||
pub fn map<F>(self, op: F) -> Self
|
||||
where
|
||||
F: FnOnce(ErrorKind) -> ErrorKind,
|
||||
{
|
||||
Self {
|
||||
inner: self.inner.map(op),
|
||||
/// Returns a borrowed value which can display not only this error but also
|
||||
/// the full chain of causes and (where applicable) the stack trace.
|
||||
///
|
||||
/// The exact format may change. Currently, it displays the stack trace for
|
||||
/// the current error but not any of the sources.
|
||||
#[inline]
|
||||
pub fn chain(&self) -> impl Display + '_ {
|
||||
ErrorChain(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats this error alone (*not* its full chain).
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.0.msg {
|
||||
None => std::fmt::Display::fmt(self.0.kind.grpc_name(), f)?,
|
||||
Some(ref msg) => write!(f, "{}: {}", self.0.kind.grpc_name(), msg)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fail for Error {
|
||||
fn cause(&self) -> Option<&dyn Fail> {
|
||||
self.inner.cause()
|
||||
}
|
||||
|
||||
fn backtrace(&self) -> Option<&Backtrace> {
|
||||
self.inner.backtrace()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ErrorKind> for Error {
|
||||
fn from(kind: ErrorKind) -> Error {
|
||||
Error {
|
||||
inner: Context::new(kind),
|
||||
if let Some(ref bt) = self.0.backtrace {
|
||||
// TODO: only with "alternate"/# modifier?
|
||||
// Shorten this, maybe by switching to `backtrace` + using
|
||||
// `backtrace_ext::short_frames_strict` or similar.
|
||||
write!(f, "\nBacktrace:\n{}", bt)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Context<ErrorKind>> for Error {
|
||||
fn from(inner: Context<ErrorKind>) -> Error {
|
||||
Error { inner }
|
||||
impl Debug for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&ErrorChain(self), f)
|
||||
}
|
||||
}
|
||||
|
||||
/*impl From<failure::Error> for Error {
|
||||
fn from(e: failure::Error) -> Error {
|
||||
Error { inner: e.context(ErrorKind::Unknown) }
|
||||
}
|
||||
}
|
||||
/// Value returned by [`Error::chain`].
|
||||
struct ErrorChain<'a>(&'a Error);
|
||||
|
||||
impl<E: std::error::Error + Send + Sync + 'static> From<E> for Error {
|
||||
fn from(e: E) -> Error {
|
||||
let f = e as Fail;
|
||||
Error { inner: f.context(ErrorKind::Unknown) }
|
||||
}
|
||||
}*/
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self.inner.cause() {
|
||||
None => fmt::Display::fmt(&self.kind(), f),
|
||||
Some(c) => write!(f, "{}: {}", self.kind(), c),
|
||||
impl Display for ErrorChain<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Display::fmt(self.0, f)?;
|
||||
let mut source = self.0.source();
|
||||
while let Some(n) = source {
|
||||
write!(f, "\ncaused by: {}", n)?;
|
||||
source = n.source()
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Error kind.
|
||||
///
|
||||
/// These codes are taken from
|
||||
/// [grpc::StatusCode](https://github.com/grpc/grpc/blob/0e00c430827e81d61e1e7164ef04ca21ccbfaa77/include/grpcpp/impl/codegen/status_code_enum.h),
|
||||
/// which is a nice general-purpose classification of errors. See that link for descriptions of
|
||||
/// each error.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug, Fail)]
|
||||
#[non_exhaustive]
|
||||
#[rustfmt::skip]
|
||||
pub enum ErrorKind {
|
||||
#[fail(display = "Cancelled")] Cancelled,
|
||||
#[fail(display = "Unknown")] Unknown,
|
||||
#[fail(display = "Invalid argument")] InvalidArgument,
|
||||
#[fail(display = "Deadline exceeded")] DeadlineExceeded,
|
||||
#[fail(display = "Not found")] NotFound,
|
||||
#[fail(display = "Already exists")] AlreadyExists,
|
||||
#[fail(display = "Permission denied")] PermissionDenied,
|
||||
#[fail(display = "Unauthenticated")] Unauthenticated,
|
||||
#[fail(display = "Resource exhausted")] ResourceExhausted,
|
||||
#[fail(display = "Failed precondition")] FailedPrecondition,
|
||||
#[fail(display = "Aborted")] Aborted,
|
||||
#[fail(display = "Out of range")] OutOfRange,
|
||||
#[fail(display = "Unimplemented")] Unimplemented,
|
||||
#[fail(display = "Internal")] Internal,
|
||||
#[fail(display = "Unavailable")] Unavailable,
|
||||
#[fail(display = "Data loss")] DataLoss,
|
||||
impl StdError for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
// https://users.rust-lang.org/t/question-about-error-source-s-static-return-type/34515/8
|
||||
self.0.source.as_ref().map(|e| e.as_ref() as &_)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension methods for `Result`.
|
||||
|
@ -137,50 +329,130 @@ pub trait ResultExt<T, E> {
|
|||
|
||||
impl<T, E> ResultExt<T, E> for Result<T, E>
|
||||
where
|
||||
E: Into<failure::Error>,
|
||||
E: StdError + Sync + Send + 'static,
|
||||
{
|
||||
fn err_kind(self, k: ErrorKind) -> Result<T, Error> {
|
||||
self.map_err(|e| e.into().context(k).into())
|
||||
self.map_err(|e| ErrorBuilder::default().kind(k).source(e).build())
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `failure::bail!`, but the first argument specifies a type as an `ErrorKind`.
|
||||
/// Wrapper around `err!` which returns the error.
|
||||
///
|
||||
/// Example:
|
||||
/// Example with positional arguments:
|
||||
/// ```
|
||||
/// use moonfire_base::bail_t;
|
||||
/// use moonfire_base::bail;
|
||||
/// let e = || -> Result<(), moonfire_base::Error> {
|
||||
/// bail_t!(Unauthenticated, "unknown user: {}", "slamb");
|
||||
/// bail!(Unauthenticated, msg("unknown user: {}", "slamb"));
|
||||
/// }().unwrap_err();
|
||||
/// assert_eq!(e.kind(), moonfire_base::ErrorKind::Unauthenticated);
|
||||
/// assert_eq!(e.to_string(), "Unauthenticated: unknown user: slamb");
|
||||
/// assert_eq!(e.to_string(), "UNAUTHENTICATED: unknown user: slamb");
|
||||
/// ```
|
||||
///
|
||||
/// Example with named arguments:
|
||||
/// ```
|
||||
/// use moonfire_base::bail;
|
||||
/// let e = || -> Result<(), moonfire_base::Error> {
|
||||
/// let user = "slamb";
|
||||
/// bail!(Unauthenticated, msg("unknown user: {user}"));
|
||||
/// }().unwrap_err();
|
||||
/// assert_eq!(e.kind(), moonfire_base::ErrorKind::Unauthenticated);
|
||||
/// assert_eq!(e.to_string(), "UNAUTHENTICATED: unknown user: slamb");
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! bail_t {
|
||||
($t:ident, $e:expr) => {
|
||||
return Err($crate::Error::from(failure::err_msg($e).context($crate::ErrorKind::$t)).into());
|
||||
};
|
||||
($t:ident, $fmt:expr, $($arg:tt)+) => {
|
||||
return Err($crate::Error::from(failure::err_msg(format!($fmt, $($arg)+)).context($crate::ErrorKind::$t)).into());
|
||||
macro_rules! bail {
|
||||
($($arg:tt)+) => {
|
||||
return Err($crate::err!($($arg)+).into());
|
||||
};
|
||||
}
|
||||
|
||||
/// Like `failure::format_err!`, but the first argument specifies a type as an `ErrorKind`.
|
||||
/// Constructs an [`Error`], tersely.
|
||||
///
|
||||
/// Example:
|
||||
/// This is a shorthand way to use [`ErrorBuilder`].
|
||||
///
|
||||
/// The first argument is an `Into<ErrorBuilder>`, such as the following:
|
||||
///
|
||||
/// * an [`ErrorKind`] enum variant name like `Unauthenticated`.
|
||||
/// There's an implicit `use ::coded::ErrorKind::*` to allow the bare
|
||||
/// variant names just within this restrictive scope where you're unlikely
|
||||
/// to have conflicts with other identifiers.
|
||||
/// * an [`std::io::Error`] as a source, which sets the new `Error`'s
|
||||
/// `ErrorKind` based on the `std::io::Error`.
|
||||
/// * an `Error` as a source, which similarly copies the `ErrorKind`.
|
||||
/// * an existing `ErrorBuilder`, which does not create a new source link.
|
||||
///
|
||||
/// Following arguments may be of these forms:
|
||||
///
|
||||
/// * `msg(...)`, which expands to `.msg(format!(...))`. See [`ErrorBuilder::msg`].
|
||||
/// * `source(...)`, which simply expands to `.source($src)`. See [`ErrorBuilder::source`].
|
||||
///
|
||||
/// ## Examples
|
||||
///
|
||||
/// Simplest:
|
||||
///
|
||||
/// ```rust
|
||||
/// # use coded::err;
|
||||
/// let e = err!(InvalidArgument);
|
||||
/// let e = err!(InvalidArgument,); // trailing commas are allowed
|
||||
/// assert_eq!(e.kind(), coded::ErrorKind::InvalidArgument);
|
||||
/// ```
|
||||
/// use moonfire_base::format_err_t;
|
||||
/// let e = format_err_t!(Unauthenticated, "unknown user: {}", "slamb");
|
||||
/// assert_eq!(e.kind(), moonfire_base::ErrorKind::Unauthenticated);
|
||||
/// assert_eq!(e.to_string(), "Unauthenticated: unknown user: slamb");
|
||||
///
|
||||
/// Constructing with a fixed error variant name:
|
||||
///
|
||||
/// ```rust
|
||||
/// # use {coded::err, std::error::Error, std::num::ParseIntError};
|
||||
/// let input = "a12";
|
||||
/// let src = i32::from_str_radix(input, 10).unwrap_err();
|
||||
///
|
||||
/// let e = err!(InvalidArgument, source(src.clone()), msg("bad argument {:?}", input));
|
||||
/// // The line above is equivalent to:
|
||||
/// let e2 = ::coded::ErrorBuilder::from(::coded::ErrorKind::InvalidArgument)
|
||||
/// .source(src.clone())
|
||||
/// .msg(format!("bad argument {:?}", input))
|
||||
/// .build();
|
||||
///
|
||||
/// assert_eq!(e.kind(), coded::ErrorKind::InvalidArgument);
|
||||
/// assert_eq!(e.source().unwrap().downcast_ref::<ParseIntError>().unwrap(), &src);
|
||||
/// ```
|
||||
///
|
||||
/// Constructing from an `std::io::Error`:
|
||||
///
|
||||
/// ```rust
|
||||
/// # use coded::err;
|
||||
/// let e = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
|
||||
/// let e = err!(e, msg("path {} not found", "foo"));
|
||||
/// assert_eq!(e.kind(), coded::ErrorKind::NotFound);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! format_err_t {
|
||||
($t:ident, $e:expr) => {
|
||||
Into::<$crate::Error>::into(failure::err_msg($e).context($crate::ErrorKind::$t))
|
||||
macro_rules! err {
|
||||
// This uses the "incremental TT munchers", "internal rules", and "push-down accumulation"
|
||||
// patterns explained in the excellent "The Little Book of Rust Macros":
|
||||
// <https://veykril.github.io/tlborm/decl-macros/patterns/push-down-acc.html>.
|
||||
|
||||
(@accum $body:tt $(,)?) => {
|
||||
$body.build()
|
||||
};
|
||||
($t:ident, $fmt:expr, $($arg:tt)+) => {
|
||||
Into::<$crate::Error>::into(failure::err_msg(format!($fmt, $($arg)+))
|
||||
.context($crate::ErrorKind::$t))
|
||||
|
||||
(@accum ($($body:tt)*), source($src:expr) $($tail:tt)*) => {
|
||||
$crate::err!(@accum ($($body)*.source($src)) $($tail)*)
|
||||
};
|
||||
|
||||
// msg(...) uses the `format!` form even when there's only the format string.
|
||||
// This can catch errors (e.g. https://github.com/dtolnay/anyhow/issues/55)
|
||||
// and will allow supporting implicit named parameters:
|
||||
// https://rust-lang.github.io/rfcs/2795-format-args-implicit-identifiers.html
|
||||
(@accum ($($body:tt)*), msg($format:expr) $($tail:tt)*) => {
|
||||
$crate::err!(@accum ($($body)*.msg(format!($format))) $($tail)*)
|
||||
};
|
||||
(@accum ($($body:tt)*), msg($format:expr, $($args:tt)*) $($tail:tt)*) => {
|
||||
$crate::err!(@accum ($($body)*.msg(format!($format, $($args)*))) $($tail)*)
|
||||
};
|
||||
|
||||
($builder:expr $(, $($tail:tt)*)? ) => {
|
||||
$crate::err!(@accum ({
|
||||
use $crate::ErrorKind::*;
|
||||
$crate::ErrorBuilder::from($builder)
|
||||
})
|
||||
, $($($tail)*)*
|
||||
)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -3,9 +3,14 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
pub mod clock;
|
||||
mod error;
|
||||
pub mod error;
|
||||
pub mod shutdown;
|
||||
pub mod strutil;
|
||||
pub mod time;
|
||||
pub mod tracing_setup;
|
||||
|
||||
pub use crate::error::{prettify_failure, Error, ErrorKind, ResultExt};
|
||||
pub use crate::error::{Error, ErrorBuilder, ErrorKind, ResultExt};
|
||||
|
||||
pub use ahash::RandomState;
|
||||
pub type FastHashMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
|
||||
pub type FastHashSet<K> = std::collections::HashSet<K, ahash::RandomState>;
|
||||
|
|
|
@ -16,8 +16,8 @@ use std::sync::Arc;
|
|||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use futures::Future;
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use slab::Slab;
|
||||
use std::sync::{Condvar, Mutex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ShutdownError;
|
||||
|
@ -47,6 +47,7 @@ impl Drop for Sender {
|
|||
.0
|
||||
.wakers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.expect("only the single Sender takes the slab");
|
||||
for w in wakers.drain() {
|
||||
|
@ -77,7 +78,7 @@ const NO_WAKER: usize = usize::MAX;
|
|||
|
||||
impl Receiver {
|
||||
pub fn check(&self) -> Result<(), ShutdownError> {
|
||||
if self.0.wakers.lock().is_none() {
|
||||
if self.0.wakers.lock().unwrap().is_none() {
|
||||
Err(ShutdownError)
|
||||
} else {
|
||||
Ok(())
|
||||
|
@ -106,22 +107,22 @@ impl Receiver {
|
|||
}
|
||||
|
||||
pub fn wait_for(&self, timeout: std::time::Duration) -> Result<(), ShutdownError> {
|
||||
let mut l = self.0.wakers.lock();
|
||||
if l.is_none() {
|
||||
return Err(ShutdownError);
|
||||
}
|
||||
if self.0.condvar.wait_for(&mut l, timeout).timed_out() {
|
||||
let l = self.0.wakers.lock().unwrap();
|
||||
let result = self
|
||||
.0
|
||||
.condvar
|
||||
.wait_timeout_while(l, timeout, |wakers| wakers.is_some())
|
||||
.unwrap();
|
||||
if result.1.timed_out() {
|
||||
Ok(())
|
||||
} else {
|
||||
// parking_lot guarantees no spurious wakeups.
|
||||
debug_assert!(l.is_none());
|
||||
Err(ShutdownError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_impl(inner: &Inner, waker_i: &mut usize, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let mut l = inner.wakers.lock();
|
||||
let mut l = inner.wakers.lock().unwrap();
|
||||
let wakers = match &mut *l {
|
||||
None => return Poll::Ready(()),
|
||||
Some(w) => w,
|
||||
|
@ -132,7 +133,7 @@ fn poll_impl(inner: &Inner, waker_i: &mut usize, cx: &mut Context<'_>) -> Poll<(
|
|||
} else {
|
||||
let existing_waker = &mut wakers[*waker_i];
|
||||
if !new_waker.will_wake(existing_waker) {
|
||||
*existing_waker = new_waker.clone();
|
||||
existing_waker.clone_from(new_waker);
|
||||
}
|
||||
}
|
||||
Poll::Pending
|
||||
|
|
|
@ -28,7 +28,7 @@ pub fn encode_size(mut raw: i64) -> String {
|
|||
}
|
||||
}
|
||||
if raw > 0 || encoded.is_empty() {
|
||||
write!(&mut encoded, "{}", raw).unwrap();
|
||||
write!(&mut encoded, "{raw}").unwrap();
|
||||
} else {
|
||||
encoded.pop(); // remove trailing space.
|
||||
}
|
||||
|
@ -61,6 +61,7 @@ fn decode_size_internal(input: &str) -> IResult<&str, i64> {
|
|||
}
|
||||
|
||||
/// Decodes a human-readable size as output by encode_size.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn decode_size(encoded: &str) -> Result<i64, ()> {
|
||||
let (remaining, decoded) = decode_size_internal(encoded).map_err(|_e| ())?;
|
||||
if !remaining.is_empty() {
|
||||
|
@ -85,6 +86,7 @@ pub fn hex(raw: &[u8]) -> String {
|
|||
}
|
||||
|
||||
/// Returns [0, 16) or error.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
fn dehex_byte(hex_byte: u8) -> Result<u8, ()> {
|
||||
match hex_byte {
|
||||
b'0'..=b'9' => Ok(hex_byte - b'0'),
|
||||
|
@ -95,6 +97,7 @@ fn dehex_byte(hex_byte: u8) -> Result<u8, ()> {
|
|||
|
||||
/// Returns a 20-byte raw form of the given hex string.
|
||||
/// (This is the size of a SHA1 hash, the only current use of this function.)
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn dehex(hexed: &[u8]) -> Result<[u8; 20], ()> {
|
||||
if hexed.len() != 40 {
|
||||
return Err(());
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
//! Time and durations for Moonfire NVR's internal format.
|
||||
|
||||
use failure::{bail, format_err, Error};
|
||||
use crate::{bail, err, Error};
|
||||
use nom::branch::alt;
|
||||
use nom::bytes::complete::{tag, take_while_m_n};
|
||||
use nom::combinator::{map, map_res, opt};
|
||||
|
@ -106,13 +106,16 @@ impl Time {
|
|||
opt(parse_zone),
|
||||
))(input)
|
||||
.map_err(|e| match e {
|
||||
nom::Err::Incomplete(_) => format_err!("incomplete"),
|
||||
nom::Err::Incomplete(_) => err!(InvalidArgument, msg("incomplete")),
|
||||
nom::Err::Error(e) | nom::Err::Failure(e) => {
|
||||
format_err!("{}", nom::error::convert_error(input, e))
|
||||
err!(InvalidArgument, source(nom::error::convert_error(input, e)))
|
||||
}
|
||||
})?;
|
||||
if !remaining.is_empty() {
|
||||
bail!("unexpected suffix {:?} following time string", remaining);
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg("unexpected suffix {remaining:?} following time string")
|
||||
);
|
||||
}
|
||||
let (tm_hour, tm_min, tm_sec, subsec) = opt_time.unwrap_or((0, 0, 0, 0));
|
||||
let mut tm = time::Tm {
|
||||
|
@ -129,11 +132,11 @@ impl Time {
|
|||
tm_nsec: 0,
|
||||
};
|
||||
if tm.tm_mon == 0 {
|
||||
bail!("time {:?} has month 0", input);
|
||||
bail!(InvalidArgument, msg("time {input:?} has month 0"));
|
||||
}
|
||||
tm.tm_mon -= 1;
|
||||
if tm.tm_year < 1900 {
|
||||
bail!("time {:?} has year before 1900", input);
|
||||
bail!(InvalidArgument, msg("time {input:?} has year before 1900"));
|
||||
}
|
||||
tm.tm_year -= 1900;
|
||||
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
//! Logic for setting up a `tracing` subscriber according to our preferences
|
||||
//! and [OpenTelemetry conventions](https://opentelemetry.io/docs/reference/specification/logs/).
|
||||
|
||||
use tracing::error;
|
||||
use tracing_core::{Event, Level, Subscriber};
|
||||
use tracing_log::NormalizeEvent;
|
||||
use tracing_subscriber::{
|
||||
fmt::{format::Writer, time::FormatTime, FmtContext, FormatFields, FormattedFields},
|
||||
layer::SubscriberExt,
|
||||
registry::LookupSpan,
|
||||
Layer,
|
||||
};
|
||||
|
||||
struct FormatSystemd;
|
||||
|
||||
struct ChronoTimer;
|
||||
|
||||
impl FormatTime for ChronoTimer {
|
||||
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
|
||||
const TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.6f";
|
||||
write!(w, "{}", chrono::Local::now().format(TIME_FORMAT))
|
||||
}
|
||||
}
|
||||
|
||||
fn systemd_prefix(level: Level) -> &'static str {
|
||||
if level >= Level::TRACE {
|
||||
"<7>" // SD_DEBUG
|
||||
} else if level >= Level::DEBUG {
|
||||
"<6>" // SD_INFO
|
||||
} else if level >= Level::INFO {
|
||||
"<5>" // SD_NOTICE
|
||||
} else if level >= Level::WARN {
|
||||
"<4>" // SD_WARN
|
||||
} else {
|
||||
"<3>" // SD_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, N> tracing_subscriber::fmt::FormatEvent<S, N> for FormatSystemd
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'a> FormatFields<'a> + 'static,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &FmtContext<'_, S, N>,
|
||||
mut writer: Writer<'_>,
|
||||
event: &Event<'_>,
|
||||
) -> std::fmt::Result {
|
||||
let normalized_meta = event.normalized_metadata();
|
||||
let meta = normalized_meta.as_ref().unwrap_or_else(|| event.metadata());
|
||||
let prefix = systemd_prefix(*meta.level());
|
||||
|
||||
let thread = std::thread::current();
|
||||
let thread_name = thread.name().unwrap_or("unnamed-thread");
|
||||
write!(writer, "{prefix}{thread_name} ")?;
|
||||
if let Some(scope) = ctx.event_scope() {
|
||||
let mut seen = false;
|
||||
|
||||
for span in scope.from_root() {
|
||||
write!(writer, "{}", span.metadata().name())?;
|
||||
seen = true;
|
||||
|
||||
let ext = span.extensions();
|
||||
if let Some(fields) = &ext.get::<FormattedFields<N>>() {
|
||||
if !fields.is_empty() {
|
||||
write!(writer, "{{{fields}}}")?;
|
||||
}
|
||||
}
|
||||
writer.write_char(':')?;
|
||||
}
|
||||
|
||||
if seen {
|
||||
writer.write_char(' ')?;
|
||||
}
|
||||
}
|
||||
|
||||
write!(writer, "{}: ", meta.target())?;
|
||||
ctx.format_fields(writer.by_ref(), event)?;
|
||||
writeln!(writer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom panic hook that logs instead of directly writing to stderr.
|
||||
///
|
||||
/// This means it includes a timestamp, follows [OpenTelemetry Semantic
|
||||
/// Conventions for Exceptions](https://opentelemetry.io/docs/reference/specification/logs/semantic_conventions/exceptions/),
|
||||
/// etc.
|
||||
fn panic_hook(p: &std::panic::PanicInfo) {
|
||||
let payload: Option<&str> = if let Some(s) = p.payload().downcast_ref::<&str>() {
|
||||
Some(*s)
|
||||
} else if let Some(s) = p.payload().downcast_ref::<String>() {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
error!(
|
||||
target: std::env!("CARGO_CRATE_NAME"),
|
||||
location = p.location().map(tracing::field::display),
|
||||
payload = payload.map(tracing::field::display),
|
||||
backtrace = %std::backtrace::Backtrace::force_capture(),
|
||||
"panic",
|
||||
);
|
||||
}
|
||||
|
||||
pub fn install() {
|
||||
let filter = tracing_subscriber::EnvFilter::builder()
|
||||
.with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
|
||||
.with_env_var("MOONFIRE_LOG")
|
||||
.from_env_lossy();
|
||||
tracing_log::LogTracer::init().unwrap();
|
||||
|
||||
match std::env::var("MOONFIRE_FORMAT") {
|
||||
Ok(s) if s == "systemd" => {
|
||||
let sub = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::Layer::new()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_ansi(false)
|
||||
.event_format(FormatSystemd)
|
||||
.with_filter(filter),
|
||||
);
|
||||
tracing::subscriber::set_global_default(sub).unwrap();
|
||||
}
|
||||
Ok(s) if s == "json" => {
|
||||
let sub = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::Layer::new()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_thread_names(true)
|
||||
.json()
|
||||
.with_filter(filter),
|
||||
);
|
||||
tracing::subscriber::set_global_default(sub).unwrap();
|
||||
}
|
||||
_ => {
|
||||
let sub = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::Layer::new()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_timer(ChronoTimer)
|
||||
.with_thread_names(true)
|
||||
.with_filter(filter),
|
||||
);
|
||||
tracing::subscriber::set_global_default(sub).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let use_panic_hook = ::std::env::var("MOONFIRE_PANIC_HOOK")
|
||||
.map(|s| s != "false" && s != "0")
|
||||
.unwrap_or(true);
|
||||
if use_panic_hook {
|
||||
std::panic::set_hook(Box::new(&panic_hook));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_for_tests() {
|
||||
let filter = tracing_subscriber::EnvFilter::builder()
|
||||
.with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into())
|
||||
.with_env_var("MOONFIRE_LOG")
|
||||
.from_env_lossy();
|
||||
tracing_log::LogTracer::init().unwrap();
|
||||
let sub = tracing_subscriber::registry().with(
|
||||
tracing_subscriber::fmt::Layer::new()
|
||||
.with_test_writer()
|
||||
.with_timer(ChronoTimer)
|
||||
.with_thread_names(true)
|
||||
.with_filter(filter),
|
||||
);
|
||||
tracing::subscriber::set_global_default(sub).unwrap();
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
|
||||
//! Build script to bundle UI files if `bundled-ui` Cargo feature is selected.
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const UI_DIST_DIR_ENV_VAR: &str = "UI_DIST_DIR";
|
||||
const DEFAULT_UI_DIST_DIR: &str = "../ui/dist";
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
|
||||
|
||||
fn ensure_link(original: &Path, link: &Path) {
|
||||
match std::fs::read_link(link) {
|
||||
Ok(dst) if dst == original => return,
|
||||
Ok(_) => std::fs::remove_file(link).expect("removing stale symlink should succeed"),
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
|
||||
panic!("couldn't create link {link:?} to original path {original:?}: {e}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::os::unix::fs::symlink(original, link).expect("symlink creation should succeed");
|
||||
}
|
||||
|
||||
struct File {
|
||||
/// Path with `ui_files/` prefix and the encoding suffix; suitable for
|
||||
/// passing to `include_bytes!` in the expanded code.
|
||||
///
|
||||
/// E.g. `ui_files/index.html.gz`.
|
||||
include_path: String,
|
||||
encoding: FileEncoding,
|
||||
etag: blake3::Hash,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
enum FileEncoding {
|
||||
Uncompressed,
|
||||
Gzipped,
|
||||
}
|
||||
|
||||
impl FileEncoding {
|
||||
fn to_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Uncompressed => "FileEncoding::Uncompressed",
|
||||
Self::Gzipped => "FileEncoding::Gzipped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map of "bare path" to the best representation.
|
||||
///
|
||||
/// A "bare path" has no prefix for the root and no suffix for encoding, e.g.
|
||||
/// `favicons/blah.ico` rather than `../../ui/dist/favicons/blah.ico.gz`.
|
||||
///
|
||||
/// The best representation is gzipped if available, uncompressed otherwise.
|
||||
type FileMap = std::collections::HashMap<String, File, ahash::RandomState>;
|
||||
|
||||
fn stringify_files(files: &FileMap) -> Result<String, std::fmt::Error> {
|
||||
let mut buf = String::new();
|
||||
writeln!(buf, "const FILES: [BuildFile; {}] = [", files.len())?;
|
||||
for (bare_path, file) in files {
|
||||
let include_path = &file.include_path;
|
||||
let etag = file.etag.to_hex();
|
||||
let encoding = file.encoding.to_str();
|
||||
writeln!(buf, " BuildFile {{ bare_path: {bare_path:?}, data: include_bytes!({include_path:?}), etag: {etag:?}, encoding: {encoding} }},")?;
|
||||
}
|
||||
writeln!(buf, "];")?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn handle_bundled_ui() -> Result<(), BoxError> {
|
||||
// Nothing to do if the feature is off. cargo will re-run if features change.
|
||||
if !cfg!(feature = "bundled-ui") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ui_dir =
|
||||
std::env::var(UI_DIST_DIR_ENV_VAR).unwrap_or_else(|_| DEFAULT_UI_DIST_DIR.to_owned());
|
||||
|
||||
// If the feature is on, also re-run if the actual UI files change.
|
||||
println!("cargo:rerun-if-env-changed={UI_DIST_DIR_ENV_VAR}");
|
||||
println!("cargo:rerun-if-changed={ui_dir}");
|
||||
|
||||
let out_dir: PathBuf = std::env::var_os("OUT_DIR")
|
||||
.expect("cargo should set OUT_DIR")
|
||||
.into();
|
||||
|
||||
let abs_ui_dir = std::fs::canonicalize(&ui_dir).map_err(|e| format!("ui dir {ui_dir:?} should be accessible. Did you run `npm run build` first?\n\ncaused by:\n{e}"))?;
|
||||
|
||||
let mut files = FileMap::default();
|
||||
for entry in walkdir::WalkDir::new(&abs_ui_dir) {
|
||||
let entry = entry.map_err(|e| {
|
||||
format!("walkdir failed. Did you run `npm run build` first?\n\ncaused by:\n{e}")
|
||||
})?;
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry
|
||||
.path()
|
||||
.strip_prefix(&abs_ui_dir)
|
||||
.expect("walkdir should return root-prefixed entries");
|
||||
let path = path.to_str().expect("ui file paths should be valid UTF-8");
|
||||
let (bare_path, encoding);
|
||||
match path.strip_suffix(".gz") {
|
||||
Some(p) => {
|
||||
bare_path = p;
|
||||
encoding = FileEncoding::Gzipped;
|
||||
}
|
||||
None => {
|
||||
bare_path = path;
|
||||
encoding = FileEncoding::Uncompressed;
|
||||
if files.contains_key(bare_path) {
|
||||
continue; // don't replace with suboptimal encoding.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let contents = std::fs::read(entry.path()).expect("ui files should be readable");
|
||||
let etag = blake3::hash(&contents);
|
||||
let include_path = format!("ui_files/{path}");
|
||||
files.insert(
|
||||
bare_path.to_owned(),
|
||||
File {
|
||||
include_path,
|
||||
encoding,
|
||||
etag,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if !files.contains_key("index.html") {
|
||||
return Err(format!(
|
||||
"No `index.html` within {ui_dir:?}. Did you run `npm run build` first?"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let files = stringify_files(&files).expect("write to String should succeed");
|
||||
let mut out_rs_path = std::path::PathBuf::new();
|
||||
out_rs_path.push(&out_dir);
|
||||
out_rs_path.push("ui_files.rs");
|
||||
std::fs::write(&out_rs_path, files).expect("writing ui_files.rs should succeed");
|
||||
|
||||
let mut out_link_path = std::path::PathBuf::new();
|
||||
out_link_path.push(&out_dir);
|
||||
out_link_path.push("ui_files");
|
||||
ensure_link(&abs_ui_dir, &out_link_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns one-line `stdout` from a `git` command; `args` are simply space-separated (no escapes).
|
||||
fn git_oneline_output(args: &str) -> Result<String, BoxError> {
|
||||
static HELP_TEXT: &str =
|
||||
"If you are building from a release archive or without the `git` CLI available, \n\
|
||||
try again with the `VERSION` environment variable set";
|
||||
|
||||
// `output()` returns `Err` e.g. if `git` was not found.
|
||||
let mut output = Command::new("git")
|
||||
.args(args.split(' '))
|
||||
.output()
|
||||
.map_err(|e| format!("`git {args}` failed: {e}\n\n{HELP_TEXT}"))?;
|
||||
|
||||
// `status` is non-success if `git` launched and then failed.
|
||||
if !output.status.success() {
|
||||
let status = output.status;
|
||||
let stderr = output.stderr.escape_ascii();
|
||||
return Err(format!("`git {args}` failed with {status}: {stderr}\n\n{HELP_TEXT}").into());
|
||||
}
|
||||
if output.stdout.pop() != Some(b'\n') {
|
||||
return Err(format!("`git {args}` stdout should end with newline").into());
|
||||
}
|
||||
if output.stdout.contains(&b'\n') {
|
||||
return Err(format!("`git {args}` stdout should be single line").into());
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)
|
||||
.map_err(|_| format!("`git {args}` stdout should be valid UTF-8"))?)
|
||||
}
|
||||
|
||||
fn handle_version() -> Result<(), BoxError> {
|
||||
println!("cargo:rerun-if-env-changed=VERSION");
|
||||
if std::env::var("VERSION").is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get version from `git describe`. Inspired by the `git-version` crate.
|
||||
// We don't use that directly because `cross`'s default docker image doesn't install `git`,
|
||||
// and thus we need the environment variable pass-through above.
|
||||
|
||||
// Avoid reruns when the output doesn't meaningfully change. I don't think this is quite right:
|
||||
// it won't recognize toggling between `-dirty` and not. But it'll do.
|
||||
let dir = git_oneline_output("rev-parse --git-dir")?;
|
||||
println!("cargo:rerun-if-changed={dir}/logs/HEAD");
|
||||
println!("cargo:rerun-if-changed={dir}/index");
|
||||
|
||||
// Plumb the version through.
|
||||
let version = git_oneline_output("describe --always --dirty")?;
|
||||
println!("cargo:rustc-env=VERSION={version}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), BoxError> {
|
||||
// Explicitly declare dependencies, so this doesn't re-run if other source files change.
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
handle_bundled_ui()?;
|
||||
handle_version()?;
|
||||
Ok(())
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
[package]
|
||||
name = "moonfire-db"
|
||||
version = "0.7.5"
|
||||
version = "0.0.0"
|
||||
authors = ["Scott Lamb <slamb@slamb.org>"]
|
||||
readme = "../README.md"
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
license-file = "../../LICENSE.txt"
|
||||
rust-version = "1.70"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
nightly = []
|
||||
|
@ -14,39 +16,34 @@ path = "lib.rs"
|
|||
|
||||
[dependencies]
|
||||
base = { package = "moonfire-base", path = "../base" }
|
||||
base64 = "0.13.0"
|
||||
base64 = { workspace = true }
|
||||
blake3 = "1.0.0"
|
||||
byteorder = "1.0"
|
||||
cstr = "0.2.5"
|
||||
diff = "0.1.12"
|
||||
failure = "0.1.1"
|
||||
fnv = "1.0"
|
||||
futures = "0.3"
|
||||
h264-reader = "0.5.0"
|
||||
hashlink = "0.7.0"
|
||||
lazy_static = "1.0"
|
||||
h264-reader = { workspace = true }
|
||||
hashlink = "0.8.1"
|
||||
itertools = { workspace = true }
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
mylog = { git = "https://github.com/scottlamb/mylog" }
|
||||
nix = "0.23.0"
|
||||
nix = { workspace = true, features = ["dir", "feature", "fs", "mman"] }
|
||||
num-rational = { version = "0.4.0", default-features = false, features = ["std"] }
|
||||
odds = { version = "0.4.0", features = ["std-vec"] }
|
||||
parking_lot = "0.12.0"
|
||||
pretty-hex = "0.2.1"
|
||||
pretty-hex = { workspace = true }
|
||||
protobuf = "3.0"
|
||||
ring = "0.16.2"
|
||||
rusqlite = "0.27.0"
|
||||
scrypt = "0.9.0"
|
||||
ring = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
scrypt = "0.11.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
#similar = "2.1.0"
|
||||
smallvec = "1.0"
|
||||
tempfile = "3.2.0"
|
||||
time = "0.1"
|
||||
tokio = { version = "1.0", features = ["macros", "parking_lot", "rt-multi-thread", "sync"] }
|
||||
tokio = { version = "1.24", features = ["macros", "rt-multi-thread", "sync"] }
|
||||
tracing = "0.1.37"
|
||||
ulid = "1.0.0"
|
||||
url = { version = "2.1.1", features = ["serde"] }
|
||||
uuid = { version = "0.8", features = ["serde", "std", "v4"] }
|
||||
itertools = "0.10.0"
|
||||
uuid = { version = "1.1.2", features = ["serde", "std", "v4"] }
|
||||
|
||||
[build-dependencies]
|
||||
protobuf-codegen = "3.0"
|
||||
|
|
|
@ -6,12 +6,9 @@
|
|||
|
||||
use crate::json::UserConfig;
|
||||
use crate::schema::Permissions;
|
||||
use base::{bail_t, format_err_t, strutil, ErrorKind, ResultExt as _};
|
||||
use failure::{bail, format_err, Error, Fail, ResultExt as _};
|
||||
use fnv::FnvHashMap;
|
||||
use lazy_static::lazy_static;
|
||||
use log::info;
|
||||
use parking_lot::Mutex;
|
||||
use base::FastHashMap;
|
||||
use base::{bail, err, strutil, Error, ErrorKind, ResultExt as _};
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||
use protobuf::Message;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use rusqlite::{named_params, params, Connection, Transaction};
|
||||
|
@ -20,16 +17,42 @@ use std::collections::BTreeMap;
|
|||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::info;
|
||||
|
||||
lazy_static! {
|
||||
static ref PARAMS: Mutex<scrypt::Params> = Mutex::new(scrypt::Params::recommended());
|
||||
/// Wrapper around [`scrypt::Params`].
|
||||
///
|
||||
/// `scrypt::Params` does not implement `PartialEq`; so for the benefit of `set_test_config`
|
||||
/// error handling, keep track of whether these params are the recommended
|
||||
/// production ones or the cheap test ones.
|
||||
struct Params {
|
||||
actual: scrypt::Params,
|
||||
is_test: bool,
|
||||
}
|
||||
|
||||
static PARAMS: OnceLock<Params> = OnceLock::new();
|
||||
|
||||
fn params() -> &'static Params {
|
||||
PARAMS.get_or_init(|| Params {
|
||||
actual: scrypt::Params::recommended(),
|
||||
is_test: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// For testing only: use fast but insecure hashes.
|
||||
/// Call via `testutil::init()`.
|
||||
pub(crate) fn set_test_config() {
|
||||
let params = scrypt::Params::new(8, 8, 1).unwrap();
|
||||
*PARAMS.lock() = params;
|
||||
let test_params = scrypt::Params::new(8, 8, 1, scrypt::Params::RECOMMENDED_LEN)
|
||||
.expect("test params should be valid");
|
||||
if let Err(existing_params) = PARAMS.set(Params {
|
||||
actual: test_params,
|
||||
is_test: true,
|
||||
}) {
|
||||
assert!(
|
||||
existing_params.is_test,
|
||||
"set_test_config must be called before any use of the parameters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
@ -62,6 +85,39 @@ impl User {
|
|||
pub fn has_password(&self) -> bool {
|
||||
self.password_hash.is_some()
|
||||
}
|
||||
|
||||
/// Checks if the user's password hash matches the supplied password.
|
||||
///
|
||||
/// As a side effect, increments `password_failure_count` and sets `dirty`
|
||||
/// if `password` is incorrect.
|
||||
pub fn check_password(&mut self, password: Option<&str>) -> Result<bool, base::Error> {
|
||||
let hash = self.password_hash.as_ref();
|
||||
let (password, hash) = match (password, hash) {
|
||||
(None, None) => return Ok(true),
|
||||
(Some(p), Some(h)) => (p, h),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
let hash = PasswordHash::new(hash).map_err(|e| {
|
||||
err!(
|
||||
DataLoss,
|
||||
msg("bad stored password hash for user {:?}", self.username),
|
||||
source(e),
|
||||
)
|
||||
})?;
|
||||
match scrypt::Scrypt.verify_password(password.as_bytes(), &hash) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(scrypt::password_hash::errors::Error::Password) => {
|
||||
self.dirty = true;
|
||||
self.password_failure_count += 1;
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => Err(err!(
|
||||
Internal,
|
||||
msg("unable to verify password for user {:?}", self.username),
|
||||
source(e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A change to a user.
|
||||
|
@ -92,9 +148,9 @@ impl UserChange {
|
|||
|
||||
pub fn set_password(&mut self, pwd: String) {
|
||||
let salt = SaltString::generate(&mut scrypt::password_hash::rand_core::OsRng);
|
||||
let params = PARAMS.lock().clone();
|
||||
let params = params();
|
||||
let hash = scrypt::Scrypt
|
||||
.hash_password_customized(pwd.as_bytes(), None, None, params, &salt)
|
||||
.hash_password_customized(pwd.as_bytes(), None, None, params.actual, &salt)
|
||||
.unwrap();
|
||||
self.set_password_hash = Some(Some(hash.to_string()));
|
||||
}
|
||||
|
@ -142,7 +198,7 @@ impl rusqlite::types::FromSql for FromSqlIpAddr {
|
|||
use rusqlite::types::ValueRef;
|
||||
match value {
|
||||
ValueRef::Null => Ok(FromSqlIpAddr(None)),
|
||||
ValueRef::Blob(ref b) => match b.len() {
|
||||
ValueRef::Blob(b) => match b.len() {
|
||||
4 => {
|
||||
let mut buf = [0u8; 4];
|
||||
buf.copy_from_slice(b);
|
||||
|
@ -178,7 +234,7 @@ impl FromStr for SessionFlag {
|
|||
"secure" => Ok(Self::Secure),
|
||||
"same-site" => Ok(Self::SameSite),
|
||||
"same-site-strict" => Ok(Self::SameSiteStrict),
|
||||
_ => bail!("No such session flag {:?}", s),
|
||||
_ => bail!(InvalidArgument, msg("No such session flag {s:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -228,9 +284,11 @@ pub struct RawSessionId([u8; 48]);
|
|||
impl RawSessionId {
|
||||
pub fn decode_base64(input: &[u8]) -> Result<Self, Error> {
|
||||
let mut s = RawSessionId([0u8; 48]);
|
||||
let l = ::base64::decode_config_slice(input, ::base64::STANDARD_NO_PAD, &mut s.0[..])?;
|
||||
let l = STANDARD_NO_PAD
|
||||
.decode_slice(input, &mut s.0[..])
|
||||
.map_err(|e| err!(InvalidArgument, msg("bad session id"), source(e)))?;
|
||||
if l != 48 {
|
||||
bail!("session id must be 48 bytes");
|
||||
bail!(InvalidArgument, msg("session id must be 48 bytes"));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
@ -271,14 +329,18 @@ pub struct SessionHash(pub [u8; 24]);
|
|||
|
||||
impl SessionHash {
|
||||
pub fn encode_base64(&self, output: &mut [u8; 32]) {
|
||||
::base64::encode_config_slice(&self.0, ::base64::STANDARD_NO_PAD, output);
|
||||
STANDARD_NO_PAD
|
||||
.encode_slice(self.0, output)
|
||||
.expect("base64 encode should succeed");
|
||||
}
|
||||
|
||||
pub fn decode_base64(input: &[u8]) -> Result<Self, Error> {
|
||||
let mut h = SessionHash([0u8; 24]);
|
||||
let l = ::base64::decode_config_slice(input, ::base64::STANDARD_NO_PAD, &mut h.0[..])?;
|
||||
let l = STANDARD_NO_PAD
|
||||
.decode_slice(input, &mut h.0[..])
|
||||
.map_err(|e| err!(InvalidArgument, msg("invalid session hash"), source(e)))?;
|
||||
if l != 24 {
|
||||
bail!("session hash must be 24 bytes");
|
||||
bail!(InvalidArgument, msg("session hash must be 24 bytes"));
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
|
@ -303,9 +365,10 @@ impl rusqlite::types::FromSql for Seed {
|
|||
fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult<Self> {
|
||||
let b = value.as_blob()?;
|
||||
if b.len() != 32 {
|
||||
return Err(rusqlite::types::FromSqlError::Other(Box::new(
|
||||
format_err!("expected a 32-byte seed").compat(),
|
||||
)));
|
||||
return Err(rusqlite::types::FromSqlError::Other(Box::new(err!(
|
||||
Internal,
|
||||
msg("expected a 32-byte seed")
|
||||
))));
|
||||
}
|
||||
let mut s = Seed::default();
|
||||
s.0.copy_from_slice(b);
|
||||
|
@ -324,7 +387,7 @@ pub(crate) struct State {
|
|||
/// TODO: Add eviction of clean sessions. Keep a linked hash set of clean session hashes and
|
||||
/// evict the oldest when its size exceeds a threshold. Or just evict everything on every flush
|
||||
/// (and accept more frequent database accesses).
|
||||
sessions: FnvHashMap<SessionHash, Session>,
|
||||
sessions: FastHashMap<SessionHash, Session>,
|
||||
|
||||
rand: SystemRandom,
|
||||
}
|
||||
|
@ -334,7 +397,7 @@ impl State {
|
|||
let mut state = State {
|
||||
users_by_id: BTreeMap::new(),
|
||||
users_by_name: BTreeMap::new(),
|
||||
sessions: FnvHashMap::default(),
|
||||
sessions: FastHashMap::default(),
|
||||
rand: ring::rand::SystemRandom::new(),
|
||||
};
|
||||
let mut stmt = conn.prepare(
|
||||
|
@ -356,7 +419,9 @@ impl State {
|
|||
let id = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let mut permissions = Permissions::new();
|
||||
permissions.merge_from_bytes(row.get_ref(6)?.as_blob()?)?;
|
||||
permissions
|
||||
.merge_from_bytes(row.get_ref(6)?.as_blob()?)
|
||||
.err_kind(ErrorKind::DataLoss)?;
|
||||
state.users_by_id.insert(
|
||||
id,
|
||||
User {
|
||||
|
@ -375,7 +440,7 @@ impl State {
|
|||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> {
|
||||
pub fn apply(&mut self, conn: &Connection, change: UserChange) -> Result<&User, base::Error> {
|
||||
if let Some(id) = change.id {
|
||||
self.update_user(conn, id, change)
|
||||
} else {
|
||||
|
@ -387,12 +452,16 @@ impl State {
|
|||
&self.users_by_id
|
||||
}
|
||||
|
||||
pub fn get_user_by_id_mut(&mut self, id: i32) -> Option<&mut User> {
|
||||
self.users_by_id.get_mut(&id)
|
||||
}
|
||||
|
||||
fn update_user(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
id: i32,
|
||||
change: UserChange,
|
||||
) -> Result<&User, Error> {
|
||||
) -> Result<&User, base::Error> {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
r#"
|
||||
update user
|
||||
|
@ -409,7 +478,7 @@ impl State {
|
|||
)?;
|
||||
let e = self.users_by_id.entry(id);
|
||||
let e = match e {
|
||||
::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id),
|
||||
::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {id}!"),
|
||||
::std::collections::btree_map::Entry::Occupied(e) => e,
|
||||
};
|
||||
{
|
||||
|
@ -435,7 +504,11 @@ impl State {
|
|||
})?;
|
||||
}
|
||||
let u = e.into_mut();
|
||||
u.username = change.username;
|
||||
if u.username != change.username {
|
||||
self.users_by_name.remove(&u.username);
|
||||
self.users_by_name.insert(change.username.clone(), u.id);
|
||||
u.username = change.username;
|
||||
}
|
||||
if let Some(h) = change.set_password_hash {
|
||||
u.password_hash = h;
|
||||
u.password_id += 1;
|
||||
|
@ -446,7 +519,7 @@ impl State {
|
|||
Ok(u)
|
||||
}
|
||||
|
||||
fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> {
|
||||
fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, base::Error> {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
r#"
|
||||
insert into user (username, password_hash, config, permissions)
|
||||
|
@ -469,7 +542,7 @@ impl State {
|
|||
let e = self.users_by_id.entry(id);
|
||||
let e = match e {
|
||||
::std::collections::btree_map::Entry::Vacant(e) => e,
|
||||
::std::collections::btree_map::Entry::Occupied(_) => panic!("uid {} conflict!", id),
|
||||
::std::collections::btree_map::Entry::Occupied(_) => panic!("uid {id} conflict!"),
|
||||
};
|
||||
Ok(e.insert(User {
|
||||
id,
|
||||
|
@ -483,18 +556,20 @@ impl State {
|
|||
}))
|
||||
}
|
||||
|
||||
pub fn delete_user(&mut self, conn: &mut Connection, id: i32) -> Result<(), Error> {
|
||||
pub fn delete_user(&mut self, conn: &mut Connection, id: i32) -> Result<(), base::Error> {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("delete from user_session where user_id = ?", params![id])?;
|
||||
{
|
||||
let mut user_stmt = tx.prepare_cached("delete from user where id = ?")?;
|
||||
if user_stmt.execute(params![id])? != 1 {
|
||||
bail!("user {} not found", id);
|
||||
bail!(NotFound, msg("user {id} not found"));
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
let name = self.users_by_id.remove(&id).unwrap().username;
|
||||
self.users_by_name.remove(&name).unwrap();
|
||||
self.users_by_name
|
||||
.remove(&name)
|
||||
.expect("users_by_name should be consistent with users_by_id");
|
||||
self.sessions.retain(|_k, ref mut v| v.user_id != id);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -515,38 +590,21 @@ impl State {
|
|||
password: String,
|
||||
domain: Option<Vec<u8>>,
|
||||
session_flags: i32,
|
||||
) -> Result<(RawSessionId, &Session), Error> {
|
||||
) -> Result<(RawSessionId, &Session), base::Error> {
|
||||
let id = self
|
||||
.users_by_name
|
||||
.get(username)
|
||||
.ok_or_else(|| format_err!("no such user {:?}", username))?;
|
||||
.ok_or_else(|| err!(Unauthenticated, msg("no such user {username:?}")))?;
|
||||
let u = self
|
||||
.users_by_id
|
||||
.get_mut(id)
|
||||
.expect("users_by_name implies users_by_id");
|
||||
if u.config.disabled {
|
||||
bail!("user {:?} is disabled", username);
|
||||
bail!(Unauthenticated, msg("user {username:?} is disabled"));
|
||||
}
|
||||
let hash = u
|
||||
.password_hash
|
||||
.as_ref()
|
||||
.ok_or_else(|| format_err!("no password set for user {:?}", username))?;
|
||||
let hash = PasswordHash::new(hash)
|
||||
.with_context(|_| format!("bad stored password hash for user {:?}", username))?;
|
||||
match scrypt::Scrypt.verify_password(password.as_bytes(), &hash) {
|
||||
Ok(()) => {}
|
||||
Err(scrypt::password_hash::errors::Error::Password) => {
|
||||
u.dirty = true;
|
||||
u.password_failure_count += 1;
|
||||
bail!("incorrect password for user {:?}", username);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e
|
||||
.context(format!("unable to verify password for user {:?}", username))
|
||||
.into());
|
||||
}
|
||||
if !u.check_password(Some(&password))? {
|
||||
bail!(Unauthenticated, msg("incorrect password"));
|
||||
}
|
||||
|
||||
let password_id = u.password_id;
|
||||
State::make_session_int(
|
||||
&self.rand,
|
||||
|
@ -570,13 +628,13 @@ impl State {
|
|||
domain: Option<Vec<u8>>,
|
||||
flags: i32,
|
||||
permissions: Permissions,
|
||||
) -> Result<(RawSessionId, &'s Session), Error> {
|
||||
) -> Result<(RawSessionId, &'s Session), base::Error> {
|
||||
let u = self
|
||||
.users_by_id
|
||||
.get_mut(&uid)
|
||||
.ok_or_else(|| format_err!("no such uid {:?}", uid))?;
|
||||
.ok_or_else(|| err!(NotFound, msg("no such uid {uid:?}")))?;
|
||||
if u.config.disabled {
|
||||
bail!("user is disabled");
|
||||
bail!(FailedPrecondition, msg("user is disabled"));
|
||||
}
|
||||
State::make_session_int(
|
||||
&self.rand,
|
||||
|
@ -591,6 +649,7 @@ impl State {
|
|||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_session_int<'s>(
|
||||
rand: &SystemRandom,
|
||||
conn: &Connection,
|
||||
|
@ -599,9 +658,9 @@ impl State {
|
|||
domain: Option<Vec<u8>>,
|
||||
creation_password_id: Option<i32>,
|
||||
flags: i32,
|
||||
sessions: &'s mut FnvHashMap<SessionHash, Session>,
|
||||
sessions: &'s mut FastHashMap<SessionHash, Session>,
|
||||
permissions: Permissions,
|
||||
) -> Result<(RawSessionId, &'s Session), Error> {
|
||||
) -> Result<(RawSessionId, &'s Session), base::Error> {
|
||||
let mut session_id = RawSessionId([0u8; 48]);
|
||||
rand.fill(&mut session_id.0).unwrap();
|
||||
let mut seed = [0u8; 32];
|
||||
|
@ -672,17 +731,20 @@ impl State {
|
|||
}
|
||||
};
|
||||
let u = match self.users_by_id.get(&s.user_id) {
|
||||
None => bail_t!(Internal, "session references nonexistent user!"),
|
||||
None => bail!(Internal, msg("session references nonexistent user!")),
|
||||
Some(u) => u,
|
||||
};
|
||||
if let Some(r) = s.revocation_reason {
|
||||
bail_t!(Unauthenticated, "session is no longer valid (reason={})", r);
|
||||
bail!(
|
||||
Unauthenticated,
|
||||
msg("session is no longer valid (reason={r})")
|
||||
);
|
||||
}
|
||||
s.last_use = req;
|
||||
s.use_count += 1;
|
||||
s.dirty = true;
|
||||
if u.config.disabled {
|
||||
bail_t!(Unauthenticated, "user {:?} is disabled", &u.username);
|
||||
bail!(Unauthenticated, msg("user {:?} is disabled", &u.username));
|
||||
}
|
||||
Ok((s, u))
|
||||
}
|
||||
|
@ -770,18 +832,20 @@ impl State {
|
|||
":id": &id,
|
||||
})?;
|
||||
}
|
||||
for s in self.sessions.values() {
|
||||
for (sh, s) in &self.sessions {
|
||||
if !s.dirty {
|
||||
continue;
|
||||
}
|
||||
let addr = s.last_use.addr_buf();
|
||||
let addr: Option<&[u8]> = addr.as_ref().map(|a| a.as_ref());
|
||||
s_stmt.execute(named_params! {
|
||||
let cnt = s_stmt.execute(named_params! {
|
||||
":last_use_time_sec": &s.last_use.when_sec,
|
||||
":last_use_user_agent": &s.last_use.user_agent,
|
||||
":last_use_peer_addr": &addr,
|
||||
":use_count": &s.use_count,
|
||||
":hash": &sh.0[..],
|
||||
})?;
|
||||
debug_assert_eq!(cnt, 1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
@ -800,9 +864,8 @@ impl State {
|
|||
}
|
||||
|
||||
fn lookup_session(conn: &Connection, hash: &SessionHash) -> Result<Session, base::Error> {
|
||||
let mut stmt = conn
|
||||
.prepare_cached(
|
||||
r#"
|
||||
let mut stmt = conn.prepare_cached(
|
||||
r#"
|
||||
select
|
||||
user_id,
|
||||
seed,
|
||||
|
@ -828,52 +891,43 @@ fn lookup_session(conn: &Connection, hash: &SessionHash) -> Result<Session, base
|
|||
where
|
||||
session_id_hash = ?
|
||||
"#,
|
||||
)
|
||||
.err_kind(ErrorKind::Internal)?;
|
||||
let mut rows = stmt
|
||||
.query(params![&hash.0[..]])
|
||||
.err_kind(ErrorKind::Internal)?;
|
||||
)?;
|
||||
let mut rows = stmt.query(params![&hash.0[..]])?;
|
||||
let row = rows
|
||||
.next()
|
||||
.err_kind(ErrorKind::Internal)?
|
||||
.ok_or_else(|| format_err_t!(NotFound, "no such session"))?;
|
||||
let creation_addr: FromSqlIpAddr = row.get(8).err_kind(ErrorKind::Internal)?;
|
||||
let revocation_addr: FromSqlIpAddr = row.get(11).err_kind(ErrorKind::Internal)?;
|
||||
let last_use_addr: FromSqlIpAddr = row.get(16).err_kind(ErrorKind::Internal)?;
|
||||
.next()?
|
||||
.ok_or_else(|| err!(NotFound, msg("no such session")))?;
|
||||
let creation_addr: FromSqlIpAddr = row.get(8)?;
|
||||
let revocation_addr: FromSqlIpAddr = row.get(11)?;
|
||||
let last_use_addr: FromSqlIpAddr = row.get(16)?;
|
||||
let mut permissions = Permissions::new();
|
||||
permissions
|
||||
.merge_from_bytes(
|
||||
row.get_ref(18)
|
||||
.err_kind(ErrorKind::Internal)?
|
||||
.as_blob()
|
||||
.err_kind(ErrorKind::Internal)?,
|
||||
)
|
||||
.err_kind(ErrorKind::Internal)?;
|
||||
.merge_from_bytes(row.get_ref(18)?.as_blob()?)
|
||||
.err_kind(ErrorKind::DataLoss)?;
|
||||
Ok(Session {
|
||||
user_id: row.get(0).err_kind(ErrorKind::Internal)?,
|
||||
seed: row.get(1).err_kind(ErrorKind::Internal)?,
|
||||
flags: row.get(2).err_kind(ErrorKind::Internal)?,
|
||||
domain: row.get(3).err_kind(ErrorKind::Internal)?,
|
||||
description: row.get(4).err_kind(ErrorKind::Internal)?,
|
||||
creation_password_id: row.get(5).err_kind(ErrorKind::Internal)?,
|
||||
user_id: row.get(0)?,
|
||||
seed: row.get(1)?,
|
||||
flags: row.get(2)?,
|
||||
domain: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
creation_password_id: row.get(5)?,
|
||||
creation: Request {
|
||||
when_sec: row.get(6).err_kind(ErrorKind::Internal)?,
|
||||
user_agent: row.get(7).err_kind(ErrorKind::Internal)?,
|
||||
when_sec: row.get(6)?,
|
||||
user_agent: row.get(7)?,
|
||||
addr: creation_addr.0,
|
||||
},
|
||||
revocation: Request {
|
||||
when_sec: row.get(9).err_kind(ErrorKind::Internal)?,
|
||||
user_agent: row.get(10).err_kind(ErrorKind::Internal)?,
|
||||
when_sec: row.get(9)?,
|
||||
user_agent: row.get(10)?,
|
||||
addr: revocation_addr.0,
|
||||
},
|
||||
revocation_reason: row.get(12).err_kind(ErrorKind::Internal)?,
|
||||
revocation_reason_detail: row.get(13).err_kind(ErrorKind::Internal)?,
|
||||
revocation_reason: row.get(12)?,
|
||||
revocation_reason_detail: row.get(13)?,
|
||||
last_use: Request {
|
||||
when_sec: row.get(14).err_kind(ErrorKind::Internal)?,
|
||||
user_agent: row.get(15).err_kind(ErrorKind::Internal)?,
|
||||
when_sec: row.get(14)?,
|
||||
user_agent: row.get(15)?,
|
||||
addr: last_use_addr.0,
|
||||
},
|
||||
use_count: row.get(17).err_kind(ErrorKind::Internal)?,
|
||||
use_count: row.get(17)?,
|
||||
dirty: false,
|
||||
permissions,
|
||||
})
|
||||
|
@ -924,7 +978,8 @@ mod tests {
|
|||
0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(format!("{}", e), "no password set for user \"slamb\"");
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "incorrect password");
|
||||
c.set_password("hunter2".to_owned());
|
||||
state.apply(&conn, c).unwrap();
|
||||
let e = state
|
||||
|
@ -937,7 +992,8 @@ mod tests {
|
|||
0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(format!("{}", e), "incorrect password for user \"slamb\"");
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "incorrect password");
|
||||
let sid = {
|
||||
let (sid, s) = state
|
||||
.login_by_password(
|
||||
|
@ -971,10 +1027,7 @@ mod tests {
|
|||
let e = state
|
||||
.authenticate_session(&conn, req.clone(), &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{}", e),
|
||||
"Unauthenticated: session is no longer valid (reason=1)"
|
||||
);
|
||||
assert_eq!(e.msg().unwrap(), "session is no longer valid (reason=1)");
|
||||
|
||||
// Everything should persist across reload.
|
||||
drop(state);
|
||||
|
@ -982,10 +1035,54 @@ mod tests {
|
|||
let e = state
|
||||
.authenticate_session(&conn, req, &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{}", e),
|
||||
"Unauthenticated: session is no longer valid (reason=1)"
|
||||
);
|
||||
assert_eq!(e.msg().unwrap(), "session is no longer valid (reason=1)");
|
||||
}
|
||||
|
||||
/// Tests that flush works, including updating dirty sessions.
|
||||
#[test]
|
||||
fn flush() {
|
||||
testutil::init();
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::init(&mut conn).unwrap();
|
||||
let mut state = State::init(&conn).unwrap();
|
||||
let req = Request {
|
||||
when_sec: Some(42),
|
||||
addr: Some(::std::net::IpAddr::V4(::std::net::Ipv4Addr::new(
|
||||
127, 0, 0, 1,
|
||||
))),
|
||||
user_agent: Some(b"some ua".to_vec()),
|
||||
};
|
||||
{
|
||||
let mut c = UserChange::add_user("slamb".to_owned());
|
||||
c.set_password("hunter2".to_owned());
|
||||
state.apply(&conn, c).unwrap();
|
||||
}
|
||||
let (sid, _) = state
|
||||
.login_by_password(
|
||||
&conn,
|
||||
req.clone(),
|
||||
"slamb",
|
||||
"hunter2".to_owned(),
|
||||
Some(b"nvr.example.com".to_vec()),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (s, _u) = state
|
||||
.authenticate_session(&conn, req.clone(), &sid.hash())
|
||||
.unwrap();
|
||||
assert_eq!(s.use_count, 1);
|
||||
|
||||
let mut tx = conn.transaction().unwrap();
|
||||
state.flush(&mut tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
state.post_flush();
|
||||
|
||||
// Everything should persist across reload.
|
||||
drop(state);
|
||||
let mut state = State::init(&conn).unwrap();
|
||||
let (s, _u) = state.authenticate_session(&conn, req, &sid.hash()).unwrap();
|
||||
assert_eq!(s.use_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1036,10 +1133,8 @@ mod tests {
|
|||
let e = state
|
||||
.authenticate_session(&conn, req, &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{}", e),
|
||||
"Unauthenticated: session is no longer valid (reason=1)"
|
||||
);
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "session is no longer valid (reason=1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1092,16 +1187,15 @@ mod tests {
|
|||
0,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(format!("{}", e), "user \"slamb\" is disabled");
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "user \"slamb\" is disabled");
|
||||
|
||||
// Authenticating existing sessions shouldn't work either.
|
||||
let e = state
|
||||
.authenticate_session(&conn, req.clone(), &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{}", e),
|
||||
"Unauthenticated: user \"slamb\" is disabled"
|
||||
);
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "user \"slamb\" is disabled");
|
||||
|
||||
// The user should still be disabled after reload.
|
||||
drop(state);
|
||||
|
@ -1109,10 +1203,29 @@ mod tests {
|
|||
let e = state
|
||||
.authenticate_session(&conn, req, &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
format!("{}", e),
|
||||
"Unauthenticated: user \"slamb\" is disabled"
|
||||
);
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "user \"slamb\" is disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change() {
|
||||
testutil::init();
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::init(&mut conn).unwrap();
|
||||
let mut state = State::init(&conn).unwrap();
|
||||
let uid = {
|
||||
let mut c = UserChange::add_user("slamb".to_owned());
|
||||
c.set_password("hunter2".to_owned());
|
||||
state.apply(&conn, c).unwrap().id
|
||||
};
|
||||
|
||||
let user = state.users_by_id().get(&uid).unwrap();
|
||||
let mut c = user.change();
|
||||
c.username = "foo".to_owned();
|
||||
state.apply(&conn, c).unwrap();
|
||||
|
||||
assert!(state.users_by_name.get("slamb").is_none());
|
||||
assert!(state.users_by_name.get("foo").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -1151,16 +1264,18 @@ mod tests {
|
|||
let e = state
|
||||
.authenticate_session(&conn, req.clone(), &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(format!("{}", e), "Unauthenticated: no such session");
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "no such session");
|
||||
|
||||
// The user should still be deleted after reload.
|
||||
drop(state);
|
||||
let mut state = State::init(&conn).unwrap();
|
||||
assert!(state.users_by_id().get(&uid).is_none());
|
||||
let e = state
|
||||
.authenticate_session(&conn, req.clone(), &sid.hash())
|
||||
.authenticate_session(&conn, req, &sid.hash())
|
||||
.unwrap_err();
|
||||
assert_eq!(format!("{}", e), "Unauthenticated: no such session");
|
||||
assert_eq!(e.kind(), ErrorKind::Unauthenticated);
|
||||
assert_eq!(e.msg().unwrap(), "no such session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
@ -6,7 +6,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
Ok(protobuf_codegen::Codegen::new()
|
||||
.pure()
|
||||
.out_dir(std::env::var("OUT_DIR")?)
|
||||
.inputs(&["proto/schema.proto"])
|
||||
.inputs(["proto/schema.proto"])
|
||||
.include("proto")
|
||||
.customize(protobuf_codegen::Customize::default().gen_mod_rs(true))
|
||||
.run()?)
|
||||
|
|
|
@ -11,12 +11,12 @@ use crate::json::SampleFileDirConfig;
|
|||
use crate::raw;
|
||||
use crate::recording;
|
||||
use crate::schema;
|
||||
use failure::Error;
|
||||
use fnv::{FnvHashMap, FnvHashSet};
|
||||
use log::{error, info, warn};
|
||||
use base::{err, Error};
|
||||
use base::{FastHashMap, FastHashSet};
|
||||
use nix::fcntl::AtFlags;
|
||||
use rusqlite::params;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub struct Options {
|
||||
pub compare_lens: bool,
|
||||
|
@ -27,8 +27,8 @@ pub struct Options {
|
|||
|
||||
#[derive(Default)]
|
||||
pub struct Context {
|
||||
rows_to_delete: FnvHashSet<CompositeId>,
|
||||
files_to_trash: FnvHashSet<(i32, CompositeId)>, // (dir_id, composite_id)
|
||||
rows_to_delete: FastHashSet<CompositeId>,
|
||||
files_to_trash: FastHashSet<(i32, CompositeId)>, // (dir_id, composite_id)
|
||||
}
|
||||
|
||||
pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error> {
|
||||
|
@ -43,7 +43,7 @@ pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error
|
|||
if e == "ok" {
|
||||
continue;
|
||||
}
|
||||
error!("{}", e);
|
||||
error!(err = %e, "sqlite integrity error");
|
||||
printed_error = true;
|
||||
}
|
||||
}
|
||||
|
@ -54,7 +54,10 @@ pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error
|
|||
error!("Schema version is not as expected:\n{}", e);
|
||||
printed_error = true;
|
||||
} else {
|
||||
info!("Schema at expected version {}.", db::EXPECTED_VERSION);
|
||||
info!(
|
||||
"Schema at expected version {}.",
|
||||
db::EXPECTED_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
// Compare schemas.
|
||||
|
@ -73,10 +76,10 @@ pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error
|
|||
warn!("The following analysis may be incorrect or encounter errors due to schema differences.");
|
||||
}
|
||||
|
||||
let (db_uuid, _config) = raw::read_meta(&conn)?;
|
||||
let (db_uuid, _config) = raw::read_meta(conn)?;
|
||||
|
||||
// Scan directories.
|
||||
let mut dirs_by_id: FnvHashMap<i32, Dir> = FnvHashMap::default();
|
||||
let mut dirs_by_id: FastHashMap<i32, Dir> = FastHashMap::default();
|
||||
{
|
||||
let mut dir_stmt = conn.prepare(
|
||||
r#"
|
||||
|
@ -104,7 +107,7 @@ pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error
|
|||
|
||||
// Open the directory (checking its metadata) and hold it open (for the lock).
|
||||
let dir = dir::SampleFileDir::open(&config.path, &meta)
|
||||
.map_err(|e| e.context(format!("unable to open dir {}", config.path.display())))?;
|
||||
.map_err(|e| err!(e, msg("unable to open dir {}", config.path.display())))?;
|
||||
let mut streams = read_dir(&dir, opts)?;
|
||||
let mut rows = garbage_stmt.query(params![dir_id])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
|
@ -141,7 +144,7 @@ pub fn run(conn: &mut rusqlite::Connection, opts: &Options) -> Result<i32, Error
|
|||
let cum_recordings = row.get(2)?;
|
||||
let mut stream = match dirs_by_id.get_mut(&dir_id) {
|
||||
None => Stream::default(),
|
||||
Some(d) => d.remove(&stream_id).unwrap_or_else(Stream::default),
|
||||
Some(d) => d.remove(&stream_id).unwrap_or_default(),
|
||||
};
|
||||
stream.cum_recordings = Some(cum_recordings);
|
||||
printed_error |= compare_stream(conn, dir_id, stream_id, opts, stream, &mut ctx)?;
|
||||
|
@ -226,11 +229,11 @@ struct Recording {
|
|||
|
||||
#[derive(Default)]
|
||||
struct Stream {
|
||||
recordings: FnvHashMap<i32, Recording>,
|
||||
recordings: FastHashMap<i32, Recording>,
|
||||
cum_recordings: Option<i32>,
|
||||
}
|
||||
|
||||
type Dir = FnvHashMap<i32, Stream>;
|
||||
type Dir = FastHashMap<i32, Stream>;
|
||||
|
||||
fn summarize_index(video_index: &[u8]) -> Result<RecordingSummary, Error> {
|
||||
let mut it = recording::SampleIndexIterator::default();
|
||||
|
@ -342,7 +345,7 @@ fn compare_stream(
|
|||
stream
|
||||
.recordings
|
||||
.entry(id.recording())
|
||||
.or_insert_with(Recording::default)
|
||||
.or_default()
|
||||
.recording_row = Some(s);
|
||||
}
|
||||
}
|
||||
|
@ -379,7 +382,7 @@ fn compare_stream(
|
|||
stream
|
||||
.recordings
|
||||
.entry(id.recording())
|
||||
.or_insert_with(Recording::default)
|
||||
.or_default()
|
||||
.playback_row = Some(s);
|
||||
}
|
||||
}
|
||||
|
@ -402,7 +405,7 @@ fn compare_stream(
|
|||
stream
|
||||
.recordings
|
||||
.entry(id.recording())
|
||||
.or_insert_with(Recording::default)
|
||||
.or_default()
|
||||
.integrity_row = true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -207,7 +207,7 @@ mod tests {
|
|||
b"\x80\x80\x80\x80\x80\x00",
|
||||
];
|
||||
for (i, encoded) in tests.iter().enumerate() {
|
||||
assert!(decode_varint32(encoded, 0).is_err(), "while on test {}", i);
|
||||
assert!(decode_varint32(encoded, 0).is_err(), "while on test {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
//! This is used as part of the `moonfire-nvr check` database integrity checking
|
||||
//! and for tests of `moonfire-nvr upgrade`.
|
||||
|
||||
use failure::Error;
|
||||
use base::Error;
|
||||
use rusqlite::params;
|
||||
use std::fmt::Write;
|
||||
|
||||
|
@ -22,7 +22,7 @@ struct Column {
|
|||
|
||||
impl std::fmt::Display for Column {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,7 +37,7 @@ struct Index {
|
|||
|
||||
impl std::fmt::Display for Index {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,7 +50,7 @@ struct IndexColumn {
|
|||
|
||||
impl std::fmt::Display for IndexColumn {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self)
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,18 +61,18 @@ fn diff_slices<T: std::fmt::Display + PartialEq>(
|
|||
name2: &str,
|
||||
slice2: &[T],
|
||||
) -> Option<String> {
|
||||
let mut diff = format!("--- {}\n+++ {}\n", name1, name2);
|
||||
let mut diff = format!("--- {name1}\n+++ {name2}\n");
|
||||
let mut changed = false;
|
||||
for item in diff::slice(slice1, slice2) {
|
||||
match item {
|
||||
diff::Result::Left(i) => {
|
||||
changed = true;
|
||||
write!(&mut diff, "-{}\n", i)
|
||||
writeln!(&mut diff, "-{i}")
|
||||
}
|
||||
diff::Result::Both(i, _) => write!(&mut diff, " {}\n", i),
|
||||
diff::Result::Both(i, _) => writeln!(&mut diff, " {i}"),
|
||||
diff::Result::Right(i) => {
|
||||
changed = true;
|
||||
write!(&mut diff, "+{}\n", i)
|
||||
writeln!(&mut diff, "+{i}")
|
||||
}
|
||||
}
|
||||
.unwrap();
|
||||
|
@ -109,7 +109,7 @@ fn get_table_columns(
|
|||
// Note that placeholders aren't allowed for these pragmas. Just assume sane table names
|
||||
// (no escaping). "select * from pragma_..." syntax would be nicer but requires SQLite
|
||||
// 3.16.0 (2017-01-02). Ubuntu 16.04 Xenial (still used on Travis CI) has an older SQLite.
|
||||
c.prepare(&format!("pragma table_info(\"{}\")", table))?
|
||||
c.prepare(&format!("pragma table_info(\"{table}\")"))?
|
||||
.query_map(params![], |r| {
|
||||
Ok(Column {
|
||||
cid: r.get(0)?,
|
||||
|
@ -126,7 +126,7 @@ fn get_table_columns(
|
|||
/// Returns a vec of indices associated with the given table.
|
||||
fn get_indices(c: &rusqlite::Connection, table: &str) -> Result<Vec<Index>, rusqlite::Error> {
|
||||
// See note at get_tables_columns about placeholders.
|
||||
c.prepare(&format!("pragma index_list(\"{}\")", table))?
|
||||
c.prepare(&format!("pragma index_list(\"{table}\")"))?
|
||||
.query_map(params![], |r| {
|
||||
Ok(Index {
|
||||
seq: r.get(0)?,
|
||||
|
@ -145,7 +145,7 @@ fn get_index_columns(
|
|||
index: &str,
|
||||
) -> Result<Vec<IndexColumn>, rusqlite::Error> {
|
||||
// See note at get_tables_columns about placeholders.
|
||||
c.prepare(&format!("pragma index_info(\"{}\")", index))?
|
||||
c.prepare(&format!("pragma index_info(\"{index}\")"))?
|
||||
.query_map(params![], |r| {
|
||||
Ok(IndexColumn {
|
||||
seqno: r.get(0)?,
|
||||
|
@ -168,35 +168,26 @@ pub fn get_diffs(
|
|||
let tables1 = get_tables(c1)?;
|
||||
let tables2 = get_tables(c2)?;
|
||||
if let Some(diff) = diff_slices(n1, &tables1[..], n2, &tables2[..]) {
|
||||
write!(
|
||||
&mut diffs,
|
||||
"table list mismatch, {} vs {}:\n{}",
|
||||
n1, n2, diff
|
||||
)?;
|
||||
write!(&mut diffs, "table list mismatch, {n1} vs {n2}:\n{diff}")
|
||||
.expect("write to String shouldn't fail");
|
||||
}
|
||||
|
||||
// Compare columns and indices for each table.
|
||||
for t in &tables1 {
|
||||
let columns1 = get_table_columns(c1, &t)?;
|
||||
let columns2 = get_table_columns(c2, &t)?;
|
||||
let columns1 = get_table_columns(c1, t)?;
|
||||
let columns2 = get_table_columns(c2, t)?;
|
||||
if let Some(diff) = diff_slices(n1, &columns1[..], n2, &columns2[..]) {
|
||||
write!(
|
||||
&mut diffs,
|
||||
"table {:?} column, {} vs {}:\n{}",
|
||||
t, n1, n2, diff
|
||||
)?;
|
||||
write!(&mut diffs, "table {t:?} column, {n1} vs {n2}:\n{diff}")
|
||||
.expect("write to String shouldn't fail");
|
||||
}
|
||||
|
||||
let mut indices1 = get_indices(c1, &t)?;
|
||||
let mut indices2 = get_indices(c2, &t)?;
|
||||
let mut indices1 = get_indices(c1, t)?;
|
||||
let mut indices2 = get_indices(c2, t)?;
|
||||
indices1.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
indices2.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Some(diff) = diff_slices(n1, &indices1[..], n2, &indices2[..]) {
|
||||
write!(
|
||||
&mut diffs,
|
||||
"table {:?} indices, {} vs {}:\n{}",
|
||||
t, n1, n2, diff
|
||||
)?;
|
||||
write!(&mut diffs, "table {t:?} indices, {n1} vs {n2}:\n{diff}")
|
||||
.expect("write to String shouldn't fail");
|
||||
}
|
||||
|
||||
for i in &indices1 {
|
||||
|
@ -205,9 +196,9 @@ pub fn get_diffs(
|
|||
if let Some(diff) = diff_slices(n1, &ic1[..], n2, &ic2[..]) {
|
||||
write!(
|
||||
&mut diffs,
|
||||
"table {:?} index {:?} columns {} vs {}:\n{}",
|
||||
t, i, n1, n2, diff
|
||||
)?;
|
||||
"table {t:?} index {i:?} columns {n1} vs {n2}:\n{diff}"
|
||||
)
|
||||
.expect("write to String shouldn't fail");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,7 @@
|
|||
//! In-memory indexes by calendar day.
|
||||
|
||||
use base::time::{Duration, Time, TIME_UNITS_PER_SEC};
|
||||
use failure::Error;
|
||||
use log::{error, trace};
|
||||
use base::{err, Error};
|
||||
use smallvec::SmallVec;
|
||||
use std::cmp;
|
||||
use std::collections::BTreeMap;
|
||||
|
@ -14,6 +13,7 @@ use std::convert::TryFrom;
|
|||
use std::io::Write;
|
||||
use std::ops::Range;
|
||||
use std::str;
|
||||
use tracing::{error, trace};
|
||||
|
||||
/// A calendar day in `YYYY-mm-dd` format.
|
||||
#[derive(Copy, Clone, Eq, Ord, PartialEq, PartialOrd)]
|
||||
|
@ -22,7 +22,12 @@ pub struct Key(pub(crate) [u8; 10]);
|
|||
impl Key {
|
||||
fn new(tm: time::Tm) -> Result<Self, Error> {
|
||||
let mut s = Key([0u8; 10]);
|
||||
write!(&mut s.0[..], "{}", tm.strftime("%Y-%m-%d")?)?;
|
||||
write!(
|
||||
&mut s.0[..],
|
||||
"{}",
|
||||
tm.strftime("%Y-%m-%d")
|
||||
.map_err(|e| err!(Internal, source(e)))?
|
||||
)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
|
@ -97,7 +102,7 @@ impl Value for SignalValue {
|
|||
type Change = SignalChange;
|
||||
|
||||
fn apply(&mut self, c: &SignalChange) {
|
||||
if self.states.len() < usize::try_from(c.new_state).unwrap() {
|
||||
if self.states.len() < usize::from(c.new_state) {
|
||||
self.states.resize(c.new_state as usize, 0);
|
||||
}
|
||||
|
||||
|
@ -106,23 +111,21 @@ impl Value for SignalValue {
|
|||
let s = &mut self.states[c.new_state as usize - 1];
|
||||
let n = s
|
||||
.checked_add(u64::try_from(c.duration.0).unwrap())
|
||||
.unwrap_or_else(|| panic!("add range violation: s={:?} c={:?}", s, c));
|
||||
.unwrap_or_else(|| panic!("add range violation: s={s:?} c={c:?}"));
|
||||
*s = n;
|
||||
}
|
||||
|
||||
if c.old_state > 0 {
|
||||
// remove from old state.
|
||||
let i = usize::try_from(c.old_state).unwrap() - 1;
|
||||
let i = usize::from(c.old_state) - 1;
|
||||
assert!(
|
||||
self.states.len() > i,
|
||||
"no such old state: s={:?} c={:?}",
|
||||
self,
|
||||
c
|
||||
"no such old state: s={self:?} c={c:?}"
|
||||
);
|
||||
let s = &mut self.states[c.old_state as usize - 1];
|
||||
let n = s
|
||||
.checked_sub(u64::try_from(c.duration.0).unwrap())
|
||||
.unwrap_or_else(|| panic!("sub range violation: s={:?} c={:?}", s, c));
|
||||
.unwrap_or_else(|| panic!("sub range violation: s={s:?} c={c:?}"));
|
||||
*s = n;
|
||||
}
|
||||
|
||||
|
|
459
server/db/db.rs
459
server/db/db.rs
File diff suppressed because it is too large
Load Diff
|
@ -12,9 +12,8 @@ mod reader;
|
|||
use crate::coding;
|
||||
use crate::db::CompositeId;
|
||||
use crate::schema;
|
||||
use base::{bail, err, Error};
|
||||
use cstr::cstr;
|
||||
use failure::{bail, format_err, Error, Fail};
|
||||
use log::warn;
|
||||
use nix::sys::statvfs::Statvfs;
|
||||
use nix::{
|
||||
fcntl::{FlockArg, OFlag},
|
||||
|
@ -26,9 +25,11 @@ use std::ffi::CStr;
|
|||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::ops::Range;
|
||||
use std::os::fd::{AsFd, BorrowedFd};
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
/// The fixed length of a directory's `meta` file.
|
||||
///
|
||||
|
@ -87,16 +88,16 @@ impl NixPath for CompositeIdPath {
|
|||
#[derive(Debug)]
|
||||
pub struct Fd(std::os::unix::io::RawFd);
|
||||
|
||||
impl std::os::unix::io::AsRawFd for Fd {
|
||||
fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
|
||||
self.0
|
||||
impl AsFd for Fd {
|
||||
fn as_fd(&self) -> std::os::unix::prelude::BorrowedFd<'_> {
|
||||
unsafe { BorrowedFd::borrow_raw(self.0) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fd {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = nix::unistd::close(self.0) {
|
||||
warn!("Unable to close sample file dir: {}", e);
|
||||
if let Err(err) = nix::unistd::close(self.0) {
|
||||
warn!(%err, "unable to close sample file dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -145,20 +146,23 @@ pub(crate) fn read_meta(dir: &Fd) -> Result<schema::DirMeta, Error> {
|
|||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data)?;
|
||||
let (len, pos) = coding::decode_varint32(&data, 0)
|
||||
.map_err(|_| format_err!("Unable to decode varint length in meta file"))?;
|
||||
.map_err(|_| err!(DataLoss, msg("Unable to decode varint length in meta file")))?;
|
||||
if data.len() != FIXED_DIR_META_LEN || len as usize + pos > FIXED_DIR_META_LEN {
|
||||
bail!(
|
||||
"Expected a {}-byte file with a varint length of a DirMeta message; got \
|
||||
a {}-byte file with length {}",
|
||||
FIXED_DIR_META_LEN,
|
||||
data.len(),
|
||||
len
|
||||
DataLoss,
|
||||
msg(
|
||||
"Expected a {}-byte file with a varint length of a DirMeta message; got \
|
||||
a {}-byte file with length {}",
|
||||
FIXED_DIR_META_LEN,
|
||||
data.len(),
|
||||
len,
|
||||
),
|
||||
);
|
||||
}
|
||||
let data = &data[pos..pos + len as usize];
|
||||
let mut s = protobuf::CodedInputStream::from_bytes(&data);
|
||||
let mut s = protobuf::CodedInputStream::from_bytes(data);
|
||||
meta.merge_from(&mut s)
|
||||
.map_err(|e| e.context("Unable to parse metadata proto"))?;
|
||||
.map_err(|e| err!(DataLoss, msg("Unable to parse metadata proto"), source(e)))?;
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
|
@ -169,9 +173,12 @@ pub(crate) fn write_meta(dirfd: RawFd, meta: &schema::DirMeta) -> Result<(), Err
|
|||
.expect("proto3->vec is infallible");
|
||||
if data.len() > FIXED_DIR_META_LEN {
|
||||
bail!(
|
||||
"Length-delimited DirMeta message requires {} bytes, over limit of {}",
|
||||
data.len(),
|
||||
FIXED_DIR_META_LEN
|
||||
Internal,
|
||||
msg(
|
||||
"length-delimited DirMeta message requires {} bytes, over limit of {}",
|
||||
data.len(),
|
||||
FIXED_DIR_META_LEN,
|
||||
),
|
||||
);
|
||||
}
|
||||
data.resize(FIXED_DIR_META_LEN, 0); // pad to required length.
|
||||
|
@ -181,28 +188,31 @@ pub(crate) fn write_meta(dirfd: RawFd, meta: &schema::DirMeta) -> Result<(), Err
|
|||
OFlag::O_CREAT | OFlag::O_WRONLY,
|
||||
Mode::S_IRUSR | Mode::S_IWUSR,
|
||||
)
|
||||
.map_err(|e| e.context("Unable to open meta file"))?;
|
||||
.map_err(|e| err!(e, msg("unable to open meta file")))?;
|
||||
let stat = f
|
||||
.metadata()
|
||||
.map_err(|e| e.context("Unable to stat meta file"))?;
|
||||
.map_err(|e| err!(e, msg("unable to stat meta file")))?;
|
||||
if stat.len() == 0 {
|
||||
// Need to sync not only the data but also the file metadata and dirent.
|
||||
f.write_all(&data)
|
||||
.map_err(|e| e.context("Unable to write to meta file"))?;
|
||||
.map_err(|e| err!(e, msg("unable to write to meta file")))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| e.context("Unable to sync meta file"))?;
|
||||
nix::unistd::fsync(dirfd).map_err(|e| e.context("Unable to sync dir"))?;
|
||||
.map_err(|e| err!(e, msg("unable to sync meta file")))?;
|
||||
nix::unistd::fsync(dirfd).map_err(|e| err!(e, msg("unable to sync dir")))?;
|
||||
} else if stat.len() == FIXED_DIR_META_LEN as u64 {
|
||||
// Just syncing the data will suffice; existing metadata and dirent are fine.
|
||||
f.write_all(&data)
|
||||
.map_err(|e| e.context("Unable to write to meta file"))?;
|
||||
.map_err(|e| err!(e, msg("unable to write to meta file")))?;
|
||||
f.sync_data()
|
||||
.map_err(|e| e.context("Unable to sync meta file"))?;
|
||||
.map_err(|e| err!(e, msg("unable to sync meta file")))?;
|
||||
} else {
|
||||
bail!(
|
||||
"Existing meta file is {}-byte; expected {}",
|
||||
stat.len(),
|
||||
FIXED_DIR_META_LEN
|
||||
DataLoss,
|
||||
msg(
|
||||
"existing meta file is {}-byte; expected {}",
|
||||
stat.len(),
|
||||
FIXED_DIR_META_LEN,
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
@ -221,14 +231,16 @@ impl SampleFileDir {
|
|||
} else {
|
||||
FlockArg::LockSharedNonblock
|
||||
})
|
||||
.map_err(|e| e.context(format!("unable to lock dir {}", path.display())))?;
|
||||
let dir_meta = read_meta(&s.fd).map_err(|e| e.context("unable to read meta file"))?;
|
||||
.map_err(|e| err!(e, msg("unable to lock dir {}", path.display())))?;
|
||||
let dir_meta = read_meta(&s.fd).map_err(|e| err!(e, msg("unable to read meta file")))?;
|
||||
if let Err(e) = SampleFileDir::check_consistent(expected_meta, &dir_meta) {
|
||||
bail!(
|
||||
"metadata mismatch: {}.\nexpected:\n{:#?}\n\nactual:\n{:#?}",
|
||||
e,
|
||||
expected_meta,
|
||||
&dir_meta
|
||||
Internal,
|
||||
msg(
|
||||
"metadata mismatch\nexpected:\n{expected_meta:#?}\n\nactual:\n\
|
||||
{dir_meta:#?}",
|
||||
),
|
||||
source(e),
|
||||
);
|
||||
}
|
||||
if expected_meta.in_progress_open.is_some() {
|
||||
|
@ -275,22 +287,28 @@ impl SampleFileDir {
|
|||
) -> Result<Arc<SampleFileDir>, Error> {
|
||||
let s = SampleFileDir::open_self(path, true)?;
|
||||
s.fd.lock(FlockArg::LockExclusiveNonblock)
|
||||
.map_err(|e| e.context(format!("unable to lock dir {}", path.display())))?;
|
||||
.map_err(|e| err!(e, msg("unable to lock dir {}", path.display())))?;
|
||||
let old_meta = read_meta(&s.fd)?;
|
||||
|
||||
// Verify metadata. We only care that it hasn't been completely opened.
|
||||
// Partial opening by this or another database is fine; we won't overwrite anything.
|
||||
if old_meta.last_complete_open.is_some() {
|
||||
bail!(
|
||||
"Can't create dir at path {}: is already in use:\n{:?}",
|
||||
path.display(),
|
||||
old_meta
|
||||
FailedPrecondition,
|
||||
msg(
|
||||
"can't create dir at path {}: is already in use:\n{:?}",
|
||||
path.display(),
|
||||
old_meta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if !s.is_empty()? {
|
||||
bail!(
|
||||
"Can't create dir at path {} with existing files",
|
||||
path.display()
|
||||
FailedPrecondition,
|
||||
msg(
|
||||
"can't create dir at path {} with existing files",
|
||||
path.display(),
|
||||
),
|
||||
);
|
||||
}
|
||||
s.write_meta(db_meta)?;
|
||||
|
@ -299,7 +317,7 @@ impl SampleFileDir {
|
|||
|
||||
pub(crate) fn opendir(&self) -> Result<nix::dir::Dir, nix::Error> {
|
||||
nix::dir::Dir::openat(
|
||||
self.fd.as_raw_fd(),
|
||||
self.fd.as_fd().as_raw_fd(),
|
||||
".",
|
||||
OFlag::O_DIRECTORY | OFlag::O_RDONLY,
|
||||
Mode::empty(),
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
|
||||
use std::convert::TryFrom;
|
||||
use std::future::Future;
|
||||
use std::os::unix::prelude::AsRawFd;
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
ops::Range,
|
||||
|
@ -31,9 +30,9 @@ use std::{
|
|||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use base::bail_t;
|
||||
use base::bail;
|
||||
use base::clock::{RealClocks, TimerGuard};
|
||||
use base::{format_err_t, Error, ErrorKind, ResultExt};
|
||||
use base::{err, Error, ErrorKind, ResultExt};
|
||||
use nix::{fcntl::OFlag, sys::stat::Mode};
|
||||
|
||||
use crate::CompositeId;
|
||||
|
@ -53,10 +52,14 @@ impl Reader {
|
|||
.expect("PAGE_SIZE must be defined"),
|
||||
)
|
||||
.expect("PAGE_SIZE fits in usize");
|
||||
assert_eq!(page_size.count_ones(), 1, "invalid page size {}", page_size);
|
||||
assert_eq!(page_size.count_ones(), 1, "invalid page size {page_size}");
|
||||
let span = tracing::info_span!("reader", path = %path.display());
|
||||
std::thread::Builder::new()
|
||||
.name(format!("r-{}", path.display()))
|
||||
.spawn(move || ReaderInt { dir, page_size }.run(rx))
|
||||
.spawn(move || {
|
||||
let _guard = span.enter();
|
||||
ReaderInt { dir, page_size }.run(rx)
|
||||
})
|
||||
.expect("unable to create reader thread");
|
||||
Self(tx)
|
||||
}
|
||||
|
@ -70,6 +73,7 @@ impl Reader {
|
|||
}
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
self.send(ReaderCommand::OpenFile {
|
||||
span: tracing::Span::current(),
|
||||
composite_id,
|
||||
range,
|
||||
tx,
|
||||
|
@ -93,7 +97,7 @@ pub struct FileStream {
|
|||
reader: Reader,
|
||||
}
|
||||
|
||||
type ReadReceiver = tokio::sync::oneshot::Receiver<Result<(Option<OpenFile>, Vec<u8>), Error>>;
|
||||
type ReadReceiver = tokio::sync::oneshot::Receiver<Result<SuccessfulRead, Error>>;
|
||||
|
||||
enum FileStreamState {
|
||||
Idle(OpenFile),
|
||||
|
@ -111,20 +115,23 @@ impl FileStream {
|
|||
match Pin::new(&mut rx).poll(cx) {
|
||||
Poll::Ready(Err(_)) => {
|
||||
self.state = FileStreamState::Invalid;
|
||||
Poll::Ready(Some(Err(format_err_t!(
|
||||
Poll::Ready(Some(Err(err!(
|
||||
Internal,
|
||||
"reader thread panicked; see logs"
|
||||
msg("reader thread panicked; see logs")
|
||||
))))
|
||||
}
|
||||
Poll::Ready(Ok(Err(e))) => {
|
||||
self.state = FileStreamState::Invalid;
|
||||
Poll::Ready(Some(Err(e)))
|
||||
}
|
||||
Poll::Ready(Ok(Ok((Some(file), chunk)))) => {
|
||||
Poll::Ready(Ok(Ok(SuccessfulRead {
|
||||
chunk,
|
||||
file: Some(file),
|
||||
}))) => {
|
||||
self.state = FileStreamState::Idle(file);
|
||||
Poll::Ready(Some(Ok(chunk)))
|
||||
}
|
||||
Poll::Ready(Ok(Ok((None, chunk)))) => {
|
||||
Poll::Ready(Ok(Ok(SuccessfulRead { chunk, file: None }))) => {
|
||||
self.state = FileStreamState::Invalid;
|
||||
Poll::Ready(Some(Ok(chunk)))
|
||||
}
|
||||
|
@ -173,6 +180,8 @@ impl Drop for FileStream {
|
|||
/// around between it and the [FileStream] to avoid maintaining extra data
|
||||
/// structures.
|
||||
struct OpenFile {
|
||||
span: tracing::Span,
|
||||
|
||||
composite_id: CompositeId,
|
||||
|
||||
/// The memory-mapped region backed by the file. Valid up to length `map_len`.
|
||||
|
@ -192,11 +201,9 @@ unsafe impl Sync for OpenFile {}
|
|||
|
||||
impl Drop for OpenFile {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) =
|
||||
unsafe { nix::sys::mman::munmap(self.map_ptr as *mut std::ffi::c_void, self.map_len) }
|
||||
{
|
||||
if let Err(e) = unsafe { nix::sys::mman::munmap(self.map_ptr, self.map_len) } {
|
||||
// This should never happen.
|
||||
log::error!(
|
||||
tracing::error!(
|
||||
"unable to munmap {}, {:?} len {}: {}",
|
||||
self.composite_id,
|
||||
self.map_ptr,
|
||||
|
@ -207,18 +214,26 @@ impl Drop for OpenFile {
|
|||
}
|
||||
}
|
||||
|
||||
struct SuccessfulRead {
|
||||
chunk: Vec<u8>,
|
||||
|
||||
/// If this is not the final requested chunk, the `OpenFile` for next time.
|
||||
file: Option<OpenFile>,
|
||||
}
|
||||
|
||||
enum ReaderCommand {
|
||||
/// Opens a file and reads the first chunk.
|
||||
OpenFile {
|
||||
span: tracing::Span,
|
||||
composite_id: CompositeId,
|
||||
range: std::ops::Range<u64>,
|
||||
tx: tokio::sync::oneshot::Sender<Result<(Option<OpenFile>, Vec<u8>), Error>>,
|
||||
tx: tokio::sync::oneshot::Sender<Result<SuccessfulRead, Error>>,
|
||||
},
|
||||
|
||||
/// Reads the next chunk of the file.
|
||||
ReadNextChunk {
|
||||
file: OpenFile,
|
||||
tx: tokio::sync::oneshot::Sender<Result<(Option<OpenFile>, Vec<u8>), Error>>,
|
||||
tx: tokio::sync::oneshot::Sender<Result<SuccessfulRead, Error>>,
|
||||
},
|
||||
|
||||
/// Closes the file early, as when the [FileStream] is dropped before completing.
|
||||
|
@ -240,6 +255,7 @@ impl ReaderInt {
|
|||
// the CloseFile operation.
|
||||
match cmd {
|
||||
ReaderCommand::OpenFile {
|
||||
span,
|
||||
composite_id,
|
||||
range,
|
||||
tx,
|
||||
|
@ -248,9 +264,11 @@ impl ReaderInt {
|
|||
// avoid spending effort on expired commands
|
||||
continue;
|
||||
}
|
||||
let _guard =
|
||||
TimerGuard::new(&RealClocks {}, || format!("open {}", composite_id));
|
||||
let _ = tx.send(self.open(composite_id, range));
|
||||
let span2 = span.clone();
|
||||
let _span_enter = span2.enter();
|
||||
let _timer_guard =
|
||||
TimerGuard::new(&RealClocks {}, || format!("open {composite_id}"));
|
||||
let _ = tx.send(self.open(span, composite_id, range));
|
||||
}
|
||||
ReaderCommand::ReadNextChunk { file, tx } => {
|
||||
if tx.is_closed() {
|
||||
|
@ -258,20 +276,30 @@ impl ReaderInt {
|
|||
continue;
|
||||
}
|
||||
let composite_id = file.composite_id;
|
||||
let span2 = file.span.clone();
|
||||
let _span_enter = span2.enter();
|
||||
let _guard =
|
||||
TimerGuard::new(&RealClocks {}, || format!("read from {}", composite_id));
|
||||
TimerGuard::new(&RealClocks {}, || format!("read from {composite_id}"));
|
||||
let _ = tx.send(Ok(self.chunk(file)));
|
||||
}
|
||||
ReaderCommand::CloseFile(_) => {}
|
||||
ReaderCommand::CloseFile(mut file) => {
|
||||
let composite_id = file.composite_id;
|
||||
let span = std::mem::replace(&mut file.span, tracing::Span::none());
|
||||
let _span_enter = span.enter();
|
||||
let _guard =
|
||||
TimerGuard::new(&RealClocks {}, || format!("close {composite_id}"));
|
||||
drop(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open(
|
||||
&self,
|
||||
span: tracing::Span,
|
||||
composite_id: CompositeId,
|
||||
range: Range<u64>,
|
||||
) -> Result<(Option<OpenFile>, Vec<u8>), Error> {
|
||||
) -> Result<SuccessfulRead, Error> {
|
||||
let p = super::CompositeIdPath::from(composite_id);
|
||||
|
||||
// Reader::open_file checks for an empty range, but check again right
|
||||
|
@ -290,14 +318,14 @@ impl ReaderInt {
|
|||
let map_len = usize::try_from(
|
||||
range.end - range.start + u64::try_from(unaligned).expect("usize fits in u64"),
|
||||
)
|
||||
.map_err(|_| {
|
||||
format_err_t!(
|
||||
.map_err(|e| {
|
||||
err!(
|
||||
OutOfRange,
|
||||
"file {}'s range {:?} len exceeds usize::MAX",
|
||||
composite_id,
|
||||
range
|
||||
msg("file {composite_id}'s range {range:?} len exceeds usize::MAX"),
|
||||
source(e),
|
||||
)
|
||||
})?;
|
||||
let map_len = std::num::NonZeroUsize::new(map_len).expect("range is non-empty");
|
||||
|
||||
let file = crate::fs::openat(self.dir.0, &p, OFlag::O_RDONLY, Mode::empty())
|
||||
.err_kind(ErrorKind::Unknown)?;
|
||||
|
@ -306,62 +334,61 @@ impl ReaderInt {
|
|||
// for it to be less than the requested read. Check for this now rather than crashing
|
||||
// with a SIGBUS or reading bad data at the end of the last page later.
|
||||
let metadata = file.metadata().err_kind(ErrorKind::Unknown)?;
|
||||
if metadata.len() < u64::try_from(offset).unwrap() + u64::try_from(map_len).unwrap() {
|
||||
bail_t!(
|
||||
Internal,
|
||||
"file {}, range {:?}, len {}",
|
||||
composite_id,
|
||||
range,
|
||||
metadata.len()
|
||||
if metadata.len() < u64::try_from(offset).unwrap() + u64::try_from(map_len.get()).unwrap() {
|
||||
bail!(
|
||||
OutOfRange,
|
||||
msg(
|
||||
"file {}, range {:?}, len {}",
|
||||
composite_id,
|
||||
range,
|
||||
metadata.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
let map_ptr = unsafe {
|
||||
nix::sys::mman::mmap(
|
||||
std::ptr::null_mut(),
|
||||
None,
|
||||
map_len,
|
||||
nix::sys::mman::ProtFlags::PROT_READ,
|
||||
nix::sys::mman::MapFlags::MAP_SHARED,
|
||||
file.as_raw_fd(),
|
||||
Some(&file),
|
||||
offset,
|
||||
)
|
||||
}
|
||||
.map_err(|e| {
|
||||
format_err_t!(
|
||||
Internal,
|
||||
"mmap failed for {} off={} len={}: {}",
|
||||
composite_id,
|
||||
offset,
|
||||
map_len,
|
||||
e
|
||||
err!(
|
||||
e,
|
||||
msg("mmap failed for {composite_id} off={offset} len={map_len}")
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Err(e) = unsafe {
|
||||
if let Err(err) = unsafe {
|
||||
nix::sys::mman::madvise(
|
||||
map_ptr as *mut libc::c_void,
|
||||
map_len,
|
||||
map_ptr,
|
||||
map_len.get(),
|
||||
nix::sys::mman::MmapAdvise::MADV_SEQUENTIAL,
|
||||
)
|
||||
} {
|
||||
// This shouldn't happen but is "just" a performance problem.
|
||||
log::warn!(
|
||||
"madvise(MADV_SEQUENTIAL) failed for {} off={} len={}: {}",
|
||||
composite_id,
|
||||
tracing::warn!(
|
||||
%err,
|
||||
%composite_id,
|
||||
offset,
|
||||
map_len,
|
||||
e
|
||||
"madvise(MADV_SEQUENTIAL) failed",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(self.chunk(OpenFile {
|
||||
span,
|
||||
composite_id,
|
||||
map_ptr,
|
||||
map_pos: unaligned,
|
||||
map_len,
|
||||
map_len: map_len.get(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn chunk(&self, mut file: OpenFile) -> (Option<OpenFile>, Vec<u8>) {
|
||||
fn chunk(&self, mut file: OpenFile) -> SuccessfulRead {
|
||||
// Read a chunk that's large enough to minimize thread handoffs but
|
||||
// short enough to keep memory usage under control. It's hopefully
|
||||
// unnecessary to worry about disk seeks; the madvise call should cause
|
||||
|
@ -392,7 +419,7 @@ impl ReaderInt {
|
|||
file.map_pos = end;
|
||||
Some(file)
|
||||
};
|
||||
(file, chunk)
|
||||
SuccessfulRead { chunk, file }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -410,7 +437,7 @@ mod tests {
|
|||
let fd = std::sync::Arc::new(super::super::Fd::open(tmpdir.path(), false).unwrap());
|
||||
let reader = super::Reader::spawn(tmpdir.path(), fd);
|
||||
std::fs::write(tmpdir.path().join("0123456789abcdef"), b"blah blah").unwrap();
|
||||
let f = reader.open_file(crate::CompositeId(0x01234567_89abcdef), 1..8);
|
||||
let f = reader.open_file(crate::CompositeId(0x0123_4567_89ab_cdef), 1..8);
|
||||
assert_eq!(f.try_concat().await.unwrap(), b"lah bla");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -239,7 +239,7 @@ pub struct StreamConfig {
|
|||
}
|
||||
sql!(StreamConfig);
|
||||
|
||||
pub const STREAM_MODE_RECORD: &'static str = "record";
|
||||
pub const STREAM_MODE_RECORD: &str = "record";
|
||||
|
||||
impl StreamConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
// Copyright (C) 2018 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.';
|
||||
|
||||
// Protobuf portion of the Moonfire NVR schema. In general Moonfire's schema
|
||||
// uses a SQLite3 database with some fields in JSON representation. The protobuf
|
||||
// stuff is just high-cardinality things that must be compact, e.g. permissions
|
||||
// that can be stuffed into every user session.
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
// Metadata stored in sample file dirs as `<dir>/meta`. This is checked
|
||||
|
@ -55,21 +60,12 @@ message DirMeta {
|
|||
Open in_progress_open = 4;
|
||||
}
|
||||
|
||||
// Permissions to perform actions, currently all simple bools.
|
||||
// Permissions to perform actions. See description in ref/api.md.
|
||||
//
|
||||
// These indicate actions which may be unnecessary in some contexts. Some
|
||||
// basic access - like listing the cameras - is currently always allowed.
|
||||
// See design/api.md for a description of what requires these permissions.
|
||||
//
|
||||
// These are used in a few contexts:
|
||||
// * a session - affects what can be done when using that session to
|
||||
// authenticate.
|
||||
// * a user - when a new session is created, it inherits these permissions.
|
||||
// * on the commandline - to specify what permissions are available for
|
||||
// unauthenticated access.
|
||||
// This protobuf form is stored in user and session rows.
|
||||
message Permissions {
|
||||
bool view_video = 1;
|
||||
bool read_camera_configs = 2;
|
||||
|
||||
bool update_signals = 3;
|
||||
bool admin_users = 4;
|
||||
}
|
||||
|
|
|
@ -7,9 +7,8 @@
|
|||
use crate::db::{self, CompositeId, SqlUuid};
|
||||
use crate::json::GlobalConfig;
|
||||
use crate::recording;
|
||||
use base::{ErrorKind, ResultExt as _};
|
||||
use failure::{bail, Error, ResultExt as _};
|
||||
use fnv::FnvHashSet;
|
||||
use base::FastHashSet;
|
||||
use base::{bail, err, Error, ErrorKind, ResultExt as _};
|
||||
use rusqlite::{named_params, params};
|
||||
use std::ops::Range;
|
||||
use uuid::Uuid;
|
||||
|
@ -27,7 +26,8 @@ const LIST_RECORDINGS_BY_TIME_SQL: &str = r#"
|
|||
recording.video_samples,
|
||||
recording.video_sync_samples,
|
||||
recording.video_sample_entry_id,
|
||||
recording.open_id
|
||||
recording.open_id,
|
||||
recording.end_reason
|
||||
from
|
||||
recording
|
||||
where
|
||||
|
@ -52,6 +52,7 @@ const LIST_RECORDINGS_BY_ID_SQL: &str = r#"
|
|||
recording.video_sync_samples,
|
||||
recording.video_sample_entry_id,
|
||||
recording.open_id,
|
||||
recording.end_reason,
|
||||
recording.prev_media_duration_90k,
|
||||
recording.prev_runs
|
||||
from
|
||||
|
@ -159,11 +160,12 @@ fn list_recordings_inner(
|
|||
video_sync_samples: row.get(8).err_kind(ErrorKind::Internal)?,
|
||||
video_sample_entry_id: row.get(9).err_kind(ErrorKind::Internal)?,
|
||||
open_id: row.get(10).err_kind(ErrorKind::Internal)?,
|
||||
end_reason: row.get(11).err_kind(ErrorKind::Internal)?,
|
||||
prev_media_duration_and_runs: match include_prev {
|
||||
false => None,
|
||||
true => Some((
|
||||
recording::Duration(row.get(11).err_kind(ErrorKind::Internal)?),
|
||||
row.get(12).err_kind(ErrorKind::Internal)?,
|
||||
recording::Duration(row.get(12).err_kind(ErrorKind::Internal)?),
|
||||
row.get(13).err_kind(ErrorKind::Internal)?,
|
||||
)),
|
||||
},
|
||||
})?;
|
||||
|
@ -190,9 +192,8 @@ pub(crate) fn insert_recording(
|
|||
id: CompositeId,
|
||||
r: &db::RecordingToInsert,
|
||||
) -> Result<(), Error> {
|
||||
let mut stmt = tx
|
||||
.prepare_cached(
|
||||
r#"
|
||||
let mut stmt = tx.prepare_cached(
|
||||
r#"
|
||||
insert into recording (composite_id, stream_id, open_id, run_offset, flags,
|
||||
sample_file_bytes, start_time_90k, prev_media_duration_90k,
|
||||
prev_runs, wall_duration_90k, media_duration_delta_90k,
|
||||
|
@ -204,8 +205,7 @@ pub(crate) fn insert_recording(
|
|||
:video_samples, :video_sync_samples, :video_sample_entry_id,
|
||||
:end_reason)
|
||||
"#,
|
||||
)
|
||||
.with_context(|e| format!("can't prepare recording insert: {}", e))?;
|
||||
)?;
|
||||
stmt.execute(named_params! {
|
||||
":composite_id": id.0,
|
||||
":stream_id": i64::from(id.stream()),
|
||||
|
@ -223,23 +223,21 @@ pub(crate) fn insert_recording(
|
|||
":video_sample_entry_id": r.video_sample_entry_id,
|
||||
":end_reason": r.end_reason.as_deref(),
|
||||
})
|
||||
.with_context(|e| {
|
||||
format!(
|
||||
"unable to insert recording for recording {} {:#?}: {}",
|
||||
id, r, e
|
||||
.map_err(|e| {
|
||||
err!(
|
||||
e,
|
||||
msg("unable to insert recording for recording {id} {r:#?}")
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stmt = tx
|
||||
.prepare_cached(
|
||||
r#"
|
||||
let mut stmt = tx.prepare_cached(
|
||||
r#"
|
||||
insert into recording_integrity (composite_id, local_time_delta_90k,
|
||||
sample_file_blake3)
|
||||
values (:composite_id, :local_time_delta_90k,
|
||||
:sample_file_blake3)
|
||||
"#,
|
||||
)
|
||||
.with_context(|e| format!("can't prepare recording_integrity insert: {}", e))?;
|
||||
)?;
|
||||
let blake3 = r.sample_file_blake3.as_ref().map(|b| &b[..]);
|
||||
let delta = match r.run_offset {
|
||||
0 => None,
|
||||
|
@ -250,21 +248,19 @@ pub(crate) fn insert_recording(
|
|||
":local_time_delta_90k": delta,
|
||||
":sample_file_blake3": blake3,
|
||||
})
|
||||
.with_context(|e| format!("unable to insert recording_integrity for {:#?}: {}", r, e))?;
|
||||
.map_err(|e| err!(e, msg("unable to insert recording_integrity for {r:#?}")))?;
|
||||
|
||||
let mut stmt = tx
|
||||
.prepare_cached(
|
||||
r#"
|
||||
let mut stmt = tx.prepare_cached(
|
||||
r#"
|
||||
insert into recording_playback (composite_id, video_index)
|
||||
values (:composite_id, :video_index)
|
||||
"#,
|
||||
)
|
||||
.with_context(|e| format!("can't prepare recording_playback insert: {}", e))?;
|
||||
)?;
|
||||
stmt.execute(named_params! {
|
||||
":composite_id": id.0,
|
||||
":video_index": &r.video_index,
|
||||
})
|
||||
.with_context(|e| format!("unable to insert recording_playback for {:#?}: {}", r, e))?;
|
||||
.map_err(|e| err!(e, msg("unable to insert recording_playback for {r:#?}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -327,26 +323,31 @@ pub(crate) fn delete_recordings(
|
|||
let n_playback = del_playback.execute(p)?;
|
||||
if n_playback != n {
|
||||
bail!(
|
||||
"inserted {} garbage rows but deleted {} recording_playback rows!",
|
||||
n,
|
||||
n_playback
|
||||
Internal,
|
||||
msg(
|
||||
"inserted {} garbage rows but deleted {} recording_playback rows!",
|
||||
n,
|
||||
n_playback
|
||||
),
|
||||
);
|
||||
}
|
||||
let n_integrity = del_integrity.execute(p)?;
|
||||
if n_integrity > n {
|
||||
// fewer is okay; recording_integrity is optional.
|
||||
bail!(
|
||||
"inserted {} garbage rows but deleted {} recording_integrity rows!",
|
||||
n,
|
||||
n_integrity
|
||||
Internal,
|
||||
msg(
|
||||
"inserted {} garbage rows but deleted {} recording_integrity rows!",
|
||||
n,
|
||||
n_integrity
|
||||
),
|
||||
);
|
||||
}
|
||||
let n_main = del_main.execute(p)?;
|
||||
if n_main != n {
|
||||
bail!(
|
||||
"inserted {} garbage rows but deleted {} recording rows!",
|
||||
n,
|
||||
n_main
|
||||
Internal,
|
||||
msg("inserted {n} garbage rows but deleted {n_main} recording rows!"),
|
||||
);
|
||||
}
|
||||
Ok(n)
|
||||
|
@ -371,7 +372,7 @@ pub(crate) fn mark_sample_files_deleted(
|
|||
// Tempting to just consider logging error and moving on, but this represents a logic
|
||||
// flaw, so complain loudly. The freshly deleted file might still be referenced in the
|
||||
// recording table.
|
||||
panic!("no garbage row for {}", id);
|
||||
panic!("no garbage row for {id}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
@ -413,9 +414,8 @@ pub(crate) fn get_range(
|
|||
let max_end = match maxes_opt {
|
||||
Some(Range { start: _, end: e }) => e,
|
||||
None => bail!(
|
||||
"missing max for stream {} which had min {}",
|
||||
stream_id,
|
||||
min_start
|
||||
Internal,
|
||||
msg("missing max for stream {stream_id} which had min {min_start}"),
|
||||
),
|
||||
};
|
||||
Ok(Some(min_start..max_end))
|
||||
|
@ -425,11 +425,11 @@ pub(crate) fn get_range(
|
|||
pub(crate) fn list_garbage(
|
||||
conn: &rusqlite::Connection,
|
||||
dir_id: i32,
|
||||
) -> Result<FnvHashSet<CompositeId>, Error> {
|
||||
let mut garbage = FnvHashSet::default();
|
||||
) -> Result<FastHashSet<CompositeId>, Error> {
|
||||
let mut garbage = FastHashSet::default();
|
||||
let mut stmt =
|
||||
conn.prepare_cached("select composite_id from garbage where sample_file_dir_id = ?")?;
|
||||
let mut rows = stmt.query(&[&dir_id])?;
|
||||
let mut rows = stmt.query([&dir_id])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
garbage.insert(CompositeId(row.get(0)?));
|
||||
}
|
||||
|
|
|
@ -6,10 +6,10 @@
|
|||
|
||||
use crate::coding::{append_varint32, decode_varint32, unzigzag32, zigzag32};
|
||||
use crate::db;
|
||||
use failure::{bail, Error};
|
||||
use log::trace;
|
||||
use base::{bail, Error};
|
||||
use std::convert::TryFrom;
|
||||
use std::ops::Range;
|
||||
use tracing::trace;
|
||||
|
||||
pub use base::time::TIME_UNITS_PER_SEC;
|
||||
|
||||
|
@ -23,10 +23,7 @@ pub use base::time::Time;
|
|||
pub fn rescale(from_off_90k: i32, from_duration_90k: i32, to_duration_90k: i32) -> i32 {
|
||||
debug_assert!(
|
||||
from_off_90k <= from_duration_90k,
|
||||
"from_off_90k={} from_duration_90k={} to_duration_90k={}",
|
||||
from_off_90k,
|
||||
from_duration_90k,
|
||||
to_duration_90k
|
||||
"from_off_90k={from_off_90k} from_duration_90k={from_duration_90k} to_duration_90k={to_duration_90k}"
|
||||
);
|
||||
if from_duration_90k == 0 {
|
||||
return 0; // avoid a divide by zero.
|
||||
|
@ -41,8 +38,7 @@ pub fn rescale(from_off_90k: i32, from_duration_90k: i32, to_duration_90k: i32)
|
|||
)
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"rescale overflow: {} * {} / {} > i32::max_value()",
|
||||
from_off_90k, to_duration_90k, from_duration_90k
|
||||
"rescale overflow: {from_off_90k} * {to_duration_90k} / {from_duration_90k} > i32::max_value()"
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
|
@ -83,32 +79,38 @@ impl SampleIndexIterator {
|
|||
}
|
||||
let (raw1, i1) = match decode_varint32(data, i) {
|
||||
Ok(tuple) => tuple,
|
||||
Err(()) => bail!("bad varint 1 at offset {}", i),
|
||||
Err(()) => bail!(DataLoss, msg("bad varint 1 at offset {i}")),
|
||||
};
|
||||
let (raw2, i2) = match decode_varint32(data, i1) {
|
||||
Ok(tuple) => tuple,
|
||||
Err(()) => bail!("bad varint 2 at offset {}", i1),
|
||||
Err(()) => bail!(DataLoss, msg("bad varint 2 at offset {i1}")),
|
||||
};
|
||||
let duration_90k_delta = unzigzag32(raw1 >> 1);
|
||||
self.duration_90k += duration_90k_delta;
|
||||
if self.duration_90k < 0 {
|
||||
bail!(
|
||||
"negative duration {} after applying delta {}",
|
||||
self.duration_90k,
|
||||
duration_90k_delta
|
||||
DataLoss,
|
||||
msg(
|
||||
"negative duration {} after applying delta {}",
|
||||
self.duration_90k,
|
||||
duration_90k_delta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if self.duration_90k == 0 && data.len() > i2 {
|
||||
bail!(
|
||||
"zero duration only allowed at end; have {} bytes left",
|
||||
data.len() - i2
|
||||
DataLoss,
|
||||
msg(
|
||||
"zero duration only allowed at end; have {} bytes left",
|
||||
data.len() - i2
|
||||
),
|
||||
);
|
||||
}
|
||||
let (prev_bytes_key, prev_bytes_nonkey) = match self.is_key() {
|
||||
true => (self.bytes, self.bytes_other),
|
||||
false => (self.bytes_other, self.bytes),
|
||||
};
|
||||
self.i_and_is_key = (i2 as u32) | (((raw1 & 1) as u32) << 31);
|
||||
self.i_and_is_key = (i2 as u32) | ((raw1 & 1) << 31);
|
||||
let bytes_delta = unzigzag32(raw2);
|
||||
if self.is_key() {
|
||||
self.bytes = prev_bytes_key + bytes_delta;
|
||||
|
@ -119,11 +121,14 @@ impl SampleIndexIterator {
|
|||
}
|
||||
if self.bytes <= 0 {
|
||||
bail!(
|
||||
"non-positive bytes {} after applying delta {} to key={} frame at ts {}",
|
||||
self.bytes,
|
||||
bytes_delta,
|
||||
self.is_key(),
|
||||
self.start_90k
|
||||
DataLoss,
|
||||
msg(
|
||||
"non-positive bytes {} after applying delta {} to key={} frame at ts {}",
|
||||
self.bytes,
|
||||
bytes_delta,
|
||||
self.is_key(),
|
||||
self.start_90k,
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(true)
|
||||
|
@ -232,10 +237,13 @@ impl Segment {
|
|||
|| desired_media_range_90k.end > recording.media_duration_90k
|
||||
{
|
||||
bail!(
|
||||
"desired media range [{}, {}) invalid for recording of length {}",
|
||||
desired_media_range_90k.start,
|
||||
desired_media_range_90k.end,
|
||||
recording.media_duration_90k
|
||||
OutOfRange,
|
||||
msg(
|
||||
"desired media range [{}, {}) invalid for recording of length {}",
|
||||
desired_media_range_90k.start,
|
||||
desired_media_range_90k.end,
|
||||
recording.media_duration_90k,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -257,14 +265,14 @@ impl Segment {
|
|||
recording
|
||||
);
|
||||
db.with_recording_playback(self_.id, &mut |playback| {
|
||||
let mut begin = Box::new(SampleIndexIterator::default());
|
||||
let mut begin = Box::<SampleIndexIterator>::default();
|
||||
let data = &playback.video_index;
|
||||
let mut it = SampleIndexIterator::default();
|
||||
if !it.next(data)? {
|
||||
bail!("no index");
|
||||
bail!(Internal, msg("no index"));
|
||||
}
|
||||
if !it.is_key() {
|
||||
bail!("not key frame");
|
||||
bail!(Internal, msg("not key frame"));
|
||||
}
|
||||
|
||||
// Stop when hitting a frame with this start time.
|
||||
|
@ -340,10 +348,13 @@ impl Segment {
|
|||
None => {
|
||||
let mut it = SampleIndexIterator::default();
|
||||
if !it.next(data)? {
|
||||
bail!("recording {} has no frames", self.id);
|
||||
bail!(Internal, msg("recording {} has no frames", self.id));
|
||||
}
|
||||
if !it.is_key() {
|
||||
bail!("recording {} doesn't start with key frame", self.id);
|
||||
bail!(
|
||||
Internal,
|
||||
msg("recording {} doesn't start with key frame", self.id)
|
||||
);
|
||||
}
|
||||
it
|
||||
}
|
||||
|
@ -354,19 +365,25 @@ impl Segment {
|
|||
for i in 0..self.frames {
|
||||
if !have_frame {
|
||||
bail!(
|
||||
"recording {}: expected {} frames, found only {}",
|
||||
self.id,
|
||||
self.frames,
|
||||
i + 1
|
||||
Internal,
|
||||
msg(
|
||||
"recording {}: expected {} frames, found only {}",
|
||||
self.id,
|
||||
self.frames,
|
||||
i + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
if it.is_key() {
|
||||
key_frame += 1;
|
||||
if key_frame > self.key_frames {
|
||||
bail!(
|
||||
"recording {}: more than expected {} key frames",
|
||||
self.id,
|
||||
self.key_frames
|
||||
Internal,
|
||||
msg(
|
||||
"recording {}: more than expected {} key frames",
|
||||
self.id,
|
||||
self.key_frames,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -374,6 +391,7 @@ impl Segment {
|
|||
// Note: this inner loop avoids ? for performance. Don't change these lines without
|
||||
// reading https://github.com/rust-lang/rust/issues/37939 and running
|
||||
// mp4::bench::build_index.
|
||||
#[allow(clippy::question_mark)]
|
||||
if let Err(e) = f(&it) {
|
||||
return Err(e);
|
||||
}
|
||||
|
@ -384,10 +402,13 @@ impl Segment {
|
|||
}
|
||||
if key_frame < self.key_frames {
|
||||
bail!(
|
||||
"recording {}: expected {} key frames, found only {}",
|
||||
self.id,
|
||||
self.key_frames,
|
||||
key_frame
|
||||
Internal,
|
||||
msg(
|
||||
"recording {}: expected {} key frames, found only {}",
|
||||
self.id,
|
||||
self.key_frames,
|
||||
key_frame,
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
@ -502,7 +523,7 @@ mod tests {
|
|||
];
|
||||
for test in &tests {
|
||||
let mut it = SampleIndexIterator::default();
|
||||
assert_eq!(it.next(test.encoded).unwrap_err().to_string(), test.err);
|
||||
assert_eq!(it.next(test.encoded).unwrap_err().msg().unwrap(), test.err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -361,8 +361,8 @@ create table user_session (
|
|||
|
||||
-- A value indicating the reason for revocation, with optional additional
|
||||
-- text detail. Enumeration values:
|
||||
-- 0: logout link clicked (i.e. from within the session itself)
|
||||
-- 1: obsoleted by a change in hashing algorithm (eg schema 5->6 upgrade)
|
||||
-- 1: logout link clicked (i.e. from within the session itself)
|
||||
-- 2: obsoleted by a change in hashing algorithm (eg schema 5->6 upgrade)
|
||||
--
|
||||
-- This might be extended for a variety of other reasons:
|
||||
-- x: user revoked (while authenticated in another way)
|
||||
|
|
|
@ -8,15 +8,14 @@
|
|||
use crate::json::{SignalConfig, SignalTypeConfig};
|
||||
use crate::{coding, days};
|
||||
use crate::{recording, SqlUuid};
|
||||
use base::bail_t;
|
||||
use failure::{bail, format_err, Error};
|
||||
use fnv::FnvHashMap;
|
||||
use log::debug;
|
||||
use base::FastHashMap;
|
||||
use base::{bail, err, Error};
|
||||
use rusqlite::{params, Connection, Transaction};
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::convert::TryFrom;
|
||||
use std::ops::Range;
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// All state associated with signals. This is the entry point to this module.
|
||||
|
@ -26,7 +25,7 @@ pub(crate) struct State {
|
|||
/// All types with known states. Note that currently there's no requirement an entry here
|
||||
/// exists for every `type_` specified in a `Signal`, and there's an implied `0` (unknown)
|
||||
/// state for every `Type`.
|
||||
types_by_uuid: FnvHashMap<Uuid, Type>,
|
||||
types_by_uuid: FastHashMap<Uuid, Type>,
|
||||
|
||||
/// All points in time.
|
||||
/// Invariants, checked by `State::debug_assert_point_invariants`:
|
||||
|
@ -149,19 +148,29 @@ impl<'a> PointDataIterator<'a> {
|
|||
return Ok(None);
|
||||
}
|
||||
let (signal_delta, p) = coding::decode_varint32(self.data, self.cur_pos).map_err(|()| {
|
||||
format_err!(
|
||||
"varint32 decode failure; data={:?} pos={}",
|
||||
self.data,
|
||||
self.cur_pos
|
||||
err!(
|
||||
DataLoss,
|
||||
msg(
|
||||
"varint32 decode failure; data={:?} pos={}",
|
||||
self.data,
|
||||
self.cur_pos
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let (state, p) = coding::decode_varint32(self.data, p).map_err(|()| {
|
||||
err!(
|
||||
DataLoss,
|
||||
msg("varint32 decode failure; data={:?} pos={}", self.data, p)
|
||||
)
|
||||
})?;
|
||||
let (state, p) = coding::decode_varint32(self.data, p)
|
||||
.map_err(|()| format_err!("varint32 decode failure; data={:?} pos={}", self.data, p))?;
|
||||
let signal = self.cur_signal.checked_add(signal_delta).ok_or_else(|| {
|
||||
format_err!("signal overflow: {} + {}", self.cur_signal, signal_delta)
|
||||
err!(
|
||||
OutOfRange,
|
||||
msg("signal overflow: {} + {}", self.cur_signal, signal_delta)
|
||||
)
|
||||
})?;
|
||||
if state > u16::max_value() as u32 {
|
||||
bail!("state overflow: {}", state);
|
||||
bail!(OutOfRange, msg("state overflow: {state}"));
|
||||
}
|
||||
self.cur_pos = p;
|
||||
self.cur_signal = signal + 1;
|
||||
|
@ -335,27 +344,31 @@ impl State {
|
|||
/// Helper for `update_signals` to do validation.
|
||||
fn update_signals_validate(&self, signals: &[u32], states: &[u16]) -> Result<(), base::Error> {
|
||||
if signals.len() != states.len() {
|
||||
bail_t!(InvalidArgument, "signals and states must have same length");
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg("signals and states must have same length")
|
||||
);
|
||||
}
|
||||
let mut next_allowed = 0u32;
|
||||
for (&signal, &state) in signals.iter().zip(states) {
|
||||
if signal < next_allowed {
|
||||
bail_t!(InvalidArgument, "signals must be monotonically increasing");
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg("signals must be monotonically increasing")
|
||||
);
|
||||
}
|
||||
match self.signals_by_id.get(&signal) {
|
||||
None => bail_t!(InvalidArgument, "unknown signal {}", signal),
|
||||
Some(ref s) => {
|
||||
None => bail!(InvalidArgument, msg("unknown signal {signal}")),
|
||||
Some(s) => {
|
||||
let states = self
|
||||
.types_by_uuid
|
||||
.get(&s.type_)
|
||||
.map(|t| t.valid_states)
|
||||
.unwrap_or(0);
|
||||
if state >= 16 || (states & (1 << state)) == 0 {
|
||||
bail_t!(
|
||||
bail!(
|
||||
FailedPrecondition,
|
||||
"signal {} specifies unknown state {}",
|
||||
signal,
|
||||
state
|
||||
msg("signal {signal} specifies unknown state {state}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -659,7 +672,8 @@ impl State {
|
|||
let mut rows = stmt.query(params![])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i32 = row.get(0)?;
|
||||
let id = u32::try_from(id)?;
|
||||
let id = u32::try_from(id)
|
||||
.map_err(|e| err!(Internal, msg("signal id out of range"), source(e)))?;
|
||||
let uuid: SqlUuid = row.get(1)?;
|
||||
let type_: SqlUuid = row.get(2)?;
|
||||
let config: SignalConfig = row.get(3)?;
|
||||
|
@ -677,8 +691,8 @@ impl State {
|
|||
Ok(signals)
|
||||
}
|
||||
|
||||
fn init_types(conn: &Connection) -> Result<FnvHashMap<Uuid, Type>, Error> {
|
||||
let mut types = FnvHashMap::default();
|
||||
fn init_types(conn: &Connection) -> Result<FastHashMap<Uuid, Type>, Error> {
|
||||
let mut types = FastHashMap::default();
|
||||
let mut stmt = conn.prepare(
|
||||
r#"
|
||||
select
|
||||
|
@ -698,9 +712,12 @@ impl State {
|
|||
for &value in type_.config.values.keys() {
|
||||
if value == 0 || value >= 16 {
|
||||
bail!(
|
||||
"signal type {} value {} out of accepted range [0, 16)",
|
||||
uuid.0,
|
||||
value
|
||||
OutOfRange,
|
||||
msg(
|
||||
"signal type {} value {} out of accepted range [0, 16)",
|
||||
uuid.0,
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
type_.valid_states |= 1 << value;
|
||||
|
@ -741,9 +758,12 @@ impl State {
|
|||
let e = sig_last_state.entry(signal);
|
||||
if let Entry::Occupied(ref e) = e {
|
||||
let (prev_time, prev_state) = *e.get();
|
||||
let s = signals_by_id.get_mut(&signal).ok_or_else(|| {
|
||||
format_err!("time {} references invalid signal {}", time_90k, signal)
|
||||
})?;
|
||||
let Some(s) = signals_by_id.get_mut(&signal) else {
|
||||
bail!(
|
||||
DataLoss,
|
||||
msg("time {time_90k} references invalid signal {signal}")
|
||||
);
|
||||
};
|
||||
s.days.adjust(prev_time..time_90k, 0, prev_state);
|
||||
}
|
||||
if state == 0 {
|
||||
|
@ -760,8 +780,8 @@ impl State {
|
|||
}
|
||||
if !cur.is_empty() {
|
||||
bail!(
|
||||
"far future state should be unknown for all signals; is: {:?}",
|
||||
cur
|
||||
Internal,
|
||||
msg("far future state should be unknown for all signals; is: {cur:?}")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
@ -770,7 +790,7 @@ impl State {
|
|||
pub fn signals_by_id(&self) -> &BTreeMap<u32, Signal> {
|
||||
&self.signals_by_id
|
||||
}
|
||||
pub fn types_by_uuid(&self) -> &FnvHashMap<Uuid, Type> {
|
||||
pub fn types_by_uuid(&self) -> &FastHashMap<Uuid, Type> {
|
||||
&self.types_by_uuid
|
||||
}
|
||||
|
||||
|
@ -783,7 +803,7 @@ impl State {
|
|||
let mut expected_prev = BTreeMap::new();
|
||||
for (t, p) in self.points_by_time.iter() {
|
||||
let cur = p.prev().into_map().expect("in-mem prev is valid");
|
||||
assert_eq!(&expected_prev, &cur, "time {} prev mismatch", t);
|
||||
assert_eq!(&expected_prev, &cur, "time {t} prev mismatch");
|
||||
p.changes().update_map(&mut expected_prev);
|
||||
}
|
||||
assert_eq!(
|
||||
|
|
|
@ -9,14 +9,14 @@ use crate::db;
|
|||
use crate::dir;
|
||||
use crate::writer;
|
||||
use base::clock::Clocks;
|
||||
use fnv::FnvHashMap;
|
||||
use base::FastHashMap;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
static INIT: parking_lot::Once = parking_lot::Once::new();
|
||||
static INIT: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
/// id of the camera created by `TestDb::new` below.
|
||||
pub const TEST_CAMERA_ID: i32 = 1;
|
||||
|
@ -38,10 +38,7 @@ pub const TEST_VIDEO_SAMPLE_ENTRY_DATA: &[u8] =
|
|||
/// * use a fast but insecure password hashing format.
|
||||
pub fn init() {
|
||||
INIT.call_once(|| {
|
||||
let h = mylog::Builder::new()
|
||||
.set_spec(&::std::env::var("MOONFIRE_LOG").unwrap_or_else(|_| "info".to_owned()))
|
||||
.build();
|
||||
h.install().unwrap();
|
||||
base::tracing_setup::install_for_tests();
|
||||
env::set_var("TZ", "America/Los_Angeles");
|
||||
time::tzset();
|
||||
crate::auth::set_test_config();
|
||||
|
@ -50,7 +47,7 @@ pub fn init() {
|
|||
|
||||
pub struct TestDb<C: Clocks + Clone> {
|
||||
pub db: Arc<db::Database<C>>,
|
||||
pub dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<dir::SampleFileDir>>>,
|
||||
pub dirs_by_stream_id: Arc<FastHashMap<i32, Arc<dir::SampleFileDir>>>,
|
||||
pub shutdown_tx: base::shutdown::Sender,
|
||||
pub shutdown_rx: base::shutdown::Receiver,
|
||||
pub syncer_channel: writer::SyncerChannel<::std::fs::File>,
|
||||
|
@ -119,7 +116,7 @@ impl<C: Clocks + Clone> TestDb<C> {
|
|||
.get()
|
||||
.unwrap();
|
||||
}
|
||||
let mut dirs_by_stream_id = FnvHashMap::default();
|
||||
let mut dirs_by_stream_id = FastHashMap::default();
|
||||
dirs_by_stream_id.insert(TEST_STREAM_ID, dir);
|
||||
let (shutdown_tx, shutdown_rx) = base::shutdown::channel();
|
||||
let (syncer_channel, syncer_join) =
|
||||
|
|
|
@ -6,13 +6,13 @@
|
|||
//!
|
||||
//! See `guide/schema.md` for more information.
|
||||
|
||||
use crate::db;
|
||||
use failure::{bail, Error};
|
||||
use log::info;
|
||||
use crate::db::{self, EXPECTED_SCHEMA_VERSION};
|
||||
use base::{bail, Error};
|
||||
use nix::NixPath;
|
||||
use rusqlite::params;
|
||||
use std::ffi::CStr;
|
||||
use std::io::Write;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod v0_to_v1;
|
||||
|
@ -23,8 +23,6 @@ mod v4_to_v5;
|
|||
mod v5_to_v6;
|
||||
mod v6_to_v7;
|
||||
|
||||
const UPGRADE_NOTES: &str = concat!("upgraded using moonfire-db ", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Args<'a> {
|
||||
pub sample_file_dir: Option<&'a std::path::Path>,
|
||||
|
@ -35,7 +33,7 @@ pub struct Args<'a> {
|
|||
fn set_journal_mode(conn: &rusqlite::Connection, requested: &str) -> Result<(), Error> {
|
||||
assert!(!requested.contains(';')); // quick check for accidental sql injection.
|
||||
let actual = conn.query_row(
|
||||
&format!("pragma journal_mode = {}", requested),
|
||||
&format!("pragma journal_mode = {requested}"),
|
||||
params![],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
|
@ -46,7 +44,12 @@ fn set_journal_mode(conn: &rusqlite::Connection, requested: &str) -> Result<(),
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn upgrade(args: &Args, target_ver: i32, conn: &mut rusqlite::Connection) -> Result<(), Error> {
|
||||
fn upgrade(
|
||||
args: &Args,
|
||||
target_schema_ver: i32,
|
||||
sw_version: &str,
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> Result<(), Error> {
|
||||
let upgraders = [
|
||||
v0_to_v1::run,
|
||||
v1_to_v2::run,
|
||||
|
@ -58,31 +61,39 @@ fn upgrade(args: &Args, target_ver: i32, conn: &mut rusqlite::Connection) -> Res
|
|||
];
|
||||
|
||||
{
|
||||
assert_eq!(upgraders.len(), db::EXPECTED_VERSION as usize);
|
||||
let old_ver = conn.query_row("select max(id) from version", params![], |row| row.get(0))?;
|
||||
if old_ver > db::EXPECTED_VERSION {
|
||||
assert_eq!(upgraders.len(), db::EXPECTED_SCHEMA_VERSION as usize);
|
||||
let old_schema_ver =
|
||||
conn.query_row("select max(id) from version", params![], |row| row.get(0))?;
|
||||
if old_schema_ver > EXPECTED_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Database is at version {}, later than expected {}",
|
||||
old_ver,
|
||||
db::EXPECTED_VERSION
|
||||
FailedPrecondition,
|
||||
msg("database is at version {old_schema_ver}, \
|
||||
later than expected {EXPECTED_SCHEMA_VERSION}"),
|
||||
);
|
||||
} else if old_schema_ver < 0 {
|
||||
bail!(
|
||||
FailedPrecondition,
|
||||
msg("Database is at negative version {old_schema_ver}!")
|
||||
);
|
||||
} else if old_ver < 0 {
|
||||
bail!("Database is at negative version {}!", old_ver);
|
||||
}
|
||||
info!(
|
||||
"Upgrading database from version {} to version {}...",
|
||||
old_ver, target_ver
|
||||
"Upgrading database from schema version {} to schema version {}...",
|
||||
old_schema_ver, target_schema_ver
|
||||
);
|
||||
for ver in old_ver..target_ver {
|
||||
info!("...from version {} to version {}", ver, ver + 1);
|
||||
for ver in old_schema_ver..target_schema_ver {
|
||||
info!(
|
||||
"...from schema version {} to schema version {}",
|
||||
ver,
|
||||
ver + 1
|
||||
);
|
||||
let tx = conn.transaction()?;
|
||||
upgraders[ver as usize](&args, &tx)?;
|
||||
upgraders[ver as usize](args, &tx)?;
|
||||
tx.execute(
|
||||
r#"
|
||||
insert into version (id, unix_time, notes)
|
||||
values (?, cast(strftime('%s', 'now') as int32), ?)
|
||||
"#,
|
||||
params![ver + 1, UPGRADE_NOTES],
|
||||
params![ver + 1, format!("Upgraded using moonfire-nvr {sw_version}")],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
@ -91,11 +102,11 @@ fn upgrade(args: &Args, target_ver: i32, conn: &mut rusqlite::Connection) -> Res
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(args: &Args, conn: &mut rusqlite::Connection) -> Result<(), Error> {
|
||||
pub fn run(args: &Args, sw_version: &str, conn: &mut rusqlite::Connection) -> Result<(), Error> {
|
||||
db::check_sqlite_version()?;
|
||||
db::set_integrity_pragmas(conn)?;
|
||||
set_journal_mode(&conn, args.preset_journal)?;
|
||||
upgrade(args, db::EXPECTED_VERSION, conn)?;
|
||||
set_journal_mode(conn, args.preset_journal)?;
|
||||
upgrade(args, EXPECTED_SCHEMA_VERSION, sw_version, conn)?;
|
||||
|
||||
// As in "moonfire-nvr init": try for page_size=16384 and wal for the reasons explained there.
|
||||
//
|
||||
|
@ -114,7 +125,7 @@ pub fn run(args: &Args, conn: &mut rusqlite::Connection) -> Result<(), Error> {
|
|||
)?;
|
||||
}
|
||||
|
||||
set_journal_mode(&conn, "wal")?;
|
||||
set_journal_mode(conn, "wal")?;
|
||||
info!("...done.");
|
||||
|
||||
Ok(())
|
||||
|
@ -126,7 +137,7 @@ struct UuidPath([u8; 37]);
|
|||
impl UuidPath {
|
||||
pub(crate) fn from(uuid: Uuid) -> Self {
|
||||
let mut buf = [0u8; 37];
|
||||
write!(&mut buf[..36], "{}", uuid.to_hyphenated_ref())
|
||||
write!(&mut buf[..36], "{}", uuid.as_hyphenated())
|
||||
.expect("can't format uuid to pathname buf");
|
||||
UuidPath(buf)
|
||||
}
|
||||
|
@ -154,8 +165,8 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::compare;
|
||||
use crate::testutil;
|
||||
use failure::ResultExt;
|
||||
use fnv::FnvHashMap;
|
||||
use base::err;
|
||||
use base::FastHashMap;
|
||||
|
||||
const BAD_ANAMORPHIC_VIDEO_SAMPLE_ENTRY: &[u8] = b"\x00\x00\x00\x84\x61\x76\x63\x31\x00\x00\
|
||||
\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\
|
||||
|
@ -191,12 +202,12 @@ mod tests {
|
|||
let fresh = new_conn()?;
|
||||
fresh.execute_batch(fresh_sql)?;
|
||||
if let Some(diffs) = compare::get_diffs(
|
||||
&format!("upgraded to version {}", ver),
|
||||
&c,
|
||||
&format!("fresh version {}", ver),
|
||||
&format!("upgraded to version {ver}"),
|
||||
c,
|
||||
&format!("fresh version {ver}"),
|
||||
&fresh,
|
||||
)? {
|
||||
panic!("Version {}: differences found:\n{}", ver, diffs);
|
||||
panic!("Version {ver}: differences found:\n{diffs}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
@ -209,7 +220,7 @@ mod tests {
|
|||
let tmpdir = tempfile::Builder::new()
|
||||
.prefix("moonfire-nvr-test")
|
||||
.tempdir()?;
|
||||
//let path = tmpdir.path().to_str().ok_or_else(|| format_err!("invalid UTF-8"))?.to_owned();
|
||||
//let path = tmpdir.path().to_str().ok_or_else(|| err!("invalid UTF-8"))?.to_owned();
|
||||
let mut upgraded = new_conn()?;
|
||||
upgraded.execute_batch(include_str!("v0.sql"))?;
|
||||
upgraded.execute_batch(
|
||||
|
@ -284,14 +295,15 @@ mod tests {
|
|||
] {
|
||||
upgrade(
|
||||
&Args {
|
||||
sample_file_dir: Some(&tmpdir.path()),
|
||||
sample_file_dir: Some(tmpdir.path()),
|
||||
preset_journal: "delete",
|
||||
no_vacuum: false,
|
||||
},
|
||||
*ver,
|
||||
"test",
|
||||
&mut upgraded,
|
||||
)
|
||||
.context(format!("upgrading to version {}", ver))?;
|
||||
.map_err(|e| err!(e, msg("upgrade to schema version {ver} failed")))?;
|
||||
if let Some(f) = fresh_sql {
|
||||
compare(&upgraded, *ver, f)?;
|
||||
}
|
||||
|
@ -332,7 +344,7 @@ mod tests {
|
|||
"#,
|
||||
)?;
|
||||
let mut rows = stmt.query(params![])?;
|
||||
let mut pasp_by_id = FnvHashMap::default();
|
||||
let mut pasp_by_id = FastHashMap::default();
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i32 = row.get(0)?;
|
||||
let pasp_h_spacing: i32 = row.get(1)?;
|
||||
|
|
|
@ -5,10 +5,10 @@
|
|||
/// Upgrades a version 0 schema to a version 1 schema.
|
||||
use crate::db;
|
||||
use crate::recording;
|
||||
use failure::Error;
|
||||
use log::warn;
|
||||
use base::Error;
|
||||
use rusqlite::{named_params, params};
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
// These create statements match the schema.sql when version 1 was the latest.
|
||||
|
@ -221,7 +221,7 @@ fn update_camera(
|
|||
update camera set next_recording_id = :next_recording_id where id = :id
|
||||
"#,
|
||||
)?;
|
||||
for (ref id, ref state) in &camera_state {
|
||||
for (ref id, state) in &camera_state {
|
||||
stmt.execute(named_params! {
|
||||
":id": &id,
|
||||
":next_recording_id": &state.next_recording_id,
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
/// Upgrades a version 1 schema to a version 2 schema.
|
||||
use crate::dir;
|
||||
use crate::schema::DirMeta;
|
||||
use failure::{bail, format_err, Error};
|
||||
use base::{bail, Error};
|
||||
use nix::fcntl::{FlockArg, OFlag};
|
||||
use nix::sys::stat::Mode;
|
||||
use rusqlite::{named_params, params};
|
||||
|
@ -13,9 +13,12 @@ use std::os::unix::io::AsRawFd;
|
|||
use uuid::Uuid;
|
||||
|
||||
pub fn run(args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
let sample_file_path = args.sample_file_dir.ok_or_else(|| {
|
||||
format_err!("--sample-file-dir required when upgrading from schema version 1 to 2.")
|
||||
})?;
|
||||
let Some(sample_file_path) = args.sample_file_dir else {
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg("--sample-file-dir required when upgrading from schema version 1 to 2."),
|
||||
);
|
||||
};
|
||||
|
||||
let mut d = nix::dir::Dir::open(
|
||||
sample_file_path,
|
||||
|
@ -97,16 +100,19 @@ pub fn run(args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
|
|||
meta.dir_uuid.extend_from_slice(dir_uuid_bytes);
|
||||
let open = meta.last_complete_open.mut_or_insert_default();
|
||||
open.id = open_id;
|
||||
open.uuid.extend_from_slice(&open_uuid_bytes);
|
||||
open.uuid.extend_from_slice(open_uuid_bytes);
|
||||
}
|
||||
dir::write_meta(d.as_raw_fd(), &meta)?;
|
||||
|
||||
let sample_file_path = sample_file_path.to_str().ok_or_else(|| {
|
||||
format_err!(
|
||||
"sample file dir {} is not a valid string",
|
||||
sample_file_path.display()
|
||||
)
|
||||
})?;
|
||||
let Some(sample_file_path) = sample_file_path.to_str() else {
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg(
|
||||
"sample file dir {} is not a valid string",
|
||||
sample_file_path.display()
|
||||
),
|
||||
);
|
||||
};
|
||||
tx.execute(
|
||||
r#"
|
||||
insert into sample_file_dir (path, uuid, last_complete_open_id)
|
||||
|
@ -302,7 +308,7 @@ fn verify_dir_contents(
|
|||
params![],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let mut files = ::fnv::FnvHashSet::with_capacity_and_hasher(n as usize, Default::default());
|
||||
let mut files = ::base::FastHashSet::with_capacity_and_hasher(n as usize, Default::default());
|
||||
for e in dir.iter() {
|
||||
let e = e?;
|
||||
let f = e.file_name();
|
||||
|
@ -317,15 +323,24 @@ fn verify_dir_contents(
|
|||
};
|
||||
let s = match f.to_str() {
|
||||
Ok(s) => s,
|
||||
Err(_) => bail!("unexpected file {:?} in {:?}", f, sample_file_path),
|
||||
Err(_) => bail!(
|
||||
FailedPrecondition,
|
||||
msg("unexpected file {f:?} in {sample_file_path:?}")
|
||||
),
|
||||
};
|
||||
let uuid = match Uuid::parse_str(s) {
|
||||
Ok(u) => u,
|
||||
Err(_) => bail!("unexpected file {:?} in {:?}", f, sample_file_path),
|
||||
Err(_) => bail!(
|
||||
FailedPrecondition,
|
||||
msg("unexpected file {f:?} in {sample_file_path:?}")
|
||||
),
|
||||
};
|
||||
if s != uuid.to_hyphenated_ref().to_string() {
|
||||
if s != uuid.as_hyphenated().to_string() {
|
||||
// non-canonical form.
|
||||
bail!("unexpected file {:?} in {:?}", f, sample_file_path);
|
||||
bail!(
|
||||
FailedPrecondition,
|
||||
msg("unexpected file {f:?} in {sample_file_path:?}")
|
||||
);
|
||||
}
|
||||
files.insert(uuid);
|
||||
}
|
||||
|
@ -338,9 +353,12 @@ fn verify_dir_contents(
|
|||
let uuid: crate::db::SqlUuid = row.get(0)?;
|
||||
if !files.remove(&uuid.0) {
|
||||
bail!(
|
||||
"{} is missing from dir {}!",
|
||||
uuid.0,
|
||||
sample_file_path.display()
|
||||
FailedPrecondition,
|
||||
msg(
|
||||
"{} is missing from dir {}!",
|
||||
uuid.0,
|
||||
sample_file_path.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -367,10 +385,13 @@ fn verify_dir_contents(
|
|||
|
||||
if !files.is_empty() {
|
||||
bail!(
|
||||
"{} unexpected sample file uuids in dir {}: {:?}!",
|
||||
files.len(),
|
||||
sample_file_path.display(),
|
||||
files
|
||||
FailedPrecondition,
|
||||
msg(
|
||||
"{} unexpected sample file uuids in dir {}: {:?}!",
|
||||
files.len(),
|
||||
sample_file_path.display(),
|
||||
files,
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
|
@ -413,13 +434,12 @@ fn fix_video_sample_entry(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
fn rfc6381_codec_from_sample_entry(sample_entry: &[u8]) -> Result<String, Error> {
|
||||
if sample_entry.len() < 99 || &sample_entry[4..8] != b"avc1" || &sample_entry[90..94] != b"avcC"
|
||||
{
|
||||
bail!("not a valid AVCSampleEntry");
|
||||
bail!(InvalidArgument, msg("not a valid AVCSampleEntry"));
|
||||
}
|
||||
let profile_idc = sample_entry[103];
|
||||
let constraint_flags_byte = sample_entry[104];
|
||||
let level_idc = sample_entry[105];
|
||||
Ok(format!(
|
||||
"avc1.{:02x}{:02x}{:02x}",
|
||||
profile_idc, constraint_flags_byte, level_idc
|
||||
"avc1.{profile_idc:02x}{constraint_flags_byte:02x}{level_idc:02x}"
|
||||
))
|
||||
}
|
||||
|
|
|
@ -8,9 +8,9 @@
|
|||
use crate::db::{self, SqlUuid};
|
||||
use crate::dir;
|
||||
use crate::schema;
|
||||
use failure::Error;
|
||||
use base::Error;
|
||||
use rusqlite::params;
|
||||
use std::convert::TryFrom;
|
||||
use std::os::fd::AsFd as _;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
@ -50,12 +50,12 @@ fn open_sample_file_dir(tx: &rusqlite::Transaction) -> Result<Arc<dir::SampleFil
|
|||
open.id = o_id as u32;
|
||||
open.uuid.extend_from_slice(&o_uuid.0.as_bytes()[..]);
|
||||
}
|
||||
let p = PathBuf::try_from(p)?;
|
||||
let p = PathBuf::from(p);
|
||||
dir::SampleFileDir::open(&p, &meta)
|
||||
}
|
||||
|
||||
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
let d = open_sample_file_dir(&tx)?;
|
||||
let d = open_sample_file_dir(tx)?;
|
||||
let mut stmt = tx.prepare(
|
||||
r#"
|
||||
select
|
||||
|
@ -72,9 +72,9 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
|
|||
let from_path = super::UuidPath::from(sample_file_uuid.0);
|
||||
let to_path = crate::dir::CompositeIdPath::from(id);
|
||||
if let Err(e) = nix::fcntl::renameat(
|
||||
Some(d.fd.as_raw_fd()),
|
||||
Some(d.fd.as_fd().as_raw_fd()),
|
||||
&from_path,
|
||||
Some(d.fd.as_raw_fd()),
|
||||
Some(d.fd.as_fd().as_raw_fd()),
|
||||
&to_path,
|
||||
) {
|
||||
if e == nix::Error::ENOENT {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
/// Upgrades a version 3 schema to a version 4 schema.
|
||||
use failure::Error;
|
||||
use base::Error;
|
||||
|
||||
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
// These create statements match the schema.sql when version 4 was the latest.
|
||||
|
|
|
@ -8,15 +8,16 @@
|
|||
/// Otherwise, verify they are consistent with the database then upgrade them.
|
||||
use crate::db::SqlUuid;
|
||||
use crate::{dir, schema};
|
||||
use base::{bail, err, Error};
|
||||
use cstr::cstr;
|
||||
use failure::{bail, Error, Fail};
|
||||
use log::info;
|
||||
use nix::fcntl::{FlockArg, OFlag};
|
||||
use nix::sys::stat::Mode;
|
||||
use protobuf::Message;
|
||||
use rusqlite::params;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::AsFd as _;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
const FIXED_DIR_META_LEN: usize = 512;
|
||||
|
@ -25,7 +26,12 @@ const FIXED_DIR_META_LEN: usize = 512;
|
|||
fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool, Error> {
|
||||
let tmp_path = cstr!("meta.tmp");
|
||||
let meta_path = cstr!("meta");
|
||||
let mut f = crate::fs::openat(dir.as_raw_fd(), meta_path, OFlag::O_RDONLY, Mode::empty())?;
|
||||
let mut f = crate::fs::openat(
|
||||
dir.as_fd().as_raw_fd(),
|
||||
meta_path,
|
||||
OFlag::O_RDONLY,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data)?;
|
||||
if data.len() == FIXED_DIR_META_LEN {
|
||||
|
@ -34,19 +40,22 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
|
|||
|
||||
let mut s = protobuf::CodedInputStream::from_bytes(&data);
|
||||
let mut dir_meta = schema::DirMeta::new();
|
||||
dir_meta
|
||||
.merge_from(&mut s)
|
||||
.map_err(|e| e.context("Unable to parse metadata proto: {}"))?;
|
||||
if let Err(e) = dir::SampleFileDir::check_consistent(&db_meta, &dir_meta) {
|
||||
dir_meta.merge_from(&mut s).map_err(|e| {
|
||||
err!(
|
||||
FailedPrecondition,
|
||||
msg("unable to parse metadata proto"),
|
||||
source(e)
|
||||
)
|
||||
})?;
|
||||
if let Err(e) = dir::SampleFileDir::check_consistent(db_meta, &dir_meta) {
|
||||
bail!(
|
||||
"Inconsistent db_meta={:?} dir_meta={:?}: {}",
|
||||
&db_meta,
|
||||
&dir_meta,
|
||||
e
|
||||
FailedPrecondition,
|
||||
msg("inconsistent db_meta={db_meta:?} dir_meta={dir_meta:?}"),
|
||||
source(e),
|
||||
);
|
||||
}
|
||||
let mut f = crate::fs::openat(
|
||||
dir.as_raw_fd(),
|
||||
dir.as_fd().as_raw_fd(),
|
||||
tmp_path,
|
||||
OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_WRONLY,
|
||||
Mode::S_IRUSR | Mode::S_IWUSR,
|
||||
|
@ -56,9 +65,12 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
|
|||
.expect("proto3->vec is infallible");
|
||||
if data.len() > FIXED_DIR_META_LEN {
|
||||
bail!(
|
||||
"Length-delimited DirMeta message requires {} bytes, over limit of {}",
|
||||
data.len(),
|
||||
FIXED_DIR_META_LEN
|
||||
Internal,
|
||||
msg(
|
||||
"length-delimited DirMeta message requires {} bytes, over limit of {}",
|
||||
data.len(),
|
||||
FIXED_DIR_META_LEN,
|
||||
),
|
||||
);
|
||||
}
|
||||
data.resize(FIXED_DIR_META_LEN, 0); // pad to required length.
|
||||
|
@ -66,9 +78,9 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
|
|||
f.sync_all()?;
|
||||
|
||||
nix::fcntl::renameat(
|
||||
Some(dir.as_raw_fd()),
|
||||
Some(dir.as_fd().as_raw_fd()),
|
||||
tmp_path,
|
||||
Some(dir.as_raw_fd()),
|
||||
Some(dir.as_fd().as_raw_fd()),
|
||||
meta_path,
|
||||
)?;
|
||||
Ok(true)
|
||||
|
@ -83,7 +95,7 @@ fn maybe_upgrade_meta(dir: &dir::Fd, db_meta: &schema::DirMeta) -> Result<bool,
|
|||
fn maybe_cleanup_garbage_uuids(dir: &dir::Fd) -> Result<bool, Error> {
|
||||
let mut need_sync = false;
|
||||
let mut dir2 = nix::dir::Dir::openat(
|
||||
dir.as_raw_fd(),
|
||||
dir.as_fd().as_raw_fd(),
|
||||
".",
|
||||
OFlag::O_DIRECTORY | OFlag::O_RDONLY,
|
||||
Mode::empty(),
|
||||
|
@ -99,7 +111,7 @@ fn maybe_cleanup_garbage_uuids(dir: &dir::Fd) -> Result<bool, Error> {
|
|||
if Uuid::parse_str(f_str).is_ok() {
|
||||
info!("removing leftover garbage file {}", f_str);
|
||||
nix::unistd::unlinkat(
|
||||
Some(dir.as_raw_fd()),
|
||||
Some(dir.as_fd().as_raw_fd()),
|
||||
f,
|
||||
nix::unistd::UnlinkatFlags::NoRemoveDir,
|
||||
)?;
|
||||
|
@ -139,17 +151,17 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
|
|||
.extend_from_slice(&dir_uuid.0.as_bytes()[..]);
|
||||
match (open_id, open_uuid) {
|
||||
(Some(id), Some(uuid)) => {
|
||||
let mut o = db_meta.last_complete_open.mut_or_insert_default();
|
||||
let o = db_meta.last_complete_open.mut_or_insert_default();
|
||||
o.id = id;
|
||||
o.uuid.extend_from_slice(&uuid.0.as_bytes()[..]);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => bail!("open table missing id"),
|
||||
_ => bail!(Internal, msg("open table missing id")),
|
||||
}
|
||||
|
||||
let dir = dir::Fd::open(path, false)?;
|
||||
dir.lock(FlockArg::LockExclusiveNonblock)
|
||||
.map_err(|e| e.context(format!("unable to lock dir {}", path)))?;
|
||||
.map_err(|e| err!(e, msg("unable to lock dir {path}")))?;
|
||||
|
||||
let mut need_sync = maybe_upgrade_meta(&dir, &db_meta)?;
|
||||
if maybe_cleanup_garbage_uuids(&dir)? {
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
// Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use base::{bail, err, Error};
|
||||
/// Upgrades a version 4 schema to a version 5 schema.
|
||||
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
|
||||
use failure::{bail, format_err, Error, ResultExt};
|
||||
use h264_reader::avcc::AvcDecoderConfigurationRecord;
|
||||
use rusqlite::{named_params, params};
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
@ -29,22 +29,31 @@ fn default_pixel_aspect_ratio(width: u16, height: u16) -> (u16, u16) {
|
|||
|
||||
fn parse(data: &[u8]) -> Result<AvcDecoderConfigurationRecord, Error> {
|
||||
if data.len() < 94 || &data[4..8] != b"avc1" || &data[90..94] != b"avcC" {
|
||||
bail!("data of len {} doesn't have an avcC", data.len());
|
||||
bail!(
|
||||
DataLoss,
|
||||
msg("data of len {} doesn't have an avcC", data.len())
|
||||
);
|
||||
}
|
||||
let avcc_len = BigEndian::read_u32(&data[86..90]);
|
||||
if avcc_len < 8 {
|
||||
// length and type.
|
||||
bail!("invalid avcc len {}", avcc_len);
|
||||
bail!(DataLoss, msg("invalid avcc len {avcc_len}"));
|
||||
}
|
||||
let end_pos = 86 + usize::try_from(avcc_len)?;
|
||||
if end_pos != data.len() {
|
||||
let end_pos = usize::try_from(avcc_len)
|
||||
.ok()
|
||||
.and_then(|l| l.checked_add(86));
|
||||
if end_pos != Some(data.len()) {
|
||||
bail!(
|
||||
"expected avcC to be end of extradata; there are {} more bytes.",
|
||||
data.len() - end_pos
|
||||
DataLoss,
|
||||
msg(
|
||||
"avcC end pos {:?} and total data len {} should match",
|
||||
end_pos,
|
||||
data.len(),
|
||||
),
|
||||
);
|
||||
}
|
||||
AvcDecoderConfigurationRecord::try_from(&data[94..end_pos])
|
||||
.map_err(|e| format_err!("Bad AvcDecoderConfigurationRecord: {:?}", e))
|
||||
AvcDecoderConfigurationRecord::try_from(&data[94..])
|
||||
.map_err(|e| err!(DataLoss, msg("Bad AvcDecoderConfigurationRecord: {:?}", e)))
|
||||
}
|
||||
|
||||
pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
|
@ -100,24 +109,37 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
|
|||
let mut rows = stmt.query(params![])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i32 = row.get(0)?;
|
||||
let width: u16 = row.get::<_, i32>(1)?.try_into()?;
|
||||
let height: u16 = row.get::<_, i32>(2)?.try_into()?;
|
||||
let rfc6381_codec: &str = row.get_ref(3)?.as_str()?;
|
||||
let width: u16 = row
|
||||
.get::<_, i32>(1)?
|
||||
.try_into()
|
||||
.map_err(|_| err!(OutOfRange))?;
|
||||
let height: u16 = row
|
||||
.get::<_, i32>(2)?
|
||||
.try_into()
|
||||
.map_err(|_| err!(OutOfRange))?;
|
||||
let rfc6381_codec: &str = row
|
||||
.get_ref(3)?
|
||||
.as_str()
|
||||
.map_err(|_| err!(InvalidArgument))?;
|
||||
let mut data: Vec<u8> = row.get(4)?;
|
||||
let avcc = parse(&data)?;
|
||||
if avcc.num_of_sequence_parameter_sets() != 1 {
|
||||
bail!("Multiple SPSs!");
|
||||
bail!(Unimplemented, msg("multiple SPSs!"));
|
||||
}
|
||||
let ctx = avcc.create_context(()).map_err(|e| {
|
||||
format_err!(
|
||||
"Can't load SPS+PPS for video_sample_entry_id {}: {:?}",
|
||||
id,
|
||||
e
|
||||
let ctx = avcc.create_context().map_err(|e| {
|
||||
err!(
|
||||
Unknown,
|
||||
msg("can't load SPS+PPS for video_sample_entry_id {id}: {e:?}"),
|
||||
)
|
||||
})?;
|
||||
let sps = ctx
|
||||
.sps_by_id(h264_reader::nal::pps::ParamSetId::from_u32(0).unwrap())
|
||||
.ok_or_else(|| format_err!("No SPS 0 for video_sample_entry_id {}", id))?;
|
||||
.ok_or_else(|| {
|
||||
err!(
|
||||
Unimplemented,
|
||||
msg("no SPS 0 for video_sample_entry_id {id}")
|
||||
)
|
||||
})?;
|
||||
let pasp = sps
|
||||
.vui_parameters
|
||||
.as_ref()
|
||||
|
@ -129,7 +151,10 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
|
|||
data.write_u32::<BigEndian>(pasp.0.into())?;
|
||||
data.write_u32::<BigEndian>(pasp.1.into())?;
|
||||
let len = data.len();
|
||||
BigEndian::write_u32(&mut data[0..4], u32::try_from(len)?);
|
||||
BigEndian::write_u32(
|
||||
&mut data[0..4],
|
||||
u32::try_from(len).map_err(|_| err!(OutOfRange))?,
|
||||
);
|
||||
}
|
||||
|
||||
insert.execute(named_params! {
|
||||
|
@ -268,7 +293,7 @@ pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error>
|
|||
":video_sync_samples": video_sync_samples,
|
||||
":video_sample_entry_id": video_sample_entry_id,
|
||||
})
|
||||
.with_context(|_| format!("Unable to insert composite_id {}", composite_id))?;
|
||||
.map_err(|e| err!(e, msg("unable to insert composite_id {composite_id}")))?;
|
||||
cum_duration_90k += i64::from(wall_duration_90k);
|
||||
cum_runs += if run_offset == 0 { 1 } else { 0 };
|
||||
}
|
||||
|
|
|
@ -2,12 +2,12 @@
|
|||
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||
|
||||
use base::FastHashMap;
|
||||
/// Upgrades a version 6 schema to a version 7 schema.
|
||||
use failure::{format_err, Error, ResultExt};
|
||||
use fnv::FnvHashMap;
|
||||
use log::debug;
|
||||
use base::{err, Error};
|
||||
use rusqlite::{named_params, params};
|
||||
use std::{convert::TryFrom, path::PathBuf};
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
@ -28,7 +28,13 @@ fn copy_meta(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
let config = GlobalConfig {
|
||||
max_signal_changes: max_signal_changes
|
||||
.map(|s| {
|
||||
u32::try_from(s).map_err(|_| format_err!("max_signal_changes out of range"))
|
||||
u32::try_from(s).map_err(|e| {
|
||||
err!(
|
||||
OutOfRange,
|
||||
msg("max_signal_changes out of range"),
|
||||
source(e)
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
..Default::default()
|
||||
|
@ -57,7 +63,7 @@ fn copy_sample_file_dir(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
let path: String = row.get(2)?;
|
||||
let uuid: SqlUuid = row.get(1)?;
|
||||
let config = SampleFileDirConfig {
|
||||
path: PathBuf::try_from(path)?,
|
||||
path: PathBuf::from(path),
|
||||
..Default::default()
|
||||
};
|
||||
let last_complete_open_id: Option<i64> = row.get(3)?;
|
||||
|
@ -107,7 +113,10 @@ fn copy_users(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
let permissions: Vec<u8> = row.get(7)?;
|
||||
let config = UserConfig {
|
||||
disabled: (flags & 1) != 0,
|
||||
unix_uid: unix_uid.map(u64::try_from).transpose()?,
|
||||
unix_uid: unix_uid
|
||||
.map(u64::try_from)
|
||||
.transpose()
|
||||
.map_err(|_| err!(OutOfRange, msg("bad unix_uid")))?,
|
||||
..Default::default()
|
||||
};
|
||||
insert.execute(named_params! {
|
||||
|
@ -124,7 +133,7 @@ fn copy_users(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
}
|
||||
|
||||
fn copy_signal_types(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
let mut types_ = FnvHashMap::default();
|
||||
let mut types_ = FastHashMap::default();
|
||||
let mut stmt = tx.prepare("select type_uuid, value, name from signal_type_enum")?;
|
||||
let mut rows = stmt.query(params![])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
|
@ -134,7 +143,8 @@ fn copy_signal_types(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
let type_ = types_
|
||||
.entry(type_uuid.0)
|
||||
.or_insert_with(SignalTypeConfig::default);
|
||||
let value = u8::try_from(value).map_err(|_| format_err!("bad signal type value"))?;
|
||||
let value =
|
||||
u8::try_from(value).map_err(|_| err!(OutOfRange, msg("bad signal type value")))?;
|
||||
let value_config = type_.values.entry(value).or_insert_with(Default::default);
|
||||
if let Some(n) = name {
|
||||
value_config.name = n;
|
||||
|
@ -154,7 +164,7 @@ struct Signal {
|
|||
}
|
||||
|
||||
fn copy_signals(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
||||
let mut signals = FnvHashMap::default();
|
||||
let mut signals = FastHashMap::default();
|
||||
|
||||
// Read from signal table.
|
||||
{
|
||||
|
@ -163,7 +173,8 @@ fn copy_signals(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
let mut rows = stmt.query(params![])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i32 = row.get(0)?;
|
||||
let id = u32::try_from(id)?;
|
||||
let id =
|
||||
u32::try_from(id).map_err(|e| err!(OutOfRange, msg("bad signal id"), source(e)))?;
|
||||
let source_uuid: SqlUuid = row.get(1)?;
|
||||
let type_uuid: SqlUuid = row.get(2)?;
|
||||
let short_name: String = row.get(3)?;
|
||||
|
@ -187,7 +198,8 @@ fn copy_signals(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
let mut rows = stmt.query(params![])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let signal_id: i32 = row.get(0)?;
|
||||
let signal_id = u32::try_from(signal_id)?;
|
||||
let signal_id = u32::try_from(signal_id)
|
||||
.map_err(|e| err!(OutOfRange, msg("bad signal_id"), source(e)))?;
|
||||
let camera_id: i32 = row.get(1)?;
|
||||
let type_: i32 = row.get(2)?;
|
||||
let signal = signals.get_mut(&signal_id).unwrap();
|
||||
|
@ -259,9 +271,15 @@ fn copy_cameras(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
// of using a SQL NULL, so convert empty to None here.
|
||||
// https://github.com/scottlamb/moonfire-nvr/issues/182
|
||||
.filter(|h| !h.is_empty())
|
||||
.map(|h| Url::parse(&format!("http://{}/", h)))
|
||||
.map(|h| Url::parse(&format!("http://{h}/")))
|
||||
.transpose()
|
||||
.with_context(|_| "bad onvif_host")?,
|
||||
.map_err(|e| {
|
||||
err!(
|
||||
InvalidArgument,
|
||||
msg("bad onvif_host for camera id {id}"),
|
||||
source(e)
|
||||
)
|
||||
})?,
|
||||
username: username.take().unwrap_or_default(),
|
||||
password: password.take().unwrap_or_default(),
|
||||
..Default::default()
|
||||
|
@ -324,7 +342,13 @@ fn copy_streams(tx: &rusqlite::Transaction) -> Result<(), Error> {
|
|||
""
|
||||
})
|
||||
.to_owned(),
|
||||
url: Some(Url::parse(&rtsp_url)?),
|
||||
url: Some(Url::parse(&rtsp_url).map_err(|e| {
|
||||
err!(
|
||||
InvalidArgument,
|
||||
msg("bad rtsp_url for stream id {id}"),
|
||||
source(e)
|
||||
)
|
||||
})?),
|
||||
retain_bytes,
|
||||
flush_if_sec,
|
||||
..Default::default()
|
||||
|
|
|
@ -9,19 +9,19 @@ use crate::dir;
|
|||
use crate::recording::{self, MAX_RECORDING_WALL_DURATION};
|
||||
use base::clock::{self, Clocks};
|
||||
use base::shutdown::ShutdownError;
|
||||
use failure::{bail, format_err, Error};
|
||||
use fnv::FnvHashMap;
|
||||
use log::{debug, trace, warn};
|
||||
use parking_lot::Mutex;
|
||||
use base::FastHashMap;
|
||||
use base::{bail, err, Error};
|
||||
use std::cmp::{self, Ordering};
|
||||
use std::convert::TryFrom;
|
||||
use std::io;
|
||||
use std::mem;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{mpsc, Arc};
|
||||
use std::thread;
|
||||
use std::time::Duration as StdDuration;
|
||||
use time::{Duration, Timespec};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Trait to allow mocking out [crate::dir::SampleFileDir] in syncer tests.
|
||||
/// This is public because it's exposed in the [SyncerChannel] type parameters,
|
||||
|
@ -166,21 +166,30 @@ where
|
|||
{
|
||||
let db2 = db.clone();
|
||||
let (mut syncer, path) = Syncer::new(&db.lock(), shutdown_rx, db2, dir_id)?;
|
||||
syncer.initial_rotation()?;
|
||||
let span = tracing::info_span!("syncer", path = %path.display());
|
||||
span.in_scope(|| {
|
||||
tracing::info!("initial rotation");
|
||||
syncer.initial_rotation()
|
||||
})?;
|
||||
let (snd, rcv) = mpsc::channel();
|
||||
db.lock().on_flush(Box::new({
|
||||
let snd = snd.clone();
|
||||
move || {
|
||||
if let Err(e) = snd.send(SyncerCommand::DatabaseFlushed) {
|
||||
warn!("Unable to notify syncer for dir {} of flush: {}", dir_id, e);
|
||||
if let Err(err) = snd.send(SyncerCommand::DatabaseFlushed) {
|
||||
warn!(%err, "unable to notify syncer for dir {}", dir_id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
Ok((
|
||||
SyncerChannel(snd),
|
||||
thread::Builder::new()
|
||||
.name(format!("sync-{}", path.display()))
|
||||
.spawn(move || while syncer.iter(&rcv) {})
|
||||
.name(format!("sync-{dir_id}"))
|
||||
.spawn(move || {
|
||||
span.in_scope(|| {
|
||||
tracing::info!("starting");
|
||||
while syncer.iter(&rcv) {}
|
||||
})
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
|
@ -198,7 +207,7 @@ pub struct NewLimit {
|
|||
/// This is expected to be performed from `moonfire-nvr config` when no syncer is running.
|
||||
/// It potentially flushes the database twice (before and after the actual deletion).
|
||||
pub fn lower_retention(
|
||||
db: Arc<db::Database>,
|
||||
db: &Arc<db::Database>,
|
||||
dir_id: i32,
|
||||
limits: &[NewLimit],
|
||||
) -> Result<(), Error> {
|
||||
|
@ -209,10 +218,9 @@ pub fn lower_retention(
|
|||
for l in limits {
|
||||
let (fs_bytes_before, extra);
|
||||
{
|
||||
let stream = db
|
||||
.streams_by_id()
|
||||
.get(&l.stream_id)
|
||||
.ok_or_else(|| format_err!("no such stream {}", l.stream_id))?;
|
||||
let Some(stream) = db.streams_by_id().get(&l.stream_id) else {
|
||||
bail!(NotFound, msg("no such stream {}", l.stream_id));
|
||||
};
|
||||
fs_bytes_before =
|
||||
stream.fs_bytes + stream.fs_bytes_to_add - stream.fs_bytes_to_delete;
|
||||
extra = stream.config.retain_bytes - l.limit;
|
||||
|
@ -236,7 +244,7 @@ fn delete_recordings(
|
|||
) -> Result<(), Error> {
|
||||
let fs_bytes_needed = {
|
||||
let stream = match db.streams_by_id().get(&stream_id) {
|
||||
None => bail!("no stream {}", stream_id),
|
||||
None => bail!(NotFound, msg("no stream {stream_id}")),
|
||||
Some(s) => s,
|
||||
};
|
||||
stream.fs_bytes + stream.fs_bytes_to_add - stream.fs_bytes_to_delete + extra_bytes_needed
|
||||
|
@ -286,7 +294,7 @@ impl<F: FileWriter> SyncerChannel<F> {
|
|||
/// on opening.
|
||||
fn list_files_to_abandon(
|
||||
dir: &dir::SampleFileDir,
|
||||
streams_to_next: FnvHashMap<i32, i32>,
|
||||
streams_to_next: FastHashMap<i32, i32>,
|
||||
) -> Result<Vec<CompositeId>, Error> {
|
||||
let mut v = Vec::new();
|
||||
let mut d = dir.opendir()?;
|
||||
|
@ -317,12 +325,12 @@ impl<C: Clocks + Clone> Syncer<C, Arc<dir::SampleFileDir>> {
|
|||
let d = l
|
||||
.sample_file_dirs_by_id()
|
||||
.get(&dir_id)
|
||||
.ok_or_else(|| format_err!("no dir {}", dir_id))?;
|
||||
.ok_or_else(|| err!(NotFound, msg("no dir {dir_id}")))?;
|
||||
let dir = d.get()?;
|
||||
|
||||
// Abandon files.
|
||||
// First, get a list of the streams in question.
|
||||
let streams_to_next: FnvHashMap<_, _> = l
|
||||
let streams_to_next: FastHashMap<_, _> = l
|
||||
.streams_by_id()
|
||||
.iter()
|
||||
.filter_map(|(&k, v)| {
|
||||
|
@ -336,17 +344,20 @@ impl<C: Clocks + Clone> Syncer<C, Arc<dir::SampleFileDir>> {
|
|||
let to_abandon = list_files_to_abandon(&dir, streams_to_next)?;
|
||||
let mut undeletable = 0;
|
||||
for &id in &to_abandon {
|
||||
if let Err(e) = dir.unlink_file(id) {
|
||||
if e == nix::Error::ENOENT {
|
||||
warn!("dir: abandoned recording {} already deleted!", id);
|
||||
if let Err(err) = dir.unlink_file(id) {
|
||||
if err == nix::Error::ENOENT {
|
||||
warn!(%id, "dir: abandoned recording already deleted");
|
||||
} else {
|
||||
warn!("dir: Unable to unlink abandoned recording {}: {}", id, e);
|
||||
warn!(%err, %id, "dir: unable to unlink abandoned recording");
|
||||
undeletable += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if undeletable > 0 {
|
||||
bail!("Unable to delete {} abandoned recordings.", undeletable);
|
||||
bail!(
|
||||
Unknown,
|
||||
msg("unable to delete {undeletable} abandoned recordings; see logs")
|
||||
);
|
||||
}
|
||||
|
||||
Ok((
|
||||
|
@ -380,7 +391,7 @@ impl<C: Clocks + Clone> Syncer<C, Arc<dir::SampleFileDir>> {
|
|||
{
|
||||
{
|
||||
let mut db = self.db.lock();
|
||||
delete_recordings(&mut *db)?;
|
||||
delete_recordings(&mut db)?;
|
||||
db.flush("synchronous deletion")?;
|
||||
}
|
||||
let mut garbage: Vec<_> = {
|
||||
|
@ -392,17 +403,17 @@ impl<C: Clocks + Clone> Syncer<C, Arc<dir::SampleFileDir>> {
|
|||
// Try to delete files; retain ones in `garbage` that don't exist.
|
||||
let mut errors = 0;
|
||||
for &id in &garbage {
|
||||
if let Err(e) = self.dir.unlink_file(id) {
|
||||
if e != nix::Error::ENOENT {
|
||||
warn!("dir: Unable to unlink {}: {}", id, e);
|
||||
if let Err(err) = self.dir.unlink_file(id) {
|
||||
if err != nix::Error::ENOENT {
|
||||
warn!(%err, "dir: unable to unlink {}", id);
|
||||
errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors > 0 {
|
||||
bail!(
|
||||
"Unable to unlink {} files (see earlier warning messages for details)",
|
||||
errors
|
||||
Unknown,
|
||||
msg("unable to unlink {errors} files (see earlier warning messages for details)"),
|
||||
);
|
||||
}
|
||||
self.dir.sync()?;
|
||||
|
@ -432,7 +443,7 @@ impl<C: Clocks + Clone, D: DirWriter> Syncer<C, D> {
|
|||
let timeout = (t - now)
|
||||
.to_std()
|
||||
.unwrap_or_else(|_| StdDuration::new(0, 0));
|
||||
match self.db.clocks().recv_timeout(&cmds, timeout) {
|
||||
match self.db.clocks().recv_timeout(cmds, timeout) {
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return false, // cmd senders gone.
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
self.flush();
|
||||
|
@ -618,6 +629,11 @@ pub struct Writer<'a, C: Clocks + Clone, D: DirWriter> {
|
|||
state: WriterState<D::File>,
|
||||
}
|
||||
|
||||
// clippy points out that the `Open` variant is significantly larger and
|
||||
// suggests boxing it. There's no benefit to this given that we don't have a lot
|
||||
// of `WriterState`s active at once, and they should cycle between `Open` and
|
||||
// `Closed`.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum WriterState<F: FileWriter> {
|
||||
Unopened,
|
||||
Open(InnerWriter<F>),
|
||||
|
@ -704,7 +720,7 @@ impl<'a, C: Clocks + Clone, D: DirWriter> Writer<'a, C, D> {
|
|||
WriterState::Unopened => None,
|
||||
WriterState::Open(ref o) => {
|
||||
if o.video_sample_entry_id != video_sample_entry_id {
|
||||
bail!("inconsistent video_sample_entry_id");
|
||||
bail!(Internal, msg("inconsistent video_sample_entry_id"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
@ -724,7 +740,8 @@ impl<'a, C: Clocks + Clone, D: DirWriter> Writer<'a, C, D> {
|
|||
)?;
|
||||
let f = clock::retry(&self.db.clocks(), shutdown_rx, &mut || {
|
||||
self.dir.create_file(id)
|
||||
})?;
|
||||
})
|
||||
.map_err(|e| err!(Cancelled, source(e)))?;
|
||||
|
||||
self.state = WriterState::Open(InnerWriter {
|
||||
f,
|
||||
|
@ -743,7 +760,7 @@ impl<'a, C: Clocks + Clone, D: DirWriter> Writer<'a, C, D> {
|
|||
Ok(match self.state {
|
||||
WriterState::Unopened => false,
|
||||
WriterState::Closed(_) => true,
|
||||
WriterState::Open(_) => bail!("open!"),
|
||||
WriterState::Open(_) => bail!(Internal, msg("open!")),
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -772,9 +789,12 @@ impl<'a, C: Clocks + Clone, D: DirWriter> Writer<'a, C, D> {
|
|||
if duration <= 0 {
|
||||
w.unindexed_sample = Some(unindexed); // restore invariant.
|
||||
bail!(
|
||||
"pts not monotonically increasing; got {} then {}",
|
||||
unindexed.pts_90k,
|
||||
pts_90k
|
||||
InvalidArgument,
|
||||
msg(
|
||||
"pts not monotonically increasing; got {} then {}",
|
||||
unindexed.pts_90k,
|
||||
pts_90k,
|
||||
),
|
||||
);
|
||||
}
|
||||
let duration = match i32::try_from(duration) {
|
||||
|
@ -782,9 +802,12 @@ impl<'a, C: Clocks + Clone, D: DirWriter> Writer<'a, C, D> {
|
|||
Err(_) => {
|
||||
w.unindexed_sample = Some(unindexed); // restore invariant.
|
||||
bail!(
|
||||
"excessive pts jump from {} to {}",
|
||||
unindexed.pts_90k,
|
||||
pts_90k
|
||||
InvalidArgument,
|
||||
msg(
|
||||
"excessive pts jump from {} to {}",
|
||||
unindexed.pts_90k,
|
||||
pts_90k,
|
||||
),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
@ -807,11 +830,11 @@ impl<'a, C: Clocks + Clone, D: DirWriter> Writer<'a, C, D> {
|
|||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
// close() will do nothing because unindexed_sample will be None.
|
||||
log::warn!(
|
||||
"Abandoning incompletely written recording {} on shutdown",
|
||||
tracing::warn!(
|
||||
"abandoning incompletely written recording {} on shutdown",
|
||||
w.id
|
||||
);
|
||||
return Err(e.into());
|
||||
bail!(Cancelled, source(e));
|
||||
}
|
||||
};
|
||||
remaining = &remaining[written..];
|
||||
|
@ -860,7 +883,7 @@ impl<F: FileWriter> InnerWriter<F> {
|
|||
db: &db::Database<C>,
|
||||
stream_id: i32,
|
||||
) -> Result<(), Error> {
|
||||
let mut l = self.r.lock();
|
||||
let mut l = self.r.lock().unwrap();
|
||||
|
||||
// design/time.md explains these time manipulations in detail.
|
||||
let prev_media_duration_90k = l.media_duration_90k;
|
||||
|
@ -880,9 +903,8 @@ impl<F: FileWriter> InnerWriter<F> {
|
|||
+ i32::try_from(clamp(local_start.0 - start.0, -limit, limit)).unwrap();
|
||||
if wall_duration_90k > i32::try_from(MAX_RECORDING_WALL_DURATION).unwrap() {
|
||||
bail!(
|
||||
"Duration {} exceeds maximum {}",
|
||||
wall_duration_90k,
|
||||
MAX_RECORDING_WALL_DURATION
|
||||
OutOfRange,
|
||||
msg("Duration {wall_duration_90k} exceeds maximum {MAX_RECORDING_WALL_DURATION}"),
|
||||
);
|
||||
}
|
||||
l.wall_duration_90k = wall_duration_90k;
|
||||
|
@ -912,14 +934,29 @@ impl<F: FileWriter> InnerWriter<F> {
|
|||
reason: Option<String>,
|
||||
) -> Result<PreviousWriter, Error> {
|
||||
let unindexed = self.unindexed_sample.take().ok_or_else(|| {
|
||||
format_err!(
|
||||
"Unable to add recording {} to database due to aborted write",
|
||||
self.id
|
||||
err!(
|
||||
FailedPrecondition,
|
||||
msg(
|
||||
"unable to add recording {} to database due to aborted write",
|
||||
self.id,
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let (last_sample_duration, flags) = match next_pts {
|
||||
None => (0, db::RecordingFlags::TrailingZero as i32),
|
||||
Some(p) => (i32::try_from(p - unindexed.pts_90k)?, 0),
|
||||
Some(p) => (
|
||||
i32::try_from(p - unindexed.pts_90k).map_err(|_| {
|
||||
err!(
|
||||
OutOfRange,
|
||||
msg(
|
||||
"pts {} following {} creates invalid duration",
|
||||
p,
|
||||
unindexed.pts_90k
|
||||
)
|
||||
)
|
||||
})?,
|
||||
0,
|
||||
),
|
||||
};
|
||||
let blake3 = self.hasher.finalize();
|
||||
let (run_offset, end);
|
||||
|
@ -935,7 +972,7 @@ impl<F: FileWriter> InnerWriter<F> {
|
|||
// This always ends a live segment.
|
||||
let wall_duration;
|
||||
{
|
||||
let mut l = self.r.lock();
|
||||
let mut l = self.r.lock().unwrap();
|
||||
l.flags = flags;
|
||||
l.local_time_delta = self.local_start - l.start;
|
||||
l.sample_file_blake3 = Some(*blake3.as_bytes());
|
||||
|
@ -978,12 +1015,12 @@ mod tests {
|
|||
use crate::recording;
|
||||
use crate::testutil;
|
||||
use base::clock::{Clocks, SimulatedClocks};
|
||||
use log::{trace, warn};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::io;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing::{trace, warn};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockDir(Arc<Mutex<VecDeque<MockDirAction>>>);
|
||||
|
@ -1005,10 +1042,10 @@ mod tests {
|
|||
MockDir(Arc::new(Mutex::new(VecDeque::new())))
|
||||
}
|
||||
fn expect(&self, action: MockDirAction) {
|
||||
self.0.lock().push_back(action);
|
||||
self.0.lock().unwrap().push_back(action);
|
||||
}
|
||||
fn ensure_done(&self) {
|
||||
assert_eq!(self.0.lock().len(), 0);
|
||||
assert_eq!(self.0.lock().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1019,6 +1056,7 @@ mod tests {
|
|||
match self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("got create_file with no expectation")
|
||||
{
|
||||
|
@ -1026,13 +1064,14 @@ mod tests {
|
|||
assert_eq!(id, expected_id);
|
||||
f(id)
|
||||
}
|
||||
_ => panic!("got create_file({}), expected something else", id),
|
||||
_ => panic!("got create_file({id}), expected something else"),
|
||||
}
|
||||
}
|
||||
fn sync(&self) -> Result<(), nix::Error> {
|
||||
match self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("got sync with no expectation")
|
||||
{
|
||||
|
@ -1044,6 +1083,7 @@ mod tests {
|
|||
match self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("got unlink_file with no expectation")
|
||||
{
|
||||
|
@ -1051,7 +1091,7 @@ mod tests {
|
|||
assert_eq!(id, expected_id);
|
||||
f(id)
|
||||
}
|
||||
_ => panic!("got unlink({}), expected something else", id),
|
||||
_ => panic!("got unlink({id}), expected something else"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1059,7 +1099,7 @@ mod tests {
|
|||
impl Drop for MockDir {
|
||||
fn drop(&mut self) {
|
||||
if !::std::thread::panicking() {
|
||||
assert_eq!(self.0.lock().len(), 0);
|
||||
assert_eq!(self.0.lock().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1077,10 +1117,10 @@ mod tests {
|
|||
MockFile(Arc::new(Mutex::new(VecDeque::new())))
|
||||
}
|
||||
fn expect(&self, action: MockFileAction) {
|
||||
self.0.lock().push_back(action);
|
||||
self.0.lock().unwrap().push_back(action);
|
||||
}
|
||||
fn ensure_done(&self) {
|
||||
assert_eq!(self.0.lock().len(), 0);
|
||||
assert_eq!(self.0.lock().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1089,6 +1129,7 @@ mod tests {
|
|||
match self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("got sync_all with no expectation")
|
||||
{
|
||||
|
@ -1100,11 +1141,12 @@ mod tests {
|
|||
match self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("got write with no expectation")
|
||||
{
|
||||
MockFileAction::Write(f) => f(buf),
|
||||
_ => panic!("got write({:?}), expected something else", buf),
|
||||
_ => panic!("got write({buf:?}), expected something else"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1157,8 +1199,8 @@ mod tests {
|
|||
tdb.db.lock().on_flush(Box::new({
|
||||
let snd = syncer_tx.clone();
|
||||
move || {
|
||||
if let Err(e) = snd.send(super::SyncerCommand::DatabaseFlushed) {
|
||||
warn!("Unable to notify syncer for dir {} of flush: {}", dir_id, e);
|
||||
if let Err(err) = snd.send(super::SyncerCommand::DatabaseFlushed) {
|
||||
warn!(%err, "unable to notify syncer for dir {} of flush", dir_id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
|
|
@ -27,7 +27,7 @@ pub type BoxedError = Box<dyn StdError + Send + Sync>;
|
|||
pub type BodyStream = Box<dyn Stream<Item = Result<Chunk, BoxedError>> + Send>;
|
||||
|
||||
pub fn wrap_error(e: Error) -> BoxedError {
|
||||
Box::new(e.compat())
|
||||
Box::new(e)
|
||||
}
|
||||
|
||||
impl From<ARefss<'static, [u8]>> for Chunk {
|
||||
|
@ -65,7 +65,7 @@ impl hyper::body::Buf for Chunk {
|
|||
self.0.len()
|
||||
}
|
||||
fn chunk(&self) -> &[u8] {
|
||||
&*self.0
|
||||
&self.0
|
||||
}
|
||||
fn advance(&mut self, cnt: usize) {
|
||||
self.0 = ::std::mem::replace(&mut self.0, ARefss::new(&[][..])).map(|b| &b[cnt..]);
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
//! UI bundled (compiled/linked) into the executable for single-file deployment.
|
||||
|
||||
use base::FastHashMap;
|
||||
use http::{header, HeaderMap, HeaderValue};
|
||||
use std::io::Read;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::body::{BoxedError, Chunk};
|
||||
|
||||
pub struct Ui(FastHashMap<&'static str, FileSet>);
|
||||
|
||||
/// A file as passed in from `build.rs`.
|
||||
struct BuildFile {
|
||||
/// Path without any prefix (even `/`) for the root or any encoding suffix (`.gz`).
|
||||
bare_path: &'static str,
|
||||
data: &'static [u8],
|
||||
etag: &'static str,
|
||||
encoding: FileEncoding,
|
||||
}
|
||||
|
||||
#[allow(unused)] // it's valid for a UI to have all uncompressed files or vice versa.
|
||||
#[derive(Copy, Clone)]
|
||||
enum FileEncoding {
|
||||
Uncompressed,
|
||||
Gzipped,
|
||||
}
|
||||
|
||||
// `build.rs` fills in: `static FILES: [BuildFile; _] = [ ... ];`
|
||||
include!(concat!(env!("OUT_DIR"), "/ui_files.rs"));
|
||||
|
||||
/// A file, ready to serve.
|
||||
struct File {
|
||||
data: &'static [u8],
|
||||
etag: &'static str,
|
||||
}
|
||||
|
||||
struct FileSet {
|
||||
uncompressed: File,
|
||||
gzipped: Option<File>,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
pub fn get() -> &'static Self {
|
||||
UI.get_or_init(Self::init)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
fn init() -> Self {
|
||||
Ui(FILES
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let set = if matches!(f.encoding, FileEncoding::Gzipped) {
|
||||
let mut uncompressed = Vec::new();
|
||||
let mut d = flate2::read::GzDecoder::new(f.data);
|
||||
d.read_to_end(&mut uncompressed)
|
||||
.expect("bundled gzip files should be valid");
|
||||
|
||||
// TODO: use String::leak in rust 1.72+.
|
||||
let etag = format!("{}.ungzipped", f.etag);
|
||||
let etag = etag.into_bytes().leak();
|
||||
let etag =
|
||||
std::str::from_utf8(etag).expect("just-formatted str is valid utf-8");
|
||||
|
||||
FileSet {
|
||||
uncompressed: File {
|
||||
data: uncompressed.leak(),
|
||||
etag,
|
||||
},
|
||||
gzipped: Some(File {
|
||||
data: f.data,
|
||||
etag: f.etag,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
FileSet {
|
||||
uncompressed: File {
|
||||
data: f.data,
|
||||
etag: f.etag,
|
||||
},
|
||||
gzipped: None,
|
||||
}
|
||||
};
|
||||
(f.bare_path, set)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn lookup(
|
||||
&'static self,
|
||||
path: &str,
|
||||
hdrs: &HeaderMap<HeaderValue>,
|
||||
cache_control: &'static str,
|
||||
content_type: &'static str,
|
||||
) -> Option<Entity> {
|
||||
let Some(set) = self.0.get(path) else {
|
||||
return None;
|
||||
};
|
||||
let auto_gzip;
|
||||
if let Some(ref gzipped) = set.gzipped {
|
||||
auto_gzip = true;
|
||||
if http_serve::should_gzip(hdrs) {
|
||||
return Some(Entity {
|
||||
file: &gzipped,
|
||||
auto_gzip,
|
||||
is_gzipped: true,
|
||||
cache_control,
|
||||
content_type,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
auto_gzip = false
|
||||
};
|
||||
Some(Entity {
|
||||
file: &set.uncompressed,
|
||||
auto_gzip,
|
||||
is_gzipped: false,
|
||||
cache_control,
|
||||
content_type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static UI: OnceLock<Ui> = OnceLock::new();
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct Entity {
|
||||
file: &'static File,
|
||||
auto_gzip: bool,
|
||||
is_gzipped: bool,
|
||||
cache_control: &'static str,
|
||||
content_type: &'static str,
|
||||
}
|
||||
|
||||
impl http_serve::Entity for Entity {
|
||||
type Data = Chunk;
|
||||
type Error = BoxedError;
|
||||
|
||||
fn len(&self) -> u64 {
|
||||
self.file
|
||||
.data
|
||||
.len()
|
||||
.try_into()
|
||||
.expect("usize should be convertible to u64")
|
||||
}
|
||||
|
||||
fn get_range(
|
||||
&self,
|
||||
range: std::ops::Range<u64>,
|
||||
) -> Box<dyn futures::Stream<Item = Result<Self::Data, Self::Error>> + Send + Sync> {
|
||||
let file = self.file;
|
||||
Box::new(futures::stream::once(async move {
|
||||
let r = usize::try_from(range.start)?..usize::try_from(range.end)?;
|
||||
let Some(data) = file.data.get(r) else {
|
||||
let len = file.data.len();
|
||||
return Err(format!("static file range {range:?} invalid (len {len:?})").into());
|
||||
};
|
||||
Ok(data.into())
|
||||
}))
|
||||
}
|
||||
|
||||
fn add_headers(&self, hdrs: &mut http::HeaderMap) {
|
||||
if self.auto_gzip {
|
||||
hdrs.insert(header::VARY, HeaderValue::from_static("accept-encoding"));
|
||||
}
|
||||
if self.is_gzipped {
|
||||
hdrs.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
|
||||
}
|
||||
hdrs.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(self.cache_control),
|
||||
);
|
||||
hdrs.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(self.content_type),
|
||||
);
|
||||
}
|
||||
|
||||
fn etag(&self) -> Option<http::HeaderValue> {
|
||||
Some(http::HeaderValue::from_static(self.file.etag))
|
||||
}
|
||||
|
||||
fn last_modified(&self) -> Option<std::time::SystemTime> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn index_html_uncompressed() {
|
||||
let ui = Ui::get();
|
||||
let e = ui
|
||||
.lookup("index.html", &HeaderMap::new(), "public", "text/html")
|
||||
.unwrap();
|
||||
assert!(e.file.data.starts_with(b"<!doctype html"));
|
||||
}
|
||||
}
|
|
@ -4,45 +4,35 @@
|
|||
|
||||
//! Subcommand to check the database and sample file dir for errors.
|
||||
|
||||
use base::Error;
|
||||
use bpaf::Bpaf;
|
||||
use db::check;
|
||||
use failure::Error;
|
||||
use std::path::PathBuf;
|
||||
use structopt::StructOpt;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Checks database integrity (like fsck).
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(command("check"))]
|
||||
pub struct Args {
|
||||
/// Directory holding the SQLite3 index database.
|
||||
#[structopt(
|
||||
long,
|
||||
default_value = "/var/lib/moonfire-nvr/db",
|
||||
value_name = "path",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
#[bpaf(external(crate::parse_db_dir))]
|
||||
db_dir: PathBuf,
|
||||
|
||||
/// Compare sample file lengths on disk to the database.
|
||||
#[structopt(long)]
|
||||
/// Compares sample file lengths on disk to the database.
|
||||
compare_lens: bool,
|
||||
|
||||
/// Trash sample files without matching recording rows in the database.
|
||||
/// This addresses "Missing ... row" errors.
|
||||
///
|
||||
/// The ids are added to the "garbage" table to indicate the files need to
|
||||
/// be deleted. Garbage is collected on normal startup.
|
||||
#[structopt(long)]
|
||||
/// Trashes sample files without matching recording rows in the database.
|
||||
/// This addresses `Missing ... row` errors. The ids are added to the
|
||||
/// `garbage` table to indicate the files need to be deleted. Garbage is
|
||||
/// collected on normal startup.
|
||||
trash_orphan_sample_files: bool,
|
||||
|
||||
/// Delete recording rows in the database without matching sample files.
|
||||
/// This addresses "Recording ... missing file" errors.
|
||||
#[structopt(long)]
|
||||
/// Deletes recording rows in the database without matching sample files.
|
||||
/// This addresses `Recording ... missing file` errors.
|
||||
delete_orphan_rows: bool,
|
||||
|
||||
/// Trash recordings when their database rows appear corrupt.
|
||||
/// This addresses "bad video_index" errors.
|
||||
///
|
||||
/// The ids are added to the "garbage" table to indicate their files need to
|
||||
/// be deleted. Garbage is collected on normal startup.
|
||||
#[structopt(long)]
|
||||
/// Trashes recordings when their database rows appear corrupt.
|
||||
/// This addresses "bad video_index" errors. The ids are added to the
|
||||
/// `garbage` table to indicate their files need to be deleted. Garbage is
|
||||
/// collected on normal startup.
|
||||
trash_corrupt_rows: bool,
|
||||
}
|
||||
|
||||
|
|
|
@ -4,11 +4,12 @@
|
|||
|
||||
use crate::stream::{self, Opener};
|
||||
use base::strutil::{decode_size, encode_size};
|
||||
use base::{bail, err, Error};
|
||||
use cursive::traits::{Finder, Nameable, Resizable, Scrollable};
|
||||
use cursive::views::{self, ViewRef};
|
||||
use cursive::views::{self, Dialog, ViewRef};
|
||||
use cursive::Cursive;
|
||||
use db::writer;
|
||||
use failure::{bail, format_err, Error, ResultExt};
|
||||
use itertools::Itertools;
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
@ -76,28 +77,28 @@ fn get_camera(siv: &mut Cursive) -> Camera {
|
|||
};
|
||||
for &t in &db::ALL_STREAM_TYPES {
|
||||
let url = siv
|
||||
.find_name::<views::EditView>(&format!("{}_url", t.as_str()))
|
||||
.find_name::<views::EditView>(&format!("{}_url", t))
|
||||
.unwrap()
|
||||
.get_content()
|
||||
.as_str()
|
||||
.to_owned();
|
||||
let record = siv
|
||||
.find_name::<views::Checkbox>(&format!("{}_record", t.as_str()))
|
||||
.find_name::<views::Checkbox>(&format!("{}_record", t))
|
||||
.unwrap()
|
||||
.is_checked();
|
||||
let rtsp_transport = *siv
|
||||
.find_name::<views::SelectView<&'static str>>(&format!("{}_rtsp_transport", t.as_str()))
|
||||
.find_name::<views::SelectView<&'static str>>(&format!("{}_rtsp_transport", t))
|
||||
.unwrap()
|
||||
.selection()
|
||||
.unwrap();
|
||||
let flush_if_sec = siv
|
||||
.find_name::<views::EditView>(&format!("{}_flush_if_sec", t.as_str()))
|
||||
.find_name::<views::EditView>(&format!("{}_flush_if_sec", t))
|
||||
.unwrap()
|
||||
.get_content()
|
||||
.as_str()
|
||||
.to_owned();
|
||||
let sample_file_dir_id = *siv
|
||||
.find_name::<views::SelectView<Option<i32>>>(&format!("{}_sample_file_dir", t.as_str()))
|
||||
.find_name::<views::SelectView<Option<i32>>>(&format!("{}_sample_file_dir", t))
|
||||
.unwrap()
|
||||
.selection()
|
||||
.unwrap();
|
||||
|
@ -109,32 +110,53 @@ fn get_camera(siv: &mut Cursive) -> Camera {
|
|||
sample_file_dir_id,
|
||||
};
|
||||
}
|
||||
log::trace!("camera is: {:#?}", &camera);
|
||||
tracing::trace!("camera is: {:#?}", &camera);
|
||||
camera
|
||||
}
|
||||
|
||||
/// Attempts to parse a URL field into a sort-of-validated URL.
|
||||
fn parse_url(raw: &str, allowed_schemes: &'static [&'static str]) -> Result<Option<Url>, Error> {
|
||||
fn parse_url(
|
||||
field_name: &str,
|
||||
raw: &str,
|
||||
allowed_schemes: &'static [&'static str],
|
||||
) -> Result<Option<Url>, Error> {
|
||||
if raw.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let url = url::Url::parse(&raw).with_context(|_| format!("can't parse {:?} as URL", &raw))?;
|
||||
if allowed_schemes
|
||||
.iter()
|
||||
.find(|scheme| **scheme == url.scheme())
|
||||
.is_none()
|
||||
{
|
||||
bail!("Unexpected scheme in URL {}", &url);
|
||||
let url = url::Url::parse(raw).map_err(|_| {
|
||||
err!(
|
||||
InvalidArgument,
|
||||
msg("can't parse {field_name} {raw:?} as URL")
|
||||
)
|
||||
})?;
|
||||
if !allowed_schemes.iter().any(|scheme| *scheme == url.scheme()) {
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg(
|
||||
"unexpected scheme in {} {:?}; should be one of: {}",
|
||||
field_name,
|
||||
url.as_str(),
|
||||
allowed_schemes.iter().join(", "),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
bail!(
|
||||
"Unexpected credentials in URL {}; use the username and password fields instead",
|
||||
&url
|
||||
InvalidArgument,
|
||||
msg(
|
||||
"unexpected credentials in {} {:?}; use the username and password fields instead",
|
||||
field_name,
|
||||
url.as_str(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(Some(url))
|
||||
}
|
||||
|
||||
fn parse_stream_url(type_: db::StreamType, raw: &str) -> Result<Option<Url>, Error> {
|
||||
parse_url(&format!("{} stream url", type_.as_str()), raw, &["rtsp"])
|
||||
}
|
||||
|
||||
fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
|
||||
let result = (|| {
|
||||
let mut l = db.lock();
|
||||
|
@ -146,34 +168,37 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>) {
|
|||
let camera = get_camera(siv);
|
||||
change.short_name = camera.short_name;
|
||||
change.config.description = camera.description;
|
||||
change.config.onvif_base_url = parse_url(&camera.onvif_base_url, &["http", "https"])?;
|
||||
change.config.onvif_base_url =
|
||||
parse_url("onvif_base_url", &camera.onvif_base_url, &["http", "https"])?;
|
||||
change.config.username = camera.username;
|
||||
change.config.password = camera.password;
|
||||
for (i, stream) in camera.streams.iter().enumerate() {
|
||||
let type_ = db::StreamType::from_index(i).unwrap();
|
||||
if stream.record && (stream.url.is_empty() || stream.sample_file_dir_id.is_none()) {
|
||||
bail!(
|
||||
"Can't record {} stream without RTSP URL and sample file directory",
|
||||
type_.as_str()
|
||||
InvalidArgument,
|
||||
msg("can't record {type_} stream without RTSP URL and sample file directory"),
|
||||
);
|
||||
}
|
||||
let stream_change = &mut change.streams[i];
|
||||
stream_change.config.mode = (if stream.record {
|
||||
(if stream.record {
|
||||
db::json::STREAM_MODE_RECORD
|
||||
} else {
|
||||
""
|
||||
})
|
||||
.to_owned();
|
||||
stream_change.config.url = parse_url(&stream.url, &["rtsp"])?;
|
||||
stream_change.config.rtsp_transport = stream.rtsp_transport.to_owned();
|
||||
.clone_into(&mut stream_change.config.mode);
|
||||
stream_change.config.url = parse_stream_url(type_, &stream.url)?;
|
||||
stream
|
||||
.rtsp_transport
|
||||
.clone_into(&mut stream_change.config.rtsp_transport);
|
||||
stream_change.sample_file_dir_id = stream.sample_file_dir_id;
|
||||
stream_change.config.flush_if_sec = if stream.flush_if_sec.is_empty() {
|
||||
0
|
||||
} else {
|
||||
stream.flush_if_sec.parse().map_err(|_| {
|
||||
format_err!(
|
||||
"flush_if_sec for {} must be a non-negative integer",
|
||||
type_.as_str()
|
||||
err!(
|
||||
InvalidArgument,
|
||||
msg("flush_if_sec for {type_} must be a non-negative integer"),
|
||||
)
|
||||
})?
|
||||
};
|
||||
|
@ -211,17 +236,15 @@ fn press_test_inner(
|
|||
transport: retina::client::Transport,
|
||||
) -> Result<String, Error> {
|
||||
let _enter = handle.enter();
|
||||
let stream = stream::OPENER.open(
|
||||
"test stream".to_owned(),
|
||||
url,
|
||||
retina::client::SessionOptions::default()
|
||||
.creds(if username.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(retina::client::Credentials { username, password })
|
||||
})
|
||||
.transport(transport),
|
||||
)?;
|
||||
let options = stream::Options {
|
||||
session: retina::client::SessionOptions::default().creds(if username.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(retina::client::Credentials { username, password })
|
||||
}),
|
||||
setup: retina::client::SetupOptions::default().transport(transport),
|
||||
};
|
||||
let stream = stream::OPENER.open("test stream".to_owned(), url, options)?;
|
||||
let video_sample_entry = stream.video_sample_entry();
|
||||
Ok(format!(
|
||||
"codec: {}\n\
|
||||
|
@ -241,7 +264,7 @@ fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
|||
let c = get_camera(siv);
|
||||
let s = &c.streams[t.index()];
|
||||
let transport = retina::client::Transport::from_str(s.rtsp_transport).unwrap_or_default();
|
||||
let url = match parse_url(&s.url, &["rtsp"]) {
|
||||
let url = match parse_stream_url(t, &s.url) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => panic!(
|
||||
"test button should only be enabled with valid URL, not {:?}",
|
||||
|
@ -278,9 +301,14 @@ fn press_test(siv: &mut Cursive, t: db::StreamType) {
|
|||
let description = match r {
|
||||
Err(ref e) => {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("{} stream at {}:\n\n{}", t.as_str(), &url, e))
|
||||
.title("Stream test failed")
|
||||
.dismiss_button("Back"),
|
||||
views::Dialog::text(format!(
|
||||
"{} stream at {}:\n\n{}",
|
||||
t.as_str(),
|
||||
&url,
|
||||
e.chain()
|
||||
))
|
||||
.title("Stream test failed")
|
||||
.dismiss_button("Back"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -328,12 +356,14 @@ fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, name: String
|
|||
})
|
||||
} else {
|
||||
views::Dialog::text(format!(
|
||||
"Delete camera {}? This camera has no recorded video.",
|
||||
name
|
||||
"Delete camera {name}? This camera has no recorded video."
|
||||
))
|
||||
.button("Delete", {
|
||||
let db = db.clone();
|
||||
move |s| actually_delete(s, &db, id)
|
||||
move |s| {
|
||||
s.pop_layer();
|
||||
actually_delete(s, &db, id);
|
||||
}
|
||||
})
|
||||
}
|
||||
.title("Delete camera")
|
||||
|
@ -354,9 +384,8 @@ fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, to_delet
|
|||
let l = db.lock();
|
||||
for (&stream_id, stream) in l.streams_by_id() {
|
||||
if stream.camera_id == id {
|
||||
let dir_id = match stream.sample_file_dir_id {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
let Some(dir_id) = stream.sample_file_dir_id else {
|
||||
continue;
|
||||
};
|
||||
let l = zero_limits
|
||||
.entry(dir_id)
|
||||
|
@ -370,7 +399,7 @@ fn confirm_deletion(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, to_delet
|
|||
}
|
||||
if let Err(e) = lower_retention(db, zero_limits) {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to delete recordings: {}", e))
|
||||
views::Dialog::text(format!("Unable to delete recordings: {}", e.chain()))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"),
|
||||
);
|
||||
|
@ -393,7 +422,7 @@ fn lower_retention(
|
|||
let dirs_to_open: Vec<_> = zero_limits.keys().copied().collect();
|
||||
db.lock().open_sample_file_dirs(&dirs_to_open[..])?;
|
||||
for (&dir_id, l) in &zero_limits {
|
||||
writer::lower_retention(db.clone(), dir_id, &l)?;
|
||||
writer::lower_retention(db, dir_id, l)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
@ -406,7 +435,7 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
|
|||
};
|
||||
if let Err(e) = result {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to delete camera: {}", e))
|
||||
views::Dialog::text(format!("Unable to delete camera: {}", e.chain()))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"),
|
||||
);
|
||||
|
@ -417,21 +446,139 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
|
|||
}
|
||||
}
|
||||
|
||||
fn edit_url(content: &str, mut test_button: ViewRef<views::Button>) {
|
||||
let enable_test = matches!(parse_url(content, &["rtsp"]), Ok(Some(_)));
|
||||
fn edit_stream_url(type_: db::StreamType, content: &str, mut test_button: ViewRef<views::Button>) {
|
||||
let enable_test = matches!(parse_stream_url(type_, content), Ok(Some(_)));
|
||||
test_button.set_enabled(enable_test);
|
||||
}
|
||||
|
||||
fn load_camera_values(
|
||||
db: &Arc<db::Database>,
|
||||
camera_id: i32,
|
||||
dialog: &mut Dialog,
|
||||
overwrite_uuid: bool,
|
||||
) -> (String, i64) {
|
||||
let dirs: Vec<_> = ::std::iter::once(("<none>".into(), None))
|
||||
.chain(
|
||||
db.lock()
|
||||
.sample_file_dirs_by_id()
|
||||
.iter()
|
||||
.map(|(&id, d)| (d.path.to_owned(), Some(id))),
|
||||
)
|
||||
.collect();
|
||||
let l = db.lock();
|
||||
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
|
||||
if overwrite_uuid {
|
||||
dialog
|
||||
.call_on_name("uuid", |v: &mut views::TextView| {
|
||||
v.set_content(camera.uuid.to_string())
|
||||
})
|
||||
.expect("missing TextView");
|
||||
}
|
||||
|
||||
let mut bytes = 0;
|
||||
for (i, sid) in camera.streams.iter().enumerate() {
|
||||
let t = db::StreamType::from_index(i).unwrap();
|
||||
|
||||
// Find the index into dirs of the stored sample file dir.
|
||||
let mut selected_dir = 0;
|
||||
if let Some(s) = sid.map(|sid| l.streams_by_id().get(&sid).unwrap()) {
|
||||
if let Some(id) = s.sample_file_dir_id {
|
||||
for (i, &(_, d_id)) in dirs.iter().skip(1).enumerate() {
|
||||
if Some(id) == d_id {
|
||||
selected_dir = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes += s.sample_file_bytes;
|
||||
let u = if s.config.retain_bytes == 0 {
|
||||
"0 / 0 (0.0%)".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{} / {} ({:.1}%)",
|
||||
s.fs_bytes,
|
||||
s.config.retain_bytes,
|
||||
100. * s.fs_bytes as f32 / s.config.retain_bytes as f32
|
||||
)
|
||||
};
|
||||
dialog.call_on_name(&format!("{}_url", t.as_str()), |v: &mut views::EditView| {
|
||||
if let Some(url) = s.config.url.as_ref() {
|
||||
v.set_content(url.as_str().to_owned());
|
||||
}
|
||||
});
|
||||
let test_button = dialog
|
||||
.find_name::<views::Button>(&format!("{}_test", t.as_str()))
|
||||
.unwrap();
|
||||
edit_stream_url(
|
||||
t,
|
||||
s.config.url.as_ref().map(Url::as_str).unwrap_or(""),
|
||||
test_button,
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_usage_cap", t.as_str()),
|
||||
|v: &mut views::TextView| v.set_content(u),
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_record", t.as_str()),
|
||||
|v: &mut views::Checkbox| {
|
||||
v.set_checked(s.config.mode == db::json::STREAM_MODE_RECORD)
|
||||
},
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_rtsp_transport", t.as_str()),
|
||||
|v: &mut views::SelectView<&'static str>| {
|
||||
v.set_selection(match s.config.rtsp_transport.as_str() {
|
||||
"tcp" => 1,
|
||||
"udp" => 2,
|
||||
_ => 0,
|
||||
})
|
||||
},
|
||||
);
|
||||
dialog.call_on_name(&format!("{}_flush_if_sec", t), |v: &mut views::EditView| {
|
||||
v.set_content(s.config.flush_if_sec.to_string())
|
||||
});
|
||||
}
|
||||
tracing::debug!("setting {} dir to {}", t.as_str(), selected_dir);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_sample_file_dir", t),
|
||||
|v: &mut views::SelectView<Option<i32>>| v.set_selection(selected_dir),
|
||||
);
|
||||
}
|
||||
let name = camera.short_name.clone();
|
||||
for &(view_id, content) in &[
|
||||
("short_name", &*camera.short_name),
|
||||
(
|
||||
"onvif_base_url",
|
||||
camera
|
||||
.config
|
||||
.onvif_base_url
|
||||
.as_ref()
|
||||
.map_or("", Url::as_str),
|
||||
),
|
||||
("username", &camera.config.username),
|
||||
("password", &camera.config.password),
|
||||
] {
|
||||
dialog
|
||||
.call_on_name(view_id, |v: &mut views::EditView| {
|
||||
v.set_content(content.to_string())
|
||||
})
|
||||
.expect("missing EditView");
|
||||
}
|
||||
dialog
|
||||
.call_on_name("description", |v: &mut views::TextArea| {
|
||||
v.set_content(camera.config.description.clone())
|
||||
})
|
||||
.expect("missing TextArea");
|
||||
(name, bytes)
|
||||
}
|
||||
|
||||
/// Adds or updates a camera.
|
||||
/// (The former if `item` is None; the latter otherwise.)
|
||||
fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i32>) {
|
||||
let camera_list = views::ListView::new()
|
||||
.child(
|
||||
"id",
|
||||
views::TextView::new(match *item {
|
||||
None => "<new>".to_string(),
|
||||
Some(id) => id.to_string(),
|
||||
}),
|
||||
views::TextView::new(item.map_or_else(|| "<new>".to_string(), |id| id.to_string())),
|
||||
)
|
||||
.child("uuid", views::TextView::new("<new>").with_name("uuid"))
|
||||
.child("short name", views::EditView::new().with_name("short_name"))
|
||||
|
@ -468,18 +615,18 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||
views::EditView::new()
|
||||
.on_edit(move |siv, content, _pos| {
|
||||
let test_button = siv
|
||||
.find_name::<views::Button>(&format!("{}_test", type_.as_str()))
|
||||
.find_name::<views::Button>(&format!("{}_test", type_))
|
||||
.unwrap();
|
||||
edit_url(content, test_button)
|
||||
edit_stream_url(type_, content, test_button);
|
||||
})
|
||||
.with_name(format!("{}_url", type_.as_str()))
|
||||
.with_name(format!("{}_url", type_))
|
||||
.full_width(),
|
||||
)
|
||||
.child(views::DummyView)
|
||||
.child(
|
||||
views::Button::new("Test", move |siv| press_test(siv, type_))
|
||||
.disabled()
|
||||
.with_name(format!("{}_test", type_.as_str())),
|
||||
.with_name(format!("{}_test", type_)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
|
@ -487,138 +634,36 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||
views::SelectView::<Option<i32>>::new()
|
||||
.with_all(dirs.iter().map(|(p, id)| (p.display().to_string(), *id)))
|
||||
.popup()
|
||||
.with_name(format!("{}_sample_file_dir", type_.as_str())),
|
||||
.with_name(format!("{}_sample_file_dir", type_)),
|
||||
)
|
||||
.child(
|
||||
"record",
|
||||
views::Checkbox::new().with_name(format!("{}_record", type_.as_str())),
|
||||
views::Checkbox::new().with_name(format!("{}_record", type_)),
|
||||
)
|
||||
.child(
|
||||
"rtsp_transport",
|
||||
views::SelectView::<&str>::new()
|
||||
.with_all([("(default)", ""), ("tcp", "tcp"), ("udp", "udp")])
|
||||
.popup()
|
||||
.with_name(format!("{}_rtsp_transport", type_.as_str())),
|
||||
.with_name(format!("{}_rtsp_transport", type_)),
|
||||
)
|
||||
.child(
|
||||
"flush_if_sec",
|
||||
views::EditView::new().with_name(format!("{}_flush_if_sec", type_.as_str())),
|
||||
views::EditView::new().with_name(format!("{}_flush_if_sec", type_)),
|
||||
)
|
||||
.child(
|
||||
"usage/capacity",
|
||||
views::TextView::new("").with_name(format!("{}_usage_cap", type_.as_str())),
|
||||
views::TextView::new("").with_name(format!("{}_usage_cap", type_)),
|
||||
)
|
||||
.min_height(5);
|
||||
layout.add_child(views::DummyView);
|
||||
layout.add_child(views::TextView::new(format!("{} stream", type_.as_str())));
|
||||
layout.add_child(views::TextView::new(format!("{} stream", type_)));
|
||||
layout.add_child(list);
|
||||
}
|
||||
|
||||
let mut dialog = views::Dialog::around(layout.scrollable());
|
||||
let dialog = if let Some(camera_id) = *item {
|
||||
let l = db.lock();
|
||||
let camera = l.cameras_by_id().get(&camera_id).expect("missing camera");
|
||||
dialog
|
||||
.call_on_name("uuid", |v: &mut views::TextView| {
|
||||
v.set_content(camera.uuid.to_string())
|
||||
})
|
||||
.expect("missing TextView");
|
||||
|
||||
let mut bytes = 0;
|
||||
for (i, sid) in camera.streams.iter().enumerate() {
|
||||
let t = db::StreamType::from_index(i).unwrap();
|
||||
|
||||
// Find the index into dirs of the stored sample file dir.
|
||||
let mut selected_dir = 0;
|
||||
if let Some(s) = sid.map(|sid| l.streams_by_id().get(&sid).unwrap()) {
|
||||
if let Some(id) = s.sample_file_dir_id {
|
||||
for (i, &(_, d_id)) in dirs.iter().skip(1).enumerate() {
|
||||
if Some(id) == d_id {
|
||||
selected_dir = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes += s.sample_file_bytes;
|
||||
let u = if s.config.retain_bytes == 0 {
|
||||
"0 / 0 (0.0%)".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{} / {} ({:.1}%)",
|
||||
s.fs_bytes,
|
||||
s.config.retain_bytes,
|
||||
100. * s.fs_bytes as f32 / s.config.retain_bytes as f32
|
||||
)
|
||||
};
|
||||
dialog.call_on_name(&format!("{}_url", t.as_str()), |v: &mut views::EditView| {
|
||||
if let Some(url) = s.config.url.as_ref() {
|
||||
v.set_content(url.as_str().to_owned());
|
||||
}
|
||||
});
|
||||
let test_button = dialog
|
||||
.find_name::<views::Button>(&format!("{}_test", t.as_str()))
|
||||
.unwrap();
|
||||
edit_url(
|
||||
&s.config.url.as_ref().map(Url::as_str).unwrap_or(""),
|
||||
test_button,
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_usage_cap", t.as_str()),
|
||||
|v: &mut views::TextView| v.set_content(u),
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_record", t.as_str()),
|
||||
|v: &mut views::Checkbox| {
|
||||
v.set_checked(s.config.mode == db::json::STREAM_MODE_RECORD)
|
||||
},
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_rtsp_transport", t.as_str()),
|
||||
|v: &mut views::SelectView<&'static str>| {
|
||||
v.set_selection(match s.config.rtsp_transport.as_str() {
|
||||
"tcp" => 1,
|
||||
"udp" => 2,
|
||||
_ => 0,
|
||||
})
|
||||
},
|
||||
);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_flush_if_sec", t.as_str()),
|
||||
|v: &mut views::EditView| v.set_content(s.config.flush_if_sec.to_string()),
|
||||
);
|
||||
}
|
||||
log::debug!("setting {} dir to {}", t.as_str(), selected_dir);
|
||||
dialog.call_on_name(
|
||||
&format!("{}_sample_file_dir", t.as_str()),
|
||||
|v: &mut views::SelectView<Option<i32>>| v.set_selection(selected_dir),
|
||||
);
|
||||
}
|
||||
let name = camera.short_name.clone();
|
||||
for &(view_id, content) in &[
|
||||
("short_name", &*camera.short_name),
|
||||
(
|
||||
"onvif_base_url",
|
||||
&camera
|
||||
.config
|
||||
.onvif_base_url
|
||||
.as_ref()
|
||||
.map(Url::as_str)
|
||||
.unwrap_or(""),
|
||||
),
|
||||
("username", &camera.config.username),
|
||||
("password", &camera.config.password),
|
||||
] {
|
||||
dialog
|
||||
.call_on_name(view_id, |v: &mut views::EditView| {
|
||||
v.set_content(content.to_string())
|
||||
})
|
||||
.expect("missing EditView");
|
||||
}
|
||||
dialog
|
||||
.call_on_name("description", |v: &mut views::TextArea| {
|
||||
v.set_content(camera.config.description.clone())
|
||||
})
|
||||
.expect("missing TextArea");
|
||||
let (name, bytes) = load_camera_values(db, camera_id, &mut dialog, true);
|
||||
dialog
|
||||
.title("Edit camera")
|
||||
.button("Edit", {
|
||||
|
@ -636,14 +681,47 @@ fn edit_camera_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: &Option<i
|
|||
|v: &mut views::TextView| v.set_content("<new>"),
|
||||
);
|
||||
}
|
||||
dialog.title("Add camera").button("Add", {
|
||||
let db = db.clone();
|
||||
move |s| press_edit(s, &db, None)
|
||||
})
|
||||
dialog
|
||||
.title("Add camera")
|
||||
.button("Add", {
|
||||
let db = db.clone();
|
||||
move |s| press_edit(s, &db, None)
|
||||
})
|
||||
.button("Copy config", {
|
||||
let db = db.clone();
|
||||
move |s| copy_camera_dialog(s, &db)
|
||||
})
|
||||
};
|
||||
siv.add_layer(dialog.dismiss_button("Cancel"));
|
||||
}
|
||||
|
||||
fn copy_camera_dialog(siv: &mut Cursive, db: &Arc<db::Database>) {
|
||||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
views::SelectView::new()
|
||||
.with_all(
|
||||
db.lock()
|
||||
.cameras_by_id()
|
||||
.iter()
|
||||
.map(|(&id, camera)| (format!("{}: {}", id, camera.short_name), id)),
|
||||
)
|
||||
.on_submit({
|
||||
let db = db.clone();
|
||||
move |siv, &camera_id| {
|
||||
siv.pop_layer();
|
||||
let screen = siv.screen_mut();
|
||||
let dialog = screen.get_mut(views::LayerPosition::FromFront(0)).unwrap();
|
||||
let dialog = dialog.downcast_mut::<Dialog>().unwrap();
|
||||
load_camera_values(&db, camera_id, dialog, false);
|
||||
}
|
||||
})
|
||||
.full_width(),
|
||||
)
|
||||
.dismiss_button("Cancel")
|
||||
.title("Select camera to copy"),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn top_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
||||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
|
@ -652,14 +730,15 @@ pub fn top_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
|||
let db = db.clone();
|
||||
move |siv, item| edit_camera_dialog(&db, siv, item)
|
||||
})
|
||||
.item("<new camera>".to_string(), None)
|
||||
.item("<new camera>", None)
|
||||
.with_all(
|
||||
db.lock()
|
||||
.cameras_by_id()
|
||||
.iter()
|
||||
.map(|(&id, camera)| (format!("{}: {}", id, camera.short_name), Some(id))),
|
||||
)
|
||||
.full_width(),
|
||||
.full_width()
|
||||
.scrollable(),
|
||||
)
|
||||
.dismiss_button("Done")
|
||||
.title("Edit cameras"),
|
||||
|
|
|
@ -3,17 +3,20 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use base::strutil::{decode_size, encode_size};
|
||||
use base::Error;
|
||||
use cursive::traits::{Nameable, Resizable};
|
||||
use cursive::views;
|
||||
use cursive::view::Scrollable;
|
||||
use cursive::Cursive;
|
||||
use cursive::{views, With};
|
||||
use db::writer;
|
||||
use failure::Error;
|
||||
use log::{debug, trace};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, trace};
|
||||
|
||||
use super::tab_complete::TabCompleteEditView;
|
||||
|
||||
struct Stream {
|
||||
label: String,
|
||||
|
@ -48,7 +51,7 @@ fn update_limits_inner(model: &Model) -> Result<(), Error> {
|
|||
fn update_limits(model: &Model, siv: &mut Cursive) {
|
||||
if let Err(e) = update_limits_inner(model) {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to update limits: {}", e))
|
||||
views::Dialog::text(format!("Unable to update limits: {}", e.chain()))
|
||||
.dismiss_button("Back")
|
||||
.title("Error"),
|
||||
);
|
||||
|
@ -58,7 +61,7 @@ fn update_limits(model: &Model, siv: &mut Cursive) {
|
|||
fn edit_limit(model: &RefCell<Model>, siv: &mut Cursive, id: i32, content: &str) {
|
||||
debug!("on_edit called for id {}", id);
|
||||
let mut model = model.borrow_mut();
|
||||
let model: &mut Model = &mut *model;
|
||||
let model: &mut Model = &mut model;
|
||||
let stream = model.streams.get_mut(&id).unwrap();
|
||||
let new_value = decode_size(content).ok();
|
||||
let delta = new_value.unwrap_or(0) - stream.retain.unwrap_or(0);
|
||||
|
@ -79,7 +82,7 @@ fn edit_limit(model: &RefCell<Model>, siv: &mut Cursive, id: i32, content: &str)
|
|||
}
|
||||
if new_value.is_none() != stream.retain.is_none() {
|
||||
model.errors += if new_value.is_none() { 1 } else { -1 };
|
||||
siv.find_name::<views::TextView>(&format!("{}_ok", id))
|
||||
siv.find_name::<views::TextView>(&format!("{id}_ok"))
|
||||
.unwrap()
|
||||
.set_content(if new_value.is_none() { "*" } else { " " });
|
||||
}
|
||||
|
@ -95,7 +98,7 @@ fn edit_limit(model: &RefCell<Model>, siv: &mut Cursive, id: i32, content: &str)
|
|||
|
||||
fn edit_record(model: &RefCell<Model>, id: i32, record: bool) {
|
||||
let mut model = model.borrow_mut();
|
||||
let model: &mut Model = &mut *model;
|
||||
let model: &mut Model = &mut model;
|
||||
let stream = model.streams.get_mut(&id).unwrap();
|
||||
stream.record = record;
|
||||
}
|
||||
|
@ -137,9 +140,9 @@ fn actually_delete(model: &RefCell<Model>, siv: &mut Cursive) {
|
|||
let mut l = model.db.lock();
|
||||
l.open_sample_file_dirs(&[model.dir_id]).unwrap(); // TODO: don't unwrap.
|
||||
}
|
||||
if let Err(e) = writer::lower_retention(model.db.clone(), model.dir_id, &new_limits[..]) {
|
||||
if let Err(e) = writer::lower_retention(&model.db, model.dir_id, &new_limits[..]) {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to delete excess video: {}", e))
|
||||
views::Dialog::text(format!("Unable to delete excess video: {}", e.chain()))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"),
|
||||
);
|
||||
|
@ -216,26 +219,54 @@ pub fn top_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
|||
);
|
||||
}
|
||||
|
||||
fn tab_completer(content: &str) -> Vec<String> {
|
||||
let (parent, final_segment) = content.split_at(content.rfind('/').map(|i| i + 1).unwrap_or(0));
|
||||
Path::new(parent)
|
||||
.read_dir()
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if entry.file_type().ok()?.is_dir()
|
||||
&& entry.file_name().to_str()?.starts_with(final_segment)
|
||||
{
|
||||
Some(entry.path().as_os_str().to_str()?.to_owned() + "/")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.with(|completions| {
|
||||
// Sort ignoring initial dot
|
||||
completions.sort_by(|a, b| {
|
||||
a.strip_prefix('.')
|
||||
.unwrap_or(a)
|
||||
.cmp(b.strip_prefix('.').unwrap_or(b))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn add_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
||||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
views::LinearLayout::vertical()
|
||||
.child(views::TextView::new("path"))
|
||||
.child(
|
||||
views::EditView::new()
|
||||
.on_submit({
|
||||
let db = db.clone();
|
||||
move |siv, path| add_dir(&db, siv, path.as_ref())
|
||||
})
|
||||
.with_name("path")
|
||||
.fixed_width(60),
|
||||
TabCompleteEditView::new(views::EditView::new().on_submit({
|
||||
let db = db.clone();
|
||||
move |siv, path| add_dir(&db, siv, path.as_ref())
|
||||
}))
|
||||
.on_tab_complete(tab_completer)
|
||||
.with_name("path")
|
||||
.fixed_width(60),
|
||||
),
|
||||
)
|
||||
.button("Add", {
|
||||
let db = db.clone();
|
||||
move |siv| {
|
||||
let path = siv
|
||||
.find_name::<views::EditView>("path")
|
||||
.find_name::<TabCompleteEditView>("path")
|
||||
.unwrap()
|
||||
.get_content();
|
||||
add_dir(&db, siv, path.as_ref().as_ref())
|
||||
|
@ -251,9 +282,13 @@ fn add_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive) {
|
|||
fn add_dir(db: &Arc<db::Database>, siv: &mut Cursive, path: &Path) {
|
||||
if let Err(e) = db.lock().add_sample_file_dir(path.to_owned()) {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to add path {}: {}", path.display(), e))
|
||||
.dismiss_button("Back")
|
||||
.title("Error"),
|
||||
views::Dialog::text(format!(
|
||||
"Unable to add path {}: {}",
|
||||
path.display(),
|
||||
e.chain()
|
||||
))
|
||||
.dismiss_button("Back")
|
||||
.title("Error"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -281,7 +316,7 @@ fn delete_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
|||
fn delete_dir(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
||||
if let Err(e) = db.lock().delete_sample_file_dir(dir_id) {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to delete dir id {}: {}", dir_id, e))
|
||||
views::Dialog::text(format!("Unable to delete dir id {dir_id}: {}", e.chain()))
|
||||
.dismiss_button("Back")
|
||||
.title("Error"),
|
||||
);
|
||||
|
@ -381,7 +416,7 @@ fn edit_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
|||
)
|
||||
.child(
|
||||
views::TextView::new("")
|
||||
.with_name(format!("{}_ok", id))
|
||||
.with_name(format!("{id}_ok"))
|
||||
.fixed_width(1),
|
||||
),
|
||||
);
|
||||
|
@ -423,7 +458,7 @@ fn edit_dir_dialog(db: &Arc<db::Database>, siv: &mut Cursive, dir_id: i32) {
|
|||
siv.add_layer(
|
||||
views::Dialog::around(
|
||||
views::LinearLayout::vertical()
|
||||
.child(list)
|
||||
.child(list.scrollable())
|
||||
.child(views::DummyView)
|
||||
.child(buttons),
|
||||
)
|
||||
|
|
|
@ -8,26 +8,23 @@
|
|||
//! configuration will likely be almost entirely done through a web-based UI.
|
||||
|
||||
use base::clock;
|
||||
use base::Error;
|
||||
use bpaf::Bpaf;
|
||||
use cursive::views;
|
||||
use cursive::Cursive;
|
||||
use failure::Error;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use structopt::StructOpt;
|
||||
|
||||
mod cameras;
|
||||
mod dirs;
|
||||
mod tab_complete;
|
||||
mod users;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Interactively edits configuration.
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(command("config"))]
|
||||
pub struct Args {
|
||||
/// Directory holding the SQLite3 index database.
|
||||
#[structopt(
|
||||
long,
|
||||
default_value = "/var/lib/moonfire-nvr/db",
|
||||
value_name = "path",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
#[bpaf(external(crate::parse_db_dir))]
|
||||
db_dir: PathBuf,
|
||||
}
|
||||
|
||||
|
@ -50,9 +47,9 @@ pub fn run(args: Args) -> Result<i32, Error> {
|
|||
views::Dialog::around(
|
||||
views::SelectView::<fn(&Arc<db::Database>, &mut Cursive)>::new()
|
||||
.on_submit(move |siv, item| item(&db, siv))
|
||||
.item("Cameras and streams".to_string(), cameras::top_dialog)
|
||||
.item("Directories and retention".to_string(), dirs::top_dialog)
|
||||
.item("Users".to_string(), users::top_dialog),
|
||||
.item("Cameras and streams", cameras::top_dialog)
|
||||
.item("Directories and retention", dirs::top_dialog)
|
||||
.item("Users", users::top_dialog),
|
||||
)
|
||||
.button("Quit", |siv| siv.quit())
|
||||
.title("Main menu"),
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use cursive::{
|
||||
direction::Direction,
|
||||
event::{Event, EventResult, Key},
|
||||
menu,
|
||||
view::CannotFocus,
|
||||
views::{self, EditView, MenuPopup},
|
||||
Printer, Rect, Vec2, View, With,
|
||||
};
|
||||
|
||||
type TabCompleteFn = Rc<dyn Fn(&str) -> Vec<String>>;
|
||||
|
||||
pub struct TabCompleteEditView {
|
||||
edit_view: Rc<RefCell<EditView>>,
|
||||
tab_completer: Option<TabCompleteFn>,
|
||||
}
|
||||
|
||||
impl TabCompleteEditView {
|
||||
pub fn new(edit_view: EditView) -> Self {
|
||||
Self {
|
||||
edit_view: Rc::new(RefCell::new(edit_view)),
|
||||
tab_completer: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_tab_complete(mut self, handler: impl Fn(&str) -> Vec<String> + 'static) -> Self {
|
||||
self.tab_completer = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn get_content(&self) -> Rc<String> {
|
||||
self.edit_view.borrow_mut().get_content()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for TabCompleteEditView {
|
||||
fn draw(&self, printer: &Printer) {
|
||||
self.edit_view.borrow().draw(printer)
|
||||
}
|
||||
|
||||
fn layout(&mut self, size: Vec2) {
|
||||
self.edit_view.borrow_mut().layout(size)
|
||||
}
|
||||
|
||||
fn take_focus(&mut self, source: Direction) -> Result<EventResult, CannotFocus> {
|
||||
self.edit_view.borrow_mut().take_focus(source)
|
||||
}
|
||||
|
||||
fn on_event(&mut self, event: Event) -> EventResult {
|
||||
if !self.edit_view.borrow().is_enabled() {
|
||||
return EventResult::Ignored;
|
||||
}
|
||||
|
||||
if let Event::Key(Key::Tab) = event {
|
||||
if let Some(tab_completer) = self.tab_completer.clone() {
|
||||
tab_complete(self.edit_view.clone(), tab_completer, true)
|
||||
} else {
|
||||
EventResult::consumed()
|
||||
}
|
||||
} else {
|
||||
self.edit_view.borrow_mut().on_event(event)
|
||||
}
|
||||
}
|
||||
|
||||
fn important_area(&self, view_size: Vec2) -> Rect {
|
||||
self.edit_view.borrow().important_area(view_size)
|
||||
}
|
||||
}
|
||||
|
||||
fn tab_complete(
|
||||
edit_view: Rc<RefCell<EditView>>,
|
||||
tab_completer: TabCompleteFn,
|
||||
autofill_one: bool,
|
||||
) -> EventResult {
|
||||
let completions = tab_completer(edit_view.borrow().get_content().as_str());
|
||||
EventResult::with_cb_once(move |siv| match *completions {
|
||||
[] => {}
|
||||
[ref completion] if autofill_one => edit_view.borrow_mut().set_content(completion)(siv),
|
||||
[..] => {
|
||||
siv.add_layer(TabCompletePopup {
|
||||
popup: views::MenuPopup::new(Rc::new({
|
||||
menu::Tree::new().with(|tree| {
|
||||
for completion in completions {
|
||||
let edit_view = edit_view.clone();
|
||||
tree.add_leaf(&completion.clone(), move |siv| {
|
||||
edit_view.borrow_mut().set_content(&completion)(siv)
|
||||
})
|
||||
}
|
||||
})
|
||||
})),
|
||||
edit_view,
|
||||
tab_completer,
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
struct TabCompletePopup {
|
||||
edit_view: Rc<RefCell<EditView>>,
|
||||
popup: MenuPopup,
|
||||
tab_completer: TabCompleteFn,
|
||||
}
|
||||
impl TabCompletePopup {
|
||||
fn forward_event_and_refresh(&self, event: Event) -> EventResult {
|
||||
let edit_view = self.edit_view.clone();
|
||||
let tab_completer = self.tab_completer.clone();
|
||||
EventResult::with_cb_once(move |s| {
|
||||
s.pop_layer();
|
||||
edit_view.borrow_mut().on_event(event).process(s);
|
||||
tab_complete(edit_view, tab_completer, false).process(s);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl View for TabCompletePopup {
|
||||
fn draw(&self, printer: &Printer) {
|
||||
self.popup.draw(printer)
|
||||
}
|
||||
|
||||
fn required_size(&mut self, req: Vec2) -> Vec2 {
|
||||
self.popup.required_size(req)
|
||||
}
|
||||
|
||||
fn on_event(&mut self, event: Event) -> EventResult {
|
||||
match self.popup.on_event(event.clone()) {
|
||||
EventResult::Ignored => match event {
|
||||
e @ (Event::Char(_) | Event::Key(Key::Backspace)) => {
|
||||
self.forward_event_and_refresh(e)
|
||||
}
|
||||
Event::Key(Key::Tab) => self.popup.on_event(Event::Key(Key::Enter)),
|
||||
_ => EventResult::Ignored,
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(&mut self, size: Vec2) {
|
||||
self.popup.layout(size)
|
||||
}
|
||||
|
||||
fn important_area(&self, size: Vec2) -> Rect {
|
||||
self.popup.important_area(size)
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@
|
|||
use cursive::traits::{Nameable, Resizable};
|
||||
use cursive::views;
|
||||
use cursive::Cursive;
|
||||
use log::info;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Builds a `UserChange` from an active `edit_user_dialog`.
|
||||
|
@ -48,7 +47,6 @@ fn get_change(
|
|||
),
|
||||
] {
|
||||
**b = siv.find_name::<views::Checkbox>(id).unwrap().is_checked();
|
||||
info!("{}: {}", id, **b);
|
||||
}
|
||||
change
|
||||
}
|
||||
|
@ -61,7 +59,7 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>, pw: Pa
|
|||
};
|
||||
if let Err(e) = result {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to apply change: {}", e))
|
||||
views::Dialog::text(format!("Unable to apply change: {}", e.chain()))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"),
|
||||
);
|
||||
|
@ -76,7 +74,7 @@ fn press_edit(siv: &mut Cursive, db: &Arc<db::Database>, id: Option<i32>, pw: Pa
|
|||
|
||||
fn press_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32, name: String) {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Delete user {}?", name))
|
||||
views::Dialog::text(format!("Delete user {name}?"))
|
||||
.button("Delete", {
|
||||
let db = db.clone();
|
||||
move |s| actually_delete(s, &db, id)
|
||||
|
@ -94,7 +92,7 @@ fn actually_delete(siv: &mut Cursive, db: &Arc<db::Database>, id: i32) {
|
|||
};
|
||||
if let Err(e) = result {
|
||||
siv.add_layer(
|
||||
views::Dialog::text(format!("Unable to delete user: {}", e))
|
||||
views::Dialog::text(format!("Unable to delete user: {}", e.chain()))
|
||||
.title("Error")
|
||||
.dismiss_button("Abort"),
|
||||
);
|
||||
|
@ -127,9 +125,7 @@ fn edit_user_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: Option<i32>
|
|||
let l = db.lock();
|
||||
let u = item.map(|id| l.users_by_id().get(&id).unwrap());
|
||||
username = u.map(|u| u.username.clone()).unwrap_or_default();
|
||||
id_str = item
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_else(|| "<new>".to_string());
|
||||
id_str = item.map_or_else(|| "<new>".to_string(), |id| id.to_string());
|
||||
has_password = u.map(|u| u.has_password()).unwrap_or(false);
|
||||
permissions = u.map(|u| u.permissions.clone()).unwrap_or_default();
|
||||
}
|
||||
|
@ -138,7 +134,7 @@ fn edit_user_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: Option<i32>
|
|||
.child(
|
||||
"username",
|
||||
views::EditView::new()
|
||||
.content(username.clone())
|
||||
.content(&username)
|
||||
.with_name("username"),
|
||||
);
|
||||
let mut layout = views::LinearLayout::vertical()
|
||||
|
@ -193,7 +189,7 @@ fn edit_user_dialog(db: &Arc<db::Database>, siv: &mut Cursive, item: Option<i32>
|
|||
] {
|
||||
let mut checkbox = views::Checkbox::new();
|
||||
checkbox.set_checked(*b);
|
||||
perms.add_child(name, checkbox.with_name(format!("perm_{}", name)));
|
||||
perms.add_child(name, checkbox.with_name(format!("perm_{name}")));
|
||||
}
|
||||
layout.add_child(perms);
|
||||
|
||||
|
|
|
@ -2,20 +2,16 @@
|
|||
// Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use failure::Error;
|
||||
use log::info;
|
||||
use base::Error;
|
||||
use bpaf::Bpaf;
|
||||
use std::path::PathBuf;
|
||||
use structopt::StructOpt;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Initializes a database.
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(command("init"))]
|
||||
pub struct Args {
|
||||
/// Directory holding the SQLite3 index database.
|
||||
#[structopt(
|
||||
long,
|
||||
default_value = "/var/lib/moonfire-nvr/db",
|
||||
value_name = "path",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
#[bpaf(external(crate::parse_db_dir))]
|
||||
db_dir: PathBuf,
|
||||
}
|
||||
|
||||
|
|
|
@ -5,51 +5,63 @@
|
|||
//! Subcommand to login a user (without requiring a password).
|
||||
|
||||
use base::clock::{self, Clocks};
|
||||
use base::{bail, err, Error};
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||
use bpaf::Bpaf;
|
||||
use db::auth::SessionFlag;
|
||||
use failure::{format_err, Error};
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
use std::path::PathBuf;
|
||||
use structopt::StructOpt;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Debug, Default, StructOpt)]
|
||||
fn parse_perms(perms: String) -> Result<crate::json::Permissions, serde_json::Error> {
|
||||
serde_json::from_str(&perms)
|
||||
}
|
||||
|
||||
fn parse_flags(flags: String) -> Result<Vec<SessionFlag>, base::Error> {
|
||||
flags
|
||||
.split(',')
|
||||
.map(|f| SessionFlag::from_str(f.trim()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Logs in a user, returning the session cookie.
|
||||
/// This is a privileged command that directly accesses the database. It doesn't check the
|
||||
/// user's password and even can be used to create sessions with permissions the user doesn't
|
||||
/// have.
|
||||
#[derive(Bpaf, Debug, PartialEq, Eq)]
|
||||
#[bpaf(command("login"))]
|
||||
pub struct Args {
|
||||
/// Directory holding the SQLite3 index database.
|
||||
#[structopt(
|
||||
long,
|
||||
default_value = "/var/lib/moonfire-nvr/db",
|
||||
value_name = "path",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
#[bpaf(external(crate::parse_db_dir))]
|
||||
db_dir: PathBuf,
|
||||
|
||||
/// Create a session with the given permissions.
|
||||
///
|
||||
/// Creates a session with the given permissions, as a JSON object.
|
||||
/// E.g. `{"viewVideo": true}`. See `ref/api.md` for a description of `Permissions`.
|
||||
/// If unspecified, uses user's default permissions.
|
||||
#[structopt(long, value_name="perms",
|
||||
parse(try_from_str = protobuf::text_format::parse_from_str))]
|
||||
permissions: Option<db::Permissions>,
|
||||
#[bpaf(argument::<String>("PERMS"), parse(parse_perms), optional)]
|
||||
permissions: Option<crate::json::Permissions>,
|
||||
|
||||
/// Restrict this cookie to the given domain.
|
||||
#[structopt(long)]
|
||||
/// Restricts this cookie to the given domain.
|
||||
#[bpaf(argument("DOMAIN"))]
|
||||
domain: Option<String>,
|
||||
|
||||
/// Write the cookie to a new curl-compatible cookie-jar file.
|
||||
///
|
||||
/// ---domain must be specified. This file can be used later with curl's --cookie flag.
|
||||
#[structopt(long, requires("domain"), value_name = "path")]
|
||||
/// Writes the cookie to a new curl-compatible cookie-jar file. `--domain`
|
||||
/// must be specified. This file can be used later with curl's `--cookie`
|
||||
/// flag.
|
||||
#[bpaf(argument("PATH"))]
|
||||
curl_cookie_jar: Option<PathBuf>,
|
||||
|
||||
/// Set the given db::auth::SessionFlags.
|
||||
#[structopt(
|
||||
long,
|
||||
default_value = "http-only,secure,same-site,same-site-strict",
|
||||
value_name = "flags",
|
||||
use_delimiter = true
|
||||
/// Sets the given db::auth::SessionFlags.
|
||||
#[bpaf(
|
||||
argument::<String>("FLAGS"),
|
||||
fallback("http-only,secure,same-site,same-site-strict".to_string()),
|
||||
debug_fallback,
|
||||
parse(parse_flags)
|
||||
)]
|
||||
session_flags: Vec<SessionFlag>,
|
||||
|
||||
/// Create the session for this username.
|
||||
/// Username to create a session for.
|
||||
#[bpaf(positional("USERNAME"))]
|
||||
username: String,
|
||||
}
|
||||
|
||||
|
@ -58,10 +70,13 @@ pub fn run(args: Args) -> Result<i32, Error> {
|
|||
let (_db_dir, conn) = super::open_conn(&args.db_dir, super::OpenMode::ReadWrite)?;
|
||||
let db = std::sync::Arc::new(db::Database::new(clocks, conn, true).unwrap());
|
||||
let mut l = db.lock();
|
||||
let u = l
|
||||
.get_user(&args.username)
|
||||
.ok_or_else(|| format_err!("no such user {:?}", &args.username))?;
|
||||
let permissions = args.permissions.as_ref().unwrap_or(&u.permissions).clone();
|
||||
let Some(u) = l.get_user(&args.username) else {
|
||||
bail!(NotFound, msg("no such user {:?}", &args.username));
|
||||
};
|
||||
let permissions = args
|
||||
.permissions
|
||||
.map(db::Permissions::from)
|
||||
.unwrap_or_else(|| u.permissions.clone());
|
||||
let creation = db::auth::Request {
|
||||
when_sec: Some(db.clocks().realtime().sec),
|
||||
user_agent: None,
|
||||
|
@ -80,20 +95,22 @@ pub fn run(args: Args) -> Result<i32, Error> {
|
|||
permissions,
|
||||
)?;
|
||||
let mut encoded = [0u8; 64];
|
||||
base64::encode_config_slice(&sid, base64::STANDARD_NO_PAD, &mut encoded);
|
||||
STANDARD_NO_PAD
|
||||
.encode_slice(sid, &mut encoded)
|
||||
.expect("base64 encode should succeed");
|
||||
let encoded = std::str::from_utf8(&encoded[..]).expect("base64 is valid UTF-8");
|
||||
|
||||
if let Some(ref p) = args.curl_cookie_jar {
|
||||
let d = args
|
||||
.domain
|
||||
.as_ref()
|
||||
.ok_or_else(|| format_err!("--cookiejar requires --domain"))?;
|
||||
.ok_or_else(|| err!(InvalidArgument, msg("--curl-cookie-jar requires --domain")))?;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(p)
|
||||
.map_err(|e| format_err!("Unable to open {}: {}", p.display(), e))?;
|
||||
.map_err(|e| err!(e, msg("unable to open {}", p.display())))?;
|
||||
write!(
|
||||
&mut f,
|
||||
"# Netscape HTTP Cookie File\n\
|
||||
|
@ -105,20 +122,19 @@ pub fn run(args: Args) -> Result<i32, Error> {
|
|||
f.sync_all()?;
|
||||
println!("Wrote cookie to {}", p.display());
|
||||
} else {
|
||||
println!("s={}", encoded);
|
||||
println!("s={encoded}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn curl_cookie(cookie: &str, flags: i32, domain: &str) -> String {
|
||||
format!(
|
||||
"{httponly}{domain}\t{tailmatch}\t{path}\t{secure}\t{expires}\t{name}\t{value}",
|
||||
"{httponly}{domain}\t{tailmatch}\t{path}\t{secure}\t{expires}\t{name}\t{cookie}",
|
||||
httponly = if (flags & SessionFlag::HttpOnly as i32) != 0 {
|
||||
"#HttpOnly_"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
domain = domain,
|
||||
tailmatch = "FALSE",
|
||||
path = "/",
|
||||
secure = if (flags & SessionFlag::Secure as i32) != 0 {
|
||||
|
@ -128,13 +144,42 @@ fn curl_cookie(cookie: &str, flags: i32, domain: &str) -> String {
|
|||
},
|
||||
expires = "9223372036854775807", // 64-bit CURL_OFF_T_MAX, never expires
|
||||
name = "s",
|
||||
value = cookie
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bpaf::Parser;
|
||||
|
||||
#[test]
|
||||
fn parse_args() {
|
||||
let args = args()
|
||||
.to_options()
|
||||
.run_inner(bpaf::Args::from(&[
|
||||
"login",
|
||||
"--permissions",
|
||||
"{\"viewVideo\": true}",
|
||||
"--session-flags",
|
||||
"http-only, same-site",
|
||||
"slamb",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
Args {
|
||||
db_dir: crate::DEFAULT_DB_DIR.into(),
|
||||
domain: None,
|
||||
curl_cookie_jar: None,
|
||||
permissions: Some(crate::json::Permissions {
|
||||
view_video: true,
|
||||
..Default::default()
|
||||
}),
|
||||
session_flags: vec![SessionFlag::HttpOnly, SessionFlag::SameSite],
|
||||
username: "slamb".to_owned(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_curl_cookie() {
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
// Copyright (C) 2016 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use base::{err, Error};
|
||||
use db::dir;
|
||||
use failure::{Error, Fail};
|
||||
use log::info;
|
||||
use nix::fcntl::FlockArg;
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
|
||||
pub mod check;
|
||||
pub mod config;
|
||||
|
@ -28,16 +28,19 @@ enum OpenMode {
|
|||
/// The returned `dir::Fd` holds the lock and should be kept open as long as the `Connection` is.
|
||||
fn open_dir(db_dir: &Path, mode: OpenMode) -> Result<dir::Fd, Error> {
|
||||
let dir = dir::Fd::open(db_dir, mode == OpenMode::Create).map_err(|e| {
|
||||
e.context(if mode == OpenMode::Create {
|
||||
format!("unable to create db dir {}", db_dir.display())
|
||||
if mode == OpenMode::Create {
|
||||
err!(e, msg("unable to create db dir {}", db_dir.display()))
|
||||
} else if e == nix::Error::ENOENT {
|
||||
format!(
|
||||
"db dir {} not found; try running moonfire-nvr init",
|
||||
db_dir.display()
|
||||
err!(
|
||||
NotFound,
|
||||
msg(
|
||||
"db dir {} not found; try running moonfire-nvr init",
|
||||
db_dir.display(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
format!("unable to open db dir {}", db_dir.display())
|
||||
})
|
||||
err!(e, msg("unable to open db dir {}", db_dir.display()))
|
||||
}
|
||||
})?;
|
||||
let ro = mode == OpenMode::ReadOnly;
|
||||
dir.lock(if ro {
|
||||
|
@ -46,11 +49,14 @@ fn open_dir(db_dir: &Path, mode: OpenMode) -> Result<dir::Fd, Error> {
|
|||
FlockArg::LockExclusiveNonblock
|
||||
})
|
||||
.map_err(|e| {
|
||||
e.context(format!(
|
||||
"unable to get {} lock on db dir {} ",
|
||||
if ro { "shared" } else { "exclusive" },
|
||||
db_dir.display()
|
||||
))
|
||||
err!(
|
||||
e,
|
||||
msg(
|
||||
"unable to get {} lock on db dir {} ",
|
||||
if ro { "shared" } else { "exclusive" },
|
||||
db_dir.display(),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
@ -66,7 +72,7 @@ fn open_conn(db_dir: &Path, mode: OpenMode) -> Result<(dir::Fd, rusqlite::Connec
|
|||
mode,
|
||||
rusqlite::version()
|
||||
);
|
||||
let conn = rusqlite::Connection::open_with_flags(
|
||||
let conn = rusqlite::Connection::open_with_flags_and_vfs(
|
||||
db_path,
|
||||
match mode {
|
||||
OpenMode::ReadOnly => rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
|
@ -75,9 +81,17 @@ fn open_conn(db_dir: &Path, mode: OpenMode) -> Result<(dir::Fd, rusqlite::Connec
|
|||
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
|
||||
},
|
||||
} |
|
||||
// rusqlite::Connection is not Sync, so there's no reason to tell SQLite3 to use the
|
||||
// `rusqlite::Connection` is not Sync, so there's no reason to tell SQLite3 to use the
|
||||
// serialized threading mode.
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
// In read/write mode, Moonfire holds a directory lock for its entire operation, as
|
||||
// described above. There's then no point in SQLite releasing its lock after each
|
||||
// transaction and reacquiring it, or in using shared memory for the wal-index.
|
||||
// See the following page: <https://www.sqlite.org/vfs.html>
|
||||
match mode {
|
||||
OpenMode::ReadOnly => "unix",
|
||||
_ => "unix-excl",
|
||||
},
|
||||
)?;
|
||||
Ok((dir, conn))
|
||||
}
|
||||
|
|
|
@ -3,32 +3,35 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
//! Runtime configuration file (`/etc/moonfire-nvr.toml`).
|
||||
//! See `ref/config.md` for more description.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
fn default_db_dir() -> PathBuf {
|
||||
"/var/lib/moonfire-nvr/db".into()
|
||||
}
|
||||
use crate::json::Permissions;
|
||||
|
||||
fn default_ui_dir() -> PathBuf {
|
||||
"/usr/local/lib/moonfire-nvr/ui".into()
|
||||
fn default_db_dir() -> PathBuf {
|
||||
crate::DEFAULT_DB_DIR.into()
|
||||
}
|
||||
|
||||
/// Top-level configuration file object.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigFile {
|
||||
pub binds: Vec<BindConfig>,
|
||||
|
||||
/// Directory holding the SQLite3 index database.
|
||||
///
|
||||
/// default: `/var/lib/moonfire-nvr/db`.
|
||||
#[serde(default = "default_db_dir")]
|
||||
pub db_dir: PathBuf,
|
||||
|
||||
/// Directory holding user interface files (`.html`, `.js`, etc).
|
||||
#[serde(default = "default_ui_dir")]
|
||||
pub ui_dir: PathBuf,
|
||||
#[cfg_attr(not(feature = "bundled-ui"), serde(default))]
|
||||
#[cfg_attr(feature = "bundled-ui", serde(default))]
|
||||
pub ui_dir: UiDir,
|
||||
|
||||
/// The number of worker threads used by the asynchronous runtime.
|
||||
///
|
||||
|
@ -37,9 +40,38 @@ pub struct ConfigFile {
|
|||
pub worker_threads: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", untagged)]
|
||||
pub enum UiDir {
|
||||
FromFilesystem(PathBuf),
|
||||
Bundled(#[allow(unused)] BundledUi),
|
||||
}
|
||||
|
||||
impl Default for UiDir {
|
||||
#[cfg(feature = "bundled-ui")]
|
||||
fn default() -> Self {
|
||||
UiDir::Bundled(BundledUi { bundled: true })
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "bundled-ui"))]
|
||||
fn default() -> Self {
|
||||
UiDir::FromFilesystem("/usr/local/lib/moonfire-nvr/ui".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BundledUi {
|
||||
/// Just a marker to select this variant.
|
||||
#[allow(unused)]
|
||||
bundled: bool,
|
||||
}
|
||||
|
||||
/// Per-bind configuration.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BindConfig {
|
||||
/// The address to bind to.
|
||||
#[serde(flatten)]
|
||||
|
@ -68,8 +100,8 @@ pub struct BindConfig {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AddressConfig {
|
||||
/// IPv4 address such as `0.0.0.0:8080` or `127.0.0.1:8080`.
|
||||
Ipv4(std::net::SocketAddrV4),
|
||||
|
@ -79,31 +111,10 @@ pub enum AddressConfig {
|
|||
|
||||
/// Unix socket path such as `/var/lib/moonfire-nvr/sock`.
|
||||
Unix(PathBuf),
|
||||
// TODO: SystemdFileDescriptorName(String), see
|
||||
// https://www.freedesktop.org/software/systemd/man/systemd.socket.html
|
||||
}
|
||||
|
||||
/// JSON analog of `Permissions` defined in `db/proto/schema.proto`.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Permissions {
|
||||
#[serde(default)]
|
||||
view_video: bool,
|
||||
|
||||
#[serde(default)]
|
||||
read_camera_configs: bool,
|
||||
|
||||
#[serde(default)]
|
||||
update_signals: bool,
|
||||
}
|
||||
|
||||
impl Permissions {
|
||||
pub fn as_proto(&self) -> db::schema::Permissions {
|
||||
db::schema::Permissions {
|
||||
view_video: self.view_video,
|
||||
read_camera_configs: self.read_camera_configs,
|
||||
update_signals: self.update_signals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `systemd` socket activation.
|
||||
///
|
||||
/// See [systemd.socket(5) manual
|
||||
/// page](https://www.freedesktop.org/software/systemd/man/systemd.socket.html).
|
||||
Systemd(#[cfg_attr(not(target_os = "linux"), allow(unused))] String),
|
||||
}
|
||||
|
|
|
@ -2,40 +2,45 @@
|
|||
// Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use crate::cmds::run::config::Permissions;
|
||||
use crate::streamer;
|
||||
use crate::web;
|
||||
use crate::web::accept::Listener;
|
||||
use base::clock;
|
||||
use base::err;
|
||||
use base::FastHashMap;
|
||||
use base::{bail, Error};
|
||||
use bpaf::Bpaf;
|
||||
use db::{dir, writer};
|
||||
use failure::{bail, Error, ResultExt};
|
||||
use fnv::FnvHashMap;
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use log::error;
|
||||
use log::{info, warn};
|
||||
use itertools::Itertools;
|
||||
use retina::client::SessionGroup;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use structopt::StructOpt;
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
use tracing::error;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use libsystemd::daemon::{notify, NotifyState};
|
||||
|
||||
use self::config::ConfigFile;
|
||||
|
||||
mod config;
|
||||
pub mod config;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Runs the server, saving recordings and allowing web access.
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(command("run"))]
|
||||
pub struct Args {
|
||||
#[structopt(short, long, default_value = "/etc/moonfire-nvr.toml")]
|
||||
/// Path to configuration file. See `ref/config.md` for config file documentation.
|
||||
#[bpaf(short, long, argument("PATH"), fallback("/etc/moonfire-nvr.toml".into()), debug_fallback)]
|
||||
config: PathBuf,
|
||||
|
||||
/// Open the database in read-only mode and disables recording.
|
||||
///
|
||||
/// Opens the database in read-only mode and disables recording.
|
||||
/// Note this is incompatible with session authentication; consider adding
|
||||
/// a bind with `allow_unauthenticated_permissions` to your config.
|
||||
#[structopt(long)]
|
||||
/// a bind with `allowUnauthenticatedPermissions` to your config.
|
||||
read_only: bool,
|
||||
}
|
||||
|
||||
|
@ -43,18 +48,13 @@ pub struct Args {
|
|||
// They seem to be correct for Linux and macOS at least.
|
||||
const LOCALTIME_PATH: &str = "/etc/localtime";
|
||||
const TIMEZONE_PATH: &str = "/etc/timezone";
|
||||
const ZONEINFO_PATHS: [&str; 2] = [
|
||||
"/usr/share/zoneinfo/", // Linux, macOS < High Sierra
|
||||
"/var/db/timezone/zoneinfo/", // macOS High Sierra
|
||||
];
|
||||
|
||||
fn trim_zoneinfo(path: &str) -> &str {
|
||||
for zp in &ZONEINFO_PATHS {
|
||||
if let Some(p) = path.strip_prefix(zp) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
path
|
||||
// Some well-known zone paths looks like the following:
|
||||
// /usr/share/zoneinfo/* for Linux and macOS < High Sierra
|
||||
// /var/db/timezone/zoneinfo/* for macOS High Sierra
|
||||
// /etc/zoneinfo/* for NixOS
|
||||
fn zoneinfo_name(path: &str) -> Option<&str> {
|
||||
path.rsplit_once("/zoneinfo/").map(|(_, name)| name)
|
||||
}
|
||||
|
||||
/// Attempt to resolve the timezone of the server.
|
||||
|
@ -72,13 +72,19 @@ fn resolve_zone() -> Result<String, Error> {
|
|||
p = &p[1..];
|
||||
}
|
||||
|
||||
p = trim_zoneinfo(p);
|
||||
if let Some(p) = zoneinfo_name(p) {
|
||||
return Ok(p.to_owned());
|
||||
}
|
||||
|
||||
if !p.starts_with('/') {
|
||||
return Ok(p.to_owned());
|
||||
}
|
||||
|
||||
if p != LOCALTIME_PATH {
|
||||
bail!("Unable to resolve env TZ={} to a timezone.", &tz);
|
||||
bail!(
|
||||
FailedPrecondition,
|
||||
msg("unable to resolve env TZ={tz} to a timezone")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -88,22 +94,23 @@ fn resolve_zone() -> Result<String, Error> {
|
|||
Ok(localtime_dest) => {
|
||||
let localtime_dest = match localtime_dest.to_str() {
|
||||
Some(d) => d,
|
||||
None => bail!("{} symlink destination is invalid UTF-8", LOCALTIME_PATH),
|
||||
None => bail!(
|
||||
FailedPrecondition,
|
||||
msg("{LOCALTIME_PATH} symlink destination is invalid UTF-8")
|
||||
),
|
||||
};
|
||||
let p = trim_zoneinfo(localtime_dest);
|
||||
if p.starts_with('/') {
|
||||
bail!(
|
||||
"Unable to resolve {} symlink destination {} to a timezone.",
|
||||
LOCALTIME_PATH,
|
||||
&localtime_dest
|
||||
);
|
||||
if let Some(p) = zoneinfo_name(localtime_dest) {
|
||||
return Ok(p.to_owned());
|
||||
}
|
||||
return Ok(p.to_owned());
|
||||
bail!(
|
||||
FailedPrecondition,
|
||||
msg("unable to resolve {LOCALTIME_PATH} symlink destination {localtime_dest} to a timezone"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
use ::std::io::ErrorKind;
|
||||
if e.kind() != ErrorKind::NotFound && e.kind() != ErrorKind::InvalidInput {
|
||||
bail!("Unable to read {} symlink: {}", LOCALTIME_PATH, e);
|
||||
bail!(e, msg("unable to read {LOCALTIME_PATH} symlink"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -113,10 +120,8 @@ fn resolve_zone() -> Result<String, Error> {
|
|||
Ok(z) => Ok(z.trim().to_owned()),
|
||||
Err(e) => {
|
||||
bail!(
|
||||
"Unable to resolve timezone from TZ env, {}, or {}. Last error: {}",
|
||||
LOCALTIME_PATH,
|
||||
TIMEZONE_PATH,
|
||||
e
|
||||
e,
|
||||
msg("unable to resolve timezone from TZ env, {LOCALTIME_PATH}, or {TIMEZONE_PATH}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -128,15 +133,70 @@ struct Syncer {
|
|||
join: thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_preopened_sockets() -> Result<FastHashMap<String, Listener>, Error> {
|
||||
use libsystemd::activation::IsType as _;
|
||||
use std::os::fd::{FromRawFd, IntoRawFd};
|
||||
|
||||
// `receive_descriptors_with_names` errors out if not running under systemd or not using socket
|
||||
// activation.
|
||||
if std::env::var_os("LISTEN_FDS").is_none() {
|
||||
info!("no LISTEN_FDs");
|
||||
return Ok(FastHashMap::default());
|
||||
}
|
||||
|
||||
let sockets = libsystemd::activation::receive_descriptors_with_names(false)
|
||||
.map_err(|e| err!(Unknown, source(e), msg("unable to receive systemd sockets")))?;
|
||||
sockets
|
||||
.into_iter()
|
||||
.map(|(fd, name)| {
|
||||
if fd.is_unix() {
|
||||
// SAFETY: yes, it's a socket we own.
|
||||
let l = unsafe { std::os::unix::net::UnixListener::from_raw_fd(fd.into_raw_fd()) };
|
||||
l.set_nonblocking(true)?;
|
||||
Ok(Some((
|
||||
name,
|
||||
Listener::Unix(tokio::net::UnixListener::from_std(l)?),
|
||||
)))
|
||||
} else if fd.is_inet() {
|
||||
// SAFETY: yes, it's a socket we own.
|
||||
let l = unsafe { std::net::TcpListener::from_raw_fd(fd.into_raw_fd()) };
|
||||
l.set_nonblocking(true)?;
|
||||
Ok(Some((
|
||||
name,
|
||||
Listener::Tcp(tokio::net::TcpListener::from_std(l)?),
|
||||
)))
|
||||
} else {
|
||||
warn!("ignoring systemd socket {name:?} which is not unix or inet");
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
.filter_map(Result::transpose)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn get_preopened_sockets() -> Result<FastHashMap<String, Listener>, Error> {
|
||||
Ok(FastHashMap::default())
|
||||
}
|
||||
|
||||
fn read_config(path: &Path) -> Result<ConfigFile, Error> {
|
||||
let config = std::fs::read(path)?;
|
||||
let config = toml::from_slice(&config)?;
|
||||
let config = std::str::from_utf8(&config).map_err(|e| err!(InvalidArgument, source(e)))?;
|
||||
let config = toml::from_str(config).map_err(|e| err!(InvalidArgument, source(e)))?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn run(args: Args) -> Result<i32, Error> {
|
||||
let config = read_config(&args.config)
|
||||
.with_context(|_| format!("unable to read {}", &args.config.display()))?;
|
||||
let config = read_config(&args.config).map_err(|e| {
|
||||
err!(
|
||||
e,
|
||||
msg(
|
||||
"unable to load config file {}; see documentation in ref/config.md",
|
||||
&args.config.display(),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut builder = tokio::runtime::Builder::new_multi_thread();
|
||||
builder.enable_all();
|
||||
|
@ -179,8 +239,8 @@ async fn async_run(read_only: bool, config: &ConfigFile) -> Result<i32, Error> {
|
|||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = int.recv() => bail!("immediate shutdown due to second signal (SIGINT)"),
|
||||
_ = term.recv() => bail!("immediate shutdown due to second singal (SIGTERM)"),
|
||||
_ = int.recv() => bail!(Cancelled, msg("immediate shutdown due to second signal (SIGINT)")),
|
||||
_ = term.recv() => bail!(Cancelled, msg("immediate shutdown due to second singal (SIGTERM)")),
|
||||
result = &mut inner => result,
|
||||
}
|
||||
}
|
||||
|
@ -206,23 +266,45 @@ fn prepare_unix_socket(p: &Path) {
|
|||
let _ = nix::unistd::unlink(p);
|
||||
}
|
||||
|
||||
fn make_listener(addr: &config::AddressConfig) -> Result<Listener, Error> {
|
||||
fn make_listener(
|
||||
addr: &config::AddressConfig,
|
||||
#[cfg_attr(not(target_os = "linux"), allow(unused))] preopened: &mut FastHashMap<
|
||||
String,
|
||||
Listener,
|
||||
>,
|
||||
) -> Result<Listener, Error> {
|
||||
let sa: SocketAddr = match addr {
|
||||
config::AddressConfig::Ipv4(a) => a.clone().into(),
|
||||
config::AddressConfig::Ipv6(a) => a.clone().into(),
|
||||
config::AddressConfig::Ipv4(a) => (*a).into(),
|
||||
config::AddressConfig::Ipv6(a) => (*a).into(),
|
||||
config::AddressConfig::Unix(p) => {
|
||||
prepare_unix_socket(p);
|
||||
return Ok(Listener::Unix(
|
||||
tokio::net::UnixListener::bind(p)
|
||||
.with_context(|_| format!("unable bind Unix socket {}", p.display()))?,
|
||||
));
|
||||
return Ok(Listener::Unix(tokio::net::UnixListener::bind(p).map_err(
|
||||
|e| err!(e, msg("unable bind Unix socket {}", p.display())),
|
||||
)?));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
config::AddressConfig::Systemd(n) => {
|
||||
return preopened.remove(n).ok_or_else(|| {
|
||||
err!(
|
||||
NotFound,
|
||||
msg(
|
||||
"can't find systemd socket named {}; available sockets are: {}",
|
||||
n,
|
||||
preopened.keys().join(", ")
|
||||
)
|
||||
)
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
config::AddressConfig::Systemd(_) => {
|
||||
bail!(Unimplemented, msg("systemd sockets are Linux-only"))
|
||||
}
|
||||
};
|
||||
|
||||
// Go through std::net::TcpListener to avoid needing async. That's there for DNS resolution,
|
||||
// but it's unnecessary when starting from a SocketAddr.
|
||||
let listener = std::net::TcpListener::bind(&sa)
|
||||
.with_context(|_| format!("unable to bind TCP socket {}", &sa))?;
|
||||
let listener = std::net::TcpListener::bind(sa)
|
||||
.map_err(|e| err!(e, msg("unable to bind TCP socket {sa}")))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
Ok(Listener::Tcp(tokio::net::TcpListener::from_std(listener)?))
|
||||
}
|
||||
|
@ -260,11 +342,11 @@ async fn inner(
|
|||
|
||||
// Start a streamer for each stream.
|
||||
let mut streamers = Vec::new();
|
||||
let mut session_groups_by_camera: FnvHashMap<i32, Arc<retina::client::SessionGroup>> =
|
||||
FnvHashMap::default();
|
||||
let mut session_groups_by_camera: FastHashMap<i32, Arc<retina::client::SessionGroup>> =
|
||||
FastHashMap::default();
|
||||
let syncers = if !read_only {
|
||||
let l = db.lock();
|
||||
let mut dirs = FnvHashMap::with_capacity_and_hasher(
|
||||
let mut dirs = FastHashMap::with_capacity_and_hasher(
|
||||
l.sample_file_dirs_by_id().len(),
|
||||
Default::default(),
|
||||
);
|
||||
|
@ -296,7 +378,7 @@ async fn inner(
|
|||
|
||||
// Then, with the lock dropped, create syncers.
|
||||
drop(l);
|
||||
let mut syncers = FnvHashMap::with_capacity_and_hasher(dirs.len(), Default::default());
|
||||
let mut syncers = FastHashMap::with_capacity_and_hasher(dirs.len(), Default::default());
|
||||
for (id, dir) in dirs.drain() {
|
||||
let (channel, join) = writer::start_syncer(db.clone(), shutdown_rx.clone(), id)?;
|
||||
syncers.insert(id, Syncer { dir, channel, join });
|
||||
|
@ -341,15 +423,18 @@ async fn inner(
|
|||
rotate_offset_sec,
|
||||
streamer::ROTATE_INTERVAL_SEC,
|
||||
)?;
|
||||
info!("Starting streamer for {}", streamer.short_name());
|
||||
let name = format!("s-{}", streamer.short_name());
|
||||
let span = tracing::info_span!("streamer", stream = streamer.short_name());
|
||||
let thread_name = format!("s-{}", streamer.short_name());
|
||||
let handle = handle.clone();
|
||||
streamers.push(
|
||||
thread::Builder::new()
|
||||
.name(name)
|
||||
.name(thread_name)
|
||||
.spawn(move || {
|
||||
let _enter = handle.enter();
|
||||
streamer.run();
|
||||
span.in_scope(|| {
|
||||
let _enter_tokio = handle.enter();
|
||||
info!("starting");
|
||||
streamer.run();
|
||||
})
|
||||
})
|
||||
.expect("can't create thread"),
|
||||
);
|
||||
|
@ -362,6 +447,7 @@ async fn inner(
|
|||
|
||||
// Start the web interface(s).
|
||||
let own_euid = nix::unistd::Uid::effective();
|
||||
let mut preopened = get_preopened_sockets()?;
|
||||
let web_handles: Result<Vec<_>, Error> = config
|
||||
.binds
|
||||
.iter()
|
||||
|
@ -371,11 +457,11 @@ async fn inner(
|
|||
ui_dir: Some(&config.ui_dir),
|
||||
allow_unauthenticated_permissions: b
|
||||
.allow_unauthenticated_permissions
|
||||
.as_ref()
|
||||
.map(Permissions::as_proto),
|
||||
.clone()
|
||||
.map(db::Permissions::from),
|
||||
trust_forward_hdrs: b.trust_forward_headers,
|
||||
time_zone_name: time_zone_name.clone(),
|
||||
privileged_unix_uid: b.own_uid_is_privileged.then(|| own_euid),
|
||||
privileged_unix_uid: b.own_uid_is_privileged.then_some(own_euid),
|
||||
})?);
|
||||
let make_svc = make_service_fn(move |conn: &crate::web::accept::Conn| {
|
||||
let conn_data = *conn.data();
|
||||
|
@ -384,16 +470,36 @@ async fn inner(
|
|||
move |req| Arc::clone(&svc).serve(req, conn_data)
|
||||
}))
|
||||
});
|
||||
let listener = make_listener(&b.address)?;
|
||||
let listener = make_listener(&b.address, &mut preopened)?;
|
||||
let server = ::hyper::Server::builder(listener).serve(make_svc);
|
||||
let server = server.with_graceful_shutdown(shutdown_rx.future());
|
||||
Ok(tokio::spawn(server))
|
||||
})
|
||||
.collect();
|
||||
let web_handles = web_handles?;
|
||||
if !preopened.is_empty() {
|
||||
warn!(
|
||||
"ignoring systemd sockets not referenced in config: {}",
|
||||
preopened.keys().join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Err(err) = notify(false, &[NotifyState::Ready]) {
|
||||
tracing::warn!(%err, "unable to notify systemd on ready");
|
||||
}
|
||||
}
|
||||
|
||||
info!("Ready to serve HTTP requests");
|
||||
let _ = shutdown_rx.as_future().await;
|
||||
shutdown_rx.as_future().await;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Err(err) = notify(false, &[NotifyState::Stopping]) {
|
||||
tracing::warn!(%err, "unable to notify systemd on stopping");
|
||||
}
|
||||
}
|
||||
|
||||
info!("Shutting down streamers and syncers.");
|
||||
tokio::task::spawn_blocking({
|
||||
|
@ -401,7 +507,7 @@ async fn inner(
|
|||
move || {
|
||||
for streamer in streamers.drain(..) {
|
||||
if streamer.join().is_err() {
|
||||
log::error!("streamer panicked; look for previous panic message");
|
||||
tracing::error!("streamer panicked; look for previous panic message");
|
||||
}
|
||||
}
|
||||
if let Some(mut ss) = syncers {
|
||||
|
@ -415,19 +521,22 @@ async fn inner(
|
|||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
.await
|
||||
.map_err(|e| err!(Unknown, source(e)))?;
|
||||
|
||||
db.lock().clear_watches();
|
||||
|
||||
info!("Waiting for HTTP requests to finish.");
|
||||
for h in web_handles {
|
||||
h.await??;
|
||||
h.await
|
||||
.map_err(|e| err!(Unknown, source(e)))?
|
||||
.map_err(|e| err!(Unknown, source(e)))?;
|
||||
}
|
||||
|
||||
info!("Waiting for TEARDOWN requests to complete.");
|
||||
for g in session_groups_by_camera.values() {
|
||||
if let Err(e) = g.await_teardown().await {
|
||||
error!("{}", e);
|
||||
if let Err(err) = g.await_teardown().await {
|
||||
error!(%err, "teardown failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,35 +5,32 @@
|
|||
//! Subcommand to run a SQLite shell.
|
||||
|
||||
use super::OpenMode;
|
||||
use failure::Error;
|
||||
use base::Error;
|
||||
use bpaf::Bpaf;
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use structopt::StructOpt;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Runs a SQLite3 shell on Moonfire NVR's index database.
|
||||
///
|
||||
///
|
||||
/// Note this locks the database to prevent simultaneous access with a running server. The
|
||||
/// server maintains cached state which could be invalidated otherwise.
|
||||
#[derive(Bpaf, Debug, PartialEq, Eq)]
|
||||
#[bpaf(command("sql"))]
|
||||
pub struct Args {
|
||||
/// Directory holding the SQLite3 index database.
|
||||
#[structopt(
|
||||
long,
|
||||
default_value = "/var/lib/moonfire-nvr/db",
|
||||
value_name = "path",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
#[bpaf(external(crate::parse_db_dir))]
|
||||
db_dir: PathBuf,
|
||||
|
||||
/// Opens the database in read-only mode and locks it only for shared access.
|
||||
///
|
||||
/// This can be run simultaneously with "moonfire-nvr run --read-only".
|
||||
#[structopt(long)]
|
||||
/// This can be run simultaneously with `moonfire-nvr run --read-only`.
|
||||
read_only: bool,
|
||||
|
||||
/// Arguments to pass to sqlite3.
|
||||
///
|
||||
/// Use the -- separator to pass sqlite3 options, as in
|
||||
/// "moonfire-nvr sql -- -line 'select username from user'".
|
||||
#[structopt(parse(from_os_str))]
|
||||
/// Use the `--` separator to pass sqlite3 options, as in
|
||||
/// `moonfire-nvr sql -- -line 'select username from user'`.
|
||||
#[bpaf(positional)]
|
||||
arg: Vec<OsString>,
|
||||
}
|
||||
|
||||
|
@ -52,14 +49,38 @@ pub fn run(args: Args) -> Result<i32, Error> {
|
|||
db.push("?mode=ro");
|
||||
}
|
||||
Err(Command::new("sqlite3")
|
||||
.args(
|
||||
db::db::INTEGRITY_PRAGMAS
|
||||
.iter()
|
||||
.map(|p| ["-cmd", p])
|
||||
.flatten(),
|
||||
)
|
||||
.args(db::db::INTEGRITY_PRAGMAS.iter().flat_map(|p| ["-cmd", p]))
|
||||
.arg(&db)
|
||||
.args(&args.arg)
|
||||
.exec()
|
||||
.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bpaf::Parser;
|
||||
|
||||
#[test]
|
||||
fn parse_args() {
|
||||
let args = args()
|
||||
.to_options()
|
||||
.run_inner(bpaf::Args::from(&[
|
||||
"sql",
|
||||
"--db-dir",
|
||||
"/foo/bar",
|
||||
"--",
|
||||
"-line",
|
||||
"select username from user",
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
Args {
|
||||
db_dir: "/foo/bar".into(),
|
||||
read_only: false, // default
|
||||
arg: vec!["-line".into(), "select username from user".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,18 +2,20 @@
|
|||
// Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use failure::Error;
|
||||
use structopt::StructOpt;
|
||||
use base::Error;
|
||||
use bpaf::Bpaf;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Translates between integer and human-readable timestamps.
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(command("ts"))]
|
||||
pub struct Args {
|
||||
/// Timestamp(s) to translate.
|
||||
///
|
||||
/// May be either an integer or an RFC-3339-like string:
|
||||
/// `YYYY-mm-dd[THH:MM[:SS[:FFFFF]]][{Z,{+,-,}HH:MM}]`.
|
||||
///
|
||||
/// Eg: `142913484000000`, `2020-04-26`, `2020-04-26T12:00:00:00000-07:00`.
|
||||
#[structopt(required = true)]
|
||||
/// E.g.: `142913484000000`, `2020-04-26`, `2020-04-26T12:00:00:00000-07:00`.
|
||||
#[bpaf(positional("TS"), some("must specify at least one timestamp"))]
|
||||
timestamps: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
|
@ -5,38 +5,28 @@
|
|||
/// Upgrades the database schema.
|
||||
///
|
||||
/// See `guide/schema.md` for more information.
|
||||
use failure::Error;
|
||||
use structopt::StructOpt;
|
||||
use base::Error;
|
||||
use bpaf::Bpaf;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
/// Upgrades to the latest database schema.
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(command("upgrade"))]
|
||||
pub struct Args {
|
||||
#[structopt(
|
||||
long,
|
||||
help = "Directory holding the SQLite3 index database.",
|
||||
default_value = "/var/lib/moonfire-nvr/db",
|
||||
parse(from_os_str)
|
||||
)]
|
||||
#[bpaf(external(crate::parse_db_dir))]
|
||||
db_dir: std::path::PathBuf,
|
||||
|
||||
#[structopt(
|
||||
help = "When upgrading from schema version 1 to 2, the sample file directory.",
|
||||
long,
|
||||
parse(from_os_str)
|
||||
)]
|
||||
/// When upgrading from schema version 1 to 2, the sample file directory.
|
||||
#[bpaf(argument("PATH"))]
|
||||
sample_file_dir: Option<std::path::PathBuf>,
|
||||
|
||||
#[structopt(
|
||||
help = "Resets the SQLite journal_mode to the specified mode prior to \
|
||||
the upgrade. The default, delete, is recommended. off is very \
|
||||
dangerous but may be desirable in some circumstances. See \
|
||||
guide/schema.md for more information. The journal mode will be \
|
||||
reset to wal after the upgrade.",
|
||||
long,
|
||||
default_value = "delete"
|
||||
)]
|
||||
/// Resets the SQLite journal_mode to the specified mode prior to
|
||||
/// the upgrade. `off` is very dangerous but may be desirable in some
|
||||
/// circumstances. See `guide/schema.md` for more information. The journal
|
||||
/// mode will be reset to `wal` after the upgrade.
|
||||
#[bpaf(argument("MODE"), fallback("delete".to_owned()), debug_fallback)]
|
||||
preset_journal: String,
|
||||
|
||||
#[structopt(help = "Skips the normal post-upgrade vacuum operation.", long)]
|
||||
/// Skips the normal post-upgrade vacuum operation.
|
||||
no_vacuum: bool,
|
||||
}
|
||||
|
||||
|
@ -49,6 +39,7 @@ pub fn run(args: Args) -> Result<i32, Error> {
|
|||
preset_journal: &args.preset_journal,
|
||||
no_vacuum: args.no_vacuum,
|
||||
},
|
||||
crate::VERSION,
|
||||
&mut conn,
|
||||
)?;
|
||||
Ok(0)
|
||||
|
|
|
@ -18,9 +18,11 @@
|
|||
//! through ffmpeg's own generated `.mp4` file. Extracting just this part of their `.mp4` files
|
||||
//! would be more trouble than it's worth.
|
||||
|
||||
use base::{bail, err, Error};
|
||||
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
|
||||
use db::VideoSampleEntryToInsert;
|
||||
use failure::{bail, format_err, Error};
|
||||
use h264_reader::nal::Nal;
|
||||
use pretty_hex::PrettyHex as _;
|
||||
use std::convert::TryFrom;
|
||||
|
||||
// For certain common sub stream anamorphic resolutions, add a pixel aspect ratio box.
|
||||
|
@ -60,36 +62,149 @@ fn default_pixel_aspect_ratio(width: u16, height: u16) -> (u16, u16) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parses the `AvcDecoderConfigurationRecord` in the "extra data".
|
||||
pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Error> {
|
||||
let avcc = h264_reader::avcc::AvcDecoderConfigurationRecord::try_from(extradata)
|
||||
.map_err(|e| format_err!("Bad AvcDecoderConfigurationRecord: {:?}", e))?;
|
||||
if avcc.num_of_sequence_parameter_sets() != 1 {
|
||||
bail!("Multiple SPSs!");
|
||||
/// `h264_reader::rbsp::BitRead` impl that does not care about extra trailing data.
|
||||
///
|
||||
/// Some (Reolink) cameras appear to have a stray extra byte at the end. Follow the lead of most
|
||||
/// other RTSP implementations in tolerating this.
|
||||
#[derive(Debug)]
|
||||
struct TolerantBitReader<R> {
|
||||
inner: R,
|
||||
}
|
||||
|
||||
impl<R: h264_reader::rbsp::BitRead> h264_reader::rbsp::BitRead for TolerantBitReader<R> {
|
||||
fn read_ue(&mut self, name: &'static str) -> Result<u32, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_ue(name)
|
||||
}
|
||||
let ctx = avcc
|
||||
.create_context(())
|
||||
.map_err(|e| format_err!("Can't load SPS+PPS: {:?}", e))?;
|
||||
|
||||
fn read_se(&mut self, name: &'static str) -> Result<i32, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_se(name)
|
||||
}
|
||||
|
||||
fn read_bool(&mut self, name: &'static str) -> Result<bool, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_bool(name)
|
||||
}
|
||||
|
||||
fn read_u8(
|
||||
&mut self,
|
||||
bit_count: u32,
|
||||
name: &'static str,
|
||||
) -> Result<u8, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_u8(bit_count, name)
|
||||
}
|
||||
|
||||
fn read_u16(
|
||||
&mut self,
|
||||
bit_count: u32,
|
||||
name: &'static str,
|
||||
) -> Result<u16, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_u16(bit_count, name)
|
||||
}
|
||||
|
||||
fn read_u32(
|
||||
&mut self,
|
||||
bit_count: u32,
|
||||
name: &'static str,
|
||||
) -> Result<u32, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_u32(bit_count, name)
|
||||
}
|
||||
|
||||
fn read_i32(
|
||||
&mut self,
|
||||
bit_count: u32,
|
||||
name: &'static str,
|
||||
) -> Result<i32, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.read_i32(bit_count, name)
|
||||
}
|
||||
|
||||
fn has_more_rbsp_data(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
) -> Result<bool, h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.has_more_rbsp_data(name)
|
||||
}
|
||||
|
||||
fn finish_rbsp(self) -> Result<(), h264_reader::rbsp::BitReaderError> {
|
||||
match self.inner.finish_rbsp() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(h264_reader::rbsp::BitReaderError::RemainingData) => {
|
||||
tracing::debug!("extra data at end of NAL unit");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_sei_payload(self) -> Result<(), h264_reader::rbsp::BitReaderError> {
|
||||
self.inner.finish_sei_payload()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_extra_data_inner(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Error> {
|
||||
let avcc =
|
||||
h264_reader::avcc::AvcDecoderConfigurationRecord::try_from(extradata).map_err(|e| {
|
||||
err!(
|
||||
InvalidArgument,
|
||||
msg("bad AvcDecoderConfigurationRecord: {:?}", e)
|
||||
)
|
||||
})?;
|
||||
if avcc.num_of_sequence_parameter_sets() != 1 {
|
||||
bail!(Unimplemented, msg("multiple SPSs!"));
|
||||
}
|
||||
|
||||
// This logic is essentially copied from
|
||||
// `h264_reader::avcc::AvcDecoderConfigurationRecord::create_context` but
|
||||
// using our `TolerantBitReader` wrapper.
|
||||
let mut ctx = h264_reader::Context::new();
|
||||
for sps in avcc.sequence_parameter_sets() {
|
||||
let sps = h264_reader::nal::RefNal::new(
|
||||
sps.map_err(|e| err!(InvalidArgument, msg("bad SPS: {e:?}")))?,
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
let sps = h264_reader::nal::sps::SeqParameterSet::from_bits(TolerantBitReader {
|
||||
inner: sps.rbsp_bits(),
|
||||
})
|
||||
.map_err(|e| err!(InvalidArgument, msg("bad SPS: {e:?}")))?;
|
||||
ctx.put_seq_param_set(sps);
|
||||
}
|
||||
for pps in avcc.picture_parameter_sets() {
|
||||
let pps = h264_reader::nal::RefNal::new(
|
||||
pps.map_err(|e| err!(InvalidArgument, msg("bad PPS: {e:?}")))?,
|
||||
&[],
|
||||
true,
|
||||
);
|
||||
let pps = h264_reader::nal::pps::PicParameterSet::from_bits(
|
||||
&ctx,
|
||||
TolerantBitReader {
|
||||
inner: pps.rbsp_bits(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| err!(InvalidArgument, msg("bad PPS: {e:?}")))?;
|
||||
ctx.put_pic_param_set(pps);
|
||||
}
|
||||
|
||||
let sps = ctx
|
||||
.sps_by_id(h264_reader::nal::pps::ParamSetId::from_u32(0).unwrap())
|
||||
.ok_or_else(|| format_err!("No SPS 0"))?;
|
||||
let pixel_dimensions = sps
|
||||
.pixel_dimensions()
|
||||
.map_err(|e| format_err!("SPS has invalid pixel dimensions: {:?}", e))?;
|
||||
let width = u16::try_from(pixel_dimensions.0).map_err(|_| {
|
||||
format_err!(
|
||||
"bad dimensions {}x{}",
|
||||
pixel_dimensions.0,
|
||||
pixel_dimensions.1
|
||||
)
|
||||
})?;
|
||||
let height = u16::try_from(pixel_dimensions.1).map_err(|_| {
|
||||
format_err!(
|
||||
"bad dimensions {}x{}",
|
||||
pixel_dimensions.0,
|
||||
pixel_dimensions.1
|
||||
.ok_or_else(|| err!(Unimplemented, msg("no SPS 0")))?;
|
||||
let pixel_dimensions = sps.pixel_dimensions().map_err(|e| {
|
||||
err!(
|
||||
InvalidArgument,
|
||||
msg("SPS has invalid pixel dimensions: {:?}", e)
|
||||
)
|
||||
})?;
|
||||
let (Ok(width), Ok(height)) = (
|
||||
u16::try_from(pixel_dimensions.0),
|
||||
u16::try_from(pixel_dimensions.1),
|
||||
) else {
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg(
|
||||
"bad dimensions {}x{}",
|
||||
pixel_dimensions.0,
|
||||
pixel_dimensions.1
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
let mut sample_entry = Vec::with_capacity(256);
|
||||
|
||||
|
@ -130,7 +245,7 @@ pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Er
|
|||
let cur_pos = sample_entry.len();
|
||||
BigEndian::write_u32(
|
||||
&mut sample_entry[avcc_len_pos..avcc_len_pos + 4],
|
||||
u32::try_from(cur_pos - avcc_len_pos)?,
|
||||
u32::try_from(cur_pos - avcc_len_pos).map_err(|_| err!(OutOfRange))?,
|
||||
);
|
||||
|
||||
// PixelAspectRatioBox, ISO/IEC 14496-12 section 12.1.4.2.
|
||||
|
@ -150,17 +265,14 @@ pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Er
|
|||
let cur_pos = sample_entry.len();
|
||||
BigEndian::write_u32(
|
||||
&mut sample_entry[avc1_len_pos..avc1_len_pos + 4],
|
||||
u32::try_from(cur_pos - avc1_len_pos)?,
|
||||
u32::try_from(cur_pos - avc1_len_pos).map_err(|_| err!(OutOfRange))?,
|
||||
);
|
||||
|
||||
let profile_idc = sample_entry[103];
|
||||
let constraint_flags = sample_entry[104];
|
||||
let level_idc = sample_entry[105];
|
||||
|
||||
let rfc6381_codec = format!(
|
||||
"avc1.{:02x}{:02x}{:02x}",
|
||||
profile_idc, constraint_flags, level_idc
|
||||
);
|
||||
let rfc6381_codec = format!("avc1.{profile_idc:02x}{constraint_flags:02x}{level_idc:02x}");
|
||||
Ok(VideoSampleEntryToInsert {
|
||||
data: sample_entry,
|
||||
rfc6381_codec,
|
||||
|
@ -171,17 +283,52 @@ pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Er
|
|||
})
|
||||
}
|
||||
|
||||
/// Parses the `AvcDecoderConfigurationRecord` in the "extra data".
|
||||
pub fn parse_extra_data(extradata: &[u8]) -> Result<VideoSampleEntryToInsert, Error> {
|
||||
parse_extra_data_inner(extradata).map_err(|e| {
|
||||
err!(
|
||||
e,
|
||||
msg(
|
||||
"can't parse AvcDecoderRecord {}",
|
||||
extradata.hex_conf(pretty_hex::HexConfig {
|
||||
width: 0,
|
||||
group: 0,
|
||||
chunk: 0,
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use db::testutil;
|
||||
|
||||
#[rustfmt::skip]
|
||||
const AVC_DECODER_CONFIG_TEST_INPUT: [u8; 38] = [
|
||||
0x01, 0x4d, 0x00, 0x1f, 0xff, 0xe1, 0x00, 0x17,
|
||||
0x01, 0x4d, 0x00, 0x1f, 0xff,
|
||||
|
||||
0xe1, 0x00, 0x17, // 1 SPS, length 0x17
|
||||
0x67, 0x4d, 0x00, 0x1f, 0x9a, 0x66, 0x02, 0x80,
|
||||
0x2d, 0xff, 0x35, 0x01, 0x01, 0x01, 0x40, 0x00,
|
||||
0x00, 0xfa, 0x00, 0x00, 0x1d, 0x4c, 0x01,
|
||||
|
||||
0x01, 0x00, 0x04, // 1 PPS, length 0x04
|
||||
0x68, 0xee, 0x3c, 0x80,
|
||||
];
|
||||
|
||||
#[rustfmt::skip]
|
||||
const AVC_DECODER_CONFIG_TEST_INPUT_WITH_TRAILING_GARBAGE: [u8; 40] = [
|
||||
0x01, 0x4d, 0x00, 0x1f, 0xff,
|
||||
|
||||
0xe1, 0x00, 0x18, // 1 SPS, length 0x18
|
||||
0x67, 0x4d, 0x00, 0x1f, 0x9a, 0x66, 0x02, 0x80,
|
||||
0x2d, 0xff, 0x35, 0x01, 0x01, 0x01, 0x40, 0x00,
|
||||
0x00, 0xfa, 0x00, 0x00, 0x1d, 0x4c, 0x01, 0x01,
|
||||
0x00, 0x04, 0x68, 0xee, 0x3c, 0x80,
|
||||
|
||||
0x01, 0x00, 0x04, // 1 PPS, length 0x05
|
||||
0x68, 0xee, 0x3c, 0x80, 0x80,
|
||||
];
|
||||
|
||||
#[rustfmt::skip]
|
||||
|
@ -228,4 +375,9 @@ mod tests {
|
|||
assert_eq!(Ratio::new(h * h_spacing, w * v_spacing), Ratio::new(9, 16));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_sps_data() {
|
||||
super::parse_extra_data(&AVC_DECODER_CONFIG_TEST_INPUT_WITH_TRAILING_GARBAGE).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
// Copyright (C) 2020 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
//! JSON/TOML-compatible serde types for use in the web API and `moonfire-nvr.toml`.
|
||||
|
||||
use base::time::{Duration, Time};
|
||||
use base::{err, Error};
|
||||
use db::auth::SessionHash;
|
||||
use failure::{format_err, Error};
|
||||
use serde::ser::{Error as _, SerializeMap, SerializeSeq, Serializer};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::ops::Not;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
@ -22,6 +24,8 @@ pub struct TopLevel<'a> {
|
|||
#[serde(serialize_with = "TopLevel::serialize_cameras")]
|
||||
pub cameras: (&'a db::LockedDatabase, bool, bool),
|
||||
|
||||
pub permissions: Permissions,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<ToplevelUser>,
|
||||
|
||||
|
@ -51,7 +55,7 @@ impl Session {
|
|||
}
|
||||
|
||||
/// JSON serialization wrapper for a single camera when processing `/api/` and
|
||||
/// `/api/cameras/<uuid>/`. See `design/api.md` for details.
|
||||
/// `/api/cameras/<uuid>/`. See `ref/api.md` for details.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Camera<'a> {
|
||||
|
@ -118,12 +122,15 @@ pub struct LoginRequest<'a> {
|
|||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogoutRequest<'a> {
|
||||
#[serde(borrow)]
|
||||
pub csrf: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PostSignalsRequest {
|
||||
pub struct PostSignalsRequest<'a> {
|
||||
#[serde(borrow)]
|
||||
pub csrf: Option<&'a str>,
|
||||
pub signal_ids: Vec<u32>,
|
||||
pub states: Vec<u16>,
|
||||
pub start: PostSignalsTimeBase,
|
||||
|
@ -223,7 +230,7 @@ impl<'a> Stream<'a> {
|
|||
let s = db
|
||||
.streams_by_id()
|
||||
.get(&id)
|
||||
.ok_or_else(|| format_err!("missing stream {}", id))?;
|
||||
.ok_or_else(|| err!(Internal, msg("missing stream {id}")))?;
|
||||
Ok(Some(Stream {
|
||||
id: s.id,
|
||||
retain_bytes: s.config.retain_bytes,
|
||||
|
@ -289,7 +296,7 @@ impl<'a> Signal<'a> {
|
|||
let mut map = serializer.serialize_map(Some(s.config.camera_associations.len()))?;
|
||||
for (camera_id, association) in &s.config.camera_associations {
|
||||
let c = db.cameras_by_id().get(camera_id).ok_or_else(|| {
|
||||
S::Error::custom(format!("signal has missing camera id {}", camera_id))
|
||||
S::Error::custom(format!("signal has missing camera id {camera_id}"))
|
||||
})?;
|
||||
map.serialize_key(&c.uuid)?;
|
||||
map.serialize_value(association.as_str())?;
|
||||
|
@ -446,7 +453,7 @@ impl<'a> ListRecordings<'a> {
|
|||
for id in v {
|
||||
map.serialize_entry(
|
||||
id,
|
||||
&VideoSampleEntry::from(&db.video_sample_entries_by_id().get(id).unwrap()),
|
||||
&VideoSampleEntry::from(db.video_sample_entries_by_id().get(id).unwrap()),
|
||||
)?;
|
||||
}
|
||||
map.end()
|
||||
|
@ -463,6 +470,7 @@ pub struct Recording {
|
|||
pub video_sample_entry_id: i32,
|
||||
pub start_id: i32,
|
||||
pub open_id: u32,
|
||||
pub run_start_id: i32,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_uncommitted: Option<i32>,
|
||||
|
@ -475,6 +483,9 @@ pub struct Recording {
|
|||
|
||||
#[serde(skip_serializing_if = "Not::not")]
|
||||
pub has_trailing_zero: bool,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
@ -513,13 +524,129 @@ pub struct ToplevelUser {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PostUser {
|
||||
pub update: Option<UserSubset>,
|
||||
pub precondition: Option<UserSubset>,
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PutUsers<'a> {
|
||||
#[serde(borrow)]
|
||||
pub csrf: Option<&'a str>,
|
||||
pub user: UserSubset<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserSubset {
|
||||
pub preferences: Option<db::json::UserPreferences>,
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PostUser<'a> {
|
||||
#[serde(borrow)]
|
||||
pub csrf: Option<&'a str>,
|
||||
pub update: Option<UserSubset<'a>>,
|
||||
pub precondition: Option<UserSubset<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteUser<'a> {
|
||||
#[serde(borrow)]
|
||||
pub csrf: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UserSubset<'a> {
|
||||
#[serde(borrow)]
|
||||
pub username: Option<&'a str>,
|
||||
|
||||
pub disabled: Option<bool>,
|
||||
|
||||
pub preferences: Option<db::json::UserPreferences>,
|
||||
|
||||
/// An optional password value.
|
||||
///
|
||||
/// `None` indicates the password does not wish to check/update the password.
|
||||
/// `Some(None)` indicates the password should be absent.
|
||||
#[serde(borrow, default, deserialize_with = "deserialize_some")]
|
||||
pub password: Option<Option<&'a str>>,
|
||||
|
||||
pub permissions: Option<Permissions>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a db::User> for UserSubset<'a> {
|
||||
fn from(u: &'a db::User) -> Self {
|
||||
Self {
|
||||
username: Some(&u.username),
|
||||
disabled: Some(u.config.disabled),
|
||||
preferences: Some(u.config.preferences.clone()),
|
||||
password: Some(u.has_password().then_some("(censored)")),
|
||||
permissions: Some(u.permissions.clone().into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any value that is present is considered Some value, including null.
|
||||
// https://github.com/serde-rs/serde/issues/984#issuecomment-314143738
|
||||
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
T: Deserialize<'de>,
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Deserialize::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
/// API/config analog of `Permissions` defined in `db/proto/schema.proto`.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Permissions {
|
||||
#[serde(default)]
|
||||
pub view_video: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub read_camera_configs: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub update_signals: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub admin_users: bool,
|
||||
}
|
||||
|
||||
impl From<Permissions> for db::schema::Permissions {
|
||||
fn from(p: Permissions) -> Self {
|
||||
Self {
|
||||
view_video: p.view_video,
|
||||
read_camera_configs: p.read_camera_configs,
|
||||
update_signals: p.update_signals,
|
||||
admin_users: p.admin_users,
|
||||
special_fields: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<db::schema::Permissions> for Permissions {
|
||||
fn from(p: db::schema::Permissions) -> Self {
|
||||
Self {
|
||||
view_video: p.view_video,
|
||||
read_camera_configs: p.read_camera_configs,
|
||||
update_signals: p.update_signals,
|
||||
admin_users: p.admin_users,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response to `GET /api/users/`.
|
||||
#[derive(Serialize)]
|
||||
pub struct GetUsersResponse<'a> {
|
||||
pub users: Vec<UserWithId<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserWithId<'a> {
|
||||
pub id: i32,
|
||||
pub user: UserSubset<'a>,
|
||||
}
|
||||
|
||||
/// Response to `PUT /api/users/`.
|
||||
#[derive(Serialize)]
|
||||
pub struct PutUsersResponse {
|
||||
pub id: i32,
|
||||
}
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
#![cfg_attr(all(feature = "nightly", test), feature(test))]
|
||||
|
||||
use log::{debug, error};
|
||||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
use structopt::StructOpt;
|
||||
use base::Error;
|
||||
use bpaf::{Bpaf, Parser};
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, error};
|
||||
|
||||
mod body;
|
||||
mod cmds;
|
||||
|
@ -19,47 +20,31 @@ mod stream;
|
|||
mod streamer;
|
||||
mod web;
|
||||
|
||||
#[derive(StructOpt)]
|
||||
#[structopt(
|
||||
name = "moonfire-nvr",
|
||||
about = "security camera network video recorder",
|
||||
global_settings(&[clap::AppSettings::ColoredHelp])
|
||||
)]
|
||||
#[cfg(feature = "bundled-ui")]
|
||||
mod bundled_ui;
|
||||
|
||||
const DEFAULT_DB_DIR: &str = "/var/lib/moonfire-nvr/db";
|
||||
|
||||
// This is either in the environment when `cargo` is invoked or set from within `build.rs`.
|
||||
const VERSION: &str = env!("VERSION");
|
||||
|
||||
/// Moonfire NVR: security camera network video recorder.
|
||||
#[derive(Bpaf, Debug)]
|
||||
#[bpaf(options, version(VERSION))]
|
||||
enum Args {
|
||||
/// Checks database integrity (like fsck).
|
||||
Check(cmds::check::Args),
|
||||
|
||||
/// Interactively edits configuration.
|
||||
Config(cmds::config::Args),
|
||||
|
||||
/// Initializes a database.
|
||||
Init(cmds::init::Args),
|
||||
|
||||
/// Logs in a user, returning the session cookie.
|
||||
///
|
||||
/// This is a privileged command that directly accesses the database. It doesn't check the
|
||||
/// user's password and even can be used to create sessions with permissions the user doesn't
|
||||
/// have.
|
||||
Login(cmds::login::Args),
|
||||
|
||||
/// Runs the server, saving recordings and allowing web access.
|
||||
Run(cmds::run::Args),
|
||||
|
||||
/// Runs a SQLite3 shell on Moonfire NVR's index database.
|
||||
///
|
||||
/// Note this locks the database to prevent simultaneous access with a running server. The
|
||||
/// server maintains cached state which could be invalidated otherwise.
|
||||
Sql(cmds::sql::Args),
|
||||
|
||||
/// Translates between integer and human-readable timestamps.
|
||||
Ts(cmds::ts::Args),
|
||||
|
||||
/// Upgrades to the latest database schema.
|
||||
Upgrade(cmds::upgrade::Args),
|
||||
// See docstrings of `cmds::*::Args` structs for a description of the respective subcommands.
|
||||
Check(#[bpaf(external(cmds::check::args))] cmds::check::Args),
|
||||
Config(#[bpaf(external(cmds::config::args))] cmds::config::Args),
|
||||
Init(#[bpaf(external(cmds::init::args))] cmds::init::Args),
|
||||
Login(#[bpaf(external(cmds::login::args))] cmds::login::Args),
|
||||
Run(#[bpaf(external(cmds::run::args))] cmds::run::Args),
|
||||
Sql(#[bpaf(external(cmds::sql::args))] cmds::sql::Args),
|
||||
Ts(#[bpaf(external(cmds::ts::args))] cmds::ts::Args),
|
||||
Upgrade(#[bpaf(external(cmds::upgrade::args))] cmds::upgrade::Args),
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn run(self) -> Result<i32, failure::Error> {
|
||||
fn run(self) -> Result<i32, Error> {
|
||||
match self {
|
||||
Args::Check(a) => cmds::check::run(a),
|
||||
Args::Config(a) => cmds::config::run(a),
|
||||
|
@ -73,83 +58,62 @@ impl Args {
|
|||
}
|
||||
}
|
||||
|
||||
/// Custom panic hook that logs instead of directly writing to stderr.
|
||||
///
|
||||
/// This means it includes a timestamp and is more recognizable as a serious
|
||||
/// error (including console color coding by default, a format `lnav` will
|
||||
/// recognize, etc.).
|
||||
fn panic_hook(p: &std::panic::PanicInfo) {
|
||||
let mut msg;
|
||||
if let Some(l) = p.location() {
|
||||
msg = format!("panic at '{}'", l);
|
||||
} else {
|
||||
msg = "panic".to_owned();
|
||||
}
|
||||
if let Some(s) = p.payload().downcast_ref::<&str>() {
|
||||
write!(&mut msg, ": {}", s).unwrap();
|
||||
} else if let Some(s) = p.payload().downcast_ref::<String>() {
|
||||
write!(&mut msg, ": {}", s).unwrap();
|
||||
}
|
||||
let b = failure::Backtrace::new();
|
||||
if b.is_empty() {
|
||||
write!(
|
||||
&mut msg,
|
||||
"\n\n(set environment variable RUST_BACKTRACE=1 to see backtraces)"
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
write!(&mut msg, "\n\nBacktrace:\n{}", b).unwrap();
|
||||
}
|
||||
error!("{}", msg);
|
||||
fn parse_db_dir() -> impl Parser<PathBuf> {
|
||||
bpaf::long("db-dir")
|
||||
.help("Directory holding the SQLite3 index database.")
|
||||
.argument::<PathBuf>("PATH")
|
||||
.fallback(DEFAULT_DB_DIR.into())
|
||||
.debug_fallback()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// If using the clock will fail, find out now *before* trying to log
|
||||
// anything (with timestamps...) so we can print a helpful error.
|
||||
if let Err(e) = nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC) {
|
||||
eprintln!(
|
||||
"clock_gettime failed: {}\n\n\
|
||||
This indicates a broken environment. See the troubleshooting guide.",
|
||||
e
|
||||
"clock_gettime failed: {e}\n\n\
|
||||
This indicates a broken environment. See the troubleshooting guide."
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let args = Args::from_args();
|
||||
let mut h = mylog::Builder::new()
|
||||
.set_format(
|
||||
::std::env::var("MOONFIRE_FORMAT")
|
||||
.map_err(|_| ())
|
||||
.and_then(|s| mylog::Format::from_str(&s))
|
||||
.unwrap_or(mylog::Format::Google),
|
||||
)
|
||||
.set_color(
|
||||
::std::env::var("MOONFIRE_COLOR")
|
||||
.map_err(|_| ())
|
||||
.and_then(|s| mylog::ColorMode::from_str(&s))
|
||||
.unwrap_or(mylog::ColorMode::Auto),
|
||||
)
|
||||
.set_spec(&::std::env::var("MOONFIRE_LOG").unwrap_or_else(|_| "info".to_owned()))
|
||||
.build();
|
||||
h.clone().install().unwrap();
|
||||
base::tracing_setup::install();
|
||||
|
||||
let use_panic_hook = ::std::env::var("MOONFIRE_PANIC_HOOK")
|
||||
.map(|s| s != "false" && s != "0")
|
||||
.unwrap_or(true);
|
||||
if use_panic_hook {
|
||||
std::panic::set_hook(Box::new(&panic_hook));
|
||||
}
|
||||
// Get the program name from the OS (e.g. if invoked as `target/debug/nvr`: `nvr`),
|
||||
// falling back to the crate name if conversion to a path/UTF-8 string fails.
|
||||
// `bpaf`'s default logic is similar but doesn't have the fallback.
|
||||
let progname = std::env::args_os().next().map(PathBuf::from);
|
||||
let progname = progname
|
||||
.as_deref()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or(env!("CARGO_PKG_NAME"));
|
||||
|
||||
let r = {
|
||||
let _a = h.async_scope();
|
||||
args.run()
|
||||
let args = match args()
|
||||
.fallback_to_usage()
|
||||
.run_inner(bpaf::Args::current_args().set_name(progname))
|
||||
{
|
||||
Ok(a) => a,
|
||||
Err(e) => std::process::exit(e.exit_code()),
|
||||
};
|
||||
match r {
|
||||
tracing::trace!("Parsed command-line arguments: {args:#?}");
|
||||
|
||||
match args.run() {
|
||||
Err(e) => {
|
||||
error!("Exiting due to error: {}", base::prettify_failure(&e));
|
||||
error!(err = %e.chain(), "exiting due to error");
|
||||
::std::process::exit(1);
|
||||
}
|
||||
Ok(rv) => {
|
||||
debug!("Exiting with status {}", rv);
|
||||
debug!("exiting with status {}", rv);
|
||||
std::process::exit(rv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn bpaf_invariants() {
|
||||
super::args().check_invariants(false);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
|
||||
use crate::body::{wrap_error, BoxedError, Chunk};
|
||||
use crate::slices::{self, Slices};
|
||||
use base::{bail_t, format_err_t, Error, ErrorKind, ResultExt};
|
||||
use base::{bail, err, Error, ErrorKind, ResultExt};
|
||||
use byteorder::{BigEndian, ByteOrder, WriteBytesExt};
|
||||
use bytes::BytesMut;
|
||||
use db::dir;
|
||||
|
@ -65,8 +65,6 @@ use futures::stream::{self, TryStreamExt};
|
|||
use futures::Stream;
|
||||
use http::header::HeaderValue;
|
||||
use hyper::body::Buf;
|
||||
use log::{debug, error, trace, warn};
|
||||
use parking_lot::Once;
|
||||
use reffers::ARefss;
|
||||
use smallvec::SmallVec;
|
||||
use std::cell::UnsafeCell;
|
||||
|
@ -77,7 +75,9 @@ use std::io;
|
|||
use std::mem;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Once;
|
||||
use std::time::SystemTime;
|
||||
use tracing::{debug, error, trace, warn};
|
||||
|
||||
/// This value should be incremented any time a change is made to this file that causes different
|
||||
/// bytes or headers to be output for a particular set of `FileBuilder` options. Incrementing this
|
||||
|
@ -410,14 +410,14 @@ impl Segment {
|
|||
*index = db
|
||||
.lock()
|
||||
.with_recording_playback(self.s.id, &mut |playback| self.build_index(playback))
|
||||
.map_err(|e| {
|
||||
error!("Unable to build index for segment: {:?}", e);
|
||||
.map_err(|err| {
|
||||
error!(%err, recording_id = %self.s.id, "unable to build index for segment");
|
||||
});
|
||||
});
|
||||
let index: &'a _ = unsafe { &*self.index.get() };
|
||||
match *index {
|
||||
Ok(ref b) => Ok(f(&b[..], self.lens())),
|
||||
Err(()) => bail_t!(Unknown, "Unable to build index; see previous error."),
|
||||
Err(()) => bail!(Unknown, msg("unable to build index; see logs")),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -439,15 +439,24 @@ impl Segment {
|
|||
&buf[lens.stts + lens.stsz..]
|
||||
}
|
||||
|
||||
fn build_index(&self, playback: &db::RecordingPlayback) -> Result<Box<[u8]>, failure::Error> {
|
||||
fn build_index(&self, playback: &db::RecordingPlayback) -> Result<Box<[u8]>, Error> {
|
||||
let s = &self.s;
|
||||
let lens = self.lens();
|
||||
let len = lens.stts + lens.stsz + lens.stss;
|
||||
let mut buf = {
|
||||
let mut v = Vec::with_capacity(len);
|
||||
unsafe { v.set_len(len) };
|
||||
v.into_boxed_slice()
|
||||
};
|
||||
|
||||
// This was a few percent faster when we didn't pre-initialize the
|
||||
// slice (as in the commented-out code below), but it was unsound. See
|
||||
// <https://github.com/scottlamb/moonfire-nvr/issues/185>. It might be
|
||||
// nice to use `MaybeUninit::write_slice` here when it stabilizes,
|
||||
// more ergonomic than dealing with raw pointers. In the meantime, a few
|
||||
// percent difference in speed here probably isn't the biggest unsolved
|
||||
// problem with Moonfire...
|
||||
let mut buf = vec![0; len].into_boxed_slice();
|
||||
// let mut buf = {
|
||||
// let mut v = Vec::with_capacity(len);
|
||||
// unsafe { v.set_len(len) };
|
||||
// v.into_boxed_slice()
|
||||
// };
|
||||
|
||||
{
|
||||
let (stts, rest) = buf.split_at_mut(lens.stts);
|
||||
|
@ -502,7 +511,7 @@ impl Segment {
|
|||
playback: &db::RecordingPlayback,
|
||||
initial_pos: u64,
|
||||
len: usize,
|
||||
) -> Result<Vec<u8>, failure::Error> {
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let mut v = Vec::with_capacity(len);
|
||||
|
||||
struct RunInfo {
|
||||
|
@ -614,12 +623,14 @@ impl Segment {
|
|||
);
|
||||
}
|
||||
if len != v.len() {
|
||||
bail_t!(
|
||||
bail!(
|
||||
Internal,
|
||||
"truns on {:?} expected len {} got len {}",
|
||||
self,
|
||||
len,
|
||||
v.len()
|
||||
msg(
|
||||
"truns on {:?} expected len {} got len {}",
|
||||
self,
|
||||
len,
|
||||
v.len(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(v)
|
||||
|
@ -689,12 +700,9 @@ enum SliceType {
|
|||
impl Slice {
|
||||
fn new(end: u64, t: SliceType, p: usize) -> Result<Self, Error> {
|
||||
if end >= (1 << 40) || p >= (1 << 20) {
|
||||
bail_t!(
|
||||
InvalidArgument,
|
||||
"end={} p={} too large for {:?} Slice",
|
||||
end,
|
||||
p,
|
||||
t
|
||||
bail!(
|
||||
OutOfRange,
|
||||
msg("end={} p={} too large for {:?} Slice", end, p, t,),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -721,7 +729,7 @@ impl Slice {
|
|||
.try_map(|mp4| {
|
||||
let i = mp4.segments[p].get_index(&mp4.db, f)?;
|
||||
if u64::try_from(i.len()).unwrap() != len {
|
||||
bail_t!(Internal, "expected len {} got {}", len, i.len());
|
||||
bail!(Internal, msg("expected len {} got {}", len, i.len()));
|
||||
}
|
||||
Ok::<_, Error>(&i[r])
|
||||
})?
|
||||
|
@ -751,7 +759,7 @@ impl Slice {
|
|||
.try_map(|mp4| {
|
||||
let data = &mp4.video_sample_entries[self.p()].data;
|
||||
if u64::try_from(data.len()).unwrap() != len {
|
||||
bail_t!(Internal, "expected len {} got len {}", len, data.len());
|
||||
bail!(Internal, msg("expected len {} got len {}", len, data.len()));
|
||||
}
|
||||
Ok::<_, Error>(&data[r.start as usize..r.end as usize])
|
||||
})?
|
||||
|
@ -778,11 +786,9 @@ impl slices::Slice for Slice {
|
|||
SliceType::Static => {
|
||||
let s = STATIC_BYTESTRINGS[p];
|
||||
if u64::try_from(s.len()).unwrap() != len {
|
||||
Err(format_err_t!(
|
||||
Err(err!(
|
||||
Internal,
|
||||
"expected len {} got len {}",
|
||||
len,
|
||||
s.len()
|
||||
msg("expected len {} got len {}", len, s.len())
|
||||
))
|
||||
} else {
|
||||
let part = &s[range.start as usize..range.end as usize];
|
||||
|
@ -801,19 +807,21 @@ impl slices::Slice for Slice {
|
|||
SliceType::Stsz => self.wrap_index(f, range.clone(), len, &Segment::stsz),
|
||||
SliceType::Stss => self.wrap_index(f, range.clone(), len, &Segment::stss),
|
||||
SliceType::Co64 => f.0.get_co64(range.clone(), len),
|
||||
SliceType::VideoSampleData => return f.0.get_video_sample_data(p, range.clone()),
|
||||
SliceType::VideoSampleData => return f.0.get_video_sample_data(p, range),
|
||||
SliceType::SubtitleSampleData => f.0.get_subtitle_sample_data(p, range.clone(), len),
|
||||
SliceType::Truns => self.wrap_truns(f, range.clone(), len as usize),
|
||||
};
|
||||
Box::new(stream::once(futures::future::ready(
|
||||
res.map_err(wrap_error).and_then(move |c| {
|
||||
if c.remaining() != (range.end - range.start) as usize {
|
||||
return Err(wrap_error(format_err_t!(
|
||||
return Err(wrap_error(err!(
|
||||
Internal,
|
||||
"Error producing {:?}: range {:?} produced incorrect len {}.",
|
||||
self,
|
||||
range,
|
||||
c.remaining()
|
||||
msg(
|
||||
"{:?} range {:?} produced incorrect len {}",
|
||||
self,
|
||||
range,
|
||||
c.remaining()
|
||||
)
|
||||
)));
|
||||
}
|
||||
Ok(c)
|
||||
|
@ -895,9 +903,9 @@ impl FileBuilder {
|
|||
// There's no support today for timestamp truns or for timestamps without edit lists.
|
||||
// The latter would invalidate the code's assumption that desired timespan == actual
|
||||
// timespan in the timestamp track.
|
||||
bail_t!(
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
"timestamp subtitles aren't supported on media segments"
|
||||
msg("timestamp subtitles aren't supported on media segments")
|
||||
);
|
||||
}
|
||||
self.include_timestamp_subtitle_track = b;
|
||||
|
@ -919,17 +927,19 @@ impl FileBuilder {
|
|||
pub fn append(
|
||||
&mut self,
|
||||
db: &db::LockedDatabase,
|
||||
row: db::ListRecordingsRow,
|
||||
row: &db::ListRecordingsRow,
|
||||
rel_media_range_90k: Range<i32>,
|
||||
start_at_key: bool,
|
||||
) -> Result<(), Error> {
|
||||
if let Some(prev) = self.segments.last() {
|
||||
if prev.s.have_trailing_zero() {
|
||||
bail_t!(
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
"unable to append recording {} after recording {} with trailing zero",
|
||||
row.id,
|
||||
prev.s.id
|
||||
msg(
|
||||
"unable to append recording {} after recording {} with trailing zero",
|
||||
row.id,
|
||||
prev.s.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
@ -965,7 +975,7 @@ impl FileBuilder {
|
|||
|
||||
pub fn set_filename(&mut self, filename: &str) -> Result<(), Error> {
|
||||
self.content_disposition = Some(
|
||||
HeaderValue::try_from(format!("attachment; filename=\"{}\"", filename))
|
||||
HeaderValue::try_from(format!("attachment; filename=\"{filename}\""))
|
||||
.err_kind(ErrorKind::InvalidArgument)?,
|
||||
);
|
||||
Ok(())
|
||||
|
@ -975,7 +985,7 @@ impl FileBuilder {
|
|||
pub fn build(
|
||||
mut self,
|
||||
db: Arc<db::Database>,
|
||||
dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>,
|
||||
dirs_by_stream_id: Arc<::base::FastHashMap<i32, Arc<dir::SampleFileDir>>>,
|
||||
) -> Result<File, Error> {
|
||||
let mut max_end = None;
|
||||
let mut etag = blake3::Hasher::new();
|
||||
|
@ -1062,10 +1072,12 @@ impl FileBuilder {
|
|||
// If the segment is > 4 GiB, the 32-bit trun data offsets are untrustworthy.
|
||||
// We'd need multiple moof+mdat sequences to support large media segments properly.
|
||||
if self.body.slices.len() > u32::max_value() as u64 {
|
||||
bail_t!(
|
||||
InvalidArgument,
|
||||
"media segment has length {}, greater than allowed 4 GiB",
|
||||
self.body.slices.len()
|
||||
bail!(
|
||||
OutOfRange,
|
||||
msg(
|
||||
"media segment has length {}, greater than allowed 4 GiB",
|
||||
self.body.slices.len(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1346,7 +1358,7 @@ impl FileBuilder {
|
|||
None => Some((e.width, e.height)),
|
||||
Some((w, h)) => Some((cmp::max(w, e.width), cmp::max(h, e.height))),
|
||||
})
|
||||
.ok_or_else(|| format_err_t!(InvalidArgument, "no video_sample_entries"))?;
|
||||
.ok_or_else(|| err!(InvalidArgument, msg("no video_sample_entries")))?;
|
||||
self.body.append_u32((width as u32) << 16);
|
||||
self.body.append_u32((height as u32) << 16);
|
||||
})
|
||||
|
@ -1387,7 +1399,10 @@ impl FileBuilder {
|
|||
let skip = md.start - actual_start_90k;
|
||||
let keep = md.end - md.start;
|
||||
if skip < 0 || keep < 0 {
|
||||
bail_t!(Internal, "skip={} keep={} on segment {:#?}", skip, keep, s);
|
||||
bail!(
|
||||
Internal,
|
||||
msg("skip={} keep={} on segment {:#?}", skip, keep, s)
|
||||
);
|
||||
}
|
||||
cur_media_time += skip as u64;
|
||||
if unflushed.segment_duration + unflushed.media_time == cur_media_time {
|
||||
|
@ -1630,7 +1645,7 @@ impl FileBuilder {
|
|||
self.body
|
||||
.buf
|
||||
.extend_from_slice(b"stsc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01");
|
||||
self.body.append_u32(self.num_subtitle_samples as u32);
|
||||
self.body.append_u32(self.num_subtitle_samples);
|
||||
self.body.append_u32(1);
|
||||
})
|
||||
}
|
||||
|
@ -1665,7 +1680,7 @@ impl FileBuilder {
|
|||
self.body.buf.extend_from_slice(b"stsz\x00\x00\x00\x00");
|
||||
self.body
|
||||
.append_u32((mem::size_of::<u16>() + SUBTITLE_LENGTH) as u32);
|
||||
self.body.append_u32(self.num_subtitle_samples as u32);
|
||||
self.body.append_u32(self.num_subtitle_samples);
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1762,7 +1777,7 @@ impl BodyState {
|
|||
|
||||
struct FileInner {
|
||||
db: Arc<db::Database>,
|
||||
dirs_by_stream_id: Arc<::fnv::FnvHashMap<i32, Arc<dir::SampleFileDir>>>,
|
||||
dirs_by_stream_id: Arc<::base::FastHashMap<i32, Arc<dir::SampleFileDir>>>,
|
||||
segments: Vec<Segment>,
|
||||
slices: Slices<Slice>,
|
||||
buf: Vec<u8>,
|
||||
|
@ -1808,9 +1823,10 @@ impl FileInner {
|
|||
let sr = s.s.sample_file_range();
|
||||
let f = match self.dirs_by_stream_id.get(&s.s.id.stream()) {
|
||||
None => {
|
||||
return Box::new(stream::iter(std::iter::once(Err(wrap_error(
|
||||
format_err_t!(NotFound, "{}: stream not found", s.s.id),
|
||||
)))))
|
||||
return Box::new(stream::iter(std::iter::once(Err(wrap_error(err!(
|
||||
NotFound,
|
||||
msg("{}: stream not found", s.s.id)
|
||||
))))))
|
||||
}
|
||||
Some(d) => d.open_file(s.s.id, (r.start + sr.start)..(r.end + sr.start)),
|
||||
};
|
||||
|
@ -1856,10 +1872,12 @@ impl File {
|
|||
pub async fn append_into_vec(self, v: &mut Vec<u8>) -> Result<(), Error> {
|
||||
use http_serve::Entity;
|
||||
v.reserve(usize::try_from(self.len()).map_err(|_| {
|
||||
format_err_t!(
|
||||
err!(
|
||||
InvalidArgument,
|
||||
"{}-byte mp4 is too big to send over WebSockets!",
|
||||
self.len()
|
||||
msg(
|
||||
"{}-byte mp4 is too big to send over WebSockets!",
|
||||
self.len()
|
||||
),
|
||||
)
|
||||
})?);
|
||||
let mut b = std::pin::Pin::from(self.get_range(0..self.len()));
|
||||
|
@ -1867,9 +1885,7 @@ impl File {
|
|||
use futures::stream::StreamExt;
|
||||
match b.next().await {
|
||||
Some(r) => {
|
||||
let mut chunk = r
|
||||
.map_err(failure::Error::from_boxed_compat)
|
||||
.err_kind(ErrorKind::Unknown)?;
|
||||
let mut chunk = r.map_err(|e| err!(Unknown, source(e)))?;
|
||||
while chunk.has_remaining() {
|
||||
let c = chunk.chunk();
|
||||
v.extend_from_slice(c);
|
||||
|
@ -1993,12 +2009,12 @@ mod tests {
|
|||
use futures::stream::TryStreamExt;
|
||||
use http_serve::{self, Entity};
|
||||
use hyper::body::Buf;
|
||||
use log::info;
|
||||
use std::fs;
|
||||
use std::ops::Range;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::str;
|
||||
use tracing::info;
|
||||
|
||||
async fn fill_slice<E: http_serve::Entity>(slice: &mut [u8], e: &E, start: u64)
|
||||
where
|
||||
|
@ -2064,7 +2080,7 @@ mod tests {
|
|||
impl BoxCursor {
|
||||
pub fn new(mp4: File) -> BoxCursor {
|
||||
BoxCursor {
|
||||
mp4: mp4,
|
||||
mp4,
|
||||
stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
@ -2098,7 +2114,7 @@ mod tests {
|
|||
boxtype[..].copy_from_slice(boxtype_slice);
|
||||
self.stack.push(Mp4Box {
|
||||
interior: pos + hdr_len as u64..pos + len,
|
||||
boxtype: boxtype,
|
||||
boxtype,
|
||||
});
|
||||
trace!("positioned at {}", self.path());
|
||||
true
|
||||
|
@ -2263,7 +2279,7 @@ mod tests {
|
|||
cursor.down().await;
|
||||
assert!(cursor.find(b"stbl").await);
|
||||
Track {
|
||||
edts_cursor: edts_cursor,
|
||||
edts_cursor,
|
||||
stbl_cursor: cursor,
|
||||
}
|
||||
}
|
||||
|
@ -2303,7 +2319,7 @@ mod tests {
|
|||
loop {
|
||||
let pkt = match input.next() {
|
||||
Ok(p) => p,
|
||||
Err(e) if e.to_string().contains("End of file") => {
|
||||
Err(e) if e.kind() == ErrorKind::OutOfRange => {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
|
@ -2344,13 +2360,11 @@ mod tests {
|
|||
let d = r.media_duration_90k;
|
||||
assert!(
|
||||
skip_90k + shorten_90k < d,
|
||||
"skip_90k={} shorten_90k={} r={:?}",
|
||||
skip_90k,
|
||||
shorten_90k,
|
||||
r
|
||||
"{}",
|
||||
"skip_90k={skip_90k} shorten_90k={shorten_90k} r={r:?}"
|
||||
);
|
||||
builder
|
||||
.append(&*db, r, skip_90k..d - shorten_90k, true)
|
||||
.append(&db, &r, skip_90k..d - shorten_90k, true)
|
||||
.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
|
@ -2412,14 +2426,14 @@ mod tests {
|
|||
for i in 0.. {
|
||||
let orig_pkt = match orig.next() {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) if e.to_string() == "End of file" => None,
|
||||
Err(e) if e.msg().unwrap() == "end of file" => None,
|
||||
Err(e) => {
|
||||
panic!("unexpected input error: {}", e);
|
||||
}
|
||||
};
|
||||
let new_pkt = match new.next() {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) if e.to_string() == "End of file" => {
|
||||
Err(e) if e.msg().unwrap() == "end of file" => {
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
|
@ -2433,11 +2447,10 @@ mod tests {
|
|||
};
|
||||
assert_eq!(
|
||||
orig_pkt.pts, new_pkt.pts, /*+ pts_offset*/
|
||||
"pkt {} pts",
|
||||
i
|
||||
"pkt {i} pts"
|
||||
);
|
||||
assert_eq!(orig_pkt.data, new_pkt.data, "pkt {} data", i);
|
||||
assert_eq!(orig_pkt.is_key, new_pkt.is_key, "pkt {} key", i);
|
||||
assert_eq!(orig_pkt.data, new_pkt.data, "pkt {i} data");
|
||||
assert_eq!(orig_pkt.is_key, new_pkt.is_key, "pkt {i} key");
|
||||
final_durations = Some((i64::from(orig_pkt.duration), i64::from(new_pkt.duration)));
|
||||
}
|
||||
|
||||
|
@ -2448,11 +2461,8 @@ mod tests {
|
|||
// See <https://github.com/scottlamb/moonfire-nvr/issues/10>.
|
||||
assert!(
|
||||
orig_dur - shorten + pts_offset == new_dur || orig_dur - shorten == new_dur,
|
||||
"orig_dur={} new_dur={} shorten={} pts_offset={}",
|
||||
orig_dur,
|
||||
new_dur,
|
||||
shorten,
|
||||
pts_offset
|
||||
"{}",
|
||||
"orig_dur={orig_dur} new_dur={new_dur} shorten={shorten} pts_offset={pts_offset}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2482,7 +2492,7 @@ mod tests {
|
|||
};
|
||||
duration_so_far += row.media_duration_90k;
|
||||
builder
|
||||
.append(&db.db.lock(), row, d_start..d_end, start_at_key)
|
||||
.append(&db.db.lock(), &row, d_start..d_end, start_at_key)
|
||||
.unwrap();
|
||||
}
|
||||
builder.build(db.db.clone(), db.dirs_by_stream_id.clone())
|
||||
|
@ -2631,7 +2641,8 @@ mod tests {
|
|||
let e = make_mp4_from_encoders(Type::Normal, &db, vec![], 0..0, true)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(e.to_string(), "Invalid argument: no video_sample_entries");
|
||||
assert_eq!(e.kind(), ErrorKind::InvalidArgument);
|
||||
assert_eq!(e.msg().unwrap(), "no video_sample_entries");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
@ -2836,7 +2847,7 @@ mod tests {
|
|||
"64f23b856692702b13d1811cd02dc83395b3d501dead7fd16f175eb26b4d8eee",
|
||||
hash.to_hex().as_str()
|
||||
);
|
||||
const EXPECTED_ETAG: &'static str =
|
||||
const EXPECTED_ETAG: &str =
|
||||
"\"791114c469130970608dd999b0ecf5861d077ec33fad2f0b040996e4aae4e30f\"";
|
||||
assert_eq!(
|
||||
Some(HeaderValue::from_str(EXPECTED_ETAG).unwrap()),
|
||||
|
@ -2865,7 +2876,7 @@ mod tests {
|
|||
"f9e4ed946187b2dd22ef049c4c1869d0f6c4f377ef08f8f53570850b61a06701",
|
||||
hash.to_hex().as_str()
|
||||
);
|
||||
const EXPECTED_ETAG: &'static str =
|
||||
const EXPECTED_ETAG: &str =
|
||||
"\"85703b9abadd4292e119f2f7b0d6a16e99acf8b3ba98fcb6498e60ac5cb0b0b7\"";
|
||||
assert_eq!(
|
||||
Some(HeaderValue::from_str(EXPECTED_ETAG).unwrap()),
|
||||
|
@ -2894,7 +2905,7 @@ mod tests {
|
|||
"f913d46d0119a03291e85459455b9a75a84cc9a1a5e3b88ca7e93eb718d73190",
|
||||
hash.to_hex().as_str()
|
||||
);
|
||||
const EXPECTED_ETAG: &'static str =
|
||||
const EXPECTED_ETAG: &str =
|
||||
"\"3d2031124fb995bf2fc4930e7affdcd51add396e062cfab97e1001224c5ee42c\"";
|
||||
assert_eq!(
|
||||
Some(HeaderValue::from_str(EXPECTED_ETAG).unwrap()),
|
||||
|
@ -2924,7 +2935,7 @@ mod tests {
|
|||
"64cc763fa2533118bc6bf0b01249f02524ae87e0c97815079447b235722c1e2d",
|
||||
hash.to_hex().as_str()
|
||||
);
|
||||
const EXPECTED_ETAG: &'static str =
|
||||
const EXPECTED_ETAG: &str =
|
||||
"\"aa9bb2f63787a7d21227981135326c948db3e0b3dae5d0d39c77df69d0baf504\"";
|
||||
assert_eq!(
|
||||
Some(HeaderValue::from_str(EXPECTED_ETAG).unwrap()),
|
||||
|
@ -2953,7 +2964,7 @@ mod tests {
|
|||
"6886b36ae6df9ce538f6db7ebd6159e68c2936b9d43307f7728fe75e0b62cad2",
|
||||
hash.to_hex().as_str()
|
||||
);
|
||||
const EXPECTED_ETAG: &'static str =
|
||||
const EXPECTED_ETAG: &str =
|
||||
"\"0a6accaa7b583c94209eba58b00b39a804a5c4a8c99043e58e72fed7acd8dfc6\"";
|
||||
assert_eq!(
|
||||
Some(HeaderValue::from_str(EXPECTED_ETAG).unwrap()),
|
||||
|
@ -2976,11 +2987,10 @@ mod bench {
|
|||
use futures::future;
|
||||
use http_serve;
|
||||
use hyper;
|
||||
use lazy_static::lazy_static;
|
||||
use url::Url;
|
||||
|
||||
/// An HTTP server for benchmarking.
|
||||
/// It's used as a singleton via `lazy_static!` so that when getting a CPU profile of the
|
||||
/// It's used as a singleton so that when getting a CPU profile of the
|
||||
/// benchmark, more of the profile focuses on the HTTP serving rather than the setup.
|
||||
///
|
||||
/// Currently this only serves a single `.mp4` file but we could set up variations to benchmark
|
||||
|
@ -3026,9 +3036,7 @@ mod bench {
|
|||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref SERVER: BenchServer = BenchServer::new();
|
||||
}
|
||||
static SERVER: std::sync::OnceLock<BenchServer> = std::sync::OnceLock::new();
|
||||
|
||||
#[bench]
|
||||
fn build_index(b: &mut test::Bencher) {
|
||||
|
@ -3062,7 +3070,7 @@ mod bench {
|
|||
#[bench]
|
||||
fn serve_generated_bytes(b: &mut test::Bencher) {
|
||||
testutil::init();
|
||||
let server = &*SERVER;
|
||||
let server = SERVER.get_or_init(BenchServer::new);
|
||||
let p = server.generated_len;
|
||||
b.bytes = p;
|
||||
let client = reqwest::Client::new();
|
||||
|
|
|
@ -4,14 +4,15 @@
|
|||
|
||||
//! Tools for implementing a `http_serve::Entity` body composed from many "slices".
|
||||
|
||||
use crate::body::{wrap_error, BoxedError};
|
||||
use base::format_err_t;
|
||||
use failure::{bail, Error};
|
||||
use futures::{stream, stream::StreamExt, Stream};
|
||||
use std::fmt;
|
||||
use std::ops::Range;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::body::{wrap_error, BoxedError};
|
||||
use base::{bail, err, Error};
|
||||
use futures::{stream, stream::StreamExt, Stream};
|
||||
use tracing_futures::Instrument;
|
||||
|
||||
/// Gets a byte range given a context argument.
|
||||
/// Each `Slice` instance belongs to a single `Slices`.
|
||||
pub trait Slice: fmt::Debug + Sized + Sync + 'static {
|
||||
|
@ -100,11 +101,14 @@ where
|
|||
pub fn append(&mut self, slice: S) -> Result<(), Error> {
|
||||
if slice.end() <= self.len {
|
||||
bail!(
|
||||
"end {} <= len {} while adding slice {:?} to slices:\n{:?}",
|
||||
slice.end(),
|
||||
self.len,
|
||||
slice,
|
||||
self
|
||||
Internal,
|
||||
msg(
|
||||
"end {} <= len {} while adding slice {:?} to slices:\n{:?}",
|
||||
slice.end(),
|
||||
self.len,
|
||||
slice,
|
||||
self
|
||||
),
|
||||
);
|
||||
}
|
||||
self.len = slice.end();
|
||||
|
@ -131,21 +135,17 @@ where
|
|||
) -> Box<dyn Stream<Item = Result<S::Chunk, BoxedError>> + Sync + Send> {
|
||||
#[allow(clippy::suspicious_operation_groupings)]
|
||||
if range.start > range.end || range.end > self.len {
|
||||
return Box::new(stream::once(futures::future::err(wrap_error(
|
||||
format_err_t!(
|
||||
Internal,
|
||||
"Bad range {:?} for slice of length {}",
|
||||
range,
|
||||
self.len
|
||||
),
|
||||
))));
|
||||
return Box::new(stream::once(futures::future::err(wrap_error(err!(
|
||||
Internal,
|
||||
msg("bad range {:?} for slice of length {}", range, self.len),
|
||||
)))));
|
||||
}
|
||||
|
||||
// Binary search for the first slice of the range to write, determining its index and
|
||||
// (from the preceding slice) the start of its range.
|
||||
let (i, slice_start) = match self.slices.binary_search_by_key(&range.start, |s| s.end()) {
|
||||
Ok(i) => (i + 1, self.slices[i].end()), // desired start == slice i's end; first is i+1!
|
||||
Err(i) if i == 0 => (0, 0), // desired start < slice 0's end; first is 0.
|
||||
Err(0) => (0, 0), // desired start < slice 0's end; first is 0.
|
||||
Err(i) => (i, self.slices[i - 1].end()), // desired start < slice i's end; first is i.
|
||||
};
|
||||
|
||||
|
@ -173,7 +173,7 @@ where
|
|||
futures::future::ready(Some((Pin::from(body), (c, i + 1, 0, min_end))))
|
||||
},
|
||||
);
|
||||
Box::new(bodies.flatten())
|
||||
Box::new(bodies.flatten().in_current_span())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -183,7 +183,6 @@ mod tests {
|
|||
use crate::body::BoxedError;
|
||||
use db::testutil;
|
||||
use futures::stream::{self, Stream, TryStreamExt};
|
||||
use lazy_static::lazy_static;
|
||||
use std::ops::Range;
|
||||
use std::pin::Pin;
|
||||
|
||||
|
@ -220,13 +219,16 @@ mod tests {
|
|||
}
|
||||
|
||||
fn get_slices(ctx: &&'static Slices<FakeSlice>) -> &'static Slices<Self> {
|
||||
*ctx
|
||||
ctx
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
#[rustfmt::skip]
|
||||
static ref SLICES: Slices<FakeSlice> = {
|
||||
#[rustfmt::skip]
|
||||
static SLICES: std::sync::OnceLock<Slices<FakeSlice>> = std::sync::OnceLock::new();
|
||||
|
||||
#[rustfmt::skip]
|
||||
fn slices() -> &'static Slices<FakeSlice> {
|
||||
SLICES.get_or_init(|| {
|
||||
let mut s = Slices::new();
|
||||
s.append(FakeSlice { end: 5, name: "a" }).unwrap();
|
||||
s.append(FakeSlice { end: 5 + 13, name: "b" }).unwrap();
|
||||
|
@ -234,11 +236,12 @@ mod tests {
|
|||
s.append(FakeSlice { end: 5 + 13 + 7 + 17, name: "d" }).unwrap();
|
||||
s.append(FakeSlice { end: 5 + 13 + 7 + 17 + 19, name: "e" }).unwrap();
|
||||
s
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_range(r: Range<u64>) -> Vec<FakeChunk> {
|
||||
Pin::from(SLICES.get_range(&&*SLICES, r))
|
||||
let slices = slices();
|
||||
Pin::from(slices.get_range(&slices, r))
|
||||
.try_collect()
|
||||
.await
|
||||
.unwrap()
|
||||
|
@ -247,7 +250,7 @@ mod tests {
|
|||
#[test]
|
||||
pub fn size() {
|
||||
testutil::init();
|
||||
assert_eq!(5 + 13 + 7 + 17 + 19, SLICES.len());
|
||||
assert_eq!(5 + 13 + 7 + 17 + 19, slices().len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
@ -3,30 +3,30 @@
|
|||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
use crate::h264;
|
||||
use base::{bail, err, Error};
|
||||
use bytes::Bytes;
|
||||
use failure::format_err;
|
||||
use failure::{bail, Error};
|
||||
use futures::StreamExt;
|
||||
use retina::client::Demuxed;
|
||||
use retina::codec::CodecItem;
|
||||
use std::pin::Pin;
|
||||
use std::result::Result;
|
||||
use tracing::Instrument;
|
||||
use url::Url;
|
||||
|
||||
static RETINA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
pub struct Options {
|
||||
pub session: retina::client::SessionOptions,
|
||||
pub setup: retina::client::SetupOptions,
|
||||
}
|
||||
|
||||
/// Opens a RTSP stream. This is a trait for test injection.
|
||||
pub trait Opener: Send + Sync {
|
||||
/// Opens the given RTSP URL.
|
||||
///
|
||||
/// Note: despite the blocking interface, this expects to be called from
|
||||
/// the context of a multithreaded tokio runtime with IO and time enabled.
|
||||
fn open(
|
||||
&self,
|
||||
label: String,
|
||||
url: Url,
|
||||
options: retina::client::SessionOptions,
|
||||
) -> Result<Box<dyn Stream>, Error>;
|
||||
fn open(&self, label: String, url: Url, options: Options) -> Result<Box<dyn Stream>, Error>;
|
||||
}
|
||||
|
||||
pub struct VideoFrame {
|
||||
|
@ -57,16 +57,24 @@ impl Opener for RealOpener {
|
|||
&self,
|
||||
label: String,
|
||||
url: Url,
|
||||
options: retina::client::SessionOptions,
|
||||
mut options: Options,
|
||||
) -> Result<Box<dyn Stream>, Error> {
|
||||
let options = options.user_agent(format!("Moonfire NVR {}", env!("CARGO_PKG_VERSION")));
|
||||
options.session = options
|
||||
.session
|
||||
.user_agent(format!("Moonfire NVR {}", env!("CARGO_PKG_VERSION")));
|
||||
let rt_handle = tokio::runtime::Handle::current();
|
||||
let (inner, first_frame) = rt_handle
|
||||
.block_on(rt_handle.spawn(tokio::time::timeout(
|
||||
RETINA_TIMEOUT,
|
||||
RetinaStreamInner::play(label, url, options),
|
||||
)))
|
||||
.expect("RetinaStream::play task panicked, see earlier error")??;
|
||||
.block_on(
|
||||
rt_handle.spawn(
|
||||
tokio::time::timeout(
|
||||
RETINA_TIMEOUT,
|
||||
RetinaStreamInner::play(label, url, options),
|
||||
)
|
||||
.in_current_span(),
|
||||
),
|
||||
)
|
||||
.expect("RetinaStream::play task panicked, see earlier error")
|
||||
.map_err(|e| err!(Unknown, source(e)))??;
|
||||
Ok(Box::new(RetinaStream {
|
||||
inner: Some(inner),
|
||||
rt_handle,
|
||||
|
@ -111,53 +119,46 @@ impl RetinaStreamInner {
|
|||
async fn play(
|
||||
label: String,
|
||||
url: Url,
|
||||
options: retina::client::SessionOptions,
|
||||
options: Options,
|
||||
) -> Result<(Box<Self>, retina::codec::VideoFrame), Error> {
|
||||
let mut session = retina::client::Session::describe(url, options).await?;
|
||||
log::debug!("connected to {:?}, tool {:?}", &label, session.tool());
|
||||
let (video_i, mut video_params) = session
|
||||
let mut session = retina::client::Session::describe(url, options.session)
|
||||
.await
|
||||
.map_err(|e| err!(Unknown, source(e)))?;
|
||||
tracing::debug!("connected to {:?}, tool {:?}", &label, session.tool());
|
||||
let video_i = session
|
||||
.streams()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, s)| {
|
||||
if s.media == "video" && s.encoding_name == "h264" {
|
||||
Some((
|
||||
i,
|
||||
s.parameters().and_then(|p| match p {
|
||||
retina::codec::Parameters::Video(v) => Some(Box::new(v.clone())),
|
||||
_ => None,
|
||||
}),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| format_err!("couldn't find H.264 video stream"))?;
|
||||
session.setup(video_i).await?;
|
||||
let session = session.play(retina::client::PlayOptions::default()).await?;
|
||||
let mut session = session.demuxed()?;
|
||||
.position(|s| s.media() == "video" && s.encoding_name() == "h264")
|
||||
.ok_or_else(|| err!(FailedPrecondition, msg("couldn't find H.264 video stream")))?;
|
||||
session
|
||||
.setup(video_i, options.setup)
|
||||
.await
|
||||
.map_err(|e| err!(Unknown, source(e)))?;
|
||||
let session = session
|
||||
.play(retina::client::PlayOptions::default())
|
||||
.await
|
||||
.map_err(|e| err!(Unknown, source(e)))?;
|
||||
let mut session = session.demuxed().map_err(|e| err!(Unknown, source(e)))?;
|
||||
|
||||
// First frame.
|
||||
let first_frame = loop {
|
||||
match Pin::new(&mut session).next().await {
|
||||
None => bail!("stream closed before first frame"),
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
Some(Ok(CodecItem::VideoFrame(mut v))) => {
|
||||
if let Some(v) = v.new_parameters.take() {
|
||||
video_params = Some(v);
|
||||
}
|
||||
if v.is_random_access_point {
|
||||
None => bail!(Unavailable, msg("stream closed before first frame")),
|
||||
Some(Err(e)) => bail!(Unknown, msg("unable to get first frame"), source(e)),
|
||||
Some(Ok(CodecItem::VideoFrame(v))) => {
|
||||
if v.is_random_access_point() {
|
||||
break v;
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
}
|
||||
};
|
||||
let video_sample_entry = h264::parse_extra_data(
|
||||
video_params
|
||||
.ok_or_else(|| format_err!("couldn't find H.264 parameters"))?
|
||||
.extra_data(),
|
||||
)?;
|
||||
let video_params = match session.streams()[video_i].parameters() {
|
||||
Some(retina::codec::ParametersRef::Video(v)) => v.clone(),
|
||||
Some(_) => unreachable!(),
|
||||
None => bail!(Unknown, msg("couldn't find H.264 parameters")),
|
||||
};
|
||||
let video_sample_entry = h264::parse_extra_data(video_params.extra_data())?;
|
||||
let self_ = Box::new(Self {
|
||||
label,
|
||||
session,
|
||||
|
@ -169,20 +170,40 @@ impl RetinaStreamInner {
|
|||
/// Fetches a non-initial frame.
|
||||
async fn fetch_next_frame(
|
||||
mut self: Box<Self>,
|
||||
) -> Result<(Box<Self>, retina::codec::VideoFrame), Error> {
|
||||
) -> Result<
|
||||
(
|
||||
Box<Self>,
|
||||
retina::codec::VideoFrame,
|
||||
Option<retina::codec::VideoParameters>,
|
||||
),
|
||||
Error,
|
||||
> {
|
||||
loop {
|
||||
match Pin::new(&mut self.session).next().await.transpose()? {
|
||||
None => bail!("end of stream"),
|
||||
match Pin::new(&mut self.session)
|
||||
.next()
|
||||
.await
|
||||
.transpose()
|
||||
.map_err(|e| err!(Unknown, source(e)))?
|
||||
{
|
||||
None => bail!(Unavailable, msg("end of stream")),
|
||||
Some(CodecItem::VideoFrame(v)) => {
|
||||
if v.loss > 0 {
|
||||
log::warn!(
|
||||
if v.loss() > 0 {
|
||||
tracing::warn!(
|
||||
"{}: lost {} RTP packets @ {}",
|
||||
&self.label,
|
||||
v.loss,
|
||||
v.loss(),
|
||||
v.start_ctx()
|
||||
);
|
||||
}
|
||||
return Ok((self, v));
|
||||
let p = if v.has_new_parameters() {
|
||||
Some(match self.session.streams()[v.stream_id()].parameters() {
|
||||
Some(retina::codec::ParametersRef::Video(v)) => v.clone(),
|
||||
_ => unreachable!(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return Ok((self, v, p));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
|
@ -206,19 +227,27 @@ impl Stream for RetinaStream {
|
|||
.map(|f| Ok((f, false)))
|
||||
.unwrap_or_else(move || {
|
||||
let inner = self.inner.take().unwrap();
|
||||
let (mut inner, mut frame) = self
|
||||
let (mut inner, frame, new_parameters) = self
|
||||
.rt_handle
|
||||
.block_on(self.rt_handle.spawn(tokio::time::timeout(
|
||||
RETINA_TIMEOUT,
|
||||
inner.fetch_next_frame(),
|
||||
)))
|
||||
.block_on(
|
||||
self.rt_handle.spawn(
|
||||
tokio::time::timeout(RETINA_TIMEOUT, inner.fetch_next_frame())
|
||||
.in_current_span(),
|
||||
),
|
||||
)
|
||||
.expect("fetch_next_frame task panicked, see earlier error")
|
||||
.map_err(|_| format_err!("timeout getting next frame"))??;
|
||||
.map_err(|e| {
|
||||
err!(
|
||||
DeadlineExceeded,
|
||||
msg("timeout getting next frame"),
|
||||
source(e)
|
||||
)
|
||||
})??;
|
||||
let mut new_video_sample_entry = false;
|
||||
if let Some(p) = frame.new_parameters.take() {
|
||||
if let Some(p) = new_parameters {
|
||||
let video_sample_entry = h264::parse_extra_data(p.extra_data())?;
|
||||
if video_sample_entry != inner.video_sample_entry {
|
||||
log::debug!(
|
||||
tracing::debug!(
|
||||
"{}: parameter change:\nold: {:?}\nnew: {:?}",
|
||||
&inner.label,
|
||||
&inner.video_sample_entry,
|
||||
|
@ -229,13 +258,13 @@ impl Stream for RetinaStream {
|
|||
}
|
||||
};
|
||||
self.inner = Some(inner);
|
||||
Ok::<_, failure::Error>((frame, new_video_sample_entry))
|
||||
Ok::<_, Error>((frame, new_video_sample_entry))
|
||||
})?;
|
||||
Ok(VideoFrame {
|
||||
pts: frame.timestamp.elapsed(),
|
||||
pts: frame.timestamp().elapsed(),
|
||||
duration: 0,
|
||||
is_key: frame.is_random_access_point,
|
||||
data: frame.into_data(),
|
||||
is_key: frame.is_random_access_point(),
|
||||
data: frame.into_data().into(),
|
||||
new_video_sample_entry,
|
||||
})
|
||||
}
|
||||
|
@ -259,16 +288,24 @@ pub mod testutil {
|
|||
pub fn open(path: &str) -> Result<Self, Error> {
|
||||
let f = std::fs::read(path)?;
|
||||
let len = f.len();
|
||||
let reader = mp4::Mp4Reader::read_header(Cursor::new(f), u64::try_from(len)?)?;
|
||||
let reader = mp4::Mp4Reader::read_header(
|
||||
Cursor::new(f),
|
||||
u64::try_from(len).expect("len should be in u64 range"),
|
||||
)
|
||||
.map_err(|e| err!(Unknown, source(e)))?;
|
||||
let h264_track = match reader
|
||||
.tracks()
|
||||
.values()
|
||||
.find(|t| matches!(t.media_type(), Ok(mp4::MediaType::H264)))
|
||||
{
|
||||
None => bail!("expected a H.264 track"),
|
||||
None => bail!(InvalidArgument, msg("expected a H.264 track")),
|
||||
Some(t) => t,
|
||||
};
|
||||
let video_sample_entry = h264::parse_extra_data(&h264_track.extra_data()?[..])?;
|
||||
let video_sample_entry = h264::parse_extra_data(
|
||||
&h264_track
|
||||
.extra_data()
|
||||
.map_err(|e| err!(Unknown, source(e)))?[..],
|
||||
)?;
|
||||
let h264_track_id = h264_track.track_id();
|
||||
let stream = Mp4Stream {
|
||||
reader,
|
||||
|
@ -302,8 +339,9 @@ pub mod testutil {
|
|||
fn next(&mut self) -> Result<VideoFrame, Error> {
|
||||
let sample = self
|
||||
.reader
|
||||
.read_sample(self.h264_track_id, self.next_sample_id)?
|
||||
.ok_or_else(|| format_err!("End of file"))?;
|
||||
.read_sample(self.h264_track_id, self.next_sample_id)
|
||||
.map_err(|e| err!(Unknown, source(e)))?
|
||||
.ok_or_else(|| err!(OutOfRange, msg("end of file")))?;
|
||||
self.next_sample_id += 1;
|
||||
Ok(VideoFrame {
|
||||
pts: sample.start_time as i64,
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
|
||||
use crate::stream;
|
||||
use base::clock::{Clocks, TimerGuard};
|
||||
use base::{bail, err, Error};
|
||||
use db::{dir, recording, writer, Camera, Database, Stream};
|
||||
use failure::{bail, format_err, Error};
|
||||
use log::{debug, info, trace, warn};
|
||||
use std::result::Result;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, trace, warn, Instrument};
|
||||
use url::Url;
|
||||
|
||||
pub static ROTATE_INTERVAL_SEC: i64 = 60;
|
||||
|
@ -52,6 +52,7 @@ impl<'a, C> Streamer<'a, C>
|
|||
where
|
||||
C: 'a + Clocks + Clone,
|
||||
{
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new<'tmp>(
|
||||
env: &Environment<'a, 'tmp, C>,
|
||||
dir: Arc<dir::SampleFileDir>,
|
||||
|
@ -67,9 +68,12 @@ where
|
|||
.config
|
||||
.url
|
||||
.as_ref()
|
||||
.ok_or_else(|| format_err!("Stream has no RTSP URL"))?;
|
||||
.ok_or_else(|| err!(InvalidArgument, msg("stream has no RTSP URL")))?;
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
bail!("RTSP URL shouldn't include credentials");
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg("RTSP URL shouldn't include credentials")
|
||||
);
|
||||
}
|
||||
let stream_transport = if s.config.rtsp_transport.is_empty() {
|
||||
None
|
||||
|
@ -77,7 +81,7 @@ where
|
|||
match retina::client::Transport::from_str(&s.config.rtsp_transport) {
|
||||
Ok(t) => Some(t),
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
tracing::warn!(
|
||||
"Unable to parse configured transport {:?} for {}/{}; ignoring.",
|
||||
&s.config.rtsp_transport,
|
||||
&c.short_name,
|
||||
|
@ -115,22 +119,20 @@ where
|
|||
/// the context of a multithreaded tokio runtime with IO and time enabled.
|
||||
pub fn run(&mut self) {
|
||||
while self.shutdown_rx.check().is_ok() {
|
||||
if let Err(e) = self.run_once() {
|
||||
if let Err(err) = self.run_once() {
|
||||
let sleep_time = time::Duration::seconds(1);
|
||||
warn!(
|
||||
"{}: sleeping for {} after error: {}",
|
||||
self.short_name,
|
||||
sleep_time,
|
||||
base::prettify_failure(&e)
|
||||
err = %err.chain(),
|
||||
"sleeping for 1 s after error"
|
||||
);
|
||||
self.db.clocks().sleep(sleep_time);
|
||||
}
|
||||
}
|
||||
info!("{}: shutting down", self.short_name);
|
||||
info!("shutting down");
|
||||
}
|
||||
|
||||
fn run_once(&mut self) -> Result<(), Error> {
|
||||
info!("{}: Opening input: {}", self.short_name, self.url.as_str());
|
||||
info!(url = %self.url, "opening input");
|
||||
let clocks = self.db.clocks();
|
||||
|
||||
let handle = tokio::runtime::Handle::current();
|
||||
|
@ -138,33 +140,33 @@ where
|
|||
loop {
|
||||
let status = self.session_group.stale_sessions();
|
||||
if let Some(max_expires) = status.max_expires {
|
||||
log::info!(
|
||||
"{}: waiting up to {:?} for TEARDOWN or expiration of {} stale sessions",
|
||||
&self.short_name,
|
||||
tracing::info!(
|
||||
"waiting up to {:?} for TEARDOWN or expiration of {} stale sessions",
|
||||
max_expires.saturating_duration_since(tokio::time::Instant::now()),
|
||||
status.num_sessions
|
||||
);
|
||||
handle.block_on(async {
|
||||
tokio::select! {
|
||||
_ = self.session_group.await_stale_sessions(&status) => Ok(()),
|
||||
_ = self.shutdown_rx.as_future() => Err(base::shutdown::ShutdownError),
|
||||
handle.block_on(
|
||||
async {
|
||||
tokio::select! {
|
||||
_ = self.session_group.await_stale_sessions(&status) => Ok(()),
|
||||
_ = self.shutdown_rx.as_future() => Err(base::shutdown::ShutdownError),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
.in_current_span(),
|
||||
).map_err(|e| err!(Unknown, source(e)))?;
|
||||
waited = true;
|
||||
} else {
|
||||
if waited {
|
||||
log::info!("{}: done waiting; no more stale sessions", &self.short_name);
|
||||
tracing::info!("done waiting; no more stale sessions");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stream = {
|
||||
let _t = TimerGuard::new(&clocks, || format!("opening {}", self.url.as_str()));
|
||||
self.opener.open(
|
||||
self.short_name.clone(),
|
||||
self.url.clone(),
|
||||
retina::client::SessionOptions::default()
|
||||
let _t = TimerGuard::new(&clocks, || format!("opening {}", self.url));
|
||||
let options = stream::Options {
|
||||
session: retina::client::SessionOptions::default()
|
||||
.creds(if self.username.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
@ -173,9 +175,11 @@ where
|
|||
password: self.password.clone(),
|
||||
})
|
||||
})
|
||||
.transport(self.transport)
|
||||
.session_group(self.session_group.clone()),
|
||||
)?
|
||||
setup: retina::client::SetupOptions::default().transport(self.transport.clone()),
|
||||
};
|
||||
self.opener
|
||||
.open(self.short_name.clone(), self.url.clone(), options)?
|
||||
};
|
||||
let realtime_offset = self.db.clocks().realtime() - clocks.monotonic();
|
||||
let mut video_sample_entry_id = {
|
||||
|
@ -207,22 +211,22 @@ where
|
|||
if !seen_key_frame && !frame.is_key {
|
||||
continue;
|
||||
} else if !seen_key_frame {
|
||||
debug!("{}: have first key frame", self.short_name);
|
||||
debug!("have first key frame");
|
||||
seen_key_frame = true;
|
||||
}
|
||||
let frame_realtime = clocks.monotonic() + realtime_offset;
|
||||
let local_time = recording::Time::new(frame_realtime);
|
||||
rotate = if let Some(r) = rotate {
|
||||
if frame_realtime.sec > r && frame.is_key {
|
||||
trace!("{}: close on normal rotation", self.short_name);
|
||||
trace!("close on normal rotation");
|
||||
let _t = TimerGuard::new(&clocks, || "closing writer");
|
||||
w.close(Some(frame.pts), None)?;
|
||||
None
|
||||
} else if frame.new_video_sample_entry {
|
||||
if !frame.is_key {
|
||||
bail!("parameter change on non-key frame");
|
||||
bail!(Unavailable, msg("parameter change on non-key frame"));
|
||||
}
|
||||
trace!("{}: close on parameter change", self.short_name);
|
||||
trace!("close on parameter change");
|
||||
video_sample_entry_id = {
|
||||
let _t = TimerGuard::new(&clocks, || "inserting video sample entry");
|
||||
self.db
|
||||
|
@ -285,14 +289,13 @@ where
|
|||
mod tests {
|
||||
use crate::stream::{self, Stream};
|
||||
use base::clock::{self, Clocks};
|
||||
use base::{bail, Error};
|
||||
use db::{recording, testutil, CompositeId};
|
||||
use failure::{bail, Error};
|
||||
use log::trace;
|
||||
use parking_lot::Mutex;
|
||||
use std::cmp;
|
||||
use std::convert::TryFrom;
|
||||
use std::sync::Arc;
|
||||
use time;
|
||||
use std::sync::Mutex;
|
||||
use tracing::trace;
|
||||
|
||||
struct ProxyingStream {
|
||||
clocks: clock::SimulatedClocks,
|
||||
|
@ -334,7 +337,7 @@ mod tests {
|
|||
|
||||
fn next(&mut self) -> Result<stream::VideoFrame, Error> {
|
||||
if self.pkts_left == 0 {
|
||||
bail!("end of stream");
|
||||
bail!(OutOfRange, msg("end of stream"));
|
||||
}
|
||||
self.pkts_left -= 1;
|
||||
|
||||
|
@ -382,10 +385,10 @@ mod tests {
|
|||
&self,
|
||||
_label: String,
|
||||
url: url::Url,
|
||||
_options: retina::client::SessionOptions,
|
||||
_options: stream::Options,
|
||||
) -> Result<Box<dyn stream::Stream>, Error> {
|
||||
assert_eq!(&url, &self.expected_url);
|
||||
let mut l = self.streams.lock();
|
||||
let mut l = self.streams.lock().unwrap();
|
||||
match l.pop() {
|
||||
Some(stream) => {
|
||||
trace!("MockOpener returning next stream");
|
||||
|
@ -393,8 +396,8 @@ mod tests {
|
|||
}
|
||||
None => {
|
||||
trace!("MockOpener shutting down");
|
||||
self.shutdown_tx.lock().take();
|
||||
bail!("done")
|
||||
self.shutdown_tx.lock().unwrap().take();
|
||||
bail!(Cancelled, msg("done"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -411,7 +414,7 @@ mod tests {
|
|||
db.with_recording_playback(id, &mut |rec| {
|
||||
let mut it = recording::SampleIndexIterator::default();
|
||||
let mut frames = Vec::new();
|
||||
while it.next(&rec.video_index).unwrap() {
|
||||
while it.next(rec.video_index).unwrap() {
|
||||
frames.push(Frame {
|
||||
start_90k: it.start_90k,
|
||||
duration_90k: it.duration_90k,
|
||||
|
@ -442,7 +445,7 @@ mod tests {
|
|||
streams: Mutex::new(vec![Box::new(stream)]),
|
||||
shutdown_tx: Mutex::new(Some(shutdown_tx)),
|
||||
};
|
||||
let db = testutil::TestDb::new(clocks.clone());
|
||||
let db = testutil::TestDb::new(clocks);
|
||||
let env = super::Environment {
|
||||
opener: &opener,
|
||||
db: &db.db,
|
||||
|
@ -472,7 +475,7 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
stream.run();
|
||||
assert!(opener.streams.lock().is_empty());
|
||||
assert!(opener.streams.lock().unwrap().is_empty());
|
||||
db.syncer_channel.flush();
|
||||
let db = db.db.lock();
|
||||
|
||||
|
|
|
@ -6,74 +6,27 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::body::Body;
|
||||
use base::{bail_t, format_err_t};
|
||||
use failure::Error;
|
||||
use base::{bail, err, Error};
|
||||
use futures::{future::Either, SinkExt, StreamExt};
|
||||
use http::{header, Request, Response, StatusCode};
|
||||
use log::{info, warn};
|
||||
use tokio_tungstenite::tungstenite;
|
||||
use http::header;
|
||||
use tokio_tungstenite::{tungstenite, WebSocketStream};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{mp4, web::plain_response};
|
||||
use crate::mp4;
|
||||
|
||||
use super::{bad_req, Caller, ResponseResult, Service};
|
||||
|
||||
/// Checks the `Host` and `Origin` headers match, if the latter is supplied.
|
||||
///
|
||||
/// Web browsers must supply origin, according to [RFC 6455 section
|
||||
/// 4.1](https://datatracker.ietf.org/doc/html/rfc6455#section-4.1).
|
||||
/// It's not required for non-browser HTTP clients.
|
||||
///
|
||||
/// If present, verify it. Chrome doesn't honor the `s=` cookie's
|
||||
/// `SameSite=Lax` setting for WebSocket requests, so this is the sole
|
||||
/// protection against [CSWSH](https://christian-schneider.net/CrossSiteWebSocketHijacking.html).
|
||||
fn check_origin(headers: &header::HeaderMap) -> Result<(), super::HttpError> {
|
||||
let origin_hdr = match headers.get(http::header::ORIGIN) {
|
||||
None => return Ok(()),
|
||||
Some(o) => o,
|
||||
};
|
||||
let host_hdr = headers
|
||||
.get(header::HOST)
|
||||
.ok_or_else(|| bad_req("missing Host header"))?;
|
||||
let host_str = host_hdr.to_str().map_err(|_| bad_req("bad Host header"))?;
|
||||
|
||||
// Currently this ignores the port number. This is easiest and I think matches the browser's
|
||||
// rules for when it sends a cookie, so it probably doesn't cause great security problems.
|
||||
let host = match host_str.split_once(':') {
|
||||
Some((host, _port)) => host,
|
||||
None => host_str,
|
||||
};
|
||||
let origin_url = origin_hdr
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|o| url::Url::parse(o).ok())
|
||||
.ok_or_else(|| bad_req("bad Origin header"))?;
|
||||
let origin_host = origin_url
|
||||
.host_str()
|
||||
.ok_or_else(|| bad_req("bad Origin header"))?;
|
||||
if host != origin_host {
|
||||
bail_t!(
|
||||
PermissionDenied,
|
||||
"cross-origin request forbidden (request host {:?}, origin {:?})",
|
||||
host_hdr,
|
||||
origin_hdr
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
use super::{Caller, Service};
|
||||
|
||||
impl Service {
|
||||
pub(super) fn stream_live_m4s(
|
||||
pub(super) async fn stream_live_m4s(
|
||||
self: Arc<Self>,
|
||||
req: Request<::hyper::Body>,
|
||||
caller: Caller,
|
||||
ws: &mut WebSocketStream<hyper::upgrade::Upgraded>,
|
||||
caller: Result<Caller, Error>,
|
||||
uuid: Uuid,
|
||||
stream_type: db::StreamType,
|
||||
) -> ResponseResult {
|
||||
check_origin(req.headers())?;
|
||||
) -> Result<(), Error> {
|
||||
let caller = caller?;
|
||||
if !caller.permissions.view_video {
|
||||
bail_t!(PermissionDenied, "view_video required");
|
||||
bail!(PermissionDenied, msg("view_video required"));
|
||||
}
|
||||
|
||||
let stream_id;
|
||||
|
@ -83,19 +36,18 @@ impl Service {
|
|||
let mut db = self.db.lock();
|
||||
open_id = match db.open {
|
||||
None => {
|
||||
bail_t!(
|
||||
bail!(
|
||||
FailedPrecondition,
|
||||
"database is read-only; there are no live streams"
|
||||
msg("database is read-only; there are no live streams"),
|
||||
);
|
||||
}
|
||||
Some(o) => o.id,
|
||||
};
|
||||
let camera = db.get_camera(uuid).ok_or_else(|| {
|
||||
plain_response(StatusCode::NOT_FOUND, format!("no such camera {}", uuid))
|
||||
})?;
|
||||
stream_id = camera.streams[stream_type.index()].ok_or_else(|| {
|
||||
format_err_t!(NotFound, "no such stream {}/{}", uuid, stream_type)
|
||||
})?;
|
||||
let camera = db
|
||||
.get_camera(uuid)
|
||||
.ok_or_else(|| err!(NotFound, msg("no such camera {uuid}")))?;
|
||||
stream_id = camera.streams[stream_type.index()]
|
||||
.ok_or_else(|| err!(NotFound, msg("no such stream {uuid}/{stream_type}")))?;
|
||||
db.watch_live(
|
||||
stream_id,
|
||||
Box::new(move |l| sub_tx.unbounded_send(l).is_ok()),
|
||||
|
@ -103,54 +55,6 @@ impl Service {
|
|||
.expect("stream_id refed by camera");
|
||||
}
|
||||
|
||||
let response =
|
||||
tungstenite::handshake::server::create_response_with_body(&req, hyper::Body::empty)
|
||||
.map_err(|e| bad_req(e.to_string()))?;
|
||||
let (parts, _) = response.into_parts();
|
||||
|
||||
tokio::spawn(self.stream_live_m4s_ws(stream_id, open_id, req, sub_rx));
|
||||
|
||||
Ok(Response::from_parts(parts, Body::from("")))
|
||||
}
|
||||
|
||||
async fn stream_live_m4s_ws(
|
||||
self: Arc<Self>,
|
||||
stream_id: i32,
|
||||
open_id: u32,
|
||||
req: hyper::Request<hyper::Body>,
|
||||
sub_rx: futures::channel::mpsc::UnboundedReceiver<db::LiveSegment>,
|
||||
) {
|
||||
let upgraded = match hyper::upgrade::on(req).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
warn!("Unable to upgrade stream to websocket: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
|
||||
upgraded,
|
||||
tungstenite::protocol::Role::Server,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = self
|
||||
.stream_live_m4s_ws_loop(stream_id, open_id, sub_rx, ws)
|
||||
.await
|
||||
{
|
||||
info!("Dropping WebSocket after error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for `stream_live_m4s_ws` that returns error when the stream is dropped.
|
||||
/// The outer function logs the error.
|
||||
async fn stream_live_m4s_ws_loop(
|
||||
self: Arc<Self>,
|
||||
stream_id: i32,
|
||||
open_id: u32,
|
||||
sub_rx: futures::channel::mpsc::UnboundedReceiver<db::LiveSegment>,
|
||||
mut ws: tokio_tungstenite::WebSocketStream<hyper::upgrade::Upgraded>,
|
||||
) -> Result<(), Error> {
|
||||
let keepalive = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
|
||||
std::time::Duration::new(30, 0),
|
||||
));
|
||||
|
@ -169,18 +73,29 @@ impl Service {
|
|||
.unwrap_or_else(|| unreachable!("timer stream never ends"));
|
||||
match next {
|
||||
Either::Left(live) => {
|
||||
self.stream_live_m4s_chunk(open_id, stream_id, &mut ws, live, start_at_key)
|
||||
.await?;
|
||||
if !self
|
||||
.stream_live_m4s_chunk(open_id, stream_id, ws, live, start_at_key)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
start_at_key = false;
|
||||
}
|
||||
Either::Right(_) => {
|
||||
ws.send(tungstenite::Message::Ping(Vec::new())).await?;
|
||||
if ws
|
||||
.send(tungstenite::Message::Ping(Vec::new()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a single live segment chunk of a `live.m4s` stream.
|
||||
/// Sends a single live segment chunk of a `live.m4s` stream, returning `Ok(false)` when
|
||||
/// the connection is lost.
|
||||
async fn stream_live_m4s_chunk(
|
||||
&self,
|
||||
open_id: u32,
|
||||
|
@ -188,7 +103,7 @@ impl Service {
|
|||
ws: &mut tokio_tungstenite::WebSocketStream<hyper::upgrade::Upgraded>,
|
||||
live: db::LiveSegment,
|
||||
start_at_key: bool,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<bool, Error> {
|
||||
let mut builder = mp4::FileBuilder::new(mp4::Type::MediaSegment);
|
||||
let mut row = None;
|
||||
{
|
||||
|
@ -196,15 +111,12 @@ impl Service {
|
|||
let mut rows = 0;
|
||||
db.list_recordings_by_id(stream_id, live.recording..live.recording + 1, &mut |r| {
|
||||
rows += 1;
|
||||
builder.append(&db, &r, live.media_off_90k.clone(), start_at_key)?;
|
||||
row = Some(r);
|
||||
builder.append(&db, r, live.media_off_90k.clone(), start_at_key)?;
|
||||
Ok(())
|
||||
})?;
|
||||
if rows != 1 {
|
||||
bail_t!(Internal, "unable to find {:?}", live);
|
||||
}
|
||||
}
|
||||
let row = row.unwrap();
|
||||
let row = row.ok_or_else(|| err!(Internal, msg("unable to find {live:?}")))?;
|
||||
use http_serve::Entity;
|
||||
let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone())?;
|
||||
let mut hdrs = header::HeaderMap::new();
|
||||
|
@ -231,38 +143,6 @@ impl Service {
|
|||
);
|
||||
let mut v = hdr.into_bytes();
|
||||
mp4.append_into_vec(&mut v).await?;
|
||||
ws.send(tungstenite::Message::Binary(v)).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::convert::TryInto;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn origin_port_8080_okay() {
|
||||
// By default, Moonfire binds to port 8080. Make sure that specifying a port number works.
|
||||
let mut hdrs = header::HeaderMap::new();
|
||||
hdrs.insert(header::HOST, "nvr:8080".try_into().unwrap());
|
||||
hdrs.insert(header::ORIGIN, "http://nvr:8080/".try_into().unwrap());
|
||||
assert!(check_origin(&hdrs).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_missing_okay() {
|
||||
let mut hdrs = header::HeaderMap::new();
|
||||
hdrs.insert(header::HOST, "nvr".try_into().unwrap());
|
||||
assert!(check_origin(&hdrs).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_mismatch_fails() {
|
||||
let mut hdrs = header::HeaderMap::new();
|
||||
hdrs.insert(header::HOST, "nvr".try_into().unwrap());
|
||||
hdrs.insert(header::ORIGIN, "http://evil/".try_into().unwrap());
|
||||
assert!(check_origin(&hdrs).is_err());
|
||||
Ok(ws.send(tungstenite::Message::Binary(v)).await.is_ok())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,52 +8,35 @@ mod path;
|
|||
mod session;
|
||||
mod signals;
|
||||
mod static_file;
|
||||
mod users;
|
||||
mod view;
|
||||
mod websocket;
|
||||
|
||||
use self::accept::ConnData;
|
||||
use self::path::Path;
|
||||
use crate::body::Body;
|
||||
use crate::json;
|
||||
use crate::mp4;
|
||||
use base::{bail_t, ErrorKind};
|
||||
use base::{clock::Clocks, format_err_t};
|
||||
use crate::web::static_file::Ui;
|
||||
use base::err;
|
||||
use base::Error;
|
||||
use base::FastHashMap;
|
||||
use base::ResultExt;
|
||||
use base::{bail, clock::Clocks, ErrorKind};
|
||||
use core::borrow::Borrow;
|
||||
use core::str::FromStr;
|
||||
use db::dir::SampleFileDir;
|
||||
use db::{auth, recording};
|
||||
use failure::{format_err, Error};
|
||||
use fnv::FnvHashMap;
|
||||
use http::header::{self, HeaderValue};
|
||||
use http::method::Method;
|
||||
use http::{status::StatusCode, Request, Response};
|
||||
use http_serve::dir::FsDir;
|
||||
use hyper::body::Bytes;
|
||||
use log::{debug, warn};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
use tracing::Instrument;
|
||||
use url::form_urlencoded;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// An HTTP error response.
|
||||
/// This is a thin wrapper over the hyper response type; it doesn't even verify
|
||||
/// that the response actually uses a non-2xx status code. Its purpose is to
|
||||
/// allow automatic conversion from `base::Error`. Rust's orphan rule prevents
|
||||
/// this crate from defining a direct conversion from `base::Error` to
|
||||
/// `hyper::Response`.
|
||||
struct HttpError(Response<Body>);
|
||||
|
||||
impl From<Response<Body>> for HttpError {
|
||||
fn from(response: Response<Body>) -> Self {
|
||||
HttpError(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base::Error> for HttpError {
|
||||
fn from(err: base::Error) -> Self {
|
||||
HttpError(from_base_error(err))
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_response<B: Into<Body>>(status: http::StatusCode, body: B) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
|
@ -62,27 +45,13 @@ fn plain_response<B: Into<Body>>(status: http::StatusCode, body: B) -> Response<
|
|||
.expect("hardcoded head should be valid")
|
||||
}
|
||||
|
||||
fn not_found<B: Into<Body>>(body: B) -> HttpError {
|
||||
HttpError(plain_response(StatusCode::NOT_FOUND, body))
|
||||
}
|
||||
|
||||
fn bad_req<B: Into<Body>>(body: B) -> HttpError {
|
||||
HttpError(plain_response(StatusCode::BAD_REQUEST, body))
|
||||
}
|
||||
|
||||
fn internal_server_err<E: Into<Error>>(err: E) -> HttpError {
|
||||
HttpError(plain_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.into().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn from_base_error(err: base::Error) -> Response<Body> {
|
||||
fn from_base_error(err: &base::Error) -> Response<Body> {
|
||||
use ErrorKind::*;
|
||||
let status_code = match err.kind() {
|
||||
Unauthenticated => StatusCode::UNAUTHORIZED,
|
||||
PermissionDenied => StatusCode::FORBIDDEN,
|
||||
InvalidArgument | FailedPrecondition => StatusCode::BAD_REQUEST,
|
||||
InvalidArgument => StatusCode::BAD_REQUEST,
|
||||
FailedPrecondition => StatusCode::PRECONDITION_FAILED,
|
||||
NotFound => StatusCode::NOT_FOUND,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
@ -95,16 +64,16 @@ struct Caller {
|
|||
user: Option<json::ToplevelUser>,
|
||||
}
|
||||
|
||||
type ResponseResult = Result<Response<Body>, HttpError>;
|
||||
type ResponseResult = Result<Response<Body>, base::Error>;
|
||||
|
||||
fn serve_json<T: serde::ser::Serialize>(req: &Request<hyper::Body>, out: &T) -> ResponseResult {
|
||||
let (mut resp, writer) = http_serve::streaming_body(&req).build();
|
||||
let (mut resp, writer) = http_serve::streaming_body(req).build();
|
||||
resp.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if let Some(mut w) = writer {
|
||||
serde_json::to_writer(&mut w, out).map_err(internal_server_err)?;
|
||||
serde_json::to_writer(&mut w, out).err_kind(ErrorKind::Internal)?;
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
@ -138,27 +107,42 @@ fn extract_sid(req: &Request<hyper::Body>) -> Option<auth::RawSessionId> {
|
|||
/// This returns the request body as bytes rather than performing
|
||||
/// deserialization. Keeping the bytes allows the caller to use a `Deserialize`
|
||||
/// that borrows from the bytes.
|
||||
async fn extract_json_body(req: &mut Request<hyper::Body>) -> Result<Bytes, HttpError> {
|
||||
if *req.method() != Method::POST {
|
||||
return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into());
|
||||
}
|
||||
async fn extract_json_body(req: &mut Request<hyper::Body>) -> Result<Bytes, base::Error> {
|
||||
let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) {
|
||||
Some(t) if t == "application/json" => true,
|
||||
Some(t) if t == "application/json; charset=UTF-8" => true,
|
||||
_ => false,
|
||||
};
|
||||
if !correct_mime_type {
|
||||
return Err(bad_req("expected application/json request body"));
|
||||
bail!(
|
||||
InvalidArgument,
|
||||
msg("expected application/json request body")
|
||||
);
|
||||
}
|
||||
let b = ::std::mem::replace(req.body_mut(), hyper::Body::empty());
|
||||
hyper::body::to_bytes(b)
|
||||
.await
|
||||
.map_err(|e| internal_server_err(format_err!("unable to read request body: {}", e)))
|
||||
.map_err(|e| err!(Unavailable, msg("unable to read request body"), source(e)))
|
||||
}
|
||||
|
||||
fn parse_json_body<'a, T: serde::Deserialize<'a>>(body: &'a [u8]) -> Result<T, base::Error> {
|
||||
serde_json::from_slice(body)
|
||||
.map_err(|e| err!(InvalidArgument, msg("bad request body"), source(e)))
|
||||
}
|
||||
|
||||
fn require_csrf_if_session(caller: &Caller, csrf: Option<&str>) -> Result<(), base::Error> {
|
||||
match (csrf, caller.user.as_ref().and_then(|u| u.session.as_ref())) {
|
||||
(None, Some(_)) => bail!(Unauthenticated, msg("csrf must be supplied")),
|
||||
(Some(csrf), Some(session)) if !csrf_matches(csrf, session.csrf) => {
|
||||
bail!(Unauthenticated, msg("incorrect csrf"));
|
||||
}
|
||||
(_, _) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Config<'a> {
|
||||
pub db: Arc<db::Database>,
|
||||
pub ui_dir: Option<&'a std::path::Path>,
|
||||
pub ui_dir: Option<&'a crate::cmds::run::config::UiDir>,
|
||||
pub trust_forward_hdrs: bool,
|
||||
pub time_zone_name: String,
|
||||
pub allow_unauthenticated_permissions: Option<db::Permissions>,
|
||||
|
@ -167,8 +151,8 @@ pub struct Config<'a> {
|
|||
|
||||
pub struct Service {
|
||||
db: Arc<db::Database>,
|
||||
ui_dir: Option<Arc<FsDir>>,
|
||||
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
|
||||
ui: Ui,
|
||||
dirs_by_stream_id: Arc<FastHashMap<i32, Arc<SampleFileDir>>>,
|
||||
time_zone_name: String,
|
||||
allow_unauthenticated_permissions: Option<db::Permissions>,
|
||||
trust_forward_hdrs: bool,
|
||||
|
@ -191,23 +175,11 @@ enum CacheControl {
|
|||
|
||||
impl Service {
|
||||
pub fn new(config: Config) -> Result<Self, Error> {
|
||||
let mut ui_dir = None;
|
||||
if let Some(d) = config.ui_dir {
|
||||
match FsDir::builder().for_path(&d) {
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Unable to load ui dir {}; will serve no static files: {}",
|
||||
d.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
Ok(d) => ui_dir = Some(d),
|
||||
};
|
||||
}
|
||||
let ui_dir = config.ui_dir.map(Ui::from).unwrap_or(Ui::None);
|
||||
let dirs_by_stream_id = {
|
||||
let l = config.db.lock();
|
||||
let mut d =
|
||||
FnvHashMap::with_capacity_and_hasher(l.streams_by_id().len(), Default::default());
|
||||
FastHashMap::with_capacity_and_hasher(l.streams_by_id().len(), Default::default());
|
||||
for (&id, s) in l.streams_by_id().iter() {
|
||||
let dir_id = match s.sample_file_dir_id {
|
||||
Some(d) => d,
|
||||
|
@ -221,7 +193,7 @@ impl Service {
|
|||
Ok(Service {
|
||||
db: config.db,
|
||||
dirs_by_stream_id,
|
||||
ui_dir,
|
||||
ui: ui_dir,
|
||||
allow_unauthenticated_permissions: config.allow_unauthenticated_permissions,
|
||||
trust_forward_hdrs: config.trust_forward_hdrs,
|
||||
time_zone_name: config.time_zone_name,
|
||||
|
@ -230,22 +202,51 @@ impl Service {
|
|||
}
|
||||
|
||||
/// Serves an HTTP request.
|
||||
/// Note that the `serve` wrapper handles responses the same whether they
|
||||
/// are `Ok` or `Err`. But returning `Err` here with the `?` operator is
|
||||
/// convenient for error paths.
|
||||
///
|
||||
/// The `Err` return path will cause the `serve` wrapper to log the error,
|
||||
/// as well as returning it to the HTTP client.
|
||||
async fn serve_inner(
|
||||
self: Arc<Self>,
|
||||
req: Request<::hyper::Body>,
|
||||
p: Path,
|
||||
caller: Caller,
|
||||
authreq: auth::Request,
|
||||
conn_data: ConnData,
|
||||
) -> ResponseResult {
|
||||
let (cache, mut response) = match p {
|
||||
let path = Path::decode(req.uri().path());
|
||||
tracing::trace!(?path, "path");
|
||||
let always_allow_unauthenticated = matches!(
|
||||
path,
|
||||
Path::NotFound | Path::Request | Path::Login | Path::Logout | Path::Static
|
||||
);
|
||||
let caller = self.authenticate(&req, &authreq, &conn_data, always_allow_unauthenticated);
|
||||
if let Some(username) = caller
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|c| c.user.as_ref())
|
||||
.map(|u| &u.name)
|
||||
{
|
||||
tracing::Span::current().record("enduser.id", tracing::field::display(username));
|
||||
}
|
||||
|
||||
// WebSocket stuff is handled separately, because most authentication
|
||||
// errors are returned as text messages over the protocol, rather than
|
||||
// HTTP-level errors.
|
||||
if let Path::StreamLiveMp4Segments(uuid, type_) = path {
|
||||
return websocket::upgrade(req, move |ws| {
|
||||
Box::pin(self.stream_live_m4s(ws, caller, uuid, type_))
|
||||
});
|
||||
}
|
||||
|
||||
let caller = caller?;
|
||||
let (cache, mut response) = match path {
|
||||
Path::InitSegment(sha1, debug) => (
|
||||
CacheControl::PrivateStatic,
|
||||
self.init_segment(sha1, debug, &req)?,
|
||||
),
|
||||
Path::TopLevel => (CacheControl::PrivateDynamic, self.top_level(&req, caller)?),
|
||||
Path::Request => (CacheControl::PrivateDynamic, self.request(&req, caller)?),
|
||||
Path::Request => (
|
||||
CacheControl::PrivateDynamic,
|
||||
self.request(&req, &authreq, caller)?,
|
||||
),
|
||||
Path::Camera(uuid) => (CacheControl::PrivateDynamic, self.camera(&req, uuid)?),
|
||||
Path::StreamRecordings(uuid, type_) => (
|
||||
CacheControl::PrivateDynamic,
|
||||
|
@ -259,18 +260,24 @@ impl Service {
|
|||
CacheControl::PrivateStatic,
|
||||
self.stream_view_mp4(&req, caller, uuid, type_, mp4::Type::MediaSegment, debug)?,
|
||||
),
|
||||
Path::StreamLiveMp4Segments(uuid, type_) => (
|
||||
Path::StreamLiveMp4Segments(..) => {
|
||||
unreachable!("StreamLiveMp4Segments should have already been handled")
|
||||
}
|
||||
Path::NotFound => return Err(err!(NotFound, msg("path not understood"))),
|
||||
Path::Login => (
|
||||
CacheControl::PrivateDynamic,
|
||||
self.stream_live_m4s(req, caller, uuid, type_)?,
|
||||
self.login(req, authreq).await?,
|
||||
),
|
||||
Path::Logout => (
|
||||
CacheControl::PrivateDynamic,
|
||||
self.logout(req, authreq).await?,
|
||||
),
|
||||
Path::NotFound => return Err(not_found("path not understood")),
|
||||
Path::Login => (CacheControl::PrivateDynamic, self.login(req).await?),
|
||||
Path::Logout => (CacheControl::PrivateDynamic, self.logout(req).await?),
|
||||
Path::Signals => (
|
||||
CacheControl::PrivateDynamic,
|
||||
self.signals(req, caller).await?,
|
||||
),
|
||||
Path::Static => (CacheControl::None, self.static_file(req).await?),
|
||||
Path::Users => (CacheControl::PrivateDynamic, self.users(req, caller).await?),
|
||||
Path::User(id) => (
|
||||
CacheControl::PrivateDynamic,
|
||||
self.user(req, caller, id).await?,
|
||||
|
@ -295,6 +302,7 @@ impl Service {
|
|||
}
|
||||
|
||||
/// Serves an HTTP request.
|
||||
///
|
||||
/// An error return from this method causes hyper to abruptly drop the
|
||||
/// HTTP connection rather than respond. That's not terribly useful, so this
|
||||
/// method always returns `Ok`. It delegates to a `serve_inner` which is
|
||||
|
@ -305,20 +313,69 @@ impl Service {
|
|||
req: Request<::hyper::Body>,
|
||||
conn_data: ConnData,
|
||||
) -> Result<Response<Body>, std::convert::Infallible> {
|
||||
let p = Path::decode(req.uri().path());
|
||||
let always_allow_unauthenticated = matches!(
|
||||
p,
|
||||
Path::NotFound | Path::Request | Path::Login | Path::Logout | Path::Static
|
||||
);
|
||||
debug!("request on: {}: {:?}", req.uri(), p);
|
||||
let caller = match self.authenticate(&req, &conn_data, always_allow_unauthenticated) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(from_base_error(e)),
|
||||
let request_id = ulid::Ulid::new();
|
||||
let authreq = auth::Request {
|
||||
when_sec: Some(self.db.clocks().realtime().sec),
|
||||
addr: if self.trust_forward_hdrs {
|
||||
req.headers()
|
||||
.get("X-Real-IP")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| IpAddr::from_str(v).ok())
|
||||
} else {
|
||||
conn_data.client_addr.map(|a| a.ip())
|
||||
},
|
||||
user_agent: req
|
||||
.headers()
|
||||
.get(header::USER_AGENT)
|
||||
.map(|ua| ua.as_bytes().to_vec()),
|
||||
};
|
||||
Ok(self
|
||||
.serve_inner(req, p, caller)
|
||||
.await
|
||||
.unwrap_or_else(|e| e.0))
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/http/
|
||||
let span = tracing::info_span!(
|
||||
"request",
|
||||
%request_id,
|
||||
net.sock.peer.uid = conn_data.client_unix_uid.map(tracing::field::display),
|
||||
http.client_ip = authreq.addr.map(tracing::field::display),
|
||||
http.method = %req.method(),
|
||||
http.target = %req.uri(),
|
||||
http.status_code = tracing::field::Empty,
|
||||
enduser.id = tracing::field::Empty,
|
||||
);
|
||||
tracing::debug!(parent: &span, "received request headers");
|
||||
let response = self
|
||||
.serve_inner(req, authreq, conn_data)
|
||||
.instrument(span.clone())
|
||||
.await;
|
||||
let (response, error) = match response {
|
||||
Ok(r) => (r, None),
|
||||
Err(e) => (from_base_error(&e), Some(e)),
|
||||
};
|
||||
span.record("http.status_code", response.status().as_u16());
|
||||
let latency = std::time::Instant::now().duration_since(start);
|
||||
if response.status().is_server_error() {
|
||||
tracing::error!(
|
||||
parent: &span,
|
||||
latency = latency.as_secs_f32(),
|
||||
error = error.map(tracing::field::display),
|
||||
"sending response headers",
|
||||
);
|
||||
} else if response.status().is_client_error() {
|
||||
tracing::warn!(
|
||||
parent: &span,
|
||||
latency = latency.as_secs_f32(),
|
||||
error = error.map(tracing::field::display),
|
||||
"sending response headers",
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
parent: &span,
|
||||
latency = latency.as_secs_f32(),
|
||||
error = error.map(tracing::field::display),
|
||||
"sending response headers",
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn top_level(&self, req: &Request<::hyper::Body>, caller: Caller) -> ResponseResult {
|
||||
|
@ -336,7 +393,7 @@ impl Service {
|
|||
}
|
||||
|
||||
if camera_configs && !caller.permissions.read_camera_configs {
|
||||
bail_t!(PermissionDenied, "read_camera_configs required");
|
||||
bail!(PermissionDenied, msg("read_camera_configs required"));
|
||||
}
|
||||
|
||||
let db = self.db.lock();
|
||||
|
@ -349,6 +406,7 @@ impl Service {
|
|||
user: caller.user,
|
||||
signals: (&db, days),
|
||||
signal_types: &db,
|
||||
permissions: caller.permissions.into(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -357,10 +415,10 @@ impl Service {
|
|||
let db = self.db.lock();
|
||||
let camera = db
|
||||
.get_camera(uuid)
|
||||
.ok_or_else(|| not_found(format!("no such camera {}", uuid)))?;
|
||||
.ok_or_else(|| err!(NotFound, msg("no such camera {uuid}")))?;
|
||||
serve_json(
|
||||
req,
|
||||
&json::Camera::wrap(camera, &db, true, false).map_err(internal_server_err)?,
|
||||
&json::Camera::wrap(camera, &db, true, false).err_kind(ErrorKind::Internal)?,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -378,18 +436,19 @@ impl Service {
|
|||
let (key, value) = (key.borrow(), value.borrow());
|
||||
match key {
|
||||
"startTime90k" => {
|
||||
time.start = recording::Time::parse(value)
|
||||
.map_err(|_| bad_req("unparseable startTime90k"))?
|
||||
time.start = recording::Time::parse(value).map_err(|_| {
|
||||
err!(InvalidArgument, msg("unparseable startTime90k"))
|
||||
})?
|
||||
}
|
||||
"endTime90k" => {
|
||||
time.end = recording::Time::parse(value)
|
||||
.map_err(|_| bad_req("unparseable endTime90k"))?
|
||||
.map_err(|_| err!(InvalidArgument, msg("unparseable endTime90k")))?
|
||||
}
|
||||
"split90k" => {
|
||||
split = recording::Duration(
|
||||
i64::from_str(value)
|
||||
.map_err(|_| bad_req("unparseable split90k"))?,
|
||||
)
|
||||
split =
|
||||
recording::Duration(i64::from_str(value).map_err(|_| {
|
||||
err!(InvalidArgument, msg("unparseable split90k"))
|
||||
})?)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
@ -402,15 +461,12 @@ impl Service {
|
|||
recordings: Vec::new(),
|
||||
video_sample_entries: (&db, Vec::new()),
|
||||
};
|
||||
let camera = db.get_camera(uuid).ok_or_else(|| {
|
||||
plain_response(StatusCode::NOT_FOUND, format!("no such camera {}", uuid))
|
||||
})?;
|
||||
let stream_id = camera.streams[type_.index()].ok_or_else(|| {
|
||||
plain_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("no such stream {}/{}", uuid, type_),
|
||||
)
|
||||
})?;
|
||||
let Some(camera) = db.get_camera(uuid) else {
|
||||
bail!(NotFound, msg("no such camera {uuid}"));
|
||||
};
|
||||
let Some(stream_id) = camera.streams[type_.index()] else {
|
||||
bail!(NotFound, msg("no such stream {uuid}/{type_}"));
|
||||
};
|
||||
db.list_aggregated_recordings(stream_id, r, split, &mut |row| {
|
||||
let end = row.ids.end - 1; // in api, ids are inclusive.
|
||||
out.recordings.push(json::Recording {
|
||||
|
@ -420,6 +476,7 @@ impl Service {
|
|||
} else {
|
||||
Some(end)
|
||||
},
|
||||
run_start_id: row.run_start_id,
|
||||
start_time_90k: row.time.start.0,
|
||||
end_time_90k: row.time.end.0,
|
||||
sample_file_bytes: row.sample_file_bytes,
|
||||
|
@ -429,6 +486,7 @@ impl Service {
|
|||
video_sample_entry_id: row.video_sample_entry_id,
|
||||
growing: row.growing,
|
||||
has_trailing_zero: row.has_trailing_zero,
|
||||
end_reason: row.end_reason.clone(),
|
||||
});
|
||||
if !out
|
||||
.video_sample_entries
|
||||
|
@ -439,81 +497,33 @@ impl Service {
|
|||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(internal_server_err)?;
|
||||
.err_kind(ErrorKind::Internal)?;
|
||||
serve_json(req, &out)
|
||||
}
|
||||
|
||||
fn init_segment(&self, id: i32, debug: bool, req: &Request<::hyper::Body>) -> ResponseResult {
|
||||
let mut builder = mp4::FileBuilder::new(mp4::Type::InitSegment);
|
||||
let db = self.db.lock();
|
||||
let ent = db
|
||||
.video_sample_entries_by_id()
|
||||
.get(&id)
|
||||
.ok_or_else(|| not_found("not such init segment"))?;
|
||||
let Some(ent) = db.video_sample_entries_by_id().get(&id) else {
|
||||
bail!(NotFound, msg("no such init segment"));
|
||||
};
|
||||
builder.append_video_sample_entry(ent.clone());
|
||||
let mp4 = builder
|
||||
.build(self.db.clone(), self.dirs_by_stream_id.clone())
|
||||
.map_err(from_base_error)?;
|
||||
.err_kind(ErrorKind::Internal)?;
|
||||
if debug {
|
||||
Ok(plain_response(StatusCode::OK, format!("{:#?}", mp4)))
|
||||
Ok(plain_response(StatusCode::OK, format!("{mp4:#?}")))
|
||||
} else {
|
||||
Ok(http_serve::serve(mp4, req))
|
||||
}
|
||||
}
|
||||
|
||||
async fn user(&self, req: Request<hyper::Body>, caller: Caller, id: i32) -> ResponseResult {
|
||||
if caller.user.map(|u| u.id) != Some(id) {
|
||||
bail_t!(Unauthenticated, "must be authenticated as supplied user");
|
||||
}
|
||||
match *req.method() {
|
||||
Method::POST => self.post_user(req, id).await,
|
||||
_ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_user(&self, mut req: Request<hyper::Body>, id: i32) -> ResponseResult {
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
||||
let mut db = self.db.lock();
|
||||
let user = db
|
||||
.users_by_id()
|
||||
.get(&id)
|
||||
.ok_or_else(|| format_err_t!(Internal, "can't find currently authenticated user"))?;
|
||||
if let Some(precondition) = r.precondition {
|
||||
if matches!(precondition.preferences, Some(p) if p != user.config.preferences) {
|
||||
bail_t!(FailedPrecondition, "preferences mismatch");
|
||||
}
|
||||
}
|
||||
if let Some(update) = r.update {
|
||||
let mut change = user.change();
|
||||
if let Some(preferences) = update.preferences {
|
||||
change.config.preferences = preferences;
|
||||
}
|
||||
db.apply_user_change(change).map_err(internal_server_err)?;
|
||||
}
|
||||
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
||||
}
|
||||
|
||||
fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request {
|
||||
auth::Request {
|
||||
when_sec: Some(self.db.clocks().realtime().sec),
|
||||
addr: if self.trust_forward_hdrs {
|
||||
req.headers()
|
||||
.get("X-Real-IP")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| IpAddr::from_str(v).ok())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
user_agent: req
|
||||
.headers()
|
||||
.get(header::USER_AGENT)
|
||||
.map(|ua| ua.as_bytes().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&self, req: &Request<::hyper::Body>, caller: Caller) -> ResponseResult {
|
||||
let authreq = self.authreq(req);
|
||||
fn request(
|
||||
&self,
|
||||
req: &Request<::hyper::Body>,
|
||||
authreq: &auth::Request,
|
||||
caller: Caller,
|
||||
) -> ResponseResult {
|
||||
let host = req
|
||||
.headers()
|
||||
.get(header::HOST)
|
||||
|
@ -577,13 +587,16 @@ impl Service {
|
|||
fn authenticate(
|
||||
&self,
|
||||
req: &Request<hyper::Body>,
|
||||
authreq: &auth::Request,
|
||||
conn_data: &ConnData,
|
||||
unauth_path: bool,
|
||||
) -> Result<Caller, base::Error> {
|
||||
if let Some(sid) = extract_sid(req) {
|
||||
let authreq = self.authreq(req);
|
||||
|
||||
match self.db.lock().authenticate_session(authreq, &sid.hash()) {
|
||||
match self
|
||||
.db
|
||||
.lock()
|
||||
.authenticate_session(authreq.clone(), &sid.hash())
|
||||
{
|
||||
Ok((s, u)) => {
|
||||
return Ok(Caller {
|
||||
permissions: s.permissions.clone(),
|
||||
|
@ -595,13 +608,13 @@ impl Service {
|
|||
}),
|
||||
})
|
||||
}
|
||||
Err(e) if e.kind() == base::ErrorKind::Unauthenticated => {
|
||||
Err(err) if err.kind() == base::ErrorKind::Unauthenticated => {
|
||||
// Log the specific reason this session is unauthenticated.
|
||||
// Don't let the API client see it, as it may have a
|
||||
// revocation reason that isn't for their eyes.
|
||||
warn!("Session authentication failed: {:?}", &e);
|
||||
warn!(err = %err.chain(), "session authentication failed");
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -611,6 +624,7 @@ impl Service {
|
|||
view_video: true,
|
||||
read_camera_configs: true,
|
||||
update_signals: true,
|
||||
admin_users: true,
|
||||
..Default::default()
|
||||
},
|
||||
user: None,
|
||||
|
@ -631,7 +645,7 @@ impl Service {
|
|||
});
|
||||
}
|
||||
|
||||
bail_t!(Unauthenticated, "unauthenticated");
|
||||
bail!(Unauthenticated);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -751,8 +765,7 @@ mod bench {
|
|||
|
||||
use db::testutil::{self, TestDb};
|
||||
use hyper;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Server {
|
||||
|
@ -809,14 +822,12 @@ mod bench {
|
|||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref SERVER: Server = Server::new();
|
||||
}
|
||||
static SERVER: OnceLock<Server> = OnceLock::new();
|
||||
|
||||
#[bench]
|
||||
fn serve_stream_recordings(b: &mut test::Bencher) {
|
||||
testutil::init();
|
||||
let server = &*SERVER;
|
||||
let server = SERVER.get_or_init(Server::new);
|
||||
let url = reqwest::Url::parse(&format!(
|
||||
"{}/api/cameras/{}/main/recordings",
|
||||
server.base_url, server.test_camera_uuid
|
||||
|
|
|
@ -22,6 +22,7 @@ pub(super) enum Path {
|
|||
Login, // "/api/login"
|
||||
Logout, // "/api/logout"
|
||||
Static, // (anything that doesn't start with "/api/")
|
||||
Users, // "/api/users"
|
||||
User(i32), // "/api/users/<id>"
|
||||
NotFound,
|
||||
}
|
||||
|
@ -50,10 +51,10 @@ impl Path {
|
|||
Some(p) => p,
|
||||
None => return Path::NotFound,
|
||||
};
|
||||
if let Ok(id) = i32::from_str(&path) {
|
||||
if let Ok(id) = i32::from_str(path) {
|
||||
return Path::InitSegment(id, debug);
|
||||
}
|
||||
return Path::NotFound;
|
||||
Path::NotFound
|
||||
} else if let Some(path) = path.strip_prefix("cameras/") {
|
||||
let (uuid, path) = match path.split_once('/') {
|
||||
Some(pair) => pair,
|
||||
|
@ -93,6 +94,9 @@ impl Path {
|
|||
if let Ok(id) = i32::from_str(path) {
|
||||
return Path::User(id);
|
||||
}
|
||||
if path.is_empty() {
|
||||
return Path::Users;
|
||||
}
|
||||
Path::NotFound
|
||||
} else {
|
||||
Path::NotFound
|
||||
|
@ -165,5 +169,6 @@ mod tests {
|
|||
assert_eq!(Path::decode("/api/junk"), Path::NotFound);
|
||||
assert_eq!(Path::decode("/api/users/42"), Path::User(42));
|
||||
assert_eq!(Path::decode("/api/users/asdf"), Path::NotFound);
|
||||
assert_eq!(Path::decode("/api/users/"), Path::Users);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,29 +4,37 @@
|
|||
|
||||
//! Session management: `/api/login` and `/api/logout`.
|
||||
|
||||
use base::{bail, ErrorKind, ResultExt};
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
||||
use db::auth;
|
||||
use http::{header, HeaderValue, Request, Response, StatusCode};
|
||||
use log::{info, warn};
|
||||
use http::{header, HeaderValue, Method, Request, Response, StatusCode};
|
||||
use memchr::memchr;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::json;
|
||||
use crate::{json, web::parse_json_body};
|
||||
|
||||
use super::{
|
||||
bad_req, csrf_matches, extract_json_body, extract_sid, internal_server_err, plain_response,
|
||||
ResponseResult, Service,
|
||||
csrf_matches, extract_json_body, extract_sid, plain_response, ResponseResult, Service,
|
||||
};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
impl Service {
|
||||
pub(super) async fn login(&self, mut req: Request<::hyper::Body>) -> ResponseResult {
|
||||
pub(super) async fn login(
|
||||
&self,
|
||||
mut req: Request<::hyper::Body>,
|
||||
authreq: auth::Request,
|
||||
) -> ResponseResult {
|
||||
if *req.method() != Method::POST {
|
||||
return Ok(plain_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"POST expected",
|
||||
));
|
||||
}
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let r: json::LoginRequest =
|
||||
serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
||||
let authreq = self.authreq(&req);
|
||||
let host = req
|
||||
.headers()
|
||||
.get(header::HOST)
|
||||
.ok_or_else(|| bad_req("missing Host header!"))?;
|
||||
let r: json::LoginRequest = parse_json_body(&r)?;
|
||||
let Some(host) = req.headers().get(header::HOST) else {
|
||||
bail!(InvalidArgument, msg("missing Host header"));
|
||||
};
|
||||
let host = host.as_bytes();
|
||||
let domain = match memchr(b':', host) {
|
||||
Some(colon) => &host[0..colon],
|
||||
|
@ -53,8 +61,8 @@ impl Service {
|
|||
0
|
||||
};
|
||||
let (sid, _) = l
|
||||
.login_by_password(authreq, &r.username, r.password, Some(domain), flags)
|
||||
.map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?;
|
||||
.login_by_password(authreq, r.username, r.password, Some(domain), flags)
|
||||
.err_kind(ErrorKind::Unauthenticated)?;
|
||||
let cookie = encode_sid(sid, flags);
|
||||
Ok(Response::builder()
|
||||
.header(
|
||||
|
@ -66,37 +74,39 @@ impl Service {
|
|||
.unwrap())
|
||||
}
|
||||
|
||||
pub(super) async fn logout(&self, mut req: Request<hyper::Body>) -> ResponseResult {
|
||||
pub(super) async fn logout(
|
||||
&self,
|
||||
mut req: Request<hyper::Body>,
|
||||
authreq: auth::Request,
|
||||
) -> ResponseResult {
|
||||
if *req.method() != Method::POST {
|
||||
return Ok(plain_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"POST expected",
|
||||
));
|
||||
}
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let r: json::LogoutRequest =
|
||||
serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
||||
let r: json::LogoutRequest = parse_json_body(&r)?;
|
||||
|
||||
let mut res = Response::new(b""[..].into());
|
||||
if let Some(sid) = extract_sid(&req) {
|
||||
let authreq = self.authreq(&req);
|
||||
let mut l = self.db.lock();
|
||||
let hash = sid.hash();
|
||||
let need_revoke = match l.authenticate_session(authreq.clone(), &hash) {
|
||||
match l.authenticate_session(authreq.clone(), &hash) {
|
||||
Ok((s, _)) => {
|
||||
if !csrf_matches(r.csrf, s.csrf()) {
|
||||
warn!("logout request with missing/incorrect csrf");
|
||||
return Err(bad_req("logout with incorrect csrf token"));
|
||||
bail!(InvalidArgument, msg("logout with incorrect csrf token"));
|
||||
}
|
||||
info!("revoking session");
|
||||
true
|
||||
l.revoke_session(auth::RevocationReason::LoggedOut, None, authreq, &hash)
|
||||
.err_kind(ErrorKind::Internal)?;
|
||||
}
|
||||
Err(e) => {
|
||||
Err(err) => {
|
||||
// TODO: distinguish "no such session", "session is no longer valid", and
|
||||
// "user ... is disabled" (which are all client error / bad state) from database
|
||||
// errors.
|
||||
warn!("logout failed: {}", e);
|
||||
false
|
||||
warn!(err = %err.chain(), "logout failed");
|
||||
}
|
||||
};
|
||||
if need_revoke {
|
||||
// TODO: inline this above with non-lexical lifetimes.
|
||||
l.revoke_session(auth::RevocationReason::LoggedOut, None, authreq, &hash)
|
||||
.map_err(internal_server_err)?;
|
||||
}
|
||||
|
||||
// By now the session is invalid (whether it was valid to start with or not).
|
||||
|
@ -115,7 +125,7 @@ impl Service {
|
|||
fn encode_sid(sid: db::RawSessionId, flags: i32) -> String {
|
||||
let mut cookie = String::with_capacity(128);
|
||||
cookie.push_str("s=");
|
||||
base64::encode_config_buf(&sid, base64::STANDARD_NO_PAD, &mut cookie);
|
||||
STANDARD_NO_PAD.encode_string(sid, &mut cookie);
|
||||
use auth::SessionFlag;
|
||||
if (flags & SessionFlag::HttpOnly as i32) != 0 {
|
||||
cookie.push_str("; HttpOnly");
|
||||
|
@ -134,9 +144,9 @@ fn encode_sid(sid: db::RawSessionId, flags: i32) -> String {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base::FastHashMap;
|
||||
use db::testutil;
|
||||
use fnv::FnvHashMap;
|
||||
use log::info;
|
||||
use tracing::info;
|
||||
|
||||
use crate::web::tests::Server;
|
||||
|
||||
|
@ -153,7 +163,7 @@ mod tests {
|
|||
let resp = cli.post(&login_url).send().await.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
|
||||
let mut p = FnvHashMap::default();
|
||||
let mut p = FastHashMap::default();
|
||||
p.insert("username", "slamb");
|
||||
p.insert("password", "asdf");
|
||||
let resp = cli.post(&login_url).json(&p).send().await.unwrap();
|
||||
|
@ -180,7 +190,7 @@ mod tests {
|
|||
testutil::init();
|
||||
let s = Server::new(None);
|
||||
let cli = reqwest::Client::new();
|
||||
let mut p = FnvHashMap::default();
|
||||
let mut p = FastHashMap::default();
|
||||
p.insert("username", "slamb");
|
||||
p.insert("password", "hunter2");
|
||||
let resp = cli
|
||||
|
@ -229,7 +239,7 @@ mod tests {
|
|||
.get("csrf")
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let mut p = FnvHashMap::default();
|
||||
let mut p = FastHashMap::default();
|
||||
p.insert("csrf", csrf);
|
||||
let resp = cli
|
||||
.post(&format!("{}/api/logout", &s.base_url))
|
||||
|
@ -269,14 +279,11 @@ mod tests {
|
|||
| (SessionFlag::SameSite as i32)
|
||||
| (SessionFlag::SameSiteStrict as i32)
|
||||
),
|
||||
format!(
|
||||
"s={}; HttpOnly; Secure; SameSite=Strict; Max-Age=2147483648; Path=/",
|
||||
s64
|
||||
)
|
||||
format!("s={s64}; HttpOnly; Secure; SameSite=Strict; Max-Age=2147483648; Path=/")
|
||||
);
|
||||
assert_eq!(
|
||||
encode_sid(s, SessionFlag::SameSite as i32),
|
||||
format!("s={}; SameSite=Lax; Max-Age=2147483648; Path=/", s64)
|
||||
format!("s={s64}; SameSite=Lax; Max-Age=2147483648; Path=/")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
//! `/api/signals` handling.
|
||||
|
||||
use base::{bail_t, clock::Clocks};
|
||||
use base::{bail, clock::Clocks, err};
|
||||
use db::recording;
|
||||
use http::{Method, Request, StatusCode};
|
||||
use url::form_urlencoded;
|
||||
|
@ -12,8 +12,8 @@ use url::form_urlencoded;
|
|||
use crate::json;
|
||||
|
||||
use super::{
|
||||
bad_req, extract_json_body, from_base_error, plain_response, serve_json, Caller,
|
||||
ResponseResult, Service,
|
||||
extract_json_body, parse_json_body, plain_response, require_csrf_if_session, serve_json,
|
||||
Caller, ResponseResult, Service,
|
||||
};
|
||||
|
||||
use std::borrow::Borrow;
|
||||
|
@ -27,21 +27,20 @@ impl Service {
|
|||
match *req.method() {
|
||||
Method::POST => self.post_signals(req, caller).await,
|
||||
Method::GET | Method::HEAD => self.get_signals(&req),
|
||||
_ => Err(plain_response(
|
||||
_ => Ok(plain_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"POST, GET, or HEAD expected",
|
||||
)
|
||||
.into()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_signals(&self, mut req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||
if !caller.permissions.update_signals {
|
||||
bail_t!(PermissionDenied, "update_signals required");
|
||||
bail!(PermissionDenied, msg("update_signals required"));
|
||||
}
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let r: json::PostSignalsRequest =
|
||||
serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
||||
let r: json::PostSignalsRequest = parse_json_body(&r)?;
|
||||
require_csrf_if_session(&caller, r.csrf)?;
|
||||
let now = recording::Time::new(self.db.clocks().realtime());
|
||||
let mut l = self.db.lock();
|
||||
let start = match r.start {
|
||||
|
@ -52,8 +51,7 @@ impl Service {
|
|||
json::PostSignalsTimeBase::Epoch(t) => t,
|
||||
json::PostSignalsTimeBase::Now(d) => now + d,
|
||||
};
|
||||
l.update_signals(start..end, &r.signal_ids, &r.states)
|
||||
.map_err(from_base_error)?;
|
||||
l.update_signals(start..end, &r.signal_ids, &r.states)?;
|
||||
serve_json(&req, &json::PostSignalsResponse { time_90k: now })
|
||||
}
|
||||
|
||||
|
@ -65,11 +63,11 @@ impl Service {
|
|||
match key {
|
||||
"startTime90k" => {
|
||||
time.start = recording::Time::parse(value)
|
||||
.map_err(|_| bad_req("unparseable startTime90k"))?
|
||||
.map_err(|_| err!(InvalidArgument, msg("unparseable startTime90k")))?
|
||||
}
|
||||
"endTime90k" => {
|
||||
time.end = recording::Time::parse(value)
|
||||
.map_err(|_| bad_req("unparseable endTime90k"))?
|
||||
.map_err(|_| err!(InvalidArgument, msg("unparseable endTime90k")))?
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
@ -4,45 +4,104 @@
|
|||
|
||||
//! Static file serving.
|
||||
|
||||
use http::{header, HeaderValue, Request};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{internal_server_err, not_found, ResponseResult, Service};
|
||||
use base::{bail, err, ErrorKind, ResultExt};
|
||||
use http::{header, HeaderValue, Request};
|
||||
use http_serve::dir::FsDir;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::cmds::run::config::UiDir;
|
||||
|
||||
use super::{ResponseResult, Service};
|
||||
|
||||
pub enum Ui {
|
||||
None,
|
||||
FromFilesystem(Arc<FsDir>),
|
||||
#[cfg(feature = "bundled-ui")]
|
||||
Bundled(&'static crate::bundled_ui::Ui),
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
pub fn from(cfg: &UiDir) -> Self {
|
||||
match cfg {
|
||||
UiDir::FromFilesystem(d) => match FsDir::builder().for_path(d) {
|
||||
Err(err) => {
|
||||
warn!(
|
||||
%err,
|
||||
"unable to load ui dir {}; will serve no static files",
|
||||
d.display(),
|
||||
);
|
||||
Self::None
|
||||
}
|
||||
Ok(d) => Self::FromFilesystem(d),
|
||||
},
|
||||
#[cfg(feature = "bundled-ui")]
|
||||
UiDir::Bundled(_) => Self::Bundled(crate::bundled_ui::Ui::get()),
|
||||
#[cfg(not(feature = "bundled-ui"))]
|
||||
UiDir::Bundled(_) => {
|
||||
warn!("server compiled without bundled ui; will serve not static files");
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve(
|
||||
&self,
|
||||
path: &str,
|
||||
req: &Request<hyper::Body>,
|
||||
cache_control: &'static str,
|
||||
content_type: &'static str,
|
||||
) -> ResponseResult {
|
||||
match self {
|
||||
Ui::None => bail!(
|
||||
NotFound,
|
||||
msg("ui not configured or missing; no static files available")
|
||||
),
|
||||
Ui::FromFilesystem(d) => {
|
||||
let node = d.clone().get(path, req.headers()).await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
err!(NotFound, msg("static file not found"))
|
||||
} else {
|
||||
err!(Internal, source(e))
|
||||
}
|
||||
})?;
|
||||
let mut hdrs = http::HeaderMap::new();
|
||||
node.add_encoding_headers(&mut hdrs);
|
||||
hdrs.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(cache_control),
|
||||
);
|
||||
hdrs.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
let e = node.into_file_entity(hdrs).err_kind(ErrorKind::Internal)?;
|
||||
Ok(http_serve::serve(e, req))
|
||||
}
|
||||
#[cfg(feature = "bundled-ui")]
|
||||
Ui::Bundled(ui) => {
|
||||
let Some(e) = ui.lookup(path, req.headers(), cache_control, content_type) else {
|
||||
bail!(NotFound, msg("static file not found"));
|
||||
};
|
||||
Ok(http_serve::serve(e, &req))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Service {
|
||||
/// Serves a static file if possible.
|
||||
pub(super) async fn static_file(&self, req: Request<hyper::Body>) -> ResponseResult {
|
||||
let dir = self.ui_dir.clone().ok_or_else(|| {
|
||||
not_found("ui dir not configured or missing; no static files available.")
|
||||
})?;
|
||||
let static_req = match StaticFileRequest::parse(req.uri().path()) {
|
||||
None => return Err(not_found("static file not found")),
|
||||
Some(r) => r,
|
||||
let Some(static_req) = StaticFileRequest::parse(req.uri().path()) else {
|
||||
bail!(NotFound, msg("static file not found"));
|
||||
};
|
||||
let f = dir.get(static_req.path, req.headers());
|
||||
let node = f.await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
not_found("no such static file")
|
||||
} else {
|
||||
internal_server_err(e)
|
||||
}
|
||||
})?;
|
||||
let mut hdrs = http::HeaderMap::new();
|
||||
node.add_encoding_headers(&mut hdrs);
|
||||
hdrs.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static(if static_req.immutable {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets
|
||||
"public, max-age=604800, immutable"
|
||||
} else {
|
||||
"public"
|
||||
}),
|
||||
);
|
||||
hdrs.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(static_req.mime),
|
||||
);
|
||||
let e = node.into_file_entity(hdrs).map_err(internal_server_err)?;
|
||||
Ok(http_serve::serve(e, &req))
|
||||
let cache_control = if static_req.immutable {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#Caching_static_assets
|
||||
"public, max-age=604800, immutable"
|
||||
} else {
|
||||
"public"
|
||||
};
|
||||
self.ui
|
||||
.serve(static_req.path, &req, cache_control, static_req.mime)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,219 @@
|
|||
// This file is part of Moonfire NVR, a security camera network video recorder.
|
||||
// Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception.
|
||||
|
||||
//! User management: `/api/users/*`.
|
||||
|
||||
use base::{bail, err};
|
||||
use http::{Method, Request, StatusCode};
|
||||
|
||||
use crate::json::{self, PutUsersResponse, UserSubset, UserWithId};
|
||||
|
||||
use super::{
|
||||
extract_json_body, parse_json_body, plain_response, require_csrf_if_session, serve_json,
|
||||
Caller, ResponseResult, Service,
|
||||
};
|
||||
|
||||
impl Service {
|
||||
pub(super) async fn users(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||
match *req.method() {
|
||||
Method::GET | Method::HEAD => self.get_users(req, caller).await,
|
||||
Method::POST => self.post_users(req, caller).await,
|
||||
_ => Ok(plain_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"GET, HEAD, or POST expected",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_users(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||
if !caller.permissions.admin_users {
|
||||
bail!(Unauthenticated, msg("must have admin_users permission"));
|
||||
}
|
||||
let l = self.db.lock();
|
||||
let users = l
|
||||
.users_by_id()
|
||||
.iter()
|
||||
.map(|(&id, user)| UserWithId {
|
||||
id,
|
||||
user: UserSubset::from(user),
|
||||
})
|
||||
.collect();
|
||||
serve_json(&req, &json::GetUsersResponse { users })
|
||||
}
|
||||
|
||||
async fn post_users(&self, mut req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||
if !caller.permissions.admin_users {
|
||||
bail!(Unauthenticated, msg("must have admin_users permission"));
|
||||
}
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let mut r: json::PutUsers = parse_json_body(&r)?;
|
||||
require_csrf_if_session(&caller, r.csrf)?;
|
||||
let username = r
|
||||
.user
|
||||
.username
|
||||
.take()
|
||||
.ok_or_else(|| err!(InvalidArgument, msg("username must be specified")))?;
|
||||
let mut change = db::UserChange::add_user(username.to_owned());
|
||||
if let Some(Some(pwd)) = r.user.password.take() {
|
||||
change.set_password(pwd.to_owned());
|
||||
}
|
||||
if let Some(preferences) = r.user.preferences.take() {
|
||||
change.config.preferences = preferences;
|
||||
}
|
||||
if let Some(permissions) = r.user.permissions.take() {
|
||||
change.permissions = permissions.into();
|
||||
}
|
||||
if r.user != Default::default() {
|
||||
bail!(Unimplemented, msg("unsupported user fields: {r:#?}"));
|
||||
}
|
||||
let mut l = self.db.lock();
|
||||
let user = l.apply_user_change(change)?;
|
||||
serve_json(&req, &PutUsersResponse { id: user.id })
|
||||
}
|
||||
|
||||
pub(super) async fn user(
|
||||
&self,
|
||||
req: Request<hyper::Body>,
|
||||
caller: Caller,
|
||||
id: i32,
|
||||
) -> ResponseResult {
|
||||
match *req.method() {
|
||||
Method::GET | Method::HEAD => self.get_user(req, caller, id).await,
|
||||
Method::DELETE => self.delete_user(req, caller, id).await,
|
||||
Method::PATCH => self.patch_user(req, caller, id).await,
|
||||
_ => Ok(plain_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"GET, HEAD, DELETE, or PATCH expected",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user(&self, req: Request<hyper::Body>, caller: Caller, id: i32) -> ResponseResult {
|
||||
require_same_or_admin(&caller, id)?;
|
||||
let db = self.db.lock();
|
||||
let user = db
|
||||
.users_by_id()
|
||||
.get(&id)
|
||||
.ok_or_else(|| err!(NotFound, msg("can't find requested user")))?;
|
||||
serve_json(&req, &UserSubset::from(user))
|
||||
}
|
||||
|
||||
async fn delete_user(
|
||||
&self,
|
||||
mut req: Request<hyper::Body>,
|
||||
caller: Caller,
|
||||
id: i32,
|
||||
) -> ResponseResult {
|
||||
if !caller.permissions.admin_users {
|
||||
bail!(Unauthenticated, msg("must have admin_users permission"));
|
||||
}
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let r: json::DeleteUser = parse_json_body(&r)?;
|
||||
require_csrf_if_session(&caller, r.csrf)?;
|
||||
let mut l = self.db.lock();
|
||||
l.delete_user(id)?;
|
||||
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
||||
}
|
||||
|
||||
async fn patch_user(
|
||||
&self,
|
||||
mut req: Request<hyper::Body>,
|
||||
caller: Caller,
|
||||
id: i32,
|
||||
) -> ResponseResult {
|
||||
require_same_or_admin(&caller, id)?;
|
||||
let r = extract_json_body(&mut req).await?;
|
||||
let r: json::PostUser = parse_json_body(&r)?;
|
||||
let mut db = self.db.lock();
|
||||
let user = db
|
||||
.get_user_by_id_mut(id)
|
||||
.ok_or_else(|| err!(NotFound, msg("can't find requested user")))?;
|
||||
if r.update.as_ref().and_then(|u| u.password).is_some()
|
||||
&& r.precondition.as_ref().and_then(|p| p.password).is_none()
|
||||
&& !caller.permissions.admin_users
|
||||
{
|
||||
bail!(
|
||||
Unauthenticated,
|
||||
msg("to change password, must supply previous password or have admin_users permission")
|
||||
);
|
||||
}
|
||||
require_csrf_if_session(&caller, r.csrf)?;
|
||||
if let Some(mut precondition) = r.precondition {
|
||||
if matches!(precondition.disabled.take(), Some(d) if d != user.config.disabled) {
|
||||
bail!(FailedPrecondition, msg("disabled mismatch"));
|
||||
}
|
||||
if matches!(precondition.username.take(), Some(n) if n != user.username) {
|
||||
bail!(FailedPrecondition, msg("username mismatch"));
|
||||
}
|
||||
if matches!(precondition.preferences.take(), Some(ref p) if p != &user.config.preferences)
|
||||
{
|
||||
bail!(FailedPrecondition, msg("preferences mismatch"));
|
||||
}
|
||||
if let Some(p) = precondition.password.take() {
|
||||
if !user.check_password(p)? {
|
||||
bail!(FailedPrecondition, msg("password mismatch")); // or Unauthenticated?
|
||||
}
|
||||
}
|
||||
if let Some(p) = precondition.permissions.take() {
|
||||
if user.permissions != db::Permissions::from(p) {
|
||||
bail!(FailedPrecondition, msg("permissions mismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
// Safety valve in case something is added to UserSubset and forgotten here.
|
||||
if precondition != Default::default() {
|
||||
bail!(
|
||||
Unimplemented,
|
||||
msg("preconditions not supported: {precondition:#?}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(mut update) = r.update {
|
||||
let mut change = user.change();
|
||||
|
||||
// First, set up updates which non-admins are allowed to perform on themselves.
|
||||
if let Some(preferences) = update.preferences.take() {
|
||||
change.config.preferences = preferences;
|
||||
}
|
||||
match update.password.take() {
|
||||
None => {}
|
||||
Some(None) => change.clear_password(),
|
||||
Some(Some(p)) => change.set_password(p.to_owned()),
|
||||
}
|
||||
|
||||
// Requires admin_users if there's anything else.
|
||||
if update != Default::default() && !caller.permissions.admin_users {
|
||||
bail!(Unauthenticated, msg("must have admin_users permission"));
|
||||
}
|
||||
if let Some(d) = update.disabled.take() {
|
||||
change.config.disabled = d;
|
||||
}
|
||||
if let Some(n) = update.username.take() {
|
||||
change.username = n.to_string();
|
||||
}
|
||||
if let Some(permissions) = update.permissions.take() {
|
||||
change.permissions = permissions.into();
|
||||
}
|
||||
|
||||
// Safety valve in case something is added to UserSubset and forgotten here.
|
||||
if update != Default::default() {
|
||||
bail!(Unimplemented, msg("updates not supported: {update:#?}"));
|
||||
}
|
||||
|
||||
// Then apply all together.
|
||||
db.apply_user_change(change)?;
|
||||
}
|
||||
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
||||
}
|
||||
}
|
||||
|
||||
fn require_same_or_admin(caller: &Caller, id: i32) -> Result<(), base::Error> {
|
||||
if caller.user.as_ref().map(|u| u.id) != Some(id) && !caller.permissions.admin_users {
|
||||
bail!(
|
||||
Unauthenticated,
|
||||
msg("must be authenticated as supplied user or have admin_users permission"),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue