from origin repo gh:ddworken/hishtory, commit 480630e9181167b51554f4407db55717d9b7e4dd

This commit is contained in:
setop
2022-11-13 10:56:08 +01:00
commit 96d048fba6
99 changed files with 12962 additions and 0 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
.git
node_modules/

6
.errcheck_excludes.txt Normal file
View File

@@ -0,0 +1,6 @@
(net/http.ResponseWriter).Write
(*gorm.io/gorm.DB).AutoMigrate
os.Setenv
(*os.File).Close
(io.ReadCloser).Close
(*github.com/gofrs/flock.Flock).Unlock

13
.github/push_event.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"push": {
"head": {
"ref": "users/foo/update-action"
},
"base": {
"ref": "users/foo/update-action"
}
},
"head_commit": {
"message": "build latest"
}
}

View File

@@ -0,0 +1,15 @@
version: 1
env:
- CGO_ENABLED=0
flags:
- -trimpath
goos: darwin
goarch: amd64
binary: hishtory-{{ .Os }}-{{ .Arch }}
ldflags:
- '{{ .Env.VERSION_LDFLAGS }}'

View File

@@ -0,0 +1,15 @@
version: 1
env:
- CGO_ENABLED=0
flags:
- -trimpath
goos: darwin
goarch: arm64
binary: hishtory-{{ .Os }}-{{ .Arch }}
ldflags:
- '{{ .Env.VERSION_LDFLAGS }}'

View File

@@ -0,0 +1,15 @@
version: 1
env:
- CGO_ENABLED=0
flags:
- -trimpath
goos: freebsd
goarch: amd64
binary: hishtory-{{ .Os }}-{{ .Arch }}
ldflags:
- '{{ .Env.VERSION_LDFLAGS }}'

View File

@@ -0,0 +1,15 @@
version: 1
env:
- CGO_ENABLED=0
flags:
- -trimpath
goos: linux
goarch: amd64
binary: hishtory-{{ .Os }}-{{ .Arch }}
ldflags:
- '{{ .Env.VERSION_LDFLAGS }}'

37
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '16 13 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
if: ${{ !startsWith(github.event.head_commit.message, 'Release') }}
uses: github/codeql-action/analyze@v2

View File

@@ -0,0 +1,34 @@
name: Docker Compose Tests
on:
workflow_dispatch:
pull_request:
push:
branches: [ master ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
# - uses: mxschmitt/action-tmate@v3
- name: Docker Compose test
if: ${{ !startsWith(github.event.head_commit.message, 'Release') }}
run: |
sudo apt-get update
sudo apt-get install -y zsh fish
curl -fsSL https://get.docker.com | sudo sh
sudo chmod 0755 -R /usr/share/zsh/ || true # Work around a weird bug where zsh on ubuntu actions gives that diretory 0777 which makes zsh refuse to start
sudo hostname ghaction-runner-hostname # Set a consistent hostname so we can run tests that depend on it
docker compose -f backend/server/docker-compose.yml build
docker compose -f backend/server/docker-compose.yml up -d
export HISHTORY_SERVER=http://localhost
go build
./hishtory install
source ~/.bashrc
ls
./hishtory query

39
.github/workflows/go-test.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Go Tests
on:
workflow_dispatch:
pull_request:
schedule:
- cron: '0 0 * * *'
push:
branches: [ master ]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
fail-fast: false
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Go test
if: ${{ !startsWith(github.event.head_commit.message, 'Release') }}
run: |
sudo apt-get update || true
sudo apt-get install -y zsh fish || true
brew install fish tmux bash || true
export TZ='America/Los_Angeles' # Force the time zone so that test output is consistent
sudo chmod 0755 -R /usr/share/zsh/ || true # Work around a weird bug where zsh on ubuntu actions gives that diretory 0777 which makes zsh refuse to start
sudo hostname ghaction-runner-hostname || true # Set a consistent hostname so we can run tests that depend on it
sudo scutil --set HostName ghaction-runner-hostname || true
make test
- name: Setup tmate session
if: ${{ failure() }}
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true

146
.github/workflows/slsa-releaser.yml vendored Normal file
View File

@@ -0,0 +1,146 @@
name: SLSA go releaser
on:
workflow_dispatch:
push:
tags:
- "*"
permissions: read-all
jobs:
# ldflags to embed the commit hash in the binary
args:
runs-on: ubuntu-latest
outputs:
ldflags: ${{ steps.ldflags.outputs.value }}
steps:
- id: checkout
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.3.4
with:
fetch-depth: 0
- id: ldflags
run: |
echo "::set-output name=value::$(./scripts/client-ldflags)"
# Trusted builders
build-linux-amd64:
permissions:
id-token: write
contents: write
actions: read
needs: args
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
with:
config-file: .github/slsa/.slsa-goreleaser-linux-amd64.yml
go-version: 1.18
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
build-freebsd-amd64:
permissions:
id-token: write
contents: write
actions: read
needs: args
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
with:
config-file: .github/slsa/.slsa-goreleaser-freebsd-amd64.yml
go-version: 1.18
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
build-darwin-amd64:
permissions:
id-token: write
contents: write
actions: read
needs:
- args
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
with:
config-file: .github/slsa/.slsa-goreleaser-darwin-amd64.yml
go-version: 1.18
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
build-darwin-arm64:
permissions:
id-token: write
contents: write
actions: read
needs:
- args
uses: slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@v1.2.1
with:
config-file: .github/slsa/.slsa-goreleaser-darwin-arm64.yml
go-version: 1.18
evaluated-envs: "VERSION_LDFLAGS:${{needs.args.outputs.ldflags}}"
compile-builder: true # See github.com/slsa-framework/slsa-github-generator/issues/942
# Sign the binaries and upload the signed binaries
macos_signer:
runs-on: macos-11.0
needs:
- upload
permissions:
contents: write
steps:
- uses: actions/checkout@v2
- name: Download and sign the latest executables
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
export GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}"
pip3 install requests
brew install md5sha1sum
python3 scripts/actions-sign.py
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
hishtory-darwin-arm64
hishtory-darwin-arm64-unsigned
hishtory-darwin-amd64
hishtory-darwin-amd64-unsigned
- name: Trigger the backend API service so it knows a release is finished
run: |
curl https://api.hishtory.dev/api/v1/trigger-cron
# Upload to GitHub release.
upload:
permissions:
contents: write
runs-on: ubuntu-latest
needs:
- build-linux-amd64
- build-darwin-amd64
- build-darwin-arm64
steps:
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: hishtory-linux-amd64
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: hishtory-linux-amd64.intoto.jsonl
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: hishtory-darwin-amd64
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: hishtory-darwin-amd64.intoto.jsonl
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: hishtory-darwin-arm64
- uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: hishtory-darwin-arm64.intoto.jsonl
- name: Release
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
if: ${{ startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-') }}
with:
files: |
hishtory-linux-amd64
hishtory-linux-amd64.intoto.jsonl
hishtory-darwin-amd64
hishtory-darwin-amd64.intoto.jsonl
hishtory-darwin-arm64
hishtory-darwin-arm64.intoto.jsonl

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
web/landing/www/binaries/hishtory-linux
hishtory
backend/server/server
postgres-data/

17
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,17 @@
repos:
- repo: https://github.com/Bahjat/pre-commit-golang
rev: a4be1d0f860565649a450a8d480e541844c14a07
hooks:
- id: go-fmt-import
- id: go-vet
- id: gofumpt # requires github.com/mvdan/gofumpt
- id: go-static-check # install https://staticcheck.io/docs/
exclude: /vndor/
- id: golangci-lint # requires github.com/golangci/golangci-lint
- repo: local
hooks:
- id: go-errcheck
name: go-errcheck
entry: errcheck -exclude .errcheck_excludes.txt ./...
language: system
pass_filenames: false

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 David Dworken
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
forcetest:
go clean -testcache
HISHTORY_TEST=1 HISHTORY_SKIP_INIT_IMPORT=1 go test -p 1 -timeout 30m ./...
test:
HISHTORY_TEST=1 HISHTORY_SKIP_INIT_IMPORT=1 go test -p 1 -timeout 30m ./...
acttest:
act push -j test -e .github/push_event.json --reuse --container-architecture linux/amd64
release:
# Bump the version
expr `cat VERSION` + 1 > VERSION
git add VERSION
git commit -m "Release v0.`cat VERSION`" --no-verify
git push
gh release create v0.`cat VERSION` --generate-notes
git push && git push --tags
build-static:
docker build -t gcr.io/dworken-k8s/hishtory-static -f backend/web/caddy/Dockerfile .
build-api:
docker build -t gcr.io/dworken-k8s/hishtory-api -f backend/server/Dockerfile .
deploy-static: build-static
docker push gcr.io/dworken-k8s/hishtory-static
ssh monoserver "cd ~/infra/ && docker compose pull hishtory-static && docker compose up -d --no-deps hishtory-static"
deploy-api: build-api
docker push gcr.io/dworken-k8s/hishtory-api
ssh monoserver "cd ~/infra/ && docker compose pull hishtory-api && docker compose up -d --no-deps hishtory-api"
deploy: release deploy-static deploy-api

146
README.md Normal file
View File

@@ -0,0 +1,146 @@
# hiSHtory: Better Shell History
`hishtory` is a better shell history. It stores your shell history in context (what directory you ran the command in, whether it succeeded or failed, how long it took, etc). This is all stored locally and end-to-end encrypted for syncing to to all your other computers. All of this is easily queryable via the `hishtory` CLI. This means from your laptop, you can easily find that complex bash pipeline you wrote on your server, and see the context in which you ran it.
![demo](https://raw.githubusercontent.com/ddworken/hishtory/master/backend/web/landing/www/img/demo.gif)
## Getting Started
To install `hishtory` on your first machine:
```bash
curl https://hishtory.dev/install.py | python3 -
```
At this point, `hishtory` is already managing your shell history (for bash, zsh, and fish!). Give it a try with `hishtory query` and see below for more details on the advanced query features.
Then to install `hishtory` on your other computers, you need your secret key. Get this by running `hishtory status`. Once you have it, you follow similar steps to install hiSHtory on your other computers:
```bash
curl https://hishtory.dev/install.py | python3 -
hishtory init $YOUR_HISHTORY_SECRET
```
Now if you run `hishtory query` on first computer, you can automatically see the commands you've run on all your other computers!
## Features
### Querying
There are two ways to interact with hiSHtory.
1. Via pressing `Control+R` in your terminal. Search for a command, select it via `Enter`, and then have it ready to execute in your terminal's buffer.
2. Via `hishtory query` if you just want to explore your shell history.
Both support the same query format, see the below annotated queries:
| Query | Explanation |
|---|---|
| `psql` | Find all commands containing `psql` |
| `psql db.example.com` | Find all commands containing `psql` and `db.example.com` |
| `docker hostname:my-server` | Find all commands containing `docker` that were run on the computer with hostname `my-server` |
| `nano user:root` | Find all commands containing `nano` that were run as `root` |
| `exit_code:127` | Find all commands that exited with code `127` |
| `service before:2022-02-01` | Find all commands containing `service` run before February 1st 2022 |
| `service after:2022-02-01` | Find all commands containing `service` run after February 1st 2022 |
For true power users, you can even query in SQLite via `sqlite3 -cmd 'PRAGMA journal_mode = WAL' ~/.hishtory/.hishtory.db`.
### Enable/Disable
If you want to temporarily turn on/off hiSHtory recording, you can do so via `hishtory disable` (to turn off recording) and `hishtory enable` (to turn on recording). You can check whether or not `hishtory` is enabled via `hishtory status`.
### Deletion
`hishtory redact` can be used to delete history entries that you didn't intend to record. It accepts the same search format as `hishtory query`. For example, to delete all history entries containing `psql`, run `hishtory redact psql`.
### Updating
To update `hishtory` to the latest version, just run `hishtory update` to securely download and apply the latest update.
### Advanced Features
<details>
<summary>Changing the displayed columns</summary>
You can customize the columns that are displayed via `hishtory config-set displayed-columns`. For example, to display only the cwd and command:
```
hishtory config-set displayed-columns CWD Command
```
</details>
<details>
<summary>Custom Columns</summary>
You can create custom column definitions that are populated from arbitrary commands. For example, if you want to create a new column named `git_remote` that contains the git remote if the cwd is in a git directory, you can run:
```
hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || true'
hishtory config-add displayed-columns git_remote
```
</details>
<details>
<summary>Disabling Control-R integration</summary>
If you'd like to disable the control-R integration in your shell, you can do so by running `hishtory config-set enable-control-r false`.
</details>
<details>
<summary>Filtering duplicate entries</summary>
By default, hishtory query will show all results even if this includes duplicate history entries. This helps you keep track of how many times you've run a command and in what contexts. If you'd rather disable this so that hiSHtory won't show duplicate entries, you can run:
```
hishtory config-set filter-duplicate-commands true
```
</details>
<details>
<summary>Offline Install</summary>
If you don't need the ability to sync your shell history, you can install hiSHtory in offline mode.
Download the latest binary from [Github Releases](https://github.com/ddworken/hishtory/releases), and then run `./hishtory-binary install --offline` to install hiSHtory in a fully offline mode. This disables syncing and it is not possible to re-enable syncing after doing this.
</details>
<details>
<summary>Self-Hosting</summary>
By default, hiSHtory relies on a backend for syncing. All data is end-to-end encrypted, so the backend can't view your history.
But if you'd like to self-host the hishtory backend, you can! The backend is a simple go binary in `backend/server/server.go` that uses postgres to store data. Check out the [`docker-compose.yml`](https://github.com/ddworken/hishtory/blob/master/backend/server/docker-compose.yml) file for an example config to start a hiSHtory server and how to configure it.
</details>
<details>
<summary>Importing existing history</summary>
hiSHtory imports your existing shell history by default. If for some reason this didn't work (e.g. you had your shell history in a non-standard file), you can import it by piping it into `hishtory import` (e.g. `cat ~/.my_history | hishtory import`).
</details>
<details>
<summary>Custom timestamp formats</summary>
You can configure a custom timestamp format for hiSHtory via `hishtory config-set timestamp-format '2006/Jan/2 15:04'`. The timestamp format string should be in [the format used by Go's `time.Format(...)`](https://pkg.go.dev/time#Time.Format).
</details>
<details>
<summary>Uninstalling</summary>
If you'd like to uninstall hishtory, just run `hishtory uninstall`. Note that this deletes the SQLite DB storing your history, so consider running a `hishtory export` first.
</details>
## Design
The `hishtory` CLI is written in Go. It hooks into the shell in order to track information about all commands that are run. It takes this data and saves it in a local SQLite DB managed via [GORM](https://gorm.io/). This data is then encrypted and sent to your other devices through a backend that essentially functions as a one-to-many queue. When you run `hishtory query`, a SQL query is run to find matching entries in the local SQLite DB.
### Syncing Design
See [hiSHtory: Cross-device Encrypted Syncing Design](https://blog.daviddworken.com/posts/hishtory-explained/) to learn how syncing works. The tl;dr is that everything magically works so that:
* The backend can't read your history.
* Your history is queryable from all your devices.
* You can delete items from your history as needed.
* If you go offline, you'll have an offline copy of your history. And once you come back online, syncing will transparently resume.
## Security
`hishtory` is a CLI tool written in Go and uses AES-GCM for end-to-end encrypting your history entries while syncing them. The binary is reproducibly built and [SLSA Level 3](https://slsa.dev/) to make it easy to verify you're getting the code contained in this repository.
This all ensures that the minimalist backend cannot read your shell history, it only sees encrypted data.
If you find any security issues in hiSHtory, please reach out to `david@daviddworken.com`.

1
VERSION Normal file
View File

@@ -0,0 +1 @@
178

10
backend/server/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM golang:1.18 AS builder
COPY go.mod ./
COPY go.sum ./
RUN unset GOPATH; go mod download
COPY . ./
RUN unset GOPATH; GOARCH=amd64 go build -o /server -ldflags "-X main.ReleaseVersion=v0.`cat VERSION`" backend/server/server.go
FROM golang:1.18
COPY --from=builder /server /server
CMD ["/server"]

View File

@@ -0,0 +1,35 @@
# A docker-compose file to host a hiSHtory backend. To use:
# 1. Update TODO_YOUR_POSTGRES_PASSWORD_HERE
# 2. `docker compose -f backend/server/docker-compose.yml build`
# 3. `docker compose -f backend/server/docker-compose.yml up`
# 4. Point your hiSHtory client at the server by putting `export HISHTORY_SERVER=http://1.2.3.4` in your shellrc
# 5. Run `hishtory init` to initialize hiSHtory with the local server
# 6. [Optional, but recommended] Add a TLS proxy to enable https
networks:
hishtory:
driver: bridge
services:
postgres:
image: postgres
restart: unless-stopped
networks:
- hishtory
environment:
POSTGRES_PASSWORD: TODO_YOUR_POSTGRES_PASSWORD_HERE
POSTGRES_DB: hishtory
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- ./postgres-data:/var/lib/postgresql/data
hishtory:
depends_on:
- postgres
networks:
- hishtory
build:
context: ../../
dockerfile: ./backend/server/native-arch-Dockerfile
restart: unless-stopped
environment:
HISHTORY_POSTGRES_DB: postgresql://postgres:TODO_YOUR_POSTGRES_PASSWORD_HERE@postgres:5432/hishtory?sslmode=disable
ports:
- 80:8080

View File

@@ -0,0 +1,17 @@
# A fork of Dockerfile that doesn't hard code GOARCH and that uses wait-for to wait
# until the postgres server is up. Meant to be used in the docker-compose file for self hosting.
FROM golang:1.18 AS builder
COPY go.mod ./
COPY go.sum ./
RUN unset GOPATH; go mod download
COPY . ./
RUN unset GOPATH; go build -o /server -ldflags "-X main.ReleaseVersion=v0.`cat VERSION`" backend/server/server.go
FROM golang:1.18
RUN apt-get update && apt-get install -y netcat
# Downlaod wait-for from a specific commit hash. This ensures that the owner of wait-for isn't in our TCB (though Github still is)
RUN curl https://raw.githubusercontent.com/eficode/wait-for/59bec22851ba83e9cc735a67a7d961f8aae2cd85/wait-for > /wait-for
RUN chmod +x /wait-for
COPY --from=builder /server /server
CMD ["/wait-for", "postgres:5432", "--", "/server"]

674
backend/server/server.go Normal file
View File

@@ -0,0 +1,674 @@
package main
import (
"encoding/json"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
"os"
"os/user"
"runtime"
"strconv"
"strings"
"time"
"github.com/ddworken/hishtory/shared"
_ "github.com/lib/pq"
"github.com/rodaine/table"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const (
PostgresDb = "postgresql://postgres:%s@postgres:5432/hishtory?sslmode=disable"
)
var (
GLOBAL_DB *gorm.DB
ReleaseVersion string = "UNKNOWN"
)
type UsageData struct {
UserId string `json:"user_id" gorm:"not null; uniqueIndex:usageDataUniqueIndex"`
DeviceId string `json:"device_id" gorm:"not null; uniqueIndex:usageDataUniqueIndex"`
LastUsed time.Time `json:"last_used"`
LastIp string `json:"last_ip"`
NumEntriesHandled int `json:"num_entries_handled"`
LastQueried time.Time `json:"last_queried"`
NumQueries int `json:"num_queries"`
Version string `json:"version"`
}
func getRequiredQueryParam(r *http.Request, queryParam string) string {
val := r.URL.Query().Get(queryParam)
if val == "" {
panic(fmt.Sprintf("request to %s is missing required query param=%#v", r.URL, queryParam))
}
return val
}
func getHishtoryVersion(r *http.Request) string {
return r.Header.Get("X-Hishtory-Version")
}
func updateUsageData(r *http.Request, userId, deviceId string, numEntriesHandled int, isQuery bool) {
var usageData []UsageData
GLOBAL_DB.Where("user_id = ? AND device_id = ?", userId, deviceId).Find(&usageData)
if len(usageData) == 0 {
GLOBAL_DB.Create(&UsageData{UserId: userId, DeviceId: deviceId, LastUsed: time.Now(), NumEntriesHandled: numEntriesHandled, Version: getHishtoryVersion(r)})
} else {
usage := usageData[0]
GLOBAL_DB.Model(&UsageData{}).Where("user_id = ? AND device_id = ?", userId, deviceId).Update("last_used", time.Now()).Update("last_ip", getRemoteAddr(r))
if numEntriesHandled > 0 {
GLOBAL_DB.Exec("UPDATE usage_data SET num_entries_handled = COALESCE(num_entries_handled, 0) + ? WHERE user_id = ? AND device_id = ?", numEntriesHandled, userId, deviceId)
}
if usage.Version != getHishtoryVersion(r) {
GLOBAL_DB.Exec("UPDATE usage_data SET version = ? WHERE user_id = ? AND device_id = ?", getHishtoryVersion(r), userId, deviceId)
}
}
if isQuery {
GLOBAL_DB.Exec("UPDATE usage_data SET num_queries = COALESCE(num_queries, 0) + 1, last_queried = ? WHERE user_id = ? AND device_id = ?", time.Now(), userId, deviceId)
}
}
func usageStatsHandler(w http.ResponseWriter, r *http.Request) {
query := `
SELECT
MIN(devices.registration_date) as registration_date,
COUNT(DISTINCT devices.device_id) as num_devices,
SUM(usage_data.num_entries_handled) as num_history_entries,
MAX(usage_data.last_used) as last_active,
COALESCE(STRING_AGG(DISTINCT usage_data.last_ip, ', ') FILTER (WHERE usage_data.last_ip != 'Unknown'), 'Unknown') as ip_addresses,
COALESCE(SUM(usage_data.num_queries), 0) as num_queries,
COALESCE(MAX(usage_data.last_queried), 'January 1, 1970') as last_queried,
STRING_AGG(DISTINCT usage_data.version, ', ') as versions
FROM devices
INNER JOIN usage_data ON devices.device_id = usage_data.device_id
GROUP BY devices.user_id
ORDER BY registration_date
`
rows, err := GLOBAL_DB.Raw(query).Rows()
if err != nil {
panic(err)
}
tbl := table.New("Registration Date", "Num Devices", "Num Entries", "Num Queries", "Last Active", "Last Query", "Versions", "IPs")
tbl.WithWriter(w)
for rows.Next() {
var registrationDate time.Time
var numDevices int
var numEntries int
var lastUsedDate time.Time
var ipAddresses string
var numQueries int
var lastQueried time.Time
var versions string
err = rows.Scan(&registrationDate, &numDevices, &numEntries, &lastUsedDate, &ipAddresses, &numQueries, &lastQueried, &versions)
if err != nil {
panic(err)
}
versions = strings.ReplaceAll(strings.ReplaceAll(versions, "Unknown", ""), ", ", "")
lastQueryStr := strings.ReplaceAll(lastQueried.Format("2006-01-02"), "1970-01-01", "")
tbl.AddRow(registrationDate.Format("2006-01-02"), numDevices, numEntries, numQueries, lastUsedDate.Format("2006-01-02"), lastQueryStr, versions, ipAddresses)
}
tbl.Print()
}
func statsHandler(w http.ResponseWriter, r *http.Request) {
var numDevices int64 = 0
checkGormResult(GLOBAL_DB.Model(&shared.Device{}).Count(&numDevices))
type numEntriesProcessed struct {
Total int
}
nep := numEntriesProcessed{}
checkGormResult(GLOBAL_DB.Model(&UsageData{}).Select("SUM(num_entries_handled) as total").Find(&nep))
var numDbEntries int64 = 0
checkGormResult(GLOBAL_DB.Model(&shared.EncHistoryEntry{}).Count(&numDbEntries))
lastWeek := time.Now().AddDate(0, 0, -7)
var weeklyActiveInstalls int64 = 0
checkGormResult(GLOBAL_DB.Model(&UsageData{}).Where("last_used > ?", lastWeek).Count(&weeklyActiveInstalls))
var weeklyQueryUsers int64 = 0
checkGormResult(GLOBAL_DB.Model(&UsageData{}).Where("last_queried > ?", lastWeek).Count(&weeklyQueryUsers))
w.Write([]byte(fmt.Sprintf("Num devices: %d\n", numDevices)))
w.Write([]byte(fmt.Sprintf("Num history entries processed: %d\n", nep.Total)))
w.Write([]byte(fmt.Sprintf("Num DB entries: %d\n", numDbEntries)))
w.Write([]byte(fmt.Sprintf("Weekly active installs: %d\n", weeklyActiveInstalls)))
w.Write([]byte(fmt.Sprintf("Weekly active queries: %d\n", weeklyQueryUsers)))
}
func apiSubmitHandler(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body)
if err != nil {
panic(err)
}
var entries []*shared.EncHistoryEntry
err = json.Unmarshal(data, &entries)
if err != nil {
panic(fmt.Sprintf("body=%#v, err=%v", data, err))
}
fmt.Printf("apiSubmitHandler: received request containg %d EncHistoryEntry\n", len(entries))
if len(entries) == 0 {
return
}
updateUsageData(r, entries[0].UserId, entries[0].DeviceId, len(entries), false)
tx := GLOBAL_DB.Where("user_id = ?", entries[0].UserId)
var devices []*shared.Device
checkGormResult(tx.Find(&devices))
if len(devices) == 0 {
panic(fmt.Errorf("found no devices associated with user_id=%s, can't save history entry", entries[0].UserId))
}
fmt.Printf("apiSubmitHandler: Found %d devices\n", len(devices))
for _, device := range devices {
for _, entry := range entries {
entry.DeviceId = device.DeviceId
}
checkGormResult(GLOBAL_DB.Create(&entries))
}
}
func apiBootstrapHandler(w http.ResponseWriter, r *http.Request) {
userId := getRequiredQueryParam(r, "user_id")
deviceId := getRequiredQueryParam(r, "device_id")
updateUsageData(r, userId, deviceId, 0, false)
tx := GLOBAL_DB.Where("user_id = ?", userId)
var historyEntries []*shared.EncHistoryEntry
checkGormResult(tx.Find(&historyEntries))
resp, err := json.Marshal(historyEntries)
if err != nil {
panic(err)
}
w.Write(resp)
}
func apiQueryHandler(w http.ResponseWriter, r *http.Request) {
userId := getRequiredQueryParam(r, "user_id")
deviceId := getRequiredQueryParam(r, "device_id")
updateUsageData(r, userId, deviceId, 0, true)
// Increment the count
checkGormResult(GLOBAL_DB.Exec("UPDATE enc_history_entries SET read_count = read_count + 1 WHERE device_id = ?", deviceId))
// Delete any entries that match a pending deletion request
var deletionRequests []*shared.DeletionRequest
checkGormResult(GLOBAL_DB.Where("destination_device_id = ? AND user_id = ?", deviceId, userId).Find(&deletionRequests))
for _, request := range deletionRequests {
_, err := applyDeletionRequestsToBackend(*request)
if err != nil {
panic(err)
}
}
// Then retrieve, to avoid a race condition
tx := GLOBAL_DB.Where("device_id = ? AND read_count < 5", deviceId)
var historyEntries []*shared.EncHistoryEntry
checkGormResult(tx.Find(&historyEntries))
fmt.Printf("apiQueryHandler: Found %d entries for %s\n", len(historyEntries), r.URL)
resp, err := json.Marshal(historyEntries)
if err != nil {
panic(err)
}
w.Write(resp)
}
func getRemoteAddr(r *http.Request) string {
addr, ok := r.Header["X-Real-Ip"]
if !ok || len(addr) == 0 {
return "Unknown"
}
return addr[0]
}
func apiRegisterHandler(w http.ResponseWriter, r *http.Request) {
userId := getRequiredQueryParam(r, "user_id")
deviceId := getRequiredQueryParam(r, "device_id")
var existingDevicesCount int64 = -1
checkGormResult(GLOBAL_DB.Model(&shared.Device{}).Where("user_id = ?", userId).Count(&existingDevicesCount))
fmt.Printf("apiRegisterHandler: existingDevicesCount=%d\n", existingDevicesCount)
checkGormResult(GLOBAL_DB.Create(&shared.Device{UserId: userId, DeviceId: deviceId, RegistrationIp: getRemoteAddr(r), RegistrationDate: time.Now()}))
if existingDevicesCount > 0 {
checkGormResult(GLOBAL_DB.Create(&shared.DumpRequest{UserId: userId, RequestingDeviceId: deviceId, RequestTime: time.Now()}))
}
updateUsageData(r, userId, deviceId, 0, false)
}
func apiGetPendingDumpRequestsHandler(w http.ResponseWriter, r *http.Request) {
userId := getRequiredQueryParam(r, "user_id")
deviceId := getRequiredQueryParam(r, "device_id")
var dumpRequests []*shared.DumpRequest
// Filter out ones requested by the hishtory instance that sent this request
checkGormResult(GLOBAL_DB.Where("user_id = ? AND requesting_device_id != ?", userId, deviceId).Find(&dumpRequests))
respBody, err := json.Marshal(dumpRequests)
if err != nil {
panic(fmt.Errorf("failed to JSON marshall the dump requests: %v", err))
}
w.Write(respBody)
}
func apiSubmitDumpHandler(w http.ResponseWriter, r *http.Request) {
userId := getRequiredQueryParam(r, "user_id")
srcDeviceId := getRequiredQueryParam(r, "source_device_id")
requestingDeviceId := getRequiredQueryParam(r, "requesting_device_id")
data, err := ioutil.ReadAll(r.Body)
if err != nil {
panic(err)
}
var entries []shared.EncHistoryEntry
err = json.Unmarshal(data, &entries)
if err != nil {
panic(fmt.Sprintf("body=%#v, err=%v", data, err))
}
fmt.Printf("apiSubmitDumpHandler: received request containg %d EncHistoryEntry\n", len(entries))
err = GLOBAL_DB.Transaction(func(tx *gorm.DB) error {
for _, entry := range entries {
entry.DeviceId = requestingDeviceId
if entry.UserId != userId {
return fmt.Errorf("batch contains an entry with UserId=%#v, when the query param contained the user_id=%#v", entry.UserId, userId)
}
checkGormResult(tx.Create(&entry))
}
return nil
})
if err != nil {
panic(fmt.Errorf("failed to execute transaction to add dumped DB: %v", err))
}
checkGormResult(GLOBAL_DB.Delete(&shared.DumpRequest{}, "user_id = ? AND requesting_device_id = ?", userId, requestingDeviceId))
updateUsageData(r, userId, srcDeviceId, len(entries), false)
}
func apiBannerHandler(w http.ResponseWriter, r *http.Request) {
commitHash := getRequiredQueryParam(r, "commit_hash")
deviceId := getRequiredQueryParam(r, "device_id")
forcedBanner := r.URL.Query().Get("forced_banner")
fmt.Printf("apiBannerHandler: commit_hash=%#v, device_id=%#v, forced_banner=%#v\n", commitHash, deviceId, forcedBanner)
if getHishtoryVersion(r) == "v0.160" {
w.Write([]byte("Warning: hiSHtory v0.160 has a bug that slows down your shell! Please run `hishtory update` to upgrade hiSHtory."))
return
}
w.Write([]byte(html.EscapeString(forcedBanner)))
}
func getDeletionRequestsHandler(w http.ResponseWriter, r *http.Request) {
userId := getRequiredQueryParam(r, "user_id")
deviceId := getRequiredQueryParam(r, "device_id")
// Increment the ReadCount
checkGormResult(GLOBAL_DB.Exec("UPDATE deletion_requests SET read_count = read_count + 1 WHERE destination_device_id = ? AND user_id = ?", deviceId, userId))
// Return all the deletion requests
var deletionRequests []*shared.DeletionRequest
checkGormResult(GLOBAL_DB.Where("user_id = ? AND destination_device_id = ?", userId, deviceId).Find(&deletionRequests))
respBody, err := json.Marshal(deletionRequests)
if err != nil {
panic(fmt.Errorf("failed to JSON marshall the dump requests: %v", err))
}
w.Write(respBody)
}
func addDeletionRequestHandler(w http.ResponseWriter, r *http.Request) {
data, err := ioutil.ReadAll(r.Body)
if err != nil {
panic(err)
}
var request shared.DeletionRequest
err = json.Unmarshal(data, &request)
if err != nil {
panic(fmt.Sprintf("body=%#v, err=%v", data, err))
}
request.ReadCount = 0
fmt.Printf("addDeletionRequestHandler: received request containg %d messages to be deleted\n", len(request.Messages.Ids))
// Store the deletion request so all the devices will get it
tx := GLOBAL_DB.Where("user_id = ?", request.UserId)
var devices []*shared.Device
checkGormResult(tx.Find(&devices))
if len(devices) == 0 {
panic(fmt.Errorf("found no devices associated with user_id=%s, can't save history entry", request.UserId))
}
fmt.Printf("addDeletionRequestHandler: Found %d devices\n", len(devices))
for _, device := range devices {
request.DestinationDeviceId = device.DeviceId
checkGormResult(GLOBAL_DB.Create(&request))
}
// Also delete anything currently in the DB matching it
numDeleted, err := applyDeletionRequestsToBackend(request)
if err != nil {
panic(err)
}
fmt.Printf("addDeletionRequestHandler: Deleted %d rows in the backend\n", numDeleted)
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
var count int64
checkGormResult(GLOBAL_DB.Model(&shared.EncHistoryEntry{}).Count(&count))
if count < 100 {
panic("Suspiciously few enc history entries!")
}
checkGormResult(GLOBAL_DB.Model(&shared.Device{}).Count(&count))
if count < 50 {
panic("Suspiciously few devices!")
}
ok := "OK"
w.Write([]byte(ok))
}
func applyDeletionRequestsToBackend(request shared.DeletionRequest) (int, error) {
tx := GLOBAL_DB.Where("false")
for _, message := range request.Messages.Ids {
tx = tx.Or(GLOBAL_DB.Where("user_id = ? AND device_id = ? AND date = ?", request.UserId, message.DeviceId, message.Date))
}
result := tx.Delete(&shared.EncHistoryEntry{})
checkGormResult(result)
return int(result.RowsAffected), nil
}
func wipeDbHandler(w http.ResponseWriter, r *http.Request) {
checkGormResult(GLOBAL_DB.Exec("DELETE FROM enc_history_entries"))
}
func isTestEnvironment() bool {
u, err := user.Current()
if err != nil {
panic(err)
}
return os.Getenv("HISHTORY_TEST") != "" || u.Username == "david"
}
func OpenDB() (*gorm.DB, error) {
if isTestEnvironment() {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to the DB: %v", err)
}
db.AutoMigrate(&shared.EncHistoryEntry{})
db.AutoMigrate(&shared.Device{})
db.AutoMigrate(&UsageData{})
db.AutoMigrate(&shared.DumpRequest{})
db.AutoMigrate(&shared.DeletionRequest{})
db.Exec("PRAGMA journal_mode = WAL")
return db, nil
}
postgresDb := fmt.Sprintf(PostgresDb, os.Getenv("POSTGRESQL_PASSWORD"))
if os.Getenv("HISHTORY_POSTGRES_DB") != "" {
postgresDb = os.Getenv("HISHTORY_POSTGRES_DB")
}
db, err := gorm.Open(postgres.Open(postgresDb), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect to the DB: %v", err)
}
db.AutoMigrate(&shared.EncHistoryEntry{})
db.AutoMigrate(&shared.Device{})
db.AutoMigrate(&UsageData{})
db.AutoMigrate(&shared.DumpRequest{})
db.AutoMigrate(&shared.DeletionRequest{})
return db, nil
}
func init() {
if ReleaseVersion == "UNKNOWN" && !isTestEnvironment() {
panic("server.go was built without a ReleaseVersion!")
}
InitDB()
go runBackgroundJobs()
}
func cron() error {
err := updateReleaseVersion()
if err != nil {
fmt.Println(err)
}
err = cleanDatabase()
if err != nil {
fmt.Println(err)
}
return nil
}
func runBackgroundJobs() {
time.Sleep(5 * time.Second)
for {
err := cron()
if err != nil {
fmt.Printf("Cron failure: %v", err)
}
time.Sleep(10 * time.Minute)
}
}
func triggerCronHandler(w http.ResponseWriter, r *http.Request) {
err := cron()
if err != nil {
panic(err)
}
}
type releaseInfo struct {
Name string `json:"name"`
}
func updateReleaseVersion() error {
resp, err := http.Get("https://api.github.com/repos/ddworken/hishtory/releases/latest")
if err != nil {
return fmt.Errorf("failed to get latest release version: %v", err)
}
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read github API response body: %v", err)
}
if resp.StatusCode == 403 && strings.Contains(string(respBody), "API rate limit exceeded for ") {
return nil
}
if resp.StatusCode != 200 {
return fmt.Errorf("failed to call github API, status_code=%d, body=%#v", resp.StatusCode, string(respBody))
}
var info releaseInfo
err = json.Unmarshal(respBody, &info)
if err != nil {
return fmt.Errorf("failed to parse github API response: %v", err)
}
latestVersionTag := info.Name
ReleaseVersion = decrementVersionIfInvalid(latestVersionTag)
return nil
}
func decrementVersionIfInvalid(initialVersion string) string {
// Decrements the version up to 5 times if the version doesn't have valid binaries yet.
version := initialVersion
for i := 0; i < 5; i++ {
updateInfo := buildUpdateInfo(version)
err := assertValidUpdate(updateInfo)
if err == nil {
fmt.Printf("Found a valid version: %v\n", version)
return version
}
fmt.Printf("Found %s to be an invalid version: %v\n", version, err)
version, err = decrementVersion(version)
if err != nil {
fmt.Printf("Failed to decrement version after finding the latest version was invalid: %v\n", err)
return initialVersion
}
}
fmt.Printf("Decremented the version 5 times and failed to find a valid version version number, initial version number: %v, last checked version number: %v\n", initialVersion, version)
return initialVersion
}
func assertValidUpdate(updateInfo shared.UpdateInfo) error {
urls := []string{updateInfo.LinuxAmd64Url, updateInfo.LinuxAmd64AttestationUrl,
updateInfo.DarwinAmd64Url, updateInfo.DarwinAmd64UnsignedUrl, updateInfo.DarwinAmd64AttestationUrl,
updateInfo.DarwinArm64Url, updateInfo.DarwinArm64UnsignedUrl, updateInfo.DarwinArm64AttestationUrl}
for _, url := range urls {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to retrieve URL %#v: %v", url, err)
}
if resp.StatusCode == 404 {
return fmt.Errorf("URL %#v returned 404", url)
}
}
return nil
}
func InitDB() {
var err error
GLOBAL_DB, err = OpenDB()
if err != nil {
panic(err)
}
tx, err := GLOBAL_DB.DB()
if err != nil {
panic(err)
}
err = tx.Ping()
if err != nil {
panic(err)
}
}
func decrementVersion(version string) (string, error) {
if version == "UNKNOWN" {
return "", fmt.Errorf("cannot decrement UNKNOWN")
}
parts := strings.Split(version, ".")
if len(parts) != 2 {
return "", fmt.Errorf("invalid version: %s", version)
}
versionNumber, err := strconv.Atoi(parts[1])
if err != nil {
return "", fmt.Errorf("invalid version: %s", version)
}
return parts[0] + "." + strconv.Itoa(versionNumber-1), nil
}
func buildUpdateInfo(version string) shared.UpdateInfo {
return shared.UpdateInfo{
LinuxAmd64Url: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-linux-amd64", version),
LinuxAmd64AttestationUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-linux-amd64.intoto.jsonl", version),
DarwinAmd64Url: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-amd64", version),
DarwinAmd64UnsignedUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-amd64-unsigned", version),
DarwinAmd64AttestationUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-amd64.intoto.jsonl", version),
DarwinArm64Url: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-arm64", version),
DarwinArm64UnsignedUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-arm64-unsigned", version),
DarwinArm64AttestationUrl: fmt.Sprintf("https://github.com/ddworken/hishtory/releases/download/%s/hishtory-darwin-arm64.intoto.jsonl", version),
Version: version,
}
}
func apiDownloadHandler(w http.ResponseWriter, r *http.Request) {
updateInfo := buildUpdateInfo(ReleaseVersion)
resp, err := json.Marshal(updateInfo)
if err != nil {
panic(err)
}
w.Write(resp)
}
func slsaStatusHandler(w http.ResponseWriter, r *http.Request) {
// returns "OK" unless there is a current SLSA bug
v := getHishtoryVersion(r)
if !strings.Contains(v, "v0.") {
w.Write([]byte("OK"))
return
}
vNum, err := strconv.Atoi(strings.Split(v, ".")[1])
if err != nil {
w.Write([]byte("OK"))
return
}
if vNum < 159 {
w.Write([]byte("Sigstore deployed a broken change. See https://github.com/slsa-framework/slsa-github-generator/issues/1163"))
return
}
w.Write([]byte("OK"))
}
type loggedResponseData struct {
size int
}
type loggingResponseWriter struct {
http.ResponseWriter
responseData *loggedResponseData
}
func (r *loggingResponseWriter) Write(b []byte) (int, error) {
size, err := r.ResponseWriter.Write(b)
r.responseData.size += size
return size, err
}
func (r *loggingResponseWriter) WriteHeader(statusCode int) {
r.ResponseWriter.WriteHeader(statusCode)
}
func withLogging(h func(http.ResponseWriter, *http.Request)) http.Handler {
logFn := func(rw http.ResponseWriter, r *http.Request) {
var responseData loggedResponseData
lrw := loggingResponseWriter{
ResponseWriter: rw,
responseData: &responseData,
}
start := time.Now()
h(&lrw, r)
duration := time.Since(start)
fmt.Printf("%s %s %#v %s %s %s\n", getRemoteAddr(r), r.Method, r.RequestURI, getHishtoryVersion(r), duration.String(), byteCountToString(responseData.size))
}
return http.HandlerFunc(logFn)
}
func byteCountToString(b int) string {
const unit = 1000
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMG"[exp])
}
func cleanDatabase() error {
checkGormResult(GLOBAL_DB.Exec("DELETE FROM enc_history_entries WHERE read_count > 10"))
checkGormResult(GLOBAL_DB.Exec("DELETE FROM deletion_requests WHERE read_count > 100"))
// TODO(optimization): Clean the database by deleting entries for users that haven't been used in X amount of time
return nil
}
func main() {
fmt.Println("Listening on localhost:8080")
http.Handle("/api/v1/submit", withLogging(apiSubmitHandler))
http.Handle("/api/v1/get-dump-requests", withLogging(apiGetPendingDumpRequestsHandler))
http.Handle("/api/v1/submit-dump", withLogging(apiSubmitDumpHandler))
http.Handle("/api/v1/query", withLogging(apiQueryHandler))
http.Handle("/api/v1/bootstrap", withLogging(apiBootstrapHandler))
http.Handle("/api/v1/register", withLogging(apiRegisterHandler))
http.Handle("/api/v1/banner", withLogging(apiBannerHandler))
http.Handle("/api/v1/download", withLogging(apiDownloadHandler))
http.Handle("/api/v1/trigger-cron", withLogging(triggerCronHandler))
http.Handle("/api/v1/get-deletion-requests", withLogging(getDeletionRequestsHandler))
http.Handle("/api/v1/add-deletion-request", withLogging(addDeletionRequestHandler))
http.Handle("/api/v1/slsa-status", withLogging(slsaStatusHandler))
http.Handle("/healthcheck", withLogging(healthCheckHandler))
http.Handle("/internal/api/v1/usage-stats", withLogging(usageStatsHandler))
http.Handle("/internal/api/v1/stats", withLogging(statsHandler))
if isTestEnvironment() {
http.Handle("/api/v1/wipe-db", withLogging(wipeDbHandler))
}
log.Fatal(http.ListenAndServe(":8080", nil))
}
func checkGormResult(result *gorm.DB) {
if result.Error != nil {
_, filename, line, _ := runtime.Caller(1)
panic(fmt.Sprintf("DB error at %s:%d: %v", filename, line, result.Error))
}
}
// TODO(optimization): Maybe optimize the endpoints a bit to reduce the number of round trips required?

View File

@@ -0,0 +1,489 @@
package main
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ddworken/hishtory/client/data"
"github.com/ddworken/hishtory/shared"
"github.com/ddworken/hishtory/shared/testutils"
"github.com/go-test/deep"
"github.com/google/uuid"
)
func TestESubmitThenQuery(t *testing.T) {
// Set up
InitDB()
// Register a few devices
userId := data.UserId("key")
devId1 := uuid.Must(uuid.NewRandom()).String()
devId2 := uuid.Must(uuid.NewRandom()).String()
otherUser := data.UserId("otherkey")
otherDev := uuid.Must(uuid.NewRandom()).String()
deviceReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev+"&user_id="+otherUser, nil)
apiRegisterHandler(nil, deviceReq)
// Submit a few entries for different devices
entry := testutils.MakeFakeHistoryEntry("ls ~/")
encEntry, err := data.EncryptHistoryEntry("key", entry)
testutils.Check(t, err)
reqBody, err := json.Marshal([]shared.EncHistoryEntry{encEntry})
testutils.Check(t, err)
submitReq := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
apiSubmitHandler(nil, submitReq)
// Query for device id 1
w := httptest.NewRecorder()
searchReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
apiQueryHandler(w, searchReq)
res := w.Result()
defer res.Body.Close()
respBody, err := ioutil.ReadAll(res.Body)
testutils.Check(t, err)
var retrievedEntries []*shared.EncHistoryEntry
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 1 {
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
}
dbEntry := retrievedEntries[0]
if dbEntry.DeviceId != devId1 {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.UserId != data.UserId("key") {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.ReadCount != 1 {
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
}
decEntry, err := data.DecryptHistoryEntry("key", *dbEntry)
testutils.Check(t, err)
if !data.EntryEquals(decEntry, entry) {
t.Fatalf("DB data is different than input! \ndb =%#v\ninput=%#v", *dbEntry, entry)
}
// Same for device id 2
w = httptest.NewRecorder()
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
apiQueryHandler(w, searchReq)
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 1 {
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
}
dbEntry = retrievedEntries[0]
if dbEntry.DeviceId != devId2 {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.UserId != data.UserId("key") {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.ReadCount != 1 {
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
}
decEntry, err = data.DecryptHistoryEntry("key", *dbEntry)
testutils.Check(t, err)
if !data.EntryEquals(decEntry, entry) {
t.Fatalf("DB data is different than input! \ndb =%#v\ninput=%#v", *dbEntry, entry)
}
// Bootstrap handler should return 2 entries, one for each device
w = httptest.NewRecorder()
searchReq = httptest.NewRequest(http.MethodGet, "/?user_id="+data.UserId("key")+"&device_id="+devId1, nil)
apiBootstrapHandler(w, searchReq)
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 2 {
t.Fatalf("Expected to retrieve 2 entries, found %d", len(retrievedEntries))
}
}
func TestDumpRequestAndResponse(t *testing.T) {
// Set up
InitDB()
// Register a first device for two different users
userId := data.UserId("dkey")
devId1 := uuid.Must(uuid.NewRandom()).String()
devId2 := uuid.Must(uuid.NewRandom()).String()
otherUser := data.UserId("dOtherkey")
otherDev1 := uuid.Must(uuid.NewRandom()).String()
otherDev2 := uuid.Must(uuid.NewRandom()).String()
deviceReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev1+"&user_id="+otherUser, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev2+"&user_id="+otherUser, nil)
apiRegisterHandler(nil, deviceReq)
// Query for dump requests, there should be one for userId
w := httptest.NewRecorder()
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+userId+"&device_id="+devId1, nil))
res := w.Result()
defer res.Body.Close()
respBody, err := ioutil.ReadAll(res.Body)
testutils.Check(t, err)
var dumpRequests []*shared.DumpRequest
testutils.Check(t, json.Unmarshal(respBody, &dumpRequests))
if len(dumpRequests) != 1 {
t.Fatalf("expected one pending dump request, got %#v", dumpRequests)
}
dumpRequest := dumpRequests[0]
if dumpRequest.RequestingDeviceId != devId2 {
t.Fatalf("unexpected device ID")
}
if dumpRequest.UserId != userId {
t.Fatalf("unexpected user ID")
}
// And one for otherUser
w = httptest.NewRecorder()
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+otherUser+"&device_id="+otherDev1, nil))
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
dumpRequests = make([]*shared.DumpRequest, 0)
testutils.Check(t, json.Unmarshal(respBody, &dumpRequests))
if len(dumpRequests) != 1 {
t.Fatalf("expected one pending dump request, got %#v", dumpRequests)
}
dumpRequest = dumpRequests[0]
if dumpRequest.RequestingDeviceId != otherDev2 {
t.Fatalf("unexpected device ID")
}
if dumpRequest.UserId != otherUser {
t.Fatalf("unexpected user ID")
}
// And none if we query for a user ID that doesn't exit
w = httptest.NewRecorder()
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id=foo&device_id=bar", nil))
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
if string(respBody) != "[]" {
t.Fatalf("got unexpected respBody: %#v", string(respBody))
}
// And none for a missing user ID
w = httptest.NewRecorder()
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id=%20&device_id=%20", nil))
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
if string(respBody) != "[]" {
t.Fatalf("got unexpected respBody: %#v", string(respBody))
}
// Now submit a dump for userId
entry1Dec := testutils.MakeFakeHistoryEntry("ls ~/")
entry1, err := data.EncryptHistoryEntry("dkey", entry1Dec)
testutils.Check(t, err)
entry2Dec := testutils.MakeFakeHistoryEntry("aaaaaaáaaa")
entry2, err := data.EncryptHistoryEntry("dkey", entry1Dec)
testutils.Check(t, err)
reqBody, err := json.Marshal([]shared.EncHistoryEntry{entry1, entry2})
testutils.Check(t, err)
submitReq := httptest.NewRequest(http.MethodPost, "/?user_id="+userId+"&requesting_device_id="+devId2+"&source_device_id="+devId1, bytes.NewReader(reqBody))
apiSubmitDumpHandler(nil, submitReq)
// Check that the dump request is no longer there for userId for either device ID
w = httptest.NewRecorder()
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+userId+"&device_id="+devId1, nil))
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
if string(respBody) != "[]" {
t.Fatalf("got unexpected respBody: %#v", string(respBody))
}
w = httptest.NewRecorder()
// The other user
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+userId+"&device_id="+devId2, nil))
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
if string(respBody) != "[]" {
t.Fatalf("got unexpected respBody: %#v", string(respBody))
}
// But it is there for the other user
w = httptest.NewRecorder()
apiGetPendingDumpRequestsHandler(w, httptest.NewRequest(http.MethodGet, "/?user_id="+otherUser+"&device_id="+otherDev1, nil))
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
dumpRequests = make([]*shared.DumpRequest, 0)
testutils.Check(t, json.Unmarshal(respBody, &dumpRequests))
if len(dumpRequests) != 1 {
t.Fatalf("expected one pending dump request, got %#v", dumpRequests)
}
dumpRequest = dumpRequests[0]
if dumpRequest.RequestingDeviceId != otherDev2 {
t.Fatalf("unexpected device ID")
}
if dumpRequest.UserId != otherUser {
t.Fatalf("unexpected user ID")
}
// And finally, query to ensure that the dumped entries are in the DB
w = httptest.NewRecorder()
searchReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
apiQueryHandler(w, searchReq)
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
var retrievedEntries []*shared.EncHistoryEntry
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 2 {
t.Fatalf("Expected to retrieve 2 entries, found %d", len(retrievedEntries))
}
for _, dbEntry := range retrievedEntries {
if dbEntry.DeviceId != devId2 {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.UserId != userId {
t.Fatalf("Response contains an incorrect user ID: %#v", *dbEntry)
}
if dbEntry.ReadCount != 1 {
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
}
decEntry, err := data.DecryptHistoryEntry("dkey", *dbEntry)
testutils.Check(t, err)
if !data.EntryEquals(decEntry, entry1Dec) && !data.EntryEquals(decEntry, entry2Dec) {
t.Fatalf("DB data is different than input! \ndb =%#v\nentry1=%#v\nentry2=%#v", *dbEntry, entry1Dec, entry2Dec)
}
}
}
func TestUpdateReleaseVersion(t *testing.T) {
if !testutils.IsOnline() {
t.Skip("skipping because we're currently offline")
}
// Set up
InitDB()
// Check that ReleaseVersion hasn't been set yet
if ReleaseVersion != "UNKNOWN" {
t.Fatalf("initial ReleaseVersion isn't as expected: %#v", ReleaseVersion)
}
// Update it
err := updateReleaseVersion()
if err != nil {
t.Fatalf("updateReleaseVersion failed: %v", err)
}
// If ReleaseVersion is still unknown, skip because we're getting rate limited
if ReleaseVersion == "UNKNOWN" {
t.Skip()
}
// Otherwise, check that the new value looks reasonable
if !strings.HasPrefix(ReleaseVersion, "v0.") {
t.Fatalf("ReleaseVersion wasn't updated to contain a version: %#v", ReleaseVersion)
}
}
func TestDeletionRequests(t *testing.T) {
// Set up
InitDB()
// Register two devices for two different users
userId := data.UserId("dkey")
devId1 := uuid.Must(uuid.NewRandom()).String()
devId2 := uuid.Must(uuid.NewRandom()).String()
otherUser := data.UserId("dOtherkey")
otherDev1 := uuid.Must(uuid.NewRandom()).String()
otherDev2 := uuid.Must(uuid.NewRandom()).String()
deviceReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId2+"&user_id="+userId, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev1+"&user_id="+otherUser, nil)
apiRegisterHandler(nil, deviceReq)
deviceReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev2+"&user_id="+otherUser, nil)
apiRegisterHandler(nil, deviceReq)
// Add an entry for user1
entry1 := testutils.MakeFakeHistoryEntry("ls ~/")
entry1.DeviceId = devId1
encEntry, err := data.EncryptHistoryEntry("dkey", entry1)
testutils.Check(t, err)
reqBody, err := json.Marshal([]shared.EncHistoryEntry{encEntry})
testutils.Check(t, err)
submitReq := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
apiSubmitHandler(nil, submitReq)
// And another entry for user1
entry2 := testutils.MakeFakeHistoryEntry("ls /foo/bar")
entry2.DeviceId = devId2
encEntry, err = data.EncryptHistoryEntry("dkey", entry2)
testutils.Check(t, err)
reqBody, err = json.Marshal([]shared.EncHistoryEntry{encEntry})
testutils.Check(t, err)
submitReq = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
apiSubmitHandler(nil, submitReq)
// And an entry for user2 that has the same timestamp as the previous entry
entry3 := testutils.MakeFakeHistoryEntry("ls /foo/bar")
entry3.StartTime = entry1.StartTime
entry3.EndTime = entry1.EndTime
encEntry, err = data.EncryptHistoryEntry("dOtherkey", entry3)
testutils.Check(t, err)
reqBody, err = json.Marshal([]shared.EncHistoryEntry{encEntry})
testutils.Check(t, err)
submitReq = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
apiSubmitHandler(nil, submitReq)
// Query for device id 1
w := httptest.NewRecorder()
searchReq := httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
apiQueryHandler(w, searchReq)
res := w.Result()
defer res.Body.Close()
respBody, err := ioutil.ReadAll(res.Body)
testutils.Check(t, err)
var retrievedEntries []*shared.EncHistoryEntry
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 2 {
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
}
for _, dbEntry := range retrievedEntries {
if dbEntry.DeviceId != devId1 {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.UserId != data.UserId("dkey") {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.ReadCount != 1 {
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
}
decEntry, err := data.DecryptHistoryEntry("dkey", *dbEntry)
testutils.Check(t, err)
if !data.EntryEquals(decEntry, entry1) && !data.EntryEquals(decEntry, entry2) {
t.Fatalf("DB data is different than input! \ndb =%#v\nentry1=%#v\nentry2=%#v", *dbEntry, entry1, entry2)
}
}
// Submit a redact request for entry1
delReqTime := time.Now()
delReq := shared.DeletionRequest{
UserId: data.UserId("dkey"),
SendTime: delReqTime,
Messages: shared.MessageIdentifiers{Ids: []shared.MessageIdentifier{
{DeviceId: devId1, Date: entry1.EndTime},
}},
}
reqBody, err = json.Marshal(delReq)
testutils.Check(t, err)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(reqBody))
addDeletionRequestHandler(nil, req)
// Query again for device id 1 and get a single result
w = httptest.NewRecorder()
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
apiQueryHandler(w, searchReq)
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 1 {
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
}
dbEntry := retrievedEntries[0]
if dbEntry.DeviceId != devId1 {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.UserId != data.UserId("dkey") {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.ReadCount != 2 {
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
}
decEntry, err := data.DecryptHistoryEntry("dkey", *dbEntry)
testutils.Check(t, err)
if !data.EntryEquals(decEntry, entry2) {
t.Fatalf("DB data is different than input! \ndb =%#v\nentry=%#v", *dbEntry, entry2)
}
// Query for user 2
w = httptest.NewRecorder()
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+otherDev1+"&user_id="+otherUser, nil)
apiQueryHandler(w, searchReq)
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
testutils.Check(t, json.Unmarshal(respBody, &retrievedEntries))
if len(retrievedEntries) != 1 {
t.Fatalf("Expected to retrieve 1 entry, found %d", len(retrievedEntries))
}
dbEntry = retrievedEntries[0]
if dbEntry.DeviceId != otherDev1 {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.UserId != data.UserId("dOtherkey") {
t.Fatalf("Response contains an incorrect device ID: %#v", *dbEntry)
}
if dbEntry.ReadCount != 1 {
t.Fatalf("db.ReadCount should have been 1, was %v", dbEntry.ReadCount)
}
decEntry, err = data.DecryptHistoryEntry("dOtherkey", *dbEntry)
testutils.Check(t, err)
if !data.EntryEquals(decEntry, entry3) {
t.Fatalf("DB data is different than input! \ndb =%#v\nentry=%#v", *dbEntry, entry3)
}
// Query for deletion requests
w = httptest.NewRecorder()
searchReq = httptest.NewRequest(http.MethodGet, "/?device_id="+devId1+"&user_id="+userId, nil)
getDeletionRequestsHandler(w, searchReq)
res = w.Result()
defer res.Body.Close()
respBody, err = ioutil.ReadAll(res.Body)
testutils.Check(t, err)
var deletionRequests []*shared.DeletionRequest
testutils.Check(t, json.Unmarshal(respBody, &deletionRequests))
if len(deletionRequests) != 1 {
t.Fatalf("received %d deletion requests, expected only one", len(deletionRequests))
}
deletionRequest := deletionRequests[0]
expected := shared.DeletionRequest{
UserId: data.UserId("dkey"),
DestinationDeviceId: devId1,
SendTime: delReqTime,
ReadCount: 1,
Messages: shared.MessageIdentifiers{Ids: []shared.MessageIdentifier{
{DeviceId: devId1, Date: entry1.EndTime},
}},
}
if diff := deep.Equal(*deletionRequest, expected); diff != nil {
t.Error(diff)
}
}

View File

@@ -0,0 +1,7 @@
hishtory.dev:80, localhost:80 {
root /srv/landing
gzip
ext .html
log stdout
tls off
}

View File

@@ -0,0 +1,6 @@
FROM abiosoft/caddy
LABEL "com.datadoghq.ad.logs"='[{"source": "caddy", "service": "web"}]'
COPY backend/web/caddy/Caddyfile /etc/
COPY backend/web/landing/www/ /srv/landing

2
backend/web/landing/www/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
bower_components

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; URL='https://github.com/ddworken/hishtory'" />
</head>
<body>
<p>See <a href="https://github.com/ddworken/hishtory">here</a>.</p>
</body>
</html>

View File

@@ -0,0 +1,40 @@
"""
A small install script to download the correct hishtory binary for the current OS/architecture.
The hishtory binary is in charge of installing itself, this just downloads the correct binary and
executes it.
"""
import json
import urllib.request
import platform
import sys
import os
with urllib.request.urlopen('https://api.hishtory.dev/api/v1/download') as response:
resp_body = response.read()
download_options = json.loads(resp_body)
if platform.system() == 'Linux':
download_url = download_options['linux_amd_64_url']
elif platform.system() == 'Darwin' and platform.machine() == 'arm64':
download_url = download_options['darwin_arm_64_url']
elif platform.system() == 'Darwin' and platform.machine() == 'x86_64':
download_url = download_options['darwin_amd_64_url']
else:
print(f"No hishtory binary for system={platform.system()}, machine={platform.machine()}!\nIf you believe this is a mistake, please open an issue here: https://github.com/ddworken/hishtory/issues")
sys.exit(1)
with urllib.request.urlopen(download_url) as response:
hishtory_binary = response.read()
tmpdir = os.environ.get('TMPDIR', '') or '/tmp/'
tmpFilePath = tmpdir+'hishtory-client'
if os.path.exists(tmpFilePath):
os.remove(tmpFilePath)
with open(tmpFilePath, 'wb') as f:
f.write(hishtory_binary)
os.system('chmod +x ' + tmpFilePath)
exitCode = os.system(tmpFilePath + ' install')
if exitCode != 0:
raise Exception("failed to install downloaded hishtory client via `" + tmpFilePath +" install` (is that directory mounted noexec? Consider setting an alternate directory via the TMPDIR environment variable)!")
print('Succesfully installed hishtory! Open a new terminal, try running a command, and then running `hishtory query`.')

2456
client/client_test.go Normal file

File diff suppressed because it is too large Load Diff

165
client/data/data.go Normal file
View File

@@ -0,0 +1,165 @@
package data
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql/driver"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"time"
"github.com/ddworken/hishtory/shared"
"github.com/google/uuid"
)
const (
KdfUserID = "user_id"
KdfEncryptionKey = "encryption_key"
CONFIG_PATH = ".hishtory.config"
HISHTORY_PATH = ".hishtory"
DB_PATH = ".hishtory.db"
)
type HistoryEntry struct {
LocalUsername string `json:"local_username" gorm:"uniqueIndex:compositeindex"`
Hostname string `json:"hostname" gorm:"uniqueIndex:compositeindex"`
Command string `json:"command" gorm:"uniqueIndex:compositeindex"`
CurrentWorkingDirectory string `json:"current_working_directory" gorm:"uniqueIndex:compositeindex"`
HomeDirectory string `json:"home_directory" gorm:"uniqueIndex:compositeindex"`
ExitCode int `json:"exit_code" gorm:"uniqueIndex:compositeindex"`
StartTime time.Time `json:"start_time" gorm:"uniqueIndex:compositeindex"`
EndTime time.Time `json:"end_time" gorm:"uniqueIndex:compositeindex"`
DeviceId string `json:"device_id" gorm:"uniqueIndex:compositeindex"`
CustomColumns CustomColumns `json:"custom_columns"`
}
type CustomColumns []CustomColumn
type CustomColumn struct {
Name string `json:"name"`
Val string `json:"value"`
}
func (c *CustomColumns) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to unmarshal CustomColumns value %#v", value)
}
return json.Unmarshal(bytes, c)
}
func (c CustomColumns) Value() (driver.Value, error) {
return json.Marshal(c)
}
func (h *HistoryEntry) GoString() string {
return fmt.Sprintf("%#v", *h)
}
func sha256hmac(key, additionalData string) []byte {
h := hmac.New(sha256.New, []byte(key))
h.Write([]byte(additionalData))
return h.Sum(nil)
}
func UserId(key string) string {
return base64.URLEncoding.EncodeToString(sha256hmac(key, KdfUserID))
}
func EncryptionKey(userSecret string) []byte {
return sha256hmac(userSecret, KdfEncryptionKey)
}
func makeAead(userSecret string) (cipher.AEAD, error) {
key := EncryptionKey(userSecret)
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return aead, nil
}
func Encrypt(userSecret string, data, additionalData []byte) ([]byte, []byte, error) {
aead, err := makeAead(userSecret)
if err != nil {
return []byte{}, []byte{}, fmt.Errorf("failed to make AEAD: %v", err)
}
nonce := make([]byte, 12)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return []byte{}, []byte{}, fmt.Errorf("failed to read a nonce: %v", err)
}
ciphertext := aead.Seal(nil, nonce, data, additionalData)
_, err = aead.Open(nil, nonce, ciphertext, additionalData)
if err != nil {
return []byte{}, []byte{}, fmt.Errorf("failed to open AEAD: %v", err)
}
return ciphertext, nonce, nil
}
func Decrypt(userSecret string, data, additionalData, nonce []byte) ([]byte, error) {
aead, err := makeAead(userSecret)
if err != nil {
return []byte{}, fmt.Errorf("failed to make AEAD: %v", err)
}
plaintext, err := aead.Open(nil, nonce, data, additionalData)
if err != nil {
return []byte{}, fmt.Errorf("failed to decrypt: %v", err)
}
return plaintext, nil
}
func EncryptHistoryEntry(userSecret string, entry HistoryEntry) (shared.EncHistoryEntry, error) {
data, err := json.Marshal(entry)
if err != nil {
return shared.EncHistoryEntry{}, err
}
ciphertext, nonce, err := Encrypt(userSecret, data, []byte(UserId(userSecret)))
if err != nil {
return shared.EncHistoryEntry{}, err
}
return shared.EncHistoryEntry{
EncryptedData: ciphertext,
Nonce: nonce,
UserId: UserId(userSecret),
Date: entry.EndTime,
EncryptedId: uuid.Must(uuid.NewRandom()).String(),
ReadCount: 0,
}, nil
}
func DecryptHistoryEntry(userSecret string, entry shared.EncHistoryEntry) (HistoryEntry, error) {
if entry.UserId != UserId(userSecret) {
return HistoryEntry{}, fmt.Errorf("refusing to decrypt history entry with mismatching UserId")
}
plaintext, err := Decrypt(userSecret, entry.EncryptedData, []byte(UserId(userSecret)), entry.Nonce)
if err != nil {
return HistoryEntry{}, nil
}
var decryptedEntry HistoryEntry
err = json.Unmarshal(plaintext, &decryptedEntry)
if err != nil {
return HistoryEntry{}, nil
}
return decryptedEntry, nil
}
func EntryEquals(entry1, entry2 HistoryEntry) bool {
return entry1.LocalUsername == entry2.LocalUsername &&
entry1.Hostname == entry2.Hostname &&
entry1.Command == entry2.Command &&
entry1.CurrentWorkingDirectory == entry2.CurrentWorkingDirectory &&
entry1.HomeDirectory == entry2.HomeDirectory &&
entry1.ExitCode == entry2.ExitCode &&
entry1.StartTime.Format(time.RFC3339) == entry2.StartTime.Format(time.RFC3339) &&
entry1.EndTime.Format(time.RFC3339) == entry2.EndTime.Format(time.RFC3339)
}

61
client/data/data_test.go Normal file
View File

@@ -0,0 +1,61 @@
package data
import (
"testing"
)
func TestEncryptDecrypt(t *testing.T) {
k1 := EncryptionKey("key")
k2 := EncryptionKey("key")
if string(k1) != string(k2) {
t.Fatalf("Expected EncryptionKey to be deterministic!")
}
ciphertext, nonce, err := Encrypt("key", []byte("hello world!"), []byte("extra"))
checkError(t, err)
plaintext, err := Decrypt("key", ciphertext, []byte("extra"), nonce)
checkError(t, err)
if string(plaintext) != "hello world!" {
t.Fatalf("Expected decrypt(encrypt(x)) to work, but it didn't!")
}
}
func checkError(t *testing.T, err error) {
if err != nil {
t.Fatal(err)
}
}
func TestCustomColumnSerialization(t *testing.T) {
cc1 := CustomColumn{
Name: "name1",
Val: "val1",
}
cc2 := CustomColumn{
Name: "name2",
Val: "val2",
}
var ccs CustomColumns = make(CustomColumns, 0)
// Empty array
v, err := ccs.Value()
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
val := string(v.([]uint8))
if val != "[]" {
t.Fatalf("unexpected val for empty CustomColumns: %#v", val)
}
// Non-empty array
ccs = append(ccs, cc1, cc2)
v, err = ccs.Value()
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
val = string(v.([]uint8))
if val != "[{\"name\":\"name1\",\"value\":\"val1\"},{\"name\":\"name2\",\"value\":\"val2\"}]" {
t.Fatalf("unexpected val for empty CustomColumns: %#v", val)
}
}

263
client/hctx/hctx.go Normal file
View File

@@ -0,0 +1,263 @@
package hctx
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"path"
"sync"
"time"
"github.com/ddworken/hishtory/client/data"
"github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
"gorm.io/gorm"
"gorm.io/gorm/logger"
// Needed to use sqlite without CGO
"github.com/glebarez/sqlite"
)
var (
hishtoryLogger *logrus.Logger
getLoggerOnce sync.Once
)
func GetLogger() *logrus.Logger {
getLoggerOnce.Do(func() {
homedir, err := os.UserHomeDir()
if err != nil {
panic(fmt.Errorf("failed to get user's home directory: %v", err))
}
err = MakeHishtoryDir()
if err != nil {
panic(err)
}
lumberjackLogger := &lumberjack.Logger{
Filename: path.Join(homedir, data.HISHTORY_PATH, "hishtory.log"),
MaxSize: 1, // MB
MaxBackups: 10,
MaxAge: 30, // days
}
logFormatter := new(logrus.TextFormatter)
logFormatter.TimestampFormat = time.RFC3339
logFormatter.FullTimestamp = true
hishtoryLogger = logrus.New()
hishtoryLogger.SetFormatter(logFormatter)
hishtoryLogger.SetLevel(logrus.InfoLevel)
hishtoryLogger.SetOutput(lumberjackLogger)
})
return hishtoryLogger
}
func MakeHishtoryDir() error {
homedir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user's home directory: %v", err)
}
err = os.MkdirAll(path.Join(homedir, data.HISHTORY_PATH), 0o744)
if err != nil {
return fmt.Errorf("failed to create ~/.hishtory dir: %v", err)
}
return nil
}
func OpenLocalSqliteDb() (*gorm.DB, error) {
homedir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get user's home directory: %v", err)
}
err = MakeHishtoryDir()
if err != nil {
return nil, err
}
newLogger := logger.New(
GetLogger(),
logger.Config{
SlowThreshold: 100 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: false,
Colorful: false,
},
)
dbFilePath := path.Join(homedir, data.HISHTORY_PATH, data.DB_PATH)
dsn := fmt.Sprintf("file:%s?cache=shared&mode=rwc&_journal_mode=WAL", dbFilePath)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{SkipDefaultTransaction: true, Logger: newLogger})
if err != nil {
return nil, fmt.Errorf("failed to connect to the DB: %v", err)
}
tx, err := db.DB()
if err != nil {
return nil, err
}
err = tx.Ping()
if err != nil {
return nil, err
}
db.AutoMigrate(&data.HistoryEntry{})
db.Exec("PRAGMA journal_mode = WAL")
return db, nil
}
type hishtoryContextKey string
func MakeContext() *context.Context {
ctx := context.Background()
config, err := GetConfig()
if err != nil {
panic(fmt.Errorf("failed to retrieve config: %v", err))
}
ctx = context.WithValue(ctx, hishtoryContextKey("config"), config)
db, err := OpenLocalSqliteDb()
if err != nil {
panic(fmt.Errorf("failed to open local DB: %v", err))
}
ctx = context.WithValue(ctx, hishtoryContextKey("db"), db)
homedir, err := os.UserHomeDir()
if err != nil {
panic(fmt.Errorf("failed to get homedir: %v", err))
}
ctx = context.WithValue(ctx, hishtoryContextKey("homedir"), homedir)
return &ctx
}
func GetConf(ctx *context.Context) ClientConfig {
v := (*ctx).Value(hishtoryContextKey("config"))
if v != nil {
return v.(ClientConfig)
}
panic(fmt.Errorf("failed to find config in ctx"))
}
func GetDb(ctx *context.Context) *gorm.DB {
v := (*ctx).Value(hishtoryContextKey("db"))
if v != nil {
return v.(*gorm.DB)
}
panic(fmt.Errorf("failed to find db in ctx"))
}
func GetHome(ctx *context.Context) string {
v := (*ctx).Value(hishtoryContextKey("homedir"))
if v != nil {
return v.(string)
}
panic(fmt.Errorf("failed to find homedir in ctx"))
}
type ClientConfig struct {
// The user secret that is used to derive encryption keys for syncing history entries
UserSecret string `json:"user_secret"`
// Whether hishtory recording is enabled
IsEnabled bool `json:"is_enabled"`
// A device ID used to track which history entry came from which device for remote syncing
DeviceId string `json:"device_id"`
// Used for skipping history entries prefixed with a space in bash
LastSavedHistoryLine string `json:"last_saved_history_line"`
// Used for uploading history entries that we failed to upload due to a missing network connection
HaveMissedUploads bool `json:"have_missed_uploads"`
MissedUploadTimestamp int64 `json:"missed_upload_timestamp"`
// Used for avoiding double imports of .bash_history
HaveCompletedInitialImport bool `json:"have_completed_initial_import"`
// Whether control-r bindings are enabled
ControlRSearchEnabled bool `json:"enable_control_r_search"`
// The set of columns that the user wants to be displayed
DisplayedColumns []string `json:"displayed_columns"`
// Custom columns
CustomColumns []CustomColumnDefinition `json:"custom_columns"`
// Whether this is an offline instance of hishtory with no syncing
IsOffline bool `json:"is_offline"`
// Whether duplicate commands should be displayed
FilterDuplicateCommands bool `json:"filter_duplicate_commands"`
// A format string for the timestamp
TimestampFormat string `json:"timestamp_format"`
}
type CustomColumnDefinition struct {
ColumnName string `json:"column_name"`
ColumnCommand string `json:"column_command"`
}
func GetConfigContents() ([]byte, error) {
homedir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to retrieve homedir: %v", err)
}
dat, err := os.ReadFile(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
if err != nil {
files, err := ioutil.ReadDir(path.Join(homedir, data.HISHTORY_PATH))
if err != nil {
return nil, fmt.Errorf("failed to read config file (and failed to list too): %v", err)
}
filenames := ""
for _, file := range files {
filenames += file.Name()
filenames += ", "
}
return nil, fmt.Errorf("failed to read config file (files in ~/.hishtory/: %s): %v", filenames, err)
}
return dat, nil
}
func GetConfig() (ClientConfig, error) {
data, err := GetConfigContents()
if err != nil {
return ClientConfig{}, err
}
var config ClientConfig
err = json.Unmarshal(data, &config)
if err != nil {
return ClientConfig{}, fmt.Errorf("failed to parse config file: %v", err)
}
if config.DisplayedColumns == nil || len(config.DisplayedColumns) == 0 {
config.DisplayedColumns = []string{"Hostname", "CWD", "Timestamp", "Runtime", "Exit Code", "Command"}
}
if config.TimestampFormat == "" {
config.TimestampFormat = "Jan 2 2006 15:04:05 MST"
}
return config, nil
}
func SetConfig(config ClientConfig) error {
serializedConfig, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("failed to serialize config: %v", err)
}
homedir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to retrieve homedir: %v", err)
}
err = MakeHishtoryDir()
if err != nil {
return err
}
configPath := path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)
stagedConfigPath := configPath + ".tmp"
err = os.WriteFile(stagedConfigPath, serializedConfig, 0o644)
if err != nil {
return fmt.Errorf("failed to write config: %v", err)
}
err = os.Rename(stagedConfigPath, configPath)
if err != nil {
return fmt.Errorf("failed to replace config file with the updated version: %v", err)
}
return nil
}
func InitConfig() error {
homedir, err := os.UserHomeDir()
if err != nil {
return err
}
_, err = os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
if errors.Is(err, os.ErrNotExist) {
return SetConfig(ClientConfig{})
}
return err
}

34
client/lib/config.fish Normal file
View File

@@ -0,0 +1,34 @@
function _hishtory_post_exec --on-event fish_postexec
# Runs after <ENTER>, but before the command is executed
set --global _hishtory_command $argv
set --global _hishtory_start_time (date +%s)
end
set --global _hishtory_first_prompt 1
function __hishtory_on_prompt --on-event fish_prompt
# Runs after the command is executed in order to render the prompt
# $? contains the exit code
set _hishtory_exit_code $status
if [ -n "$_hishtory_first_prompt" ]
set --global -e _hishtory_first_prompt
else if [ -n "$_hishtory_command" ]
hishtory saveHistoryEntry fish $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time & # Background Run
# hishtory saveHistoryEntry fish $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time # Foreground Run
set --global -e _hishtory_command # Unset _hishtory_command so we don't double-save entries when fish_prompt is invoked but fish_postexec isn't
end
end
function __hishtory_on_control_r
set -l tmp (mktemp -t fish.XXXXXX)
set -x init_query (commandline -b)
HISHTORY_TERM_INTEGRATION=1 hishtory tquery $init_query > $tmp
set -l res $status
commandline -f repaint
if [ -s $tmp ]
commandline -r (cat $tmp)
end
rm -f $tmp
end
[ (hishtory config-get enable-control-r) = true ] && bind \cr __hishtory_on_control_r

46
client/lib/config.sh Normal file
View File

@@ -0,0 +1,46 @@
# This script should be sourced inside of .bashrc to integrate bash with hishtory
# Include guard. This file is sourced in multiple places, but we want it to only execute once.
# This trick is from https://stackoverflow.com/questions/7518584/is-there-any-mechanism-in-shell-script-alike-include-guard-in-c
if [ -n "$__hishtory_bash_config_sourced" ]; then return; fi
__hishtory_bash_config_sourced=`date`
# Implementation of running before/after every command based on https://jichu4n.com/posts/debug-trap-and-prompt_command-in-bash/
function __hishtory_precommand() {
if [ -z "$HISHTORY_AT_PROMPT" ]; then
return
fi
unset HISHTORY_AT_PROMPT
# Run before every command
HISHTORY_START_TIME=`date +%s`
}
trap "__hishtory_precommand" DEBUG
HISHTORY_FIRST_PROMPT=1
function __hishtory_postcommand() {
EXIT_CODE=$?
HISHTORY_AT_PROMPT=1
if [ -n "$HISHTORY_FIRST_PROMPT" ]; then
unset HISHTORY_FIRST_PROMPT
return
fi
# Run after every prompt
(hishtory saveHistoryEntry bash $EXIT_CODE "`history 1`" $HISHTORY_START_TIME &) # Background Run
# hishtory saveHistoryEntry bash $EXIT_CODE "`history 1`" $HISHTORY_START_TIME # Foreground Run
}
PROMPT_COMMAND="__hishtory_postcommand; $PROMPT_COMMAND"
export HISTTIMEFORMAT=$HISTTIMEFORMAT
__history_control_r() {
READLINE_LINE=$(HISHTORY_TERM_INTEGRATION=1 hishtory tquery "$READLINE_LINE" | tr -d '\n')
READLINE_POINT=0x7FFFFFFF
}
__hishtory_bind_control_r() {
bind -x '"\C-r": __history_control_r'
}
[ "$(hishtory config-get enable-control-r)" = true ] && __hishtory_bind_control_r

37
client/lib/config.zsh Normal file
View File

@@ -0,0 +1,37 @@
autoload -U add-zsh-hook
add-zsh-hook zshaddhistory _hishtory_add
add-zsh-hook precmd _hishtory_precmd
_hishtory_first_prompt=1
function _hishtory_add() {
# Runs after <ENTER>, but before the command is executed
# $1 contains the command that was run
_hishtory_command=$1
_hishtory_start_time=`date +%s`
}
function _hishtory_precmd() {
# Runs after the command is executed in order to render the prompt
# $? contains the exit code
_hishtory_exit_code=$?
if [ -n "$_hishtory_first_prompt" ]; then
unset _hishtory_first_prompt
return
fi
(hishtory saveHistoryEntry zsh $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time &) # Background Run
# hishtory saveHistoryEntry zsh $_hishtory_exit_code "$_hishtory_command" $_hishtory_start_time # Foreground Run
}
_hishtory_widget() {
BUFFER=$(HISHTORY_TERM_INTEGRATION=1 hishtory tquery $BUFFER | tr -d '\n')
CURSOR=${#BUFFER}
zle reset-prompt
}
_hishtory_bind_control_r() {
zle -N _hishtory_widget
bindkey '^R' _hishtory_widget
}
[ "$(hishtory config-get enable-control-r)" = true ] && _hishtory_bind_control_r

View File

@@ -0,0 +1,6 @@
CWD Hostname Exit Code Command
/ ghaction-runner-hostname 0 hishtory config-set displayed-columns CWD Hostname 'Exit Code' Command
/ ghaction-runner-hostname 0 ls /tmp/ &
/ ghaction-runner-hostname 0 echo "foo"
/ ghaction-runner-hostname 0 echo bar
/ ghaction-runner-hostname 0 echo foo

View File

@@ -0,0 +1,3 @@
Hostname CWD Timestamp Runtime Exit Code Command
localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2
localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

View File

@@ -0,0 +1,30 @@
-pipefail -tablesizing
Search Query: > -pipefail -tablesizing
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2 │
│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,30 @@
-pipefail -tablesizing
Search Query: > -pipefail -tablesizing
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost ~/foo/ 2022/Apr/16 01:03 24s 3 table_cmd2 │
│ localhost /tmp/ 2022/Apr/16 01:03 4s 2 table_cmd1 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > ls
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │
│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > ls
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,14 @@
Search Query: > ls
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 echo 'aaaaaa bbbb' │
│ localhost /tmp/ Oct 17 2022 21:43:16 PDT 3s 2 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > cwd:/tmp/ ls
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 ls ~/bar/ │
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 ls ~/foo/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,2 @@
bash-5.2$ source /Users/david/.bashrc
bash-5.2$ echo

View File

@@ -0,0 +1,3 @@
Welcome to fish, the friendly interactive shell
Type help for instructions on how to use fish
david@Davids-MacBook-Air ~/c/hishtory (master)> echo 'aaaaaa bbbb'

View File

@@ -0,0 +1 @@
david@Davids-MacBook-Air hishtory % echo

View File

@@ -0,0 +1,26 @@
Search Query: > ls
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:36 PDT 3s 2 echo 'bar' & │
│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 echo 'aaaaaa bbbb' │
│ localhost /tmp/ Oct 17 2022 21:43:26 PDT 3s 2 ls ~/bar/ │
│ localhost /tmp/ Oct 17 2022 21:43:21 PDT 3s 2 ls ~/foo/ │
│ server /etc/ Oct 17 2022 21:43:16 PDT 3s 127 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > ls
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hostname Exit Code Command foo │
│─────────────────────────────────────────────────────────────────────────────│
│ ghaction-runner-hostname 0 ls / foo │
│ localhost 2 ls ~/bar/ │
│ localhost 2 ls ~/foo/ │
│ server 127 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > echo
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hostname Exit Code Command foo │
│─────────────────────────────────────────────────────────────────────────────│
│ localhost 2 echo 'bar' & │
│ localhost 2 echo 'aaaaaa bbbb' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > asdf
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hostname Exit Code Command foo │
│─────────────────────────────────────────────────────────────────────────────│
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > echo
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hostname Exit Code Command foo │
│─────────────────────────────────────────────────────────────────────────────│
│ localhost 2 echo 'bar' & │
│ localhost 2 echo 'aaaaaa bbbb' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > echo
┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Hostname CWD Timestamp Runtime Exit Code Command │
│────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ localhost /tmp/ Oct 17 2022 21:43:36 PDT 3s 2 echo 'bar' & │
│ localhost /tmp/ Oct 17 2022 21:43:31 PDT 3s 2 echo 'aaaaaa bbbb' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,2 @@
bash-5.2$ source /Users/david/.bashrc
(reverse-i-search)`':

View File

@@ -0,0 +1,26 @@
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────┐
│ Hostname Exit Code Command foo │
│─────────────────────────────────────────────────────────────────────────────│
│ ghaction-runner-hostname 0 ls / foo │
│ localhost 2 echo 'bar' & │
│ localhost 2 echo 'aaaaaa bbbb' │
│ localhost 2 ls ~/bar/ │
│ localhost 2 ls ~/foo/ │
│ server 127 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,26 @@
Search Query: > ls
┌────────────────────────────────────────────────────┐
│ Hostname Exit Code Command │
│────────────────────────────────────────────────────│
│ localhost 2 echo 'bar' & │
│ localhost 2 echo 'aaaaaa bbbb' │
│ localhost 2 ls ~/bar/ │
│ localhost 2 ls ~/foo/ │
│ server 127 ls ~/ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,3 @@
Welcome to fish, the friendly interactive shell
Type help for instructions on how to use fish
david@Davids-MacBook-Air ~/c/hishtory (master)>

View File

@@ -0,0 +1,2 @@
david@Davids-MacBook-Air hishtory %
bck-i-search: _

View File

@@ -0,0 +1,4 @@
export FOOBAR='hello'
echo $FOOBAR world
cd /
echo baz

View File

@@ -0,0 +1,10 @@
Exit Code git_remote Command
0 git@github.com:ddworken/hishtory.git hishtory config-set displayed-columns 'Exit Code' git_remote Command
0 echo bar
0 cd /
0 git@github.com:ddworken/hishtory.git echo foo
0 git@github.com:ddworken/hishtory.git hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || true'
0 echo baz
0 cd /
0 echo $FOOBAR world
0 export FOOBAR='hello'

View File

@@ -0,0 +1,10 @@
Exit Code git_remote Command
0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command
0 echo bar
0 cd /
0 https://github.com/ddworken/hishtory echo foo
0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || true'
0 echo baz
0 cd /
0 echo $FOOBAR world
0 export FOOBAR='hello'

View File

@@ -0,0 +1,31 @@
bash-5.2$ source /Users/david/.bashrc
bash-5.2$ hishtory tquery -pipefail
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 git@github.com:ddworken/hishtory.git hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
│ 0 git@github.com:ddworken/hishtory.git echo foo │
│ 0 git@github.com:ddworken/hishtory.git hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
│ 0 echo baz │
│ 0 cd / │
│ 0 echo $FOOBAR world │
│ 0 export FOOBAR='hello' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,31 @@
bash-5.2$ source /Users/runner/.bashrc
bash-5.2$ hishtory tquery -pipefail
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
│ 0 https://github.com/ddworken/hishtory echo foo │
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
│ 0 echo baz │
│ 0 cd / │
│ 0 echo $FOOBAR world │
│ 0 export FOOBAR='hello' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,31 @@
runner@ghaction-runner-hostname:~/work/hishtory/hishtory$ source /home/runner/.bashrc
runner@ghaction-runner-hostname:~/work/hishtory/hishtory$ hishtory tquery -pipefail
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
│ 0 https://github.com/ddworken/hishtory echo foo │
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
│ 0 echo baz │
│ 0 cd / │
│ 0 echo $FOOBAR world │
│ 0 export FOOBAR='hello' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,30 @@
david@Davids-MacBook-Air hishtory % hishtory tquery -pipefail
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 git@github.com:ddworken/hishtory.git hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
│ 0 git@github.com:ddworken/hishtory.git echo foo │
│ 0 git@github.com:ddworken/hishtory.git hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
│ 0 echo baz │
│ 0 cd / │
│ 0 echo $FOOBAR world │
│ 0 export FOOBAR='hello' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,30 @@
runner@ghaction-runner-hostname hishtory % hishtory tquery -pipefail
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
│ 0 https://github.com/ddworken/hishtory echo foo │
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
│ 0 echo baz │
│ 0 cd / │
│ 0 echo $FOOBAR world │
│ 0 export FOOBAR='hello' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,30 @@
ghaction-runner-hostname% hishtory tquery -pipefail
Search Query: > -pipefail
┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Exit Code git_remote Command │
│─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ 0 https://github.com/ddworken/hishtory hishtory config-set displayed-columns 'Exit Code' git_remote Command │
│ 0 echo bar │
│ 0 cd / │
│ 0 https://github.com/ddworken/hishtory echo foo │
│ 0 https://github.com/ddworken/hishtory hishtory config-add custom-column git_remote '(git remote -v 2>/dev/null | grep origin 1>/dev/null ) && git remote get-url origin || … │
│ 0 echo baz │
│ 0 cd / │
│ 0 echo $FOOBAR world │
│ 0 export FOOBAR='hello' │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,3 @@
Hostname Command
localhost table_cmd2
localhost table_cmd1

View File

@@ -0,0 +1,3 @@
Hostname Exit Code Command
localhost 3 table_cmd2
localhost 2 table_cmd1

View File

@@ -0,0 +1,3 @@
Hostname Exit Code Command CWD
localhost 3 table_cmd2 ~/foo/
localhost 2 table_cmd1 /tmp/

View File

@@ -0,0 +1,7 @@
Hostname Exit Code Command CWD
localhost 2 while : /tmp/
do
ls /table/
done
localhost 3 table_cmd2 ~/foo/
localhost 2 table_cmd1 /tmp/

View File

@@ -0,0 +1,9 @@
Hostname Exit Code Command CWD foo
ghaction-runner-hostname 0 echo table-2 / aaaaaaaaaaaaa
ghaction-runner-hostname 0 echo table-1 / aaaaaaaaaaaaa
localhost 2 while : /tmp/
do
ls /table/
done
localhost 3 table_cmd2 ~/foo/
localhost 2 table_cmd1 /tmp/

View File

@@ -0,0 +1,3 @@
Hostname CWD Timestamp Runtime Exit Code Command
localhost ~/foo/ Apr 16 2022 01:03:16 PDT 24s 3 table_cmd2
localhost /tmp/ Apr 16 2022 01:03:06 PDT 4s 2 table_cmd1

View File

@@ -0,0 +1,38 @@
set -emo pipefail
hishtory status
set -emo pipefail
hishtory query
ls /a
ls /bar
ls /foo
echo foo
echo bar
hishtory enable
echo thisisrecorded
set -emo pipefail
hishtory query
set -emo pipefail
hishtory query foo
echo hello | grep complex | sed s/h/i/g; echo baz && echo "fo 'o" # mycommand
set -emo pipefail
hishtory query complex
set -emo pipefail
hishtory query
set -emo pipefail
echo mynewcommand
set -emo pipefail
hishtory query
set -emo pipefail
hishtory query
set -emo pipefail
echo mynewercommand
set -emo pipefail
hishtory query
othercomputer
set -emo pipefail
hishtory query
set -emo pipefail
hishtory reupload
set -emo pipefail
hishtory export | grep -v pipefail | grep -v '/tmp/client install'
set -emo pipefail

View File

@@ -0,0 +1,24 @@
Hostname Exit Code Command
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory config-set displayed-columns Hostname 'Exit Code' Command
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory export | grep -v pipefail | grep -v '/tmp/client install'
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory reupload
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -emo pipefail
localhost 2 othercomputer
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 echo mynewercommand
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 echo mynewcommand
ghaction-runner-hostname 0 set -emo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -emo pipefail

View File

@@ -0,0 +1,24 @@
Hostname Exit Code Command
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory config-set displayed-columns Hostname 'Exit Code' Command
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory export | grep -v pipefail | grep -v '/tmp/client install'
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory reupload
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -eo pipefail
localhost 2 othercomputer
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 echo mynewercommand
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 echo mynewcommand
ghaction-runner-hostname 0 set -eo pipefail
ghaction-runner-hostname 0 hishtory query
ghaction-runner-hostname 0 set -eo pipefail

View File

@@ -0,0 +1,38 @@
set -eo pipefail
hishtory status
set -eo pipefail
hishtory query
ls /a
ls /bar
ls /foo
echo foo
echo bar
hishtory enable
echo thisisrecorded
set -eo pipefail
hishtory query
set -eo pipefail
hishtory query foo
echo hello | grep complex | sed s/h/i/g; echo baz && echo "fo 'o" # mycommand
set -eo pipefail
hishtory query complex
set -eo pipefail
hishtory query
set -eo pipefail
echo mynewcommand
set -eo pipefail
hishtory query
set -eo pipefail
hishtory query
set -eo pipefail
echo mynewercommand
set -eo pipefail
hishtory query
othercomputer
set -eo pipefail
hishtory query
set -eo pipefail
hishtory reupload
set -eo pipefail
hishtory export | grep -v pipefail | grep -v '/tmp/client install'
set -eo pipefail

View File

@@ -0,0 +1,7 @@
echo foo
echo foo
echo baz
echo baz
echo foo
hishtory config-set displayed-columns 'Exit Code' Command
hishtory config-set filter-duplicate-commands true

View File

@@ -0,0 +1,6 @@
Exit Code Command
0 hishtory config-set filter-duplicate-commands true
0 hishtory config-set displayed-columns 'Exit Code' Command
0 echo foo
0 echo baz
0 echo foo

View File

@@ -0,0 +1,30 @@
-pipefail
Search Query: > -pipefail
┌───────────────────────────────────────────────────────────────────────────┐
│ Exit Code Command │
│───────────────────────────────────────────────────────────────────────────│
│ 0 hishtory config-set filter-duplicate-commands true │
│ 0 hishtory config-set displayed-columns 'Exit Code' Command │
│ 0 echo foo │
│ 0 echo baz │
│ 0 echo foo │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,5 @@
echo foo
echo foo
echo baz
echo baz
echo foo

View File

@@ -0,0 +1,7 @@
Exit Code Command
0 hishtory config-set displayed-columns 'Exit Code' Command
0 echo foo
0 echo baz
0 echo baz
0 echo foo
0 echo foo

View File

@@ -0,0 +1,30 @@
-pipefail
Search Query: > -pipefail
┌───────────────────────────────────────────────────────────────────────────┐
│ Exit Code Command │
│───────────────────────────────────────────────────────────────────────────│
│ 0 hishtory config-set displayed-columns 'Exit Code' Command │
│ 0 echo foo │
│ 0 echo baz │
│ 0 echo baz │
│ 0 echo foo │
│ 0 echo foo │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,2 @@
foo
bar

View File

@@ -0,0 +1,8 @@
bash-5.2$ source /Users/david/.bashrc
bash-5.2$ echo foo
foo
bash-5.2$ hishtory
bash: hishtory: command not found
bash-5.2$ echo bar
bar
bash-5.2$

View File

@@ -0,0 +1,7 @@
david@Davids-MacBook-Air hishtory % echo foo
foo
david@Davids-MacBook-Air hishtory % hishtory
zsh: command not found: hishtory
david@Davids-MacBook-Air hishtory % echo bar
bar
david@Davids-MacBook-Air hishtory %

View File

@@ -0,0 +1,2 @@
echo foo
echo baz

View File

@@ -0,0 +1 @@
Are you sure you want to uninstall hiSHtory and delete all locally saved history data [y/N]Successfully uninstalled hishtory, please restart your terminal...

1700
client/lib/lib.go Normal file

File diff suppressed because it is too large Load Diff

435
client/lib/lib_test.go Normal file
View File

@@ -0,0 +1,435 @@
package lib
import (
"os"
"os/user"
"path"
"reflect"
"strings"
"testing"
"time"
"github.com/ddworken/hishtory/client/data"
"github.com/ddworken/hishtory/client/hctx"
"github.com/ddworken/hishtory/shared/testutils"
)
func TestSetup(t *testing.T) {
defer testutils.BackupAndRestore(t)()
defer testutils.RunTestServer()()
homedir, err := os.UserHomeDir()
testutils.Check(t, err)
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err == nil {
t.Fatalf("hishtory secret file already exists!")
}
testutils.Check(t, Setup([]string{}))
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err != nil {
t.Fatalf("hishtory secret file does not exist after Setup()!")
}
data, err := os.ReadFile(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
testutils.Check(t, err)
if len(data) < 10 {
t.Fatalf("hishtory secret has unexpected length: %d", len(data))
}
config := hctx.GetConf(hctx.MakeContext())
if config.IsOffline != false {
t.Fatalf("hishtory config should have been offline")
}
}
func TestSetupOffline(t *testing.T) {
defer testutils.BackupAndRestore(t)()
defer testutils.RunTestServer()()
homedir, err := os.UserHomeDir()
testutils.Check(t, err)
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err == nil {
t.Fatalf("hishtory secret file already exists!")
}
testutils.Check(t, Setup([]string{"", "", "--offline"}))
if _, err := os.Stat(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH)); err != nil {
t.Fatalf("hishtory secret file does not exist after Setup()!")
}
data, err := os.ReadFile(path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH))
testutils.Check(t, err)
if len(data) < 10 {
t.Fatalf("hishtory secret has unexpected length: %d", len(data))
}
config := hctx.GetConf(hctx.MakeContext())
if config.IsOffline != true {
t.Fatalf("hishtory config should have been offline, actual=%#v", string(data))
}
}
func TestBuildHistoryEntry(t *testing.T) {
defer testutils.BackupAndRestore(t)()
defer testutils.RunTestServer()()
testutils.Check(t, Setup([]string{}))
// Test building an actual entry for bash
entry, err := BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "bash", "120", " 123 ls /foo ", "1641774958"})
testutils.Check(t, err)
if entry.ExitCode != 120 {
t.Fatalf("history entry has unexpected exit code: %v", entry.ExitCode)
}
user, err := user.Current()
if err != nil {
t.Fatalf("failed to retrieve user: %v", err)
}
if entry.LocalUsername != user.Username {
t.Fatalf("history entry has unexpected user name: %v", entry.LocalUsername)
}
if !strings.HasPrefix(entry.CurrentWorkingDirectory, "/") && !strings.HasPrefix(entry.CurrentWorkingDirectory, "~/") {
t.Fatalf("history entry has unexpected cwd: %v", entry.CurrentWorkingDirectory)
}
if !strings.HasPrefix(entry.HomeDirectory, "/") {
t.Fatalf("history entry has unexpected home directory: %v", entry.HomeDirectory)
}
if entry.Command != "ls /foo" {
t.Fatalf("history entry has unexpected command: %v", entry.Command)
}
if !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-09T") && !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-10T") {
t.Fatalf("history entry has incorrect date in the start time: %v", entry.StartTime.Format(time.RFC3339))
}
if entry.StartTime.Unix() != 1641774958 {
t.Fatalf("history entry has incorrect Unix time in the start time: %v", entry.StartTime.Unix())
}
// Test building an entry for zsh
entry, err = BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "zsh", "120", "ls /foo\n", "1641774958"})
testutils.Check(t, err)
if entry.ExitCode != 120 {
t.Fatalf("history entry has unexpected exit code: %v", entry.ExitCode)
}
if entry.LocalUsername != user.Username {
t.Fatalf("history entry has unexpected user name: %v", entry.LocalUsername)
}
if !strings.HasPrefix(entry.CurrentWorkingDirectory, "/") && !strings.HasPrefix(entry.CurrentWorkingDirectory, "~/") {
t.Fatalf("history entry has unexpected cwd: %v", entry.CurrentWorkingDirectory)
}
if !strings.HasPrefix(entry.HomeDirectory, "/") {
t.Fatalf("history entry has unexpected home directory: %v", entry.HomeDirectory)
}
if entry.Command != "ls /foo" {
t.Fatalf("history entry has unexpected command: %v", entry.Command)
}
if !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-09T") && !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-10T") {
t.Fatalf("history entry has incorrect date in the start time: %v", entry.StartTime.Format(time.RFC3339))
}
if entry.StartTime.Unix() != 1641774958 {
t.Fatalf("history entry has incorrect Unix time in the start time: %v", entry.StartTime.Unix())
}
// Test building an entry for fish
entry, err = BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "fish", "120", "ls /foo\n", "1641774958"})
testutils.Check(t, err)
if entry.ExitCode != 120 {
t.Fatalf("history entry has unexpected exit code: %v", entry.ExitCode)
}
if entry.LocalUsername != user.Username {
t.Fatalf("history entry has unexpected user name: %v", entry.LocalUsername)
}
if !strings.HasPrefix(entry.CurrentWorkingDirectory, "/") && !strings.HasPrefix(entry.CurrentWorkingDirectory, "~/") {
t.Fatalf("history entry has unexpected cwd: %v", entry.CurrentWorkingDirectory)
}
if !strings.HasPrefix(entry.HomeDirectory, "/") {
t.Fatalf("history entry has unexpected home directory: %v", entry.HomeDirectory)
}
if entry.Command != "ls /foo" {
t.Fatalf("history entry has unexpected command: %v", entry.Command)
}
if !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-09T") && !strings.HasPrefix(entry.StartTime.Format(time.RFC3339), "2022-01-10T") {
t.Fatalf("history entry has incorrect date in the start time: %v", entry.StartTime.Format(time.RFC3339))
}
if entry.StartTime.Unix() != 1641774958 {
t.Fatalf("history entry has incorrect Unix time in the start time: %v", entry.StartTime.Unix())
}
// Test building an entry that is empty, and thus not saved
entry, err = BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "zsh", "120", " \n", "1641774958"})
testutils.Check(t, err)
if entry != nil {
t.Fatalf("expected history entry to be nil")
}
}
func TestBuildHistoryEntryWithTimestampStripping(t *testing.T) {
defer testutils.BackupAndRestoreEnv("HISTTIMEFORMAT")()
defer testutils.BackupAndRestore(t)()
defer testutils.RunTestServer()()
testutils.Check(t, Setup([]string{}))
testcases := []struct {
input, histtimeformat, expectedCommand string
}{
{" 123 ls /foo ", "", "ls /foo"},
{" 2389 [2022-09-28 04:38:32 +0000] echo", "", "[2022-09-28 04:38:32 +0000] echo"},
{" 2389 [2022-09-28 04:38:32 +0000] echo", "[%F %T %z] ", "echo"},
}
for _, tc := range testcases {
conf := hctx.GetConf(hctx.MakeContext())
conf.LastSavedHistoryLine = ""
testutils.Check(t, hctx.SetConfig(conf))
os.Setenv("HISTTIMEFORMAT", tc.histtimeformat)
entry, err := BuildHistoryEntry(hctx.MakeContext(), []string{"unused", "saveHistoryEntry", "bash", "120", tc.input, "1641774958"})
testutils.Check(t, err)
if entry == nil {
t.Fatalf("entry is unexpectedly nil")
}
if entry.Command != tc.expectedCommand {
t.Fatalf("BuildHistoryEntry(%#v) returned %#v (expected=%#v)", tc.input, entry.Command, tc.expectedCommand)
}
}
}
func TestPersist(t *testing.T) {
defer testutils.BackupAndRestore(t)()
testutils.Check(t, hctx.InitConfig())
db := hctx.GetDb(hctx.MakeContext())
entry := testutils.MakeFakeHistoryEntry("ls ~/")
db.Create(entry)
var historyEntries []*data.HistoryEntry
result := db.Find(&historyEntries)
testutils.Check(t, result.Error)
if len(historyEntries) != 1 {
t.Fatalf("DB has %d entries, expected 1!", len(historyEntries))
}
dbEntry := historyEntries[0]
if !data.EntryEquals(entry, *dbEntry) {
t.Fatalf("DB data is different than input! \ndb =%#v \ninput=%#v", *dbEntry, entry)
}
}
func TestSearch(t *testing.T) {
defer testutils.BackupAndRestore(t)()
testutils.Check(t, hctx.InitConfig())
ctx := hctx.MakeContext()
db := hctx.GetDb(ctx)
// Insert data
entry1 := testutils.MakeFakeHistoryEntry("ls /foo")
db.Create(entry1)
entry2 := testutils.MakeFakeHistoryEntry("ls /bar")
db.Create(entry2)
// Search for data
results, err := Search(ctx, db, "ls", 5)
testutils.Check(t, err)
if len(results) != 2 {
t.Fatalf("Search() returned %d results, expected 2!", len(results))
}
if !data.EntryEquals(*results[0], entry2) {
t.Fatalf("Search()[0]=%#v, expected: %#v", results[0], entry2)
}
if !data.EntryEquals(*results[1], entry1) {
t.Fatalf("Search()[0]=%#v, expected: %#v", results[1], entry1)
}
}
func TestAddToDbIfNew(t *testing.T) {
// Set up
defer testutils.BackupAndRestore(t)()
testutils.Check(t, hctx.InitConfig())
db := hctx.GetDb(hctx.MakeContext())
// Add duplicate entries
entry1 := testutils.MakeFakeHistoryEntry("ls /foo")
AddToDbIfNew(db, entry1)
AddToDbIfNew(db, entry1)
entry2 := testutils.MakeFakeHistoryEntry("ls /foo")
AddToDbIfNew(db, entry2)
AddToDbIfNew(db, entry2)
AddToDbIfNew(db, entry1)
// Check there should only be two entries
var entries []data.HistoryEntry
result := db.Find(&entries)
if result.Error != nil {
t.Fatal(result.Error)
}
if len(entries) != 2 {
t.Fatalf("entries has an incorrect length: %d", len(entries))
}
}
func TestParseCrossPlatformInt(t *testing.T) {
res, err := parseCrossPlatformInt("123")
testutils.Check(t, err)
if res != 123 {
t.Fatalf("failed to parse cross platform int %d", res)
}
res, err = parseCrossPlatformInt("123N")
testutils.Check(t, err)
if res != 123 {
t.Fatalf("failed to parse cross platform int %d", res)
}
}
func TestBuildRegexFromTimeFormat(t *testing.T) {
testcases := []struct {
formatString, regex string
}{
{"%Y ", "[0-9]{4} "},
{"%F %T ", "[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2} "},
{"%F%T", "[0-9]{4}-[0-9]{2}-[0-9]{2}[0-9]{2}:[0-9]{2}:[0-9]{2}"},
{"%%", "%"},
{"%%%%", "%%"},
{"%%%Y", "%[0-9]{4}"},
{"%%%F%T", "%[0-9]{4}-[0-9]{2}-[0-9]{2}[0-9]{2}:[0-9]{2}:[0-9]{2}"},
}
for _, tc := range testcases {
if regex := buildRegexFromTimeFormat(tc.formatString); regex != tc.regex {
t.Fatalf("building a regex for %#v returned %#v (expected=%#v)", tc.formatString, regex, tc.regex)
}
}
}
func TestGetLastCommand(t *testing.T) {
testcases := []struct {
input, expectedOutput string
}{
{" 0 ls", "ls"},
{" 33 ls", "ls"},
{" 33 ls --aaaa foo bar ", "ls --aaaa foo bar"},
{" 2389 [2022-09-28 04:38:32 +0000] echo", "[2022-09-28 04:38:32 +0000] echo"},
}
for _, tc := range testcases {
actualOutput, err := getLastCommand(tc.input)
testutils.Check(t, err)
if actualOutput != tc.expectedOutput {
t.Fatalf("getLastCommand(%#v) returned %#v (expected=%#v)", tc.input, actualOutput, tc.expectedOutput)
}
}
}
func TestMaybeSkipBashHistTimePrefix(t *testing.T) {
defer testutils.BackupAndRestoreEnv("HISTTIMEFORMAT")()
testcases := []struct {
env, cmdLine, expected string
}{
{"%F %T ", "2019-07-12 13:02:31 sudo apt update", "sudo apt update"},
{"%F %T ", "2019-07-12 13:02:31 ls a b", "ls a b"},
{"%F %T ", "2019-07-12 13:02:31 ls a ", "ls a "},
{"%F %T ", "2019-07-12 13:02:31 ls a", "ls a"},
{"%F %T ", "2019-07-12 13:02:31 ls", "ls"},
{"%F %T ", "2019-07-12 13:02:31 ls -Slah", "ls -Slah"},
{"%F ", "2019-07-12 ls -Slah", "ls -Slah"},
{"%F ", "2019-07-12 ls -Slah", "ls -Slah"},
{"%Y", "2019ls -Slah", "ls -Slah"},
{"%Y%Y", "20192020ls -Slah", "ls -Slah"},
{"%Y%Y", "20192020ls -Slah20192020", "ls -Slah20192020"},
{"", "ls -Slah", "ls -Slah"},
{"[%F %T] ", "[2019-07-12 13:02:31] sudo apt update", "sudo apt update"},
{"[%F a %T] ", "[2019-07-12 a 13:02:31] sudo apt update", "sudo apt update"},
{"aaa ", "aaa sudo apt update", "sudo apt update"},
{"%c ", "Sun Aug 19 02:56:02 2012 sudo apt update", "sudo apt update"},
{"%c ", "Sun Aug 19 02:56:02 2012 ls", "ls"},
{"[%c] ", "[Sun Aug 19 02:56:02 2012] ls", "ls"},
{"[%c %t] ", "[Sun Aug 19 02:56:02 2012 ] ls", "ls"},
{"[%c %t]", "[Sun Aug 19 02:56:02 2012 ]ls", "ls"},
{"[%c %t]", "[Sun Aug 19 02:56:02 2012 ]foo", "foo"},
{"[%c %t", "[Sun Aug 19 02:56:02 2012 foo", "foo"},
{"[%F %T %z]", "[2022-09-28 04:17:06 +0000]foo", "foo"},
{"[%F %T %z] ", "[2022-09-28 04:17:06 +0000] foo", "foo"},
}
for _, tc := range testcases {
os.Setenv("HISTTIMEFORMAT", tc.env)
stripped, err := maybeSkipBashHistTimePrefix(tc.cmdLine)
testutils.Check(t, err)
if stripped != tc.expected {
t.Fatalf("skipping the time prefix returned %#v (expected=%#v for %#v)", stripped, tc.expected, tc.cmdLine)
}
}
}
func TestChunks(t *testing.T) {
testcases := []struct {
input []int
chunkSize int
output [][]int
}{
{[]int{1, 2, 3, 4, 5}, 2, [][]int{{1, 2}, {3, 4}, {5}}},
{[]int{1, 2, 3, 4, 5}, 3, [][]int{{1, 2, 3}, {4, 5}}},
{[]int{1, 2, 3, 4, 5}, 1, [][]int{{1}, {2}, {3}, {4}, {5}}},
{[]int{1, 2, 3, 4, 5}, 4, [][]int{{1, 2, 3, 4}, {5}}},
}
for _, tc := range testcases {
actual := chunks(tc.input, tc.chunkSize)
if !reflect.DeepEqual(actual, tc.output) {
t.Fatal("chunks failure")
}
}
}
func TestZshWeirdness(t *testing.T) {
testcases := []struct {
input string
output string
}{
{": 1666062975:0;bash", "bash"},
{": 16660:0;ls", "ls"},
{"ls", "ls"},
{"0", "0"},
{"hgffddxsdsrzsz xddfgdxfdv gdfc ghcvhgfcfg vgv", "hgffddxsdsrzsz xddfgdxfdv gdfc ghcvhgfcfg vgv"},
}
for _, tc := range testcases {
actual := stripZshWeirdness(tc.input)
if !reflect.DeepEqual(actual, tc.output) {
t.Fatalf("weirdness failure for %#v", tc.input)
}
}
}
func TestParseTimeGenerously(t *testing.T) {
ts, err := parseTimeGenerously("2006-01-02T15:04:00-08:00")
testutils.Check(t, err)
if ts.Unix() != 1136243040 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02 T15:04:00 -08:00")
testutils.Check(t, err)
if ts.Unix() != 1136243040 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02_T15:04:00_-08:00")
testutils.Check(t, err)
if ts.Unix() != 1136243040 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02T15:04:00")
testutils.Check(t, err)
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02_T15:04:00")
testutils.Check(t, err)
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02_15:04:00")
testutils.Check(t, err)
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02T15:04")
testutils.Check(t, err)
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02_15:04")
testutils.Check(t, err)
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 15 || ts.Minute() != 4 || ts.Second() != 0 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
ts, err = parseTimeGenerously("2006-01-02")
testutils.Check(t, err)
if ts.Year() != 2006 || ts.Month() != time.January || ts.Day() != 2 || ts.Hour() != 0 || ts.Minute() != 0 || ts.Second() != 0 {
t.Fatalf("parsed time incorrectly: %d", ts.Unix())
}
}

99
client/lib/slsa.go Normal file
View File

@@ -0,0 +1,99 @@
package lib
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/slsa-framework/slsa-verifier/options"
"github.com/slsa-framework/slsa-verifier/verifiers"
)
func verify(ctx *context.Context, provenance []byte, artifactHash, source, branch, versionTag string) error {
provenanceOpts := &options.ProvenanceOpts{
ExpectedSourceURI: source,
ExpectedBranch: &branch,
ExpectedDigest: artifactHash,
ExpectedVersionedTag: &versionTag,
}
builderOpts := &options.BuilderOpts{}
_, _, err := verifiers.Verify(*ctx, provenance, artifactHash, provenanceOpts, builderOpts)
return err
}
func checkForDowngrade(currentVersionS, newVersionS string) error {
currentVersion, err := strconv.Atoi(strings.TrimPrefix(currentVersionS, "v0."))
if err != nil {
return fmt.Errorf("failed to parse current version %#v", currentVersionS)
}
newVersion, err := strconv.Atoi(strings.TrimPrefix(newVersionS, "v0."))
if err != nil {
return fmt.Errorf("failed to parse updated version %#v", newVersionS)
}
if currentVersion > newVersion {
return fmt.Errorf("failed to update because the new version (%#v) is a downgrade compared to the current version (%#v)", newVersionS, currentVersionS)
}
return nil
}
func verifyBinary(ctx *context.Context, binaryPath, attestationPath, versionTag string) error {
if os.Getenv("HISHTORY_DISABLE_SLSA_ATTESTATION") == "true" {
return nil
}
resp, err := ApiGet("/api/v1/slsa-status?newVersion=" + versionTag)
if err != nil {
return nil
}
if string(resp) != "OK" {
fmt.Printf("SLSA verification is currently broken (%s), skipping SLSA validation...\n", string(resp))
return nil
}
if err := checkForDowngrade(Version, versionTag); err != nil && os.Getenv("HISHTORY_ALLOW_DOWNGRADE") == "true" {
return err
}
attestation, err := os.ReadFile(attestationPath)
if err != nil {
return fmt.Errorf("failed to read attestation file: %v", err)
}
hash, err := getFileHash(binaryPath)
if err != nil {
return err
}
return verify(ctx, attestation, hash, "github.com/ddworken/hishtory", "master", versionTag)
}
func getFileHash(binaryPath string) (string, error) {
binaryFile, err := os.Open(binaryPath)
if err != nil {
return "", fmt.Errorf("failed to read binary for verification purposes: %v", err)
}
defer binaryFile.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, binaryFile); err != nil {
return "", fmt.Errorf("failed to hash binary: %v", err)
}
hash := hex.EncodeToString(hasher.Sum(nil))
return hash, nil
}
func handleSlsaFailure(srcErr error) error {
fmt.Printf("\nFailed to verify SLSA provenance! This is likely due to a SLSA bug (SLSA is a brand new standard, and like all new things, has bugs). Ignoring this failure means falling back to the way most software does updates. Do you want to ignore this failure and update anyways? [y/N]")
reader := bufio.NewReader(os.Stdin)
resp, err := reader.ReadString('\n')
if err == nil && strings.TrimSpace(resp) == "y" {
fmt.Println("Proceeding with update...")
return nil
}
return fmt.Errorf("failed to verify SLSA provenance of the updated binary, aborting update (to bypass, set `export HISHTORY_DISABLE_SLSA_ATTESTATION=true`): %v", srcErr)
}

451
client/lib/tui.go Normal file
View File

@@ -0,0 +1,451 @@
package lib
import (
"context"
"fmt"
"os"
"strings"
_ "embed" // for embedding config.sh
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/ddworken/hishtory/client/hctx"
"github.com/muesli/termenv"
"golang.org/x/term"
)
const TABLE_HEIGHT = 20
const PADDED_NUM_ENTRIES = TABLE_HEIGHT * 5
var selectedRow string = ""
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
type errMsg error
type model struct {
// context
ctx *context.Context
// Model for the loading spinner.
spinner spinner.Model
// Whether data is still loading and the spinner should still be displayed.
isLoading bool
// Whether the TUI is quitting.
quitting bool
// The table used for displaying search results.
table table.Model
// The number of entries in the table.
numEntries int
// Whether the user has hit enter to select an entry and the TUI is thus about to quit.
selected bool
// The search box for the query
queryInput textinput.Model
// The query to run. Reset to nil after it was run.
runQuery *string
// The previous query that was run.
lastQuery string
// Unrecoverable error.
err error
// An error while searching. Recoverable and displayed as a warning message.
searchErr error
// Whether the device is offline. If so, a warning will be displayed.
isOffline bool
// A banner from the backend to be displayed. Generally an empty string.
banner string
}
type doneDownloadingMsg struct{}
type offlineMsg struct{}
type bannerMsg struct {
banner string
}
func initialModel(ctx *context.Context, t table.Model, initialQuery string, numEntries int) model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
queryInput := textinput.New()
queryInput.Placeholder = "ls"
queryInput.Focus()
queryInput.CharLimit = 156
queryInput.Width = 50
if initialQuery != "" {
queryInput.SetValue(initialQuery)
}
return model{ctx: ctx, spinner: s, isLoading: true, table: t, runQuery: &initialQuery, queryInput: queryInput, numEntries: numEntries}
}
func (m model) Init() tea.Cmd {
return m.spinner.Tick
}
func runQueryAndUpdateTable(m model, updateTable bool) model {
if (m.runQuery != nil && *m.runQuery != m.lastQuery) || updateTable {
if m.runQuery == nil {
m.runQuery = &m.lastQuery
}
rows, numEntries, err := getRows(m.ctx, hctx.GetConf(m.ctx).DisplayedColumns, *m.runQuery, PADDED_NUM_ENTRIES)
if err != nil {
m.searchErr = err
return m
} else {
m.searchErr = nil
}
m.numEntries = numEntries
if updateTable {
t, err := makeTable(m.ctx, rows)
if err != nil {
m.err = err
return m
}
m.table = t
}
m.table.SetRows(rows)
m.table.SetCursor(0)
m.lastQuery = *m.runQuery
m.runQuery = nil
}
if m.table.Cursor() >= m.numEntries {
// Ensure that we can't scroll past the end of the table
m.table.SetCursor(m.numEntries - 1)
}
return m
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "ctrl+c":
m.quitting = true
return m, tea.Quit
case "enter":
if m.numEntries != 0 {
m.selected = true
}
return m, tea.Quit
default:
t, cmd1 := m.table.Update(msg)
m.table = t
if strings.HasPrefix(msg.String(), "alt+") {
return m, tea.Batch(cmd1)
}
i, cmd2 := m.queryInput.Update(msg)
m.queryInput = i
searchQuery := m.queryInput.Value()
m.runQuery = &searchQuery
m = runQueryAndUpdateTable(m, false)
return m, tea.Batch(cmd1, cmd2)
}
case tea.WindowSizeMsg:
m = runQueryAndUpdateTable(m, true)
return m, nil
case errMsg:
m.err = msg
return m, nil
case offlineMsg:
m.isOffline = true
return m, nil
case bannerMsg:
m.banner = msg.banner
return m, nil
case doneDownloadingMsg:
m.isLoading = false
return m, nil
default:
var cmd tea.Cmd
if m.isLoading {
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
} else {
m.table, cmd = m.table.Update(msg)
return m, cmd
}
}
}
func (m model) View() string {
if m.err != nil {
return fmt.Sprintf("An unrecoverable error occured: %v\n", m.err)
}
if m.selected {
indexOfCommand := -1
for i, columnName := range hctx.GetConf(m.ctx).DisplayedColumns {
if columnName == "Command" {
indexOfCommand = i
break
}
}
if indexOfCommand == -1 {
selectedRow = "Error: Table doesn't have a column named `Command`?"
return ""
}
selectedRow = m.table.SelectedRow()[indexOfCommand]
return ""
}
if m.quitting {
return ""
}
loadingMessage := ""
if m.isLoading {
loadingMessage = fmt.Sprintf("%s Loading hishtory entries from other devices...", m.spinner.View())
}
warning := ""
if m.isOffline {
warning += "Warning: failed to contact the hishtory backend (are you offline?), so some results may be stale\n\n"
}
if m.searchErr != nil {
warning += fmt.Sprintf("Warning: failed to search: %v\n\n", m.searchErr)
}
return fmt.Sprintf("\n%s\n%s%s\nSearch Query: %s\n\n%s\n", loadingMessage, warning, m.banner, m.queryInput.View(), baseStyle.Render(m.table.View()))
}
func getRows(ctx *context.Context, columnNames []string, query string, numEntries int) ([]table.Row, int, error) {
db := hctx.GetDb(ctx)
config := hctx.GetConf(ctx)
data, err := Search(ctx, db, query, numEntries)
if err != nil {
return nil, 0, err
}
var rows []table.Row
lastCommand := ""
for i := 0; i < numEntries; i++ {
if i < len(data) {
entry := data[i]
if strings.TrimSpace(entry.Command) == strings.TrimSpace(lastCommand) && config.FilterDuplicateCommands {
continue
}
entry.Command = strings.ReplaceAll(entry.Command, "\n", " ") // TODO: handle multi-line commands better here
row, err := buildTableRow(ctx, columnNames, *entry)
if err != nil {
return nil, 0, fmt.Errorf("failed to build row for entry=%#v: %v", entry, err)
}
rows = append(rows, row)
lastCommand = entry.Command
} else {
rows = append(rows, table.Row{})
}
}
return rows, len(data), nil
}
func calculateColumnWidths(rows []table.Row) []int {
numColumns := len(rows[0])
neededColumnWidth := make([]int, numColumns)
for _, row := range rows {
for i, v := range row {
neededColumnWidth[i] = max(neededColumnWidth[i], len(v))
}
}
return neededColumnWidth
}
func getTerminalSize() (int, int, error) {
return term.GetSize(2)
}
var bigQueryResults []table.Row
func makeTableColumns(ctx *context.Context, columnNames []string, rows []table.Row) ([]table.Column, error) {
// Handle an initial query with no results
if len(rows) == 0 || len(rows[0]) == 0 {
allRows, _, err := getRows(ctx, columnNames, "", 25)
if err != nil {
return nil, err
}
return makeTableColumns(ctx, columnNames, allRows)
}
// Calculate the minimum amount of space that we need for each column for the current actual search
columnWidths := calculateColumnWidths(rows)
totalWidth := 20
for i, name := range columnNames {
columnWidths[i] = max(columnWidths[i], len(name))
totalWidth += columnWidths[i]
}
// Calculate the maximum column width that is useful for each column if we search for the empty string
if bigQueryResults == nil {
bigRows, _, err := getRows(ctx, columnNames, "", 1000)
if err != nil {
return nil, err
}
bigQueryResults = bigRows
}
maximumColumnWidths := calculateColumnWidths(bigQueryResults)
// Get the actual terminal width. If we're below this, opportunistically add some padding aiming for the maximum column widths
terminalWidth, _, err := getTerminalSize()
if err != nil {
return nil, fmt.Errorf("failed to get terminal size: %v", err)
}
for totalWidth < (terminalWidth - len(columnNames)) {
prevTotalWidth := totalWidth
for i := range columnNames {
if columnWidths[i] < maximumColumnWidths[i]+5 {
columnWidths[i] += 1
totalWidth += 1
}
}
if totalWidth == prevTotalWidth {
break
}
}
// And if we are too large from the initial query, let's shrink things to make the table fit. We'll use the heuristic of always shrinking the widest column.
for totalWidth > terminalWidth {
largestColumnIdx := -1
largestColumnSize := -1
for i := range columnNames {
if columnWidths[i] > largestColumnSize {
largestColumnIdx = i
largestColumnSize = columnWidths[i]
}
}
columnWidths[largestColumnIdx] -= 2
totalWidth -= 2
}
// And finally, create some actual columns!
columns := make([]table.Column, 0)
for i, name := range columnNames {
columns = append(columns, table.Column{Title: name, Width: columnWidths[i]})
}
return columns, nil
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func makeTable(ctx *context.Context, rows []table.Row) (table.Model, error) {
config := hctx.GetConf(ctx)
columns, err := makeTableColumns(ctx, config.DisplayedColumns, rows)
if err != nil {
return table.Model{}, err
}
km := table.KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "alt+OA"),
key.WithHelp("↑", "scroll up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "alt+OB"),
key.WithHelp("↓", "scroll down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("pgdn", "page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home"),
key.WithHelp("home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end"),
key.WithHelp("end", "go to end"),
),
}
_, terminalHeight, err := getTerminalSize()
if err != nil {
return table.Model{}, err
}
tableHeight := min(TABLE_HEIGHT, terminalHeight-12)
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
table.WithKeyMap(km),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
t.SetStyles(s)
t.Focus()
return t, nil
}
func TuiQuery(ctx *context.Context, gitCommit, initialQuery string) error {
lipgloss.SetColorProfile(termenv.ANSI)
rows, numEntries, err := getRows(ctx, hctx.GetConf(ctx).DisplayedColumns, initialQuery, PADDED_NUM_ENTRIES)
if err != nil {
return err
}
t, err := makeTable(ctx, rows)
if err != nil {
return err
}
p := tea.NewProgram(initialModel(ctx, t, initialQuery, numEntries), tea.WithOutput(os.Stderr))
go func() {
err := RetrieveAdditionalEntriesFromRemote(ctx)
if err != nil {
p.Send(err)
}
p.Send(doneDownloadingMsg{})
}()
// Async: Process deletion requests
go func() {
err := ProcessDeletionRequests(ctx)
if err != nil {
p.Send(err)
}
}()
// Async: Check for any banner from the server
go func() {
banner, err := GetBanner(ctx, gitCommit)
if err != nil {
if IsOfflineError(err) {
p.Send(offlineMsg{})
} else {
p.Send(err)
}
}
p.Send(bannerMsg{banner: string(banner)})
}()
// Blocking: Start the TUI
err = p.Start()
if err != nil {
return err
}
if selectedRow == "" && os.Getenv("HISHTORY_TERM_INTEGRATION") != "" {
// Print out the initialQuery instead so that we don't clear the terminal
selectedRow = initialQuery
}
fmt.Printf("%s\n", selectedRow)
return nil
}

40
demo.vhs Normal file
View File

@@ -0,0 +1,40 @@
# Demo file used https://github.com/charmbracelet/vhs
Output backend/web/landing/www/img/demo.gif
Set FontSize 22
Set Width 2300
Set Height 1050
# Set up
Hide
Type "zsh"
Enter
Type "setopt interactivecomments"
Enter
Type "clear"
Enter
Set TypingSpeed 0.1
Show
Type "find . -iname '*.go' | xargs -I {} -- gofmt -w {}"
Enter
Sleep 4000ms
Type "ssh server"
Enter
Sleep 400ms
Type "# Then press control + r to search your history"
Enter
Sleep 3800ms
Ctrl+R
Sleep 6000ms
Type "g"
Sleep 400ms
Type "of
Sleep 400ms
Type "mt"
Sleep 1000ms
Type " cwd:~/code/hishtory/"
Sleep 7000ms
Enter
Sleep 6000ms

293
go.mod Normal file
View File

@@ -0,0 +1,293 @@
module github.com/ddworken/hishtory
go 1.18
require (
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/charmbracelet/bubbles v0.14.0
github.com/charmbracelet/bubbletea v0.23.0
github.com/charmbracelet/lipgloss v0.6.0
github.com/fatih/color v1.13.0
github.com/glebarez/sqlite v1.4.7
github.com/go-test/deep v1.0.8
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.3.0
github.com/lib/pq v1.10.4
github.com/muesli/termenv v0.13.0
github.com/rodaine/table v1.0.1
github.com/slsa-framework/slsa-verifier v1.3.2
golang.org/x/term v0.2.0
gorm.io/driver/postgres v1.3.1
gorm.io/driver/sqlite v1.3.6
gorm.io/gorm v1.23.8
)
require (
bitbucket.org/creachadair/shell v0.0.7 // indirect
cloud.google.com/go/compute v1.10.0 // indirect
github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/alibabacloudsdkgo/helper v0.2.0 // indirect
github.com/Azure/azure-sdk-for-go v67.0.0+incompatible // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.28 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.21 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/PaesslerAG/gval v1.0.0 // indirect
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
github.com/ThalesIgnite/crypto11 v1.2.5 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4 // indirect
github.com/alibabacloud-go/cr-20160607 v1.0.1 // indirect
github.com/alibabacloud-go/cr-20181201 v1.0.10 // indirect
github.com/alibabacloud-go/darabonba-openapi v0.1.18 // indirect
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.1 // indirect
github.com/alibabacloud-go/openapi-util v0.0.11 // indirect
github.com/alibabacloud-go/tea v1.1.18 // indirect
github.com/alibabacloud-go/tea-utils v1.4.4 // indirect
github.com/alibabacloud-go/tea-xml v1.1.2 // indirect
github.com/aliyun/credentials-go v1.2.3 // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.16.16 // indirect
github.com/aws/aws-sdk-go-v2/config v1.17.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.12.21 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24 // indirect
github.com/aws/aws-sdk-go-v2/service/ecr v1.15.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.11.23 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.16.19 // indirect
github.com/aws/smithy-go v1.13.3 // indirect
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220228164355-396b2034c795 // indirect
github.com/aymanbagabas/go-osc52 v1.2.1 // indirect
github.com/benbjohnson/clock v1.1.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/speakeasy v0.1.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chrismellard/docker-credential-acr-env v0.0.0-20220119192733-fe33c00cee21 // indirect
github.com/clbanning/mxj/v2 v2.5.6 // indirect
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect
github.com/containerd/console v1.0.3 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.12.1 // indirect
github.com/coreos/go-oidc/v3 v3.4.0 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20210823021906-dc406ceaf94b // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/docker/cli v20.10.20+incompatible // indirect
github.com/docker/distribution v2.8.1+incompatible // indirect
github.com/docker/docker v20.10.20+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/docker/go v1.5.1-1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fullstorydev/grpcurl v1.8.7 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.18.2 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.21.4 // indirect
github.com/go-openapi/errors v0.20.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/loads v0.21.2 // indirect
github.com/go-openapi/runtime v0.24.2 // indirect
github.com/go-openapi/spec v0.20.7 // indirect
github.com/go-openapi/strfmt v0.21.3 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/go-openapi/validate v0.22.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.11.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/certificate-transparency-go v1.1.3 // indirect
github.com/google/go-containerregistry v0.12.0 // indirect
github.com/google/go-github/v45 v45.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/trillian v1.5.0 // indirect
github.com/googleapis/gnostic v0.5.5 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.1 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/in-toto/in-toto-golang v0.3.4-0.20220709202702-fa494aaa0add // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.10.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.2.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
github.com/jackc/pgtype v1.9.1 // indirect
github.com/jackc/pgx/v4 v4.14.1 // indirect
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect
github.com/jhump/protoreflect v1.13.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.3.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.15.11 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/letsencrypt/boulder v0.0.0-20220929215747-76583552c2be // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.15 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mozillazg/docker-credential-acr-helper v0.3.0 // indirect
github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc2 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.0.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.13.0 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sassoftware/relic v0.0.0-20210427151427-dfb082b79b74 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/sigstore/cosign v1.13.1 // indirect
github.com/sigstore/fulcio v0.6.0 // indirect
github.com/sigstore/rekor v1.0.0 // indirect
github.com/sigstore/sigstore v1.4.5 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/slsa-framework/slsa-github-generator v1.2.0 // indirect
github.com/soheilhy/cmux v0.1.5 // indirect
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.6.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.13.0 // indirect
github.com/stretchr/testify v1.8.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/tent/canonical-json-go v0.0.0-20130607151641-96e4ba3a7613 // indirect
github.com/thales-e-security/pool v0.0.2 // indirect
github.com/theupdateframework/go-tuf v0.5.2-0.20220930112810-3890c1e7ace4 // indirect
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
github.com/tjfoc/gmsm v1.3.2 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect
github.com/transparency-dev/merkle v0.0.1 // indirect
github.com/urfave/cli v1.22.7 // indirect
github.com/vbatts/tar-split v0.11.2 // indirect
github.com/xanzy/go-gitlab v0.73.1 // indirect
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.etcd.io/etcd/api/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/client/v2 v2.306.0-alpha.0 // indirect
go.etcd.io/etcd/client/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/etcdctl/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/etcdutl/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/pkg/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/raft/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/server/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/tests/v3 v3.6.0-alpha.0 // indirect
go.etcd.io/etcd/v3 v3.6.0-alpha.0 // indirect
go.mongodb.org/mongo-driver v1.10.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.28.0 // indirect
go.opentelemetry.io/otel v1.7.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.7.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.7.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.7.0 // indirect
go.opentelemetry.io/otel/sdk v1.7.0 // indirect
go.opentelemetry.io/otel/trace v1.7.0 // indirect
go.opentelemetry.io/proto/otlp v0.16.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/exp v0.0.0-20220823124025-807a23277127 // indirect
golang.org/x/mod v0.6.0 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/oauth2 v0.1.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect
golang.org/x/tools v0.1.12 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a // indirect
google.golang.org/grpc v1.50.1 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.23.5 // indirect
k8s.io/apimachinery v0.23.5 // indirect
k8s.io/client-go v0.23.5 // indirect
k8s.io/klog/v2 v2.60.1-0.20220317184644-43cc75f9ae89 // indirect
k8s.io/kube-openapi v0.0.0-20220124234850-424119656bbf // indirect
k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
modernc.org/libc v1.19.0 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.19.1 // indirect
sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect
sigs.k8s.io/release-utils v0.7.3 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
replace github.com/rodaine/table => github.com/ddworken/table v1.0.2

3271
go.sum Normal file

File diff suppressed because it is too large Load Diff

483
hishtory.go Normal file
View File

@@ -0,0 +1,483 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/ddworken/hishtory/client/data"
"github.com/ddworken/hishtory/client/hctx"
"github.com/ddworken/hishtory/client/lib"
"github.com/ddworken/hishtory/shared"
)
var GitCommit string = "Unknown"
func main() {
if len(os.Args) == 1 {
fmt.Println("Must specify a command! Do you mean `hishtory query`?")
return
}
switch os.Args[1] {
case "saveHistoryEntry":
ctx := hctx.MakeContext()
lib.CheckFatalError(maybeUploadSkippedHistoryEntries(ctx))
saveHistoryEntry(ctx)
case "query":
ctx := hctx.MakeContext()
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
query(ctx, strings.Join(os.Args[2:], " "))
case "tquery":
ctx := hctx.MakeContext()
lib.CheckFatalError(lib.TuiQuery(ctx, GitCommit, strings.Join(os.Args[2:], " ")))
case "export":
ctx := hctx.MakeContext()
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
export(ctx, strings.Join(os.Args[2:], " "))
case "redact":
fallthrough
case "delete":
ctx := hctx.MakeContext()
lib.CheckFatalError(lib.RetrieveAdditionalEntriesFromRemote(ctx))
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
query := strings.Join(os.Args[2:], " ")
force := false
if os.Args[2] == "--force" {
query = strings.Join(os.Args[3:], " ")
force = true
}
lib.CheckFatalError(lib.Redact(ctx, query, force))
case "init":
db, err := hctx.OpenLocalSqliteDb()
lib.CheckFatalError(err)
data, err := lib.Search(nil, db, "", 10)
lib.CheckFatalError(err)
if len(data) > 0 {
fmt.Printf("Your current hishtory profile has saved history entries, are you sure you want to run `init` and reset?\nNote: This won't clear any imported history entries from your existing shell\n[y/N]")
reader := bufio.NewReader(os.Stdin)
resp, err := reader.ReadString('\n')
lib.CheckFatalError(err)
if strings.TrimSpace(resp) != "y" {
fmt.Printf("Aborting init per user response of %#v\n", strings.TrimSpace(resp))
return
}
}
lib.CheckFatalError(lib.Setup(os.Args))
if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" {
fmt.Println("Importing existing shell history...")
ctx := hctx.MakeContext()
numImported, err := lib.ImportHistory(ctx, false, false)
lib.CheckFatalError(err)
if numImported > 0 {
fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
}
}
case "install":
lib.CheckFatalError(lib.Install())
if os.Getenv("HISHTORY_SKIP_INIT_IMPORT") == "" {
db, err := hctx.OpenLocalSqliteDb()
lib.CheckFatalError(err)
data, err := lib.Search(nil, db, "", 10)
lib.CheckFatalError(err)
if len(data) < 10 {
fmt.Println("Importing existing shell history...")
ctx := hctx.MakeContext()
numImported, err := lib.ImportHistory(ctx, false, false)
lib.CheckFatalError(err)
if numImported > 0 {
fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
}
}
}
case "uninstall":
fmt.Printf("Are you sure you want to uninstall hiSHtory and delete all locally saved history data [y/N]")
reader := bufio.NewReader(os.Stdin)
resp, err := reader.ReadString('\n')
lib.CheckFatalError(err)
if strings.TrimSpace(resp) != "y" {
fmt.Printf("Aborting uninstall per user response of %#v\n", strings.TrimSpace(resp))
return
}
lib.CheckFatalError(lib.Uninstall(hctx.MakeContext()))
case "import":
ctx := hctx.MakeContext()
numImported, err := lib.ImportHistory(ctx, true, true)
lib.CheckFatalError(err)
if numImported > 0 {
fmt.Printf("Imported %v history entries from your existing shell history\n", numImported)
}
case "enable":
ctx := hctx.MakeContext()
lib.CheckFatalError(lib.Enable(ctx))
case "disable":
ctx := hctx.MakeContext()
lib.CheckFatalError(lib.Disable(ctx))
case "version":
fallthrough
case "status":
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
fmt.Printf("hiSHtory: v0.%s\nEnabled: %v\n", lib.Version, config.IsEnabled)
fmt.Printf("Secret Key: %s\n", config.UserSecret)
if len(os.Args) == 3 && os.Args[2] == "-v" {
fmt.Printf("User ID: %s\n", data.UserId(config.UserSecret))
fmt.Printf("Device ID: %s\n", config.DeviceId)
printDumpStatus(config)
}
fmt.Printf("Commit Hash: %s\n", GitCommit)
case "update":
err := lib.Update(hctx.MakeContext())
if err != nil {
log.Fatalf("Failed to update hishtory: %v", err)
}
case "config-get":
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
key := os.Args[2]
switch key {
case "enable-control-r":
fmt.Printf("%v", config.ControlRSearchEnabled)
case "filter-duplicate-commands":
fmt.Printf("%v", config.FilterDuplicateCommands)
case "displayed-columns":
for _, col := range config.DisplayedColumns {
if strings.Contains(col, " ") {
fmt.Printf("%q ", col)
} else {
fmt.Print(col + " ")
}
}
fmt.Print("\n")
case "custom-columns":
for _, cc := range config.CustomColumns {
fmt.Println(cc.ColumnName + ": " + cc.ColumnCommand)
}
default:
log.Fatalf("Unrecognized config key: %s", key)
}
case "config-set":
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
key := os.Args[2]
switch key {
case "enable-control-r":
val := os.Args[3]
if val != "true" && val != "false" {
log.Fatalf("Unexpected config value %s, must be one of: true, false", val)
}
config.ControlRSearchEnabled = (val == "true")
lib.CheckFatalError(hctx.SetConfig(config))
case "filter-duplicate-commands":
val := os.Args[3]
if val != "true" && val != "false" {
log.Fatalf("Unexpected config value %s, must be one of: true, false", val)
}
config.FilterDuplicateCommands = (val == "true")
lib.CheckFatalError(hctx.SetConfig(config))
case "displayed-columns":
vals := os.Args[3:]
config.DisplayedColumns = vals
lib.CheckFatalError(hctx.SetConfig(config))
case "timestamp-format":
val := os.Args[3]
config.TimestampFormat = val
lib.CheckFatalError(hctx.SetConfig(config))
case "custom-columns":
log.Fatalf("Please use config-add and config-delete to interact with custom-columns")
default:
log.Fatalf("Unrecognized config key: %s", key)
}
case "config-add":
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
key := os.Args[2]
switch key {
case "custom-column":
fallthrough
case "custom-columns":
columnName := os.Args[3]
command := os.Args[4]
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
if config.CustomColumns == nil {
config.CustomColumns = make([]hctx.CustomColumnDefinition, 0)
}
config.CustomColumns = append(config.CustomColumns, hctx.CustomColumnDefinition{ColumnName: columnName, ColumnCommand: command})
lib.CheckFatalError(hctx.SetConfig(config))
case "displayed-columns":
vals := os.Args[3:]
config.DisplayedColumns = append(config.DisplayedColumns, vals...)
lib.CheckFatalError(hctx.SetConfig(config))
default:
log.Fatalf("Unrecognized config key: %s", key)
}
case "config-delete":
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
key := os.Args[2]
switch key {
case "custom-columns":
columnName := os.Args[2]
ctx := hctx.MakeContext()
config := hctx.GetConf(ctx)
if config.CustomColumns == nil {
return
}
newColumns := make([]hctx.CustomColumnDefinition, 0)
deletedColumns := false
for _, c := range config.CustomColumns {
if c.ColumnName != columnName {
newColumns = append(newColumns, c)
deletedColumns = true
}
}
if !deletedColumns {
log.Fatalf("Did not find a column with name %#v to delete (current columns = %#v)", columnName, config.CustomColumns)
}
config.CustomColumns = newColumns
lib.CheckFatalError(hctx.SetConfig(config))
case "displayed-columns":
deletedColumns := os.Args[3:]
newColumns := make([]string, 0)
for _, c := range config.DisplayedColumns {
isDeleted := false
for _, d := range deletedColumns {
if c == d {
isDeleted = true
}
}
if !isDeleted {
newColumns = append(newColumns, c)
}
}
config.DisplayedColumns = newColumns
lib.CheckFatalError(hctx.SetConfig(config))
default:
log.Fatalf("Unrecognized config key: %s", key)
}
case "reupload":
// Purposefully undocumented since this command is generally not necessary to run
lib.CheckFatalError(lib.Reupload(hctx.MakeContext()))
case "-h":
fallthrough
case "help":
fmt.Print(`hiSHtory: Better shell history
Supported commands:
'hishtory query': Query for matching commands and display them in a table. Examples:
'hishtory query apt-get' # Find shell commands containing 'apt-get'
'hishtory query apt-get install' # Find shell commands containing 'apt-get' and 'install'
'hishtory query curl cwd:/tmp/' # Find shell commands containing 'curl' run in '/tmp/'
'hishtory query curl user:david' # Find shell commands containing 'curl' run by 'david'
'hishtory query curl host:x1' # Find shell commands containing 'curl' run on 'x1'
'hishtory query exit_code:1' # Find shell commands that exited with status code 1
'hishtory query before:2022-02-01' # Find shell commands run before 2022-02-01
'hishtory export': Query for matching commands and display them in list without any other
metadata. Supports the same query format as 'hishtory query'.
'hishtory redact': Query for matching commands and remove them from your shell history (on the
current machine and on all remote machines). Supports the same query format as 'hishtory query'.
'hishtory update': Securely update hishtory to the latest version.
'hishtory disable': Stop recording shell commands
'hishtory enable': Start recording shell commands
'hishtory status': View status info including the secret key which is needed to sync shell
history from another machine.
'hishtory init': Set the secret key to enable syncing shell commands from another
machine with a matching secret key.
'hishtory config-get', 'hishtory config-set', 'hishtory config-add', 'hishtory config-delete': Edit the config. See the README for details on each of the config options.
'hishtory uninstall': Permanently uninstall hishtory
'hishtory help': View this help page
`)
default:
lib.CheckFatalError(fmt.Errorf("unknown command: %s", os.Args[1]))
}
}
func printDumpStatus(config hctx.ClientConfig) {
dumpRequests, err := getDumpRequests(config)
lib.CheckFatalError(err)
fmt.Printf("Dump Requests: ")
for _, d := range dumpRequests {
fmt.Printf("%#v, ", *d)
}
fmt.Print("\n")
}
func getDumpRequests(config hctx.ClientConfig) ([]*shared.DumpRequest, error) {
if config.IsOffline {
return make([]*shared.DumpRequest, 0), nil
}
resp, err := lib.ApiGet("/api/v1/get-dump-requests?user_id=" + data.UserId(config.UserSecret) + "&device_id=" + config.DeviceId)
if lib.IsOfflineError(err) {
return []*shared.DumpRequest{}, nil
}
if err != nil {
return nil, err
}
var dumpRequests []*shared.DumpRequest
err = json.Unmarshal(resp, &dumpRequests)
return dumpRequests, err
}
func query(ctx *context.Context, query string) {
db := hctx.GetDb(ctx)
err := lib.RetrieveAdditionalEntriesFromRemote(ctx)
if err != nil {
if lib.IsOfflineError(err) {
fmt.Println("Warning: hishtory is offline so this may be missing recent results from your other machines!")
} else {
lib.CheckFatalError(err)
}
}
lib.CheckFatalError(displayBannerIfSet(ctx))
numResults := 25
data, err := lib.Search(ctx, db, query, numResults*5)
lib.CheckFatalError(err)
lib.CheckFatalError(lib.DisplayResults(ctx, data, numResults))
}
func displayBannerIfSet(ctx *context.Context) error {
respBody, err := lib.GetBanner(ctx, GitCommit)
if lib.IsOfflineError(err) {
return nil
}
if err != nil {
return err
}
if len(respBody) > 0 {
fmt.Println(string(respBody))
}
return nil
}
func maybeUploadSkippedHistoryEntries(ctx *context.Context) error {
config := hctx.GetConf(ctx)
if !config.HaveMissedUploads {
return nil
}
if config.IsOffline {
return nil
}
// Upload the missing entries
db := hctx.GetDb(ctx)
query := fmt.Sprintf("after:%s", time.Unix(config.MissedUploadTimestamp, 0).Format("2006-01-02"))
entries, err := lib.Search(ctx, db, query, 0)
if err != nil {
return fmt.Errorf("failed to retrieve history entries that haven't been uploaded yet: %v", err)
}
hctx.GetLogger().Infof("Uploading %d history entries that previously failed to upload (query=%#v)\n", len(entries), query)
jsonValue, err := lib.EncryptAndMarshal(config, entries)
if err != nil {
return err
}
_, err = lib.ApiPost("/api/v1/submit?source_device_id="+config.DeviceId, "application/json", jsonValue)
if err != nil {
// Failed to upload the history entry, so we must still be offline. So just return nil and we'll try again later.
return nil
}
// Mark down that we persisted it
config.HaveMissedUploads = false
config.MissedUploadTimestamp = 0
err = hctx.SetConfig(config)
if err != nil {
return fmt.Errorf("failed to mark a history entry as uploaded: %v", err)
}
return nil
}
func saveHistoryEntry(ctx *context.Context) {
config := hctx.GetConf(ctx)
if !config.IsEnabled {
hctx.GetLogger().Infof("Skipping saving a history entry because hishtory is disabled\n")
return
}
entry, err := lib.BuildHistoryEntry(ctx, os.Args)
lib.CheckFatalError(err)
if entry == nil {
hctx.GetLogger().Infof("Skipping saving a history entry because we did not build a history entry (was the command prefixed with a space and/or empty?)\n")
return
}
// Persist it locally
db := hctx.GetDb(ctx)
err = lib.ReliableDbCreate(db, *entry)
lib.CheckFatalError(err)
// Persist it remotely
if !config.IsOffline {
jsonValue, err := lib.EncryptAndMarshal(config, []*data.HistoryEntry{entry})
lib.CheckFatalError(err)
_, err = lib.ApiPost("/api/v1/submit?source_device_id="+config.DeviceId, "application/json", jsonValue)
if err != nil {
if lib.IsOfflineError(err) {
hctx.GetLogger().Infof("Failed to remotely persist hishtory entry because we failed to connect to the remote server! This is likely because the device is offline, but also could be because the remote server is having reliability issues. Original error: %v", err)
if !config.HaveMissedUploads {
config.HaveMissedUploads = true
config.MissedUploadTimestamp = time.Now().Unix()
lib.CheckFatalError(hctx.SetConfig(config))
}
} else {
lib.CheckFatalError(err)
}
}
}
// Check if there is a pending dump request and reply to it if so
dumpRequests, err := getDumpRequests(config)
if err != nil {
if lib.IsOfflineError(err) {
// It is fine to just ignore this, the next command will retry the API and eventually we will respond to any pending dump requests
dumpRequests = []*shared.DumpRequest{}
hctx.GetLogger().Infof("Failed to check for dump requests because we failed to connect to the remote server!")
} else {
lib.CheckFatalError(err)
}
}
if len(dumpRequests) > 0 {
lib.CheckFatalError(lib.RetrieveAdditionalEntriesFromRemote(ctx))
entries, err := lib.Search(ctx, db, "", 0)
lib.CheckFatalError(err)
var encEntries []*shared.EncHistoryEntry
for _, entry := range entries {
enc, err := data.EncryptHistoryEntry(config.UserSecret, *entry)
lib.CheckFatalError(err)
encEntries = append(encEntries, &enc)
}
reqBody, err := json.Marshal(encEntries)
lib.CheckFatalError(err)
for _, dumpRequest := range dumpRequests {
if !config.IsOffline {
_, err := lib.ApiPost("/api/v1/submit-dump?user_id="+dumpRequest.UserId+"&requesting_device_id="+dumpRequest.RequestingDeviceId+"&source_device_id="+config.DeviceId, "application/json", reqBody)
lib.CheckFatalError(err)
}
}
}
// Handle deletion requests
lib.CheckFatalError(lib.ProcessDeletionRequests(ctx))
}
func export(ctx *context.Context, query string) {
db := hctx.GetDb(ctx)
err := lib.RetrieveAdditionalEntriesFromRemote(ctx)
if err != nil {
if lib.IsOfflineError(err) {
fmt.Println("Warning: hishtory is offline so this may be missing recent results from your other machines!")
} else {
lib.CheckFatalError(err)
}
}
data, err := lib.Search(ctx, db, query, 0)
lib.CheckFatalError(err)
for i := len(data) - 1; i >= 0; i-- {
fmt.Println(data[i].Command)
}
}
// TODO(feature): Add a session_id column that corresponds to the shell session the command was run in

58
scripts/actions-sign.py Normal file
View File

@@ -0,0 +1,58 @@
import os
import requests
import time
import subprocess
def main():
version = os.environ['GITHUB_REF'].split('/')[-1].split("-")[0]
print("Downloading binaries (this may pause for a while)")
waitUntilPublished(f"https://github.com/ddworken/hishtory/releases/download/{version}/hishtory-darwin-arm64", "hishtory-darwin-arm64")
waitUntilPublished(f"https://github.com/ddworken/hishtory/releases/download/{version}/hishtory-darwin-amd64", "hishtory-darwin-amd64")
print("before sha1sum:")
os.system("sha1sum hishtory-* 2>&1")
print("file:")
os.system("file hishtory-* 2>&1")
notAscii("hishtory-darwin-arm64")
notAscii("hishtory-darwin-amd64")
print("signing...")
os.system("""
cp hishtory-darwin-arm64 hishtory-darwin-arm64-unsigned
cp hishtory-darwin-amd64 hishtory-darwin-amd64-unsigned
echo $MACOS_CERTIFICATE | base64 -d > certificate.p12
security create-keychain -p $MACOS_CERTIFICATE_PWD build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p $MACOS_CERTIFICATE_PWD build.keychain
security import certificate.p12 -k build.keychain -P $MACOS_CERTIFICATE_PWD -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $MACOS_CERTIFICATE_PWD build.keychain
/usr/bin/codesign --force -s 6D4E1575A0D40C370E294916A8390797106C8A6E hishtory-darwin-arm64 -v
/usr/bin/codesign --force -s 6D4E1575A0D40C370E294916A8390797106C8A6E hishtory-darwin-amd64 -v
""")
print("after sha1sum:")
os.system("sha1sum hishtory-* 2>&1")
def notAscii(fn):
out = subprocess.check_output(["file", fn]).decode('utf-8')
if "ASCII text" in out:
raise Exception(f"fn={fn} is of type {out}")
def waitUntilPublished(url, output) -> None:
startTime = time.time()
while True:
r = requests.get(url, headers={'authorization': f'bearer {os.environ["GITHUB_TOKEN"]}'})
if r.status_code == 200:
break
if (time.time() - startTime)/60 > 20:
raise Exception(f"failed to get url={url} (startTime={startTime}, endTime={time.time()}), status_code=" + str(r.status_code) + " body=" + str(r.content))
time.sleep(5)
with open(output, 'wb') as f:
f.write(r.content)
if __name__ == '__main__':
main()

4
scripts/client-ldflags Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
GIT_HASH=$(git rev-parse HEAD)
echo "-X main.GitCommit=$GIT_HASH -X github.com/ddworken/hishtory/client/lib.Version=`cat VERSION` -w -extldflags \"-static\""

87
shared/data.go Normal file
View File

@@ -0,0 +1,87 @@
package shared
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
type EncHistoryEntry struct {
EncryptedData []byte `json:"enc_data"`
Nonce []byte `json:"nonce"`
DeviceId string `json:"device_id"`
UserId string `json:"user_id"`
Date time.Time `json:"time"`
EncryptedId string `json:"id"`
ReadCount int `json:"read_count"`
}
/*
Manually created the indices:
CREATE INDEX CONCURRENTLY device_id_idx ON enc_history_entries USING btree(device_id);
CREATE INDEX CONCURRENTLY read_count_idx ON enc_history_entries USING btree(read_count);
CREATE INDEX CONCURRENTLY redact_idx ON enc_history_entries USING btree(user_id, device_id, date);
*/
type Device struct {
UserId string `json:"user_id"`
DeviceId string `json:"device_id"`
// The IP address that was used to register the device. Recorded so
// that I can count how many people are using hishtory and roughly
// from where. If you would like this deleted, please email me at
// david@daviddworken.com and I can clear it from your device entries.
RegistrationIp string `json:"registration_ip"`
RegistrationDate time.Time `json:"registration_date"`
}
type DumpRequest struct {
UserId string `json:"user_id"`
RequestingDeviceId string `json:"requesting_device_id"`
RequestTime time.Time `json:"request_time"`
}
type UpdateInfo struct {
LinuxAmd64Url string `json:"linux_amd_64_url"`
LinuxAmd64AttestationUrl string `json:"linux_amd_64_attestation_url"`
DarwinAmd64Url string `json:"darwin_amd_64_url"`
DarwinAmd64UnsignedUrl string `json:"darwin_amd_64_unsigned_url"`
DarwinAmd64AttestationUrl string `json:"darwin_amd_64_attestation_url"`
DarwinArm64Url string `json:"darwin_arm_64_url"`
DarwinArm64UnsignedUrl string `json:"darwin_arm_64_unsigned_url"`
DarwinArm64AttestationUrl string `json:"darwin_arm_64_attestation_url"`
Version string `json:"version"`
}
type DeletionRequest struct {
UserId string `json:"user_id"`
DestinationDeviceId string `json:"destination_device_id"`
SendTime time.Time `json:"send_time"`
Messages MessageIdentifiers `json:"messages"`
ReadCount int `json:"read_count"`
}
type MessageIdentifiers struct {
Ids []MessageIdentifier `json:"message_ids"`
}
type MessageIdentifier struct {
DeviceId string `json:"device_id"`
Date time.Time `json:"date"`
}
func (m *MessageIdentifiers) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to unmarshal JSONB value: %v", value)
}
result := MessageIdentifiers{}
err := json.Unmarshal(bytes, &result)
*m = result
return err
}
func (m MessageIdentifiers) Value() (driver.Value, error) {
return json.Marshal(m)
}

View File

@@ -0,0 +1,283 @@
package testutils
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/ddworken/hishtory/client/data"
)
const (
DB_WAL_PATH = data.DB_PATH + "-wal"
DB_SHM_PATH = data.DB_PATH + "-shm"
)
func ResetLocalState(t *testing.T) {
homedir, err := os.UserHomeDir()
if err != nil {
t.Fatalf("failed to retrieve homedir: %v", err)
}
_ = BackupAndRestoreWithId(t, "-reset-local-state")
_ = os.RemoveAll(path.Join(homedir, data.HISHTORY_PATH))
}
func BackupAndRestore(t *testing.T) func() {
return BackupAndRestoreWithId(t, "")
}
func getBackPath(file, id string) string {
if strings.Contains(file, "/"+data.HISHTORY_PATH+"/") {
return strings.Replace(file, data.HISHTORY_PATH, data.HISHTORY_PATH+".test", 1) + id
}
return file + ".bak" + id
}
func BackupAndRestoreWithId(t *testing.T, id string) func() {
ResetFakeHistoryTimestamp()
homedir, err := os.UserHomeDir()
Check(t, err)
initialWd, err := os.Getwd()
Check(t, err)
Check(t, os.MkdirAll(path.Join(homedir, data.HISHTORY_PATH+".test"), os.ModePerm))
renameFiles := []string{
path.Join(homedir, data.HISHTORY_PATH, data.DB_PATH),
path.Join(homedir, data.HISHTORY_PATH, DB_WAL_PATH),
path.Join(homedir, data.HISHTORY_PATH, DB_SHM_PATH),
path.Join(homedir, data.HISHTORY_PATH, data.CONFIG_PATH),
path.Join(homedir, data.HISHTORY_PATH, "hishtory"),
path.Join(homedir, data.HISHTORY_PATH, "config.sh"),
path.Join(homedir, data.HISHTORY_PATH, "config.zsh"),
path.Join(homedir, data.HISHTORY_PATH, "config.fish"),
path.Join(homedir, ".bash_history"),
path.Join(homedir, ".zsh_history"),
path.Join(homedir, ".local/share/fish/fish_history"),
}
for _, file := range renameFiles {
touchFile(file)
_ = os.Rename(file, getBackPath(file, id))
}
copyFiles := []string{
path.Join(homedir, ".zshrc"),
path.Join(homedir, ".bashrc"),
path.Join(homedir, ".bash_profile"),
}
for _, file := range copyFiles {
touchFile(file)
_ = copy(file, getBackPath(file, id))
}
configureZshrc(homedir)
touchFile(path.Join(homedir, ".bash_history"))
touchFile(path.Join(homedir, ".zsh_history"))
touchFile(path.Join(homedir, ".local/share/fish/fish_history"))
return func() {
Check(t, os.MkdirAll(path.Join(homedir, data.HISHTORY_PATH), os.ModePerm))
for _, file := range renameFiles {
checkError(os.Rename(getBackPath(file, id), file))
}
for _, file := range copyFiles {
checkError(copy(getBackPath(file, id), file))
}
if runtime.GOOS != "windows" {
cmd := exec.Command("killall", "hishtory")
stdout, err := cmd.Output()
if err != nil && err.Error() != "exit status 1" {
t.Fatalf("failed to execute killall hishtory, stdout=%#v: %v", string(stdout), err)
}
}
checkError(os.Chdir(initialWd))
}
}
func touchFile(p string) {
_, err := os.Stat(p)
if os.IsNotExist(err) {
checkError(os.MkdirAll(filepath.Dir(p), os.ModePerm))
file, err := os.Create(p)
checkError(err)
defer file.Close()
} else {
currentTime := time.Now().Local()
err := os.Chtimes(p, currentTime, currentTime)
checkError(err)
}
}
func configureZshrc(homedir string) {
f, err := os.OpenFile(path.Join(homedir, ".zshrc"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
checkError(err)
defer f.Close()
_, err = f.WriteString(`export HISTFILE=~/.zsh_history
export HISTSIZE=10000
export SAVEHIST=1000
setopt SHARE_HISTORY
`)
checkError(err)
}
func copy(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
if err != nil {
return err
}
return out.Close()
}
func BackupAndRestoreEnv(k string) func() {
origValue := os.Getenv(k)
return func() {
os.Setenv(k, origValue)
}
}
func checkError(err error) {
if err != nil {
_, filename, line, _ := runtime.Caller(1)
_, cf, cl, _ := runtime.Caller(2)
log.Fatalf("testutils fatal error at %s:%d (caller: %s:%d): %v", filename, line, cf, cl, err)
}
}
func buildServer() string {
for i := 0; i < 100; i++ {
wd, err := os.Getwd()
if err != nil {
panic(fmt.Sprintf("failed to getwd: %v", err))
}
if strings.HasSuffix(wd, "hishtory") {
break
}
err = os.Chdir("../")
if err != nil {
panic(fmt.Sprintf("failed to chdir: %v", err))
}
if wd == "/" {
panic("failed to cd into hishtory dir!")
}
}
version, err := os.ReadFile("VERSION")
if err != nil {
if runtime.GOOS == "windows" {
// TODO: Figure out why it can't read the VERSION file
version = []byte("174")
} else {
panic(fmt.Sprintf("failed to read VERSION file: %v", err))
}
}
f, err := os.CreateTemp("", "server")
checkError(err)
fn := f.Name()
cmd := exec.Command("go", "build", "-o", fn, "-ldflags", fmt.Sprintf("-X main.ReleaseVersion=v0.%s", version), "backend/server/server.go")
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
err = cmd.Start()
if err != nil {
panic(fmt.Sprintf("failed to start to build server: %v, stderr=%#v, stdout=%#v", err, stderr.String(), stdout.String()))
}
err = cmd.Wait()
if err != nil {
wd, _ := os.Getwd()
panic(fmt.Sprintf("failed to build server: %v, wd=%#v, stderr=%#v, stdout=%#v", err, wd, stderr.String(), stdout.String()))
}
return fn
}
func RunTestServer() func() {
os.Setenv("HISHTORY_SERVER", "http://localhost:8080")
fn := buildServer()
cmd := exec.Command(fn)
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
err := cmd.Start()
if err != nil {
panic(fmt.Sprintf("failed to start server: %v", err))
}
time.Sleep(time.Second * 5)
go func() {
_ = cmd.Wait()
}()
return func() {
err := cmd.Process.Kill()
if err != nil && err.Error() != "os: process already finished" {
panic(fmt.Sprintf("failed to kill server process: %v", err))
}
if strings.Contains(stderr.String()+stdout.String(), "failed to") && IsOnline() {
panic(fmt.Sprintf("server failed to do something: stderr=%#v, stdout=%#v", stderr.String(), stdout.String()))
}
if strings.Contains(stderr.String()+stdout.String(), "ERROR:") {
panic(fmt.Sprintf("server experienced an error: stderr=%#v, stdout=%#v", stderr.String(), stdout.String()))
}
// fmt.Printf("stderr=%#v, stdout=%#v\n", stderr.String(), stdout.String())
}
}
func Check(t *testing.T, err error) {
if err != nil {
_, filename, line, _ := runtime.Caller(1)
t.Fatalf("Unexpected error at %s:%d: %v", filename, line, err)
}
}
func CheckWithInfo(t *testing.T, err error, additionalInfo string) {
if err != nil {
_, filename, line, _ := runtime.Caller(1)
t.Fatalf("Unexpected error: %v at %s:%d! Additional info: %v", err, filename, line, additionalInfo)
}
}
func IsOnline() bool {
_, err := http.Get("https://hishtory.dev")
return err == nil
}
var fakeHistoryTimestamp int64 = 1666068191
func ResetFakeHistoryTimestamp() {
fakeHistoryTimestamp = 1666068191
}
func MakeFakeHistoryEntry(command string) data.HistoryEntry {
fakeHistoryTimestamp += 5
return data.HistoryEntry{
LocalUsername: "david",
Hostname: "localhost",
Command: command,
CurrentWorkingDirectory: "/tmp/",
HomeDirectory: "/home/david/",
ExitCode: 2,
StartTime: time.Unix(fakeHistoryTimestamp, 0),
EndTime: time.Unix(fakeHistoryTimestamp+3, 0),
}
}
func IsGithubAction() bool {
return os.Getenv("GITHUB_ACTION") != ""
}