From 3265107079b563a7e92dc944f9c9265b51c861b7 Mon Sep 17 00:00:00 2001 From: Viktor Smagin Date: Sat, 2 May 2026 02:16:46 +0400 Subject: [PATCH] Version 0.01 --- .editorconfig | 18 ++ .gitignore | 4 + Makefile | 41 ++++ README.md | 252 +++++++++++++++++++++++ config.ini.example | 2 + examples/sshd.config | 4 + go.mod | 3 + main.go | 478 +++++++++++++++++++++++++++++++++++++++++++ menu.ini.example | 74 +++++++ 9 files changed, 876 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 config.ini.example create mode 100644 examples/sshd.config create mode 100644 go.mod create mode 100644 main.go create mode 100644 menu.ini.example diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..10d6821 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80ce804 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/* + +config.ini +menu.ini diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..67aabf0 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..753b349 --- /dev/null +++ b/README.md @@ -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 ` -c ""` 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 (` -c ""`). | +| `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. diff --git a/config.ini.example b/config.ini.example new file mode 100644 index 0000000..240f9fb --- /dev/null +++ b/config.ini.example @@ -0,0 +1,2 @@ +shell=/bin/bash +menu=/etc/cascade/menu.ini diff --git a/examples/sshd.config b/examples/sshd.config new file mode 100644 index 0000000..72784b4 --- /dev/null +++ b/examples/sshd.config @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d1eb8e3 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module cascade + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..1ec62c0 --- /dev/null +++ b/main.go @@ -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) + } + } + } +} diff --git a/menu.ini.example b/menu.ini.example new file mode 100644 index 0000000..496f619 --- /dev/null +++ b/menu.ini.example @@ -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