208 lines
4.6 KiB
Go
208 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"inou/lib"
|
|
)
|
|
|
|
var hosts = map[string]string{
|
|
"dev": "johan@192.168.1.253",
|
|
"prod": "johan@192.168.100.2",
|
|
}
|
|
|
|
const (
|
|
remoteKeyPath = "/tank/inou/master.key"
|
|
remoteDBPath = "/tank/inou/data/inou.db"
|
|
cacheDir = ".cache/inou"
|
|
)
|
|
|
|
func main() {
|
|
if len(os.Args) < 3 {
|
|
fmt.Fprintln(os.Stderr, "Usage: rquery <dev|prod> <SQL>")
|
|
fmt.Fprintln(os.Stderr, " Runs SQL via SSH, decrypts locally.")
|
|
fmt.Fprintln(os.Stderr, " rquery dev \"SELECT * FROM entries LIMIT 5\"")
|
|
os.Exit(1)
|
|
}
|
|
|
|
env := os.Args[1]
|
|
host, ok := hosts[env]
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "Unknown env %q (use dev or prod)\n", env)
|
|
os.Exit(1)
|
|
}
|
|
query := strings.Join(os.Args[2:], " ")
|
|
|
|
if err := initCrypto(env, host); err != nil {
|
|
fmt.Fprintf(os.Stderr, "crypto: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Get column names from LIMIT 1, then re-run with hex() on all columns
|
|
colCmd := exec.Command("ssh", host,
|
|
fmt.Sprintf(`sqlite3 -json %s "SELECT * FROM (%s) LIMIT 1"`, remoteDBPath, escSQL(query)))
|
|
colOut, err := colCmd.Output()
|
|
if err != nil {
|
|
// Fallback: just run the query as-is
|
|
runRaw(host, query)
|
|
return
|
|
}
|
|
|
|
var sample []map[string]interface{}
|
|
if err := json.Unmarshal(colOut, &sample); err != nil || len(sample) == 0 {
|
|
runRaw(host, query)
|
|
return
|
|
}
|
|
|
|
// Build hex-wrapped query
|
|
var cols []string
|
|
for col := range sample[0] {
|
|
cols = append(cols, col)
|
|
}
|
|
|
|
var hexCols []string
|
|
for _, col := range cols {
|
|
hexCols = append(hexCols, fmt.Sprintf(`hex("%s") as "%s"`, col, col))
|
|
}
|
|
hexQuery := fmt.Sprintf(`SELECT %s FROM (%s)`, strings.Join(hexCols, ", "), query)
|
|
|
|
cmd := exec.Command("ssh", host,
|
|
fmt.Sprintf(`sqlite3 -json %s "%s"`, remoteDBPath, escSQL(hexQuery)))
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
fmt.Fprintf(os.Stderr, "%s\n", ee.Stderr)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "ssh: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var rows []map[string]interface{}
|
|
if err := json.Unmarshal(out, &rows); err != nil {
|
|
fmt.Print(string(out))
|
|
return
|
|
}
|
|
|
|
// Decode hex and decrypt
|
|
for _, row := range rows {
|
|
for col, v := range row {
|
|
row[col] = decodeAndDecrypt(v)
|
|
}
|
|
}
|
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
enc.Encode(rows)
|
|
}
|
|
|
|
func escSQL(s string) string {
|
|
return strings.ReplaceAll(s, `"`, `\"`)
|
|
}
|
|
|
|
func runRaw(host, query string) {
|
|
cmd := exec.Command("ssh", host,
|
|
fmt.Sprintf(`sqlite3 -json %s "%s"`, remoteDBPath, escSQL(query)))
|
|
out, _ := cmd.Output()
|
|
fmt.Print(string(out))
|
|
}
|
|
|
|
func initCrypto(env, host string) error {
|
|
home, _ := os.UserHomeDir()
|
|
dir := filepath.Join(home, cacheDir)
|
|
os.MkdirAll(dir, 0700)
|
|
keyFile := filepath.Join(dir, env+".key")
|
|
|
|
if _, err := os.Stat(keyFile); err != nil {
|
|
cmd := exec.Command("ssh", host, "cat", remoteKeyPath)
|
|
key, err := cmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("fetch key from %s: %w", env, err)
|
|
}
|
|
if err := os.WriteFile(keyFile, key, 0600); err != nil {
|
|
return fmt.Errorf("cache key: %w", err)
|
|
}
|
|
}
|
|
return lib.CryptoInit(keyFile)
|
|
}
|
|
|
|
func decodeAndDecrypt(v interface{}) interface{} {
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return v
|
|
}
|
|
|
|
// Decode hex
|
|
raw, err := hex.DecodeString(s)
|
|
if err != nil {
|
|
return s // not hex, return as-is
|
|
}
|
|
|
|
// Empty
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Try as integer (sqlite integers are stored as variable-length big-endian in hex)
|
|
// Check: if it's short and all the original was digits, it was likely a plain integer
|
|
// Actually, hex(123) = "313233" (hex of ASCII "123") for TEXT,
|
|
// but hex(123) for INTEGER = "7B". Let's handle both.
|
|
|
|
// Try Unpack first (packed BLOBs)
|
|
if unpacked := lib.Unpack(raw); unpacked != nil {
|
|
str := string(unpacked)
|
|
if strings.HasPrefix(str, "{") || strings.HasPrefix(str, "[") {
|
|
var parsed interface{}
|
|
if json.Unmarshal(unpacked, &parsed) == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return str
|
|
}
|
|
|
|
// Try as UTF-8 text, then CryptoDecrypt
|
|
str := string(raw)
|
|
|
|
// Check if it's a plain integer
|
|
isNum := len(str) > 0
|
|
for _, c := range str {
|
|
if c < '0' || c > '9' {
|
|
isNum = false
|
|
break
|
|
}
|
|
}
|
|
if isNum {
|
|
var n json.Number
|
|
n = json.Number(str)
|
|
if i, err := n.Int64(); err == nil {
|
|
return i
|
|
}
|
|
}
|
|
|
|
// Try CryptoDecrypt chain
|
|
decrypted := str
|
|
for i := 0; i < 10; i++ {
|
|
next := lib.CryptoDecrypt(decrypted)
|
|
if next == "" || next == decrypted {
|
|
break
|
|
}
|
|
decrypted = next
|
|
}
|
|
if decrypted != str {
|
|
if strings.HasPrefix(decrypted, "{") || strings.HasPrefix(decrypted, "[") {
|
|
var parsed interface{}
|
|
if json.Unmarshal([]byte(decrypted), &parsed) == nil {
|
|
return parsed
|
|
}
|
|
}
|
|
return decrypted
|
|
}
|
|
|
|
return str
|
|
}
|