Version 0.01

This commit is contained in:
2026-05-02 02:16:46 +04:00
commit 3265107079
9 changed files with 876 additions and 0 deletions

18
.editorconfig Normal file
View 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
View File

@@ -0,0 +1,4 @@
build/*
config.ini
menu.ini

41
Makefile Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
shell=/bin/bash
menu=/etc/cascade/menu.ini

4
examples/sshd.config Normal file
View 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

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module cascade
go 1.24.4

478
main.go Normal file
View 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
View 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