Version 0.01
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{yaml,yml,json,toml}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
build/*
|
||||||
|
|
||||||
|
config.ini
|
||||||
|
menu.ini
|
||||||
41
Makefile
Normal file
41
Makefile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
BINARY := ./build/cascade
|
||||||
|
|
||||||
|
.PHONY: init build run install delete clean
|
||||||
|
|
||||||
|
init:
|
||||||
|
@go mod init
|
||||||
|
@go mod tidy
|
||||||
|
|
||||||
|
build:
|
||||||
|
@mkdir -p ./build
|
||||||
|
@go build -o $(BINARY) main.go
|
||||||
|
|
||||||
|
run: build
|
||||||
|
@$(BINARY)
|
||||||
|
|
||||||
|
install:
|
||||||
|
@mkdir -p /opt/cascade
|
||||||
|
@cp $(BINARY) /opt/cascade/cascade
|
||||||
|
@if [[ -f /opt/cascade/config.ini ]]; then \
|
||||||
|
cp /opt/cascade/config.ini /opt/cascade/config.ini.old.$(shell date +%Y%m%d%H%M%S); \
|
||||||
|
fi; \
|
||||||
|
if [[ -f ./config.ini ]]; then \
|
||||||
|
cp ./config.ini /opt/cascade/config.ini; \
|
||||||
|
else \
|
||||||
|
cp ./config.ini.example /opt/cascade/config.ini; \
|
||||||
|
fi
|
||||||
|
@if [[ -f /opt/cascade/menu.ini ]]; then \
|
||||||
|
cp /opt/cascade/menu.ini /opt/cascade/menu.ini.old.$(shell date +%Y%m%d%H%M%S); \
|
||||||
|
fi; \
|
||||||
|
if [[ -f ./menu.ini ]]; then \
|
||||||
|
cp ./menu.ini /opt/cascade/menu.ini; \
|
||||||
|
else \
|
||||||
|
cp ./menu.ini.example /opt/cascade/menu.ini; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
delete:
|
||||||
|
@rm -rf /opt/cascade
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@rm -rf ./build
|
||||||
252
README.md
Normal file
252
README.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Cascade
|
||||||
|
|
||||||
|
Cascade is a lightweight, terminal-based interactive menu that lets administrators expose a curated set of shell commands to users without giving them a full shell. It is written in Go with no external dependencies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [How It Works](#how-it-works)
|
||||||
|
- [Building and Installing](#building-and-installing)
|
||||||
|
- [Configuration Files](#configuration-files)
|
||||||
|
- [Application Config (`config.ini`)](#application-config-configini)
|
||||||
|
- [Menu Config (`menu.ini`)](#menu-config-menuini)
|
||||||
|
- [Configuration Resolution Order](#configuration-resolution-order)
|
||||||
|
- [Menu File Format](#menu-file-format)
|
||||||
|
- [Comments and Section Headers](#comments-and-section-headers)
|
||||||
|
- [Dot-Separated Keys](#dot-separated-keys)
|
||||||
|
- [Mixed Nodes (Branch + Leaf)](#mixed-nodes-branch--leaf)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
|
- [Use Case: SSH User Isolation](#use-case-ssh-user-isolation)
|
||||||
|
- [Security Notes](#security-notes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When Cascade starts it:
|
||||||
|
|
||||||
|
1. Reads the **application config** to know which shell to use and where the menu file lives.
|
||||||
|
2. Reads the **menu file** and builds an in-memory tree from dot-separated keys.
|
||||||
|
3. Switches stdin to **raw mode** and presents the current menu level.
|
||||||
|
4. The user navigates and selects an item using **arrow keys** or by **typing a number** — both methods work simultaneously.
|
||||||
|
5. `0` / selecting the Back/Exit item goes one level up (or exits at the root).
|
||||||
|
6. The selected command is executed by spawning `<shell> -c "<command>"` with stdin/stdout/stderr inherited from the current terminal, so fully interactive programs (shells, `less`, `psql`, etc.) work correctly.
|
||||||
|
7. After the command exits, the menu is redrawn. The menu file is **reloaded on every redraw**, so changes take effect without restarting Cascade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building and Installing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build binary into ./build/cascade
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Build and run locally
|
||||||
|
make run
|
||||||
|
|
||||||
|
# Install to /opt/cascade/ (copies binary + config files)
|
||||||
|
sudo make install
|
||||||
|
|
||||||
|
# Remove /opt/cascade/
|
||||||
|
sudo make delete
|
||||||
|
|
||||||
|
# Remove ./build/
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install` backs up existing `/opt/cascade/config.ini` and `/opt/cascade/menu.ini` before overwriting them (timestamped `.old.*` copies).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### Application Config (`config.ini`)
|
||||||
|
|
||||||
|
A simple `key=value` file. Blank lines and lines starting with `#` are ignored.
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| `shell` | `/bin/sh` | The shell used to execute menu commands (`<shell> -c "<cmd>"`). |
|
||||||
|
| `menu` | *(none)* | Explicit path to the menu file. Overridden by `CASCADE_MENU`. |
|
||||||
|
|
||||||
|
**Example (`config.ini.example`):**
|
||||||
|
```ini
|
||||||
|
shell=/bin/bash
|
||||||
|
menu=/etc/cascade/menu.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu Config (`menu.ini`)
|
||||||
|
|
||||||
|
Defines the tree of commands shown to the user. See [Menu File Format](#menu-file-format) below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Resolution Order
|
||||||
|
|
||||||
|
### Application config
|
||||||
|
|
||||||
|
Cascade tries each candidate in order and stops at the first one it can read:
|
||||||
|
|
||||||
|
1. Path in `$CASCADE_CONF` environment variable.
|
||||||
|
2. `$HOME/.cascade/config.ini`
|
||||||
|
3. `/opt/cascade/config.ini`
|
||||||
|
4. Built-in defaults (`shell=/bin/sh`, no menu path).
|
||||||
|
|
||||||
|
### Menu file
|
||||||
|
|
||||||
|
1. Path in `$CASCADE_MENU` environment variable.
|
||||||
|
2. `menu=` value from the application config.
|
||||||
|
3. `$HOME/.cascade/menu.ini` (only if the file exists).
|
||||||
|
4. `/opt/cascade/menu.ini` (only if the file exists).
|
||||||
|
5. Fatal error — Cascade exits with a descriptive message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Menu File Format
|
||||||
|
|
||||||
|
### Comments and Section Headers
|
||||||
|
|
||||||
|
Lines starting with `#` are comments and are ignored entirely.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# This is a top-level comment
|
||||||
|
## This is a sub-section comment
|
||||||
|
Key=command
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dot-Separated Keys
|
||||||
|
|
||||||
|
Each non-comment, non-blank line must be `Key=command`.
|
||||||
|
The **key** uses dots (`.`) as level separators to express a hierarchy:
|
||||||
|
|
||||||
|
```
|
||||||
|
Level1.Level2.Level3=shell command here
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a three-level menu: entering `Level1` shows `Level2`; entering `Level2` shows `Level3`; selecting `Level3` runs the command.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```ini
|
||||||
|
Nginx.Restart=docker restart portal-nginx
|
||||||
|
Nginx.Logs.Access=docker exec -it portal-nginx less /var/log/nginx/access.log
|
||||||
|
Nginx.Logs.Error=docker exec -it portal-nginx less /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Results in this navigation tree:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Cascade v0.01 ]
|
||||||
|
|
||||||
|
Select Option:
|
||||||
|
|
||||||
|
1) Nginx <-
|
||||||
|
|
||||||
|
0) Exit
|
||||||
|
|
||||||
|
Enter number:
|
||||||
|
```
|
||||||
|
|
||||||
|
After pressing Enter (or ↓ then Enter):
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Nginx ]
|
||||||
|
|
||||||
|
Select Option:
|
||||||
|
|
||||||
|
1) Logs
|
||||||
|
2) Restart <-
|
||||||
|
|
||||||
|
0) Back
|
||||||
|
|
||||||
|
Enter number:
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Nginx > Logs ]
|
||||||
|
|
||||||
|
Select Option:
|
||||||
|
|
||||||
|
1) Access
|
||||||
|
2) Error
|
||||||
|
|
||||||
|
0) Back <-
|
||||||
|
|
||||||
|
Enter number:
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys at the same level are displayed **sorted alphabetically**.
|
||||||
|
|
||||||
|
### Navigation Controls
|
||||||
|
|
||||||
|
| Input | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| ↑ / ↓ | Move the cursor one item up or down. Wraps around at both ends. Moving clears any typed number. |
|
||||||
|
| Digit keys (`0`–`9`) | Type a number directly. The cursor jumps to the matching item as you type. |
|
||||||
|
| Backspace | Delete the last typed digit. |
|
||||||
|
| Enter | Confirm the selection — uses the typed number if one is being entered, otherwise uses the cursor position. |
|
||||||
|
|
||||||
|
Both input methods (arrows and number typing) can be mixed freely at any time.
|
||||||
|
|
||||||
|
### Mixed Nodes (Branch + Leaf)
|
||||||
|
|
||||||
|
A node can have both a direct command **and** child items. When this happens, an extra option `(run directly)` appears at the top of the submenu, allowing the user to execute the node's own command without navigating further.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
Hrm.Shell=docker exec -it portal-hrm-php sh
|
||||||
|
Hrm.Service.PhpFpm.Restart=docker restart portal-hrm-php
|
||||||
|
```
|
||||||
|
|
||||||
|
`Hrm` has its own command (`Shell`) and also has children (`Service`). Selecting `Hrm` opens a submenu that includes `(run directly)` as option 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|-----------------|-------------|
|
||||||
|
| `CASCADE_CONF` | Override the path to the application config file. |
|
||||||
|
| `CASCADE_MENU` | Override the path to the menu config file. |
|
||||||
|
|
||||||
|
These variables take highest priority in their respective resolution chains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Use Case: SSH User Isolation
|
||||||
|
|
||||||
|
Cascade is designed to be used as a restricted `ForceCommand` in OpenSSH, so that certain SSH users see only the allowed menu instead of a real shell.
|
||||||
|
|
||||||
|
**`/etc/ssh/sshd_config` (or a drop-in file):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Match User dev
|
||||||
|
SetEnv CASCADE_MENU=/opt/cascade/dev.ini
|
||||||
|
SetEnv CASCADE_CONF=/opt/cascade/config.ini
|
||||||
|
ForceCommand /usr/bin/cascade
|
||||||
|
```
|
||||||
|
|
||||||
|
What this does:
|
||||||
|
|
||||||
|
- `ForceCommand /usr/bin/cascade` — every time the user `dev` connects, Cascade is launched instead of their login shell, regardless of what command they pass to `ssh`.
|
||||||
|
- `SetEnv CASCADE_CONF` — points to the application config (defines the shell used to run commands).
|
||||||
|
- `SetEnv CASCADE_MENU` — points to a menu file specific to this user, so different users can have different sets of allowed commands.
|
||||||
|
|
||||||
|
Different users (or `Match` blocks) can each have their own `CASCADE_MENU` pointing to different `.ini` files, giving fine-grained control over which commands each user can run.
|
||||||
|
|
||||||
|
**Deployment checklist:**
|
||||||
|
|
||||||
|
1. Copy the `cascade` binary to `/usr/bin/cascade` (or `/opt/cascade/cascade`).
|
||||||
|
2. Create `/opt/cascade/config.ini` with at minimum `shell=/bin/bash`.
|
||||||
|
3. Create per-user (or shared) menu files, e.g. `/opt/cascade/dev.ini`.
|
||||||
|
4. Add the `Match` block(s) to `sshd_config` and reload sshd (`systemctl reload sshd`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Raw terminal mode** — stdin is switched to raw mode only while waiting for a key press and is restored before running any command, so executed programs see a normal terminal.
|
||||||
|
- **Input validation** — typed number input is limited to 16 characters and must be all digits. Only arrow keys, digits, Backspace, and Enter are acted upon; all other key sequences are ignored.
|
||||||
|
- **Line length** — config lines longer than 4096 bytes are rejected by the scanner.
|
||||||
|
- **No shell access** — commands are defined entirely by the administrator in the menu file. The user can only pick a number; they cannot inject arbitrary commands.
|
||||||
|
- **ForceCommand** — when used with SSH `ForceCommand`, the user cannot bypass Cascade by passing a command to `ssh` directly.
|
||||||
|
- **Menu hot-reload** — the menu file is re-read on every screen redraw. Ensure the file is writable only by root/admin to prevent privilege escalation.
|
||||||
2
config.ini.example
Normal file
2
config.ini.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
shell=/bin/bash
|
||||||
|
menu=/etc/cascade/menu.ini
|
||||||
4
examples/sshd.config
Normal file
4
examples/sshd.config
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Match User dev
|
||||||
|
SetEnv CASCADE_MENU=/opt/cascade/dev.menu.ini
|
||||||
|
SetEnv CASCADE_CONF=/opt/cascade/config.ini
|
||||||
|
ForceCommand /usr/bin/cascade
|
||||||
478
main.go
Normal file
478
main.go
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appVersion = 0.01
|
||||||
|
maxInputLen = 16
|
||||||
|
maxConfigLineLen = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// appConfig holds values read from the application config file.
|
||||||
|
type appConfig struct {
|
||||||
|
shell string // shell to use when running commands, e.g. /bin/bash
|
||||||
|
menuPath string // explicit path to the menu config
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadKV reads a simple key=value file, skipping blank lines and # comments.
|
||||||
|
func loadKV(path string) (map[string]string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
result := map[string]string{}
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, maxConfigLineLen), maxConfigLineLen)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.IndexByte(line, '=')
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
val := strings.TrimSpace(line[idx+1:])
|
||||||
|
if key != "" {
|
||||||
|
result[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAppConfig loads the application config using the cascade:
|
||||||
|
// 1. $CASCADE_CONF
|
||||||
|
// 2. $HOME/.cascade/config.ini
|
||||||
|
// 3. /opt/cascade/config.ini
|
||||||
|
// 4. built-in defaults (shell=/bin/sh, no menu)
|
||||||
|
func resolveAppConfig() appConfig {
|
||||||
|
cfg := appConfig{shell: "/bin/sh"}
|
||||||
|
|
||||||
|
var candidates []string
|
||||||
|
if v := os.Getenv("CASCADE_CONF"); v != "" {
|
||||||
|
candidates = append(candidates, v)
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Join(home, ".cascade", "config.ini"))
|
||||||
|
}
|
||||||
|
candidates = append(candidates, "/opt/cascade/config.ini")
|
||||||
|
|
||||||
|
for _, path := range candidates {
|
||||||
|
kv, err := loadKV(path)
|
||||||
|
if err != nil {
|
||||||
|
continue // not found or unreadable — try next
|
||||||
|
}
|
||||||
|
if v := kv["shell"]; v != "" {
|
||||||
|
cfg.shell = v
|
||||||
|
}
|
||||||
|
if v := kv["menu"]; v != "" {
|
||||||
|
cfg.menuPath = v
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMenuPath finds the menu file using the cascade:
|
||||||
|
// 1. $CASCADE_MENU
|
||||||
|
// 2. menu= value from appConfig
|
||||||
|
// 3. $HOME/.cascade/menu.ini
|
||||||
|
// 4. /opt/cascade/menu.ini
|
||||||
|
// 5. error
|
||||||
|
func resolveMenuPath(cfg appConfig) (string, error) {
|
||||||
|
if v := os.Getenv("CASCADE_MENU"); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if cfg.menuPath != "" {
|
||||||
|
return cfg.menuPath, nil
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
p := filepath.Join(home, ".cascade", "menu.ini")
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("/opt/cascade/menu.ini"); err == nil {
|
||||||
|
return "/opt/cascade/menu.ini", nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"menu config not found: set CASCADE_MENU env var, add 'menu=' to app config, " +
|
||||||
|
"or place menu.ini in ~/.cascade/ or /opt/cascade/",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node represents one level in the menu tree.
|
||||||
|
type Node struct {
|
||||||
|
children map[string]*Node
|
||||||
|
command string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNode() *Node {
|
||||||
|
return &Node{children: make(map[string]*Node)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTree reads the menu file and builds a tree from dot-separated keys.
|
||||||
|
func loadTree(path string) (*Node, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot open menu file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
root := newNode()
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, maxConfigLineLen), maxConfigLineLen)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.IndexByte(line, '=')
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
val := strings.TrimSpace(line[idx+1:])
|
||||||
|
if key == "" || val == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(key, ".")
|
||||||
|
node := root
|
||||||
|
for i, part := range parts {
|
||||||
|
if _, ok := node.children[part]; !ok {
|
||||||
|
node.children[part] = newNode()
|
||||||
|
}
|
||||||
|
if i == len(parts)-1 {
|
||||||
|
node.children[part].command = val
|
||||||
|
} else {
|
||||||
|
node = node.children[part]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading menu: %w", err)
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfKey is a synthetic sentinel used when a node has both its own command and children.
|
||||||
|
const selfKey = "\x00"
|
||||||
|
|
||||||
|
func sortedKeys(node *Node) []string {
|
||||||
|
keys := make([]string, 0, len(node.children))
|
||||||
|
for k := range node.children {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
if node.command != "" && len(keys) > 0 {
|
||||||
|
keys = append([]string{selfKey}, keys...)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateTo(root *Node, pathKeys []string) (*Node, []string) {
|
||||||
|
current := root
|
||||||
|
for _, key := range pathKeys {
|
||||||
|
child, ok := current.children[key]
|
||||||
|
if !ok {
|
||||||
|
return root, pathKeys[:0]
|
||||||
|
}
|
||||||
|
current = child
|
||||||
|
}
|
||||||
|
return current, pathKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
func printMenu(keys []string, pathKeys []string, cursor int, numBuf string) {
|
||||||
|
fmt.Print("\033[H\033[2J")
|
||||||
|
|
||||||
|
if len(pathKeys) > 0 {
|
||||||
|
fmt.Printf("[ %s ]\n", strings.Join(pathKeys, " > "))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ Cascade v%.2f ]\n", appVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nSelect Option:\n\n")
|
||||||
|
|
||||||
|
for i, k := range keys {
|
||||||
|
var label string
|
||||||
|
if k == selfKey {
|
||||||
|
label = "(run directly)"
|
||||||
|
} else {
|
||||||
|
label = k
|
||||||
|
}
|
||||||
|
if i == cursor {
|
||||||
|
fmt.Printf(" %d) %s <-\n", i+1, label)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %d) %s\n", i+1, label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
exitLabel := "Exit"
|
||||||
|
if len(pathKeys) > 0 {
|
||||||
|
exitLabel = "Back"
|
||||||
|
}
|
||||||
|
if cursor == len(keys) {
|
||||||
|
fmt.Printf(" 0) %s <-\n", exitLabel)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" 0) %s\n", exitLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("\nEnter number: ")
|
||||||
|
if numBuf != "" {
|
||||||
|
fmt.Print(numBuf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigitsOnly(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(shell, command string) error {
|
||||||
|
cmd := exec.Command(shell, "-c", command)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRaw switches fd to raw mode (no line buffering, no echo) and returns
|
||||||
|
// the previous terminal state so it can be restored later.
|
||||||
|
func makeRaw(fd int) (syscall.Termios, error) {
|
||||||
|
var old syscall.Termios
|
||||||
|
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
|
||||||
|
uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&old))); errno != 0 {
|
||||||
|
return old, errno
|
||||||
|
}
|
||||||
|
raw := old
|
||||||
|
raw.Lflag &^= syscall.ICANON | syscall.ECHO
|
||||||
|
raw.Cc[syscall.VMIN] = 1
|
||||||
|
raw.Cc[syscall.VTIME] = 0
|
||||||
|
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
|
||||||
|
uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&raw))); errno != 0 {
|
||||||
|
return old, errno
|
||||||
|
}
|
||||||
|
return old, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreTerminal restores fd to the given terminal state.
|
||||||
|
func restoreTerminal(fd int, old syscall.Termios) {
|
||||||
|
syscall.Syscall(syscall.SYS_IOCTL,
|
||||||
|
uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&old)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key kind constants used by readKey.
|
||||||
|
const (
|
||||||
|
keyUp = iota
|
||||||
|
keyDown
|
||||||
|
keyEnter
|
||||||
|
keyBackspace
|
||||||
|
keyDigit
|
||||||
|
keyOther
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyEvent represents a single logical key press.
|
||||||
|
type keyEvent struct {
|
||||||
|
kind int
|
||||||
|
digit byte // valid when kind == keyDigit
|
||||||
|
}
|
||||||
|
|
||||||
|
// readKey reads one logical key press from r.
|
||||||
|
// It recognises Up/Down arrow escape sequences, Enter, Backspace/Delete,
|
||||||
|
// and digit characters ('0'–'9'). Everything else returns keyOther.
|
||||||
|
func readKey(r *bufio.Reader) keyEvent {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return keyEvent{kind: keyOther}
|
||||||
|
}
|
||||||
|
switch b {
|
||||||
|
case '\r', '\n':
|
||||||
|
return keyEvent{kind: keyEnter}
|
||||||
|
case 127, 8: // DEL or BS
|
||||||
|
return keyEvent{kind: keyBackspace}
|
||||||
|
case 0x1B: // start of CSI escape sequence
|
||||||
|
b2, err := r.ReadByte()
|
||||||
|
if err != nil || b2 != '[' {
|
||||||
|
return keyEvent{kind: keyOther}
|
||||||
|
}
|
||||||
|
b3, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return keyEvent{kind: keyOther}
|
||||||
|
}
|
||||||
|
switch b3 {
|
||||||
|
case 'A':
|
||||||
|
return keyEvent{kind: keyUp}
|
||||||
|
case 'B':
|
||||||
|
return keyEvent{kind: keyDown}
|
||||||
|
}
|
||||||
|
return keyEvent{kind: keyOther}
|
||||||
|
default:
|
||||||
|
if b >= '0' && b <= '9' {
|
||||||
|
return keyEvent{kind: keyDigit, digit: b}
|
||||||
|
}
|
||||||
|
return keyEvent{kind: keyOther}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
termFd := int(os.Stdin.Fd())
|
||||||
|
|
||||||
|
cfg := resolveAppConfig()
|
||||||
|
|
||||||
|
menuPath, err := resolveMenuPath(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pathKeys []string
|
||||||
|
cursor := 0
|
||||||
|
numBuf := ""
|
||||||
|
|
||||||
|
for {
|
||||||
|
root, err := loadTree(menuPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, pathKeys2 := navigateTo(root, pathKeys)
|
||||||
|
pathKeys = pathKeys2
|
||||||
|
|
||||||
|
keys := sortedKeys(current)
|
||||||
|
if len(keys) == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "No variants found in menu file.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor > len(keys) {
|
||||||
|
cursor = len(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
printMenu(keys, pathKeys, cursor, numBuf)
|
||||||
|
|
||||||
|
oldTermios, rawErr := makeRaw(termFd)
|
||||||
|
key := readKey(reader)
|
||||||
|
if rawErr == nil {
|
||||||
|
restoreTerminal(termFd, oldTermios)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.kind {
|
||||||
|
case keyUp:
|
||||||
|
numBuf = ""
|
||||||
|
if cursor > 0 {
|
||||||
|
cursor--
|
||||||
|
} else {
|
||||||
|
cursor = len(keys)
|
||||||
|
}
|
||||||
|
case keyDown:
|
||||||
|
numBuf = ""
|
||||||
|
if cursor < len(keys) {
|
||||||
|
cursor++
|
||||||
|
} else {
|
||||||
|
cursor = 0
|
||||||
|
}
|
||||||
|
case keyDigit:
|
||||||
|
if len(numBuf) < maxInputLen {
|
||||||
|
numBuf += string(key.digit)
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(numBuf); err == nil && n >= 0 && n <= len(keys) {
|
||||||
|
if n == 0 {
|
||||||
|
cursor = len(keys)
|
||||||
|
} else {
|
||||||
|
cursor = n - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case keyBackspace:
|
||||||
|
if len(numBuf) > 0 {
|
||||||
|
numBuf = numBuf[:len(numBuf)-1]
|
||||||
|
if numBuf != "" {
|
||||||
|
if n, err := strconv.Atoi(numBuf); err == nil && n >= 0 && n <= len(keys) {
|
||||||
|
if n == 0 {
|
||||||
|
cursor = len(keys)
|
||||||
|
} else {
|
||||||
|
cursor = n - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case keyEnter:
|
||||||
|
var n int
|
||||||
|
if numBuf != "" {
|
||||||
|
parsed, err := strconv.Atoi(numBuf)
|
||||||
|
numBuf = ""
|
||||||
|
if err != nil || parsed < 0 || parsed > len(keys) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n = parsed
|
||||||
|
} else {
|
||||||
|
if cursor == len(keys) {
|
||||||
|
n = 0
|
||||||
|
} else {
|
||||||
|
n = cursor + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
if len(pathKeys) == 0 {
|
||||||
|
fmt.Print("\033[H\033[2J")
|
||||||
|
fmt.Println("Exiting.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
pathKeys = pathKeys[:len(pathKeys)-1]
|
||||||
|
cursor = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
chosen := keys[n-1]
|
||||||
|
cursor = n - 1
|
||||||
|
|
||||||
|
if chosen == selfKey {
|
||||||
|
fmt.Print("\033[H\033[2J")
|
||||||
|
if err := runCommand(cfg.shell, current.command); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Command exited with error: %v\n", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
child := current.children[chosen]
|
||||||
|
|
||||||
|
if len(child.children) > 0 {
|
||||||
|
pathKeys = append(pathKeys, chosen)
|
||||||
|
cursor = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("\033[H\033[2J")
|
||||||
|
if err := runCommand(cfg.shell, child.command); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Command exited with error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
menu.ini.example
Normal file
74
menu.ini.example
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# API Services
|
||||||
|
## HRM
|
||||||
|
Hrm.Shell=docker exec -it portal-hrm-php sh
|
||||||
|
Hrm.Service.PhpFpm.Restart=docker restart portal-hrm-php
|
||||||
|
Hrm.Service.PhpFpm.Logs=docker logs portal-hrm-php | less
|
||||||
|
Hrm.Service.Supervisor.Restart=docker restart portal-hrm-supervisor
|
||||||
|
Hrm.Service.Supervisor.Logs=docker logs portal-hrm-supervisor | less
|
||||||
|
Hrm.PgSql.Console=docker exec -it -e PGPASSWORD= portal-hrm-pgsql psql --username=hrm hrm
|
||||||
|
## Calendar
|
||||||
|
Calendar.Shell=docker exec -it portal-calendar-php sh
|
||||||
|
Calendar.Service.PhpFpm.Restart=docker restart portal-calendar-php
|
||||||
|
Calendar.Service.PhpFpm.Logs=docker logs portal-calendar-php | less
|
||||||
|
Calendar.Service.Supervisor.Restart=docker restart portal-calendar-supervisor
|
||||||
|
Calendar.Service.Supervisor.Logs=docker logs portal-calendar-supervisor | less
|
||||||
|
Calendar.PgSql.Console=docker exec -it -e PGPASSWORD= portal-calendar-pgsql psql --username=calendar calendar
|
||||||
|
## Entry
|
||||||
|
Entry.Shell=docker exec -it portal-base-php sh
|
||||||
|
Entry.Service.PhpFpm.Restart=docker restart portal-base-php
|
||||||
|
Entry.Service.PhpFpm.Logs=docker logs portal-base-php | less
|
||||||
|
Entry.Service.Supervisor.Restart=docker restart portal-base-supervisor
|
||||||
|
Entry.Service.Supervisor.Logs=docker logs portal-base-supervisor | less
|
||||||
|
Entry.PgSql.Console=docker exec -it -e PGPASSWORD= portal-base-pgsql psql --username=entry entry
|
||||||
|
## Knowledge
|
||||||
|
Knowledge.Shell=docker exec -it portal-knowledge-php sh
|
||||||
|
Knowledge.Service.PhpFpm.Restart=docker restart portal-knowledge-php
|
||||||
|
Knowledge.Service.PhpFpm.Logs=docker logs portal-knowledge-php | less
|
||||||
|
Knowledge.Service.Supervisor.Restart=docker restart portal-knowledge-supervisor
|
||||||
|
Knowledge.Service.Supervisor.Logs=docker logs portal-knowledge-supervisor | less
|
||||||
|
Knowledge.PgSql.Console=docker exec -it -e PGPASSWORD= portal-knowledge-pgsql psql --username=knowledge knowledge
|
||||||
|
## Meeting
|
||||||
|
Meeting.Shell=docker exec -it portal-meeting-php sh
|
||||||
|
Meeting.Service.PhpFpm.Restart=docker restart portal-meeting-php
|
||||||
|
Meeting.Service.PhpFpm.Logs=docker logs portal-meeting-php | less
|
||||||
|
Meeting.Service.Supervisor.Restart=docker restart portal-meeting-supervisor
|
||||||
|
Meeting.Service.Supervisor.Logs=docker logs portal-meeting-supervisor | less
|
||||||
|
Meeting.PgSql.Console=docker exec -it -e PGPASSWORD= portal-meeting-pgsql psql --username=meeting meeting
|
||||||
|
## Project
|
||||||
|
Project.Shell=docker exec -it portal-project-php sh
|
||||||
|
Project.Service.PhpFpm.Restart=docker restart portal-project-php
|
||||||
|
Project.Service.PhpFpm.Logs=docker logs portal-project-php | less
|
||||||
|
Project.Service.Supervisor.Restart=docker restart portal-project-supervisor
|
||||||
|
Project.Service.Supervisor.Logs=docker logs portal-project-supervisor | less
|
||||||
|
Project.PgSql.Console=docker exec -it -e PGPASSWORD= portal-project-pgsql psql --username=project project
|
||||||
|
## Scenarios
|
||||||
|
Scenarios.Shell=docker exec -it portal-scenarios-php sh
|
||||||
|
Scenarios.Service.PhpFpm.Restart=docker restart portal-scenarios-php
|
||||||
|
Scenarios.Service.PhpFpm.Logs=docker logs portal-scenarios-php | less
|
||||||
|
Scenarios.Service.Supervisor.Restart=docker restart portal-scenarios-supervisor
|
||||||
|
Scenarios.Service.Supervisor.Logs=docker logs portal-scenarios-supervisor | less
|
||||||
|
Scenarios.PgSql.Console=docker exec -it -e PGPASSWORD= portal-scenarios-pgsql psql --username=scenarios scenarios
|
||||||
|
# Web Application
|
||||||
|
WebApp.Shell=docker exec -it portal-webapp-node bash
|
||||||
|
WebApp.Logs=docker logs portal-webapp-node | less
|
||||||
|
WebApp.Service.Node.Restart=docker restart portal-webapp-node
|
||||||
|
# Nginx
|
||||||
|
Nginx.Restart=docker restart portal-nginx
|
||||||
|
Nginx.Maintenance.On=docker exec portal-nginx maintenance_on && docker restart portal-nginx
|
||||||
|
Nginx.Maintenance.Off=docker exec portal-nginx maintenance_off && docker restart portal-nginx
|
||||||
|
Nginx.Logs.Site.Access=docker exec -it portal-nginx less /var/log/nginx/dev.ts-portal.ru.access.log
|
||||||
|
Nginx.Logs.Site.Error=docker exec -it portal-nginx less /var/log/nginx/dev.ts-portal.ru.error.log
|
||||||
|
Nginx.Logs.Hrm.Access=docker exec -it portal-nginx less /var/log/nginx/proxy.hrm.access.log
|
||||||
|
Nginx.Logs.Hrm.Error=docker exec -it portal-nginx less /var/log/nginx/proxy.hrm.error.log
|
||||||
|
Nginx.Logs.Calendar.Access=docker exec -it portal-nginx less /var/log/nginx/proxy.calendar.access.log
|
||||||
|
Nginx.Logs.Calendar.Error=docker exec -it portal-nginx less /var/log/nginx/proxy.calendar.error.log
|
||||||
|
Nginx.Logs.Entry.Access=docker exec -it portal-nginx less /var/log/nginx/api.dev.ts-portal.ru.access.log
|
||||||
|
Nginx.Logs.Entry.Error=docker exec -it portal-nginx less /var/log/nginx/api.dev.ts-portal.ru.error.log
|
||||||
|
Nginx.Logs.Knowledge.Access=docker exec -it portal-nginx less /var/log/nginx/proxy.knowledge.access.log
|
||||||
|
Nginx.Logs.Knowledge.Error=docker exec -it portal-nginx less /var/log/nginx/proxy.knowledge.error.log
|
||||||
|
Nginx.Logs.Meeting.Access=docker exec -it portal-nginx less /var/log/nginx/proxy.meeting.access.log
|
||||||
|
Nginx.Logs.Meeting.Error=docker exec -it portal-nginx less /var/log/nginx/proxy.meeting.error.log
|
||||||
|
Nginx.Logs.Project.Access=docker exec -it portal-nginx less /var/log/nginx/proxy.project.access.log
|
||||||
|
Nginx.Logs.Project.Error=docker exec -it portal-nginx less /var/log/nginx/proxy.project.error.log
|
||||||
|
Nginx.Logs.Scenarios.Access=docker exec -it portal-nginx less /var/log/nginx/proxy.scenarios.access.log
|
||||||
|
Nginx.Logs.Scenarios.Error=docker exec -it portal-nginx less /var/log/nginx/proxy.scenarios.error.log
|
||||||
Reference in New Issue
Block a user