initial transfer

This commit is contained in:
Benjamin Diedrichsen
2026-07-27 13:09:00 +02:00
parent 9f25d48dc2
commit 736c01216a
191 changed files with 17622 additions and 136 deletions
+13 -136
View File
@@ -1,138 +1,15 @@
# ---> Node .vault
# Logs .vagrant
logs .python-version
node_modules
cache
vault/tmp
age.key
tsconfig.tsbuildinfo
*.log *.log
npm-debug.log* .nopy.history.json
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
*.img
*.img.gz
+9
View File
@@ -0,0 +1,9 @@
{
"hosts": [],
"cubeDirs": ["./cubes"],
"env": {},
"log": {
"verbosity": "info",
"debug": false
}
}
+2
View File
@@ -0,0 +1,2 @@
/cache
/project.local.yml
+133
View File
@@ -0,0 +1,133 @@
# the name by which the project can be referenced within Serena
project_name: "ansiblingz"
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# svelte swift systemverilog terraform toml
# typescript typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
Vendored
+22
View File
@@ -0,0 +1,22 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.provider "vmware_desktop" do |vmware|
vmware.gui = false
vmware.allowlist_verified = true
end
config.vm.box = "bento/ubuntu-24.04" # Use Ubuntu 24.04 box
config.ssh.insert_key = false
config.vm.box_check_update = false
config.vm.hostname = "ansiblingsvm"
config.vm.network "forwarded_port", guest: 3567, host: 3567, auto_correct: true
config.vm.network "forwarded_port", guest: 80, host: 80, auto_correct: false
config.vm.network "forwarded_port", guest: 443, host: 443, auto_correct: false
# Configure SSH with public key authentication
#config.vm.provision "shell", inline: <<-SHELL
# mkdir -p ~/.ssh
# echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan" >> ~/.ssh/authorized_keys
# chmod 600 ~/.ssh/authorized_keys
#SHELL
end
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": ["dist", "node_modules", ".yarn", "*.lock"]
},
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off"
},
"suspicious": {
"noExplicitAny": "off"
},
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "es5"
}
}
}
+76
View File
@@ -0,0 +1,76 @@
# cockpit
**Install Cockpit web-based server management interface**
## Purpose
This cube installs Cockpit, a powerful web-based interface for managing Linux servers, making system administration accessible through your browser.
## What is Cockpit?
Cockpit is a modern, interactive server admin interface that runs in your web browser. It provides:
- **Real-time monitoring**: CPU, memory, disk, and network usage graphs
- **Container management**: View and manage Docker containers
- **Service management**: Start, stop, and manage systemd services
- **Storage administration**: Manage disks, RAID, and filesystems
- **Network configuration**: Configure network interfaces and firewall
- **Terminal access**: Built-in terminal for command-line access
- **User management**: Create and manage user accounts
- **Software updates**: View and apply system updates
Think of it as a control panel for your Linux server - all accessible from any web browser.
## What This Cube Does
1. Installs the `cockpit` package
2. Installs `sscg` (Simple Signed Certificate Generator) for HTTPS support
3. Starts the Cockpit service
4. Makes Cockpit accessible on port 9090
## Configuration
This cube currently has no configurable parameters.
## Dependencies
None - this cube can run standalone.
## Post-Installation
Access Cockpit by navigating to:
```
https://your-server-ip:9090
```
Login with any valid system user account (e.g., root or a user created with the `user-add` cube).
### Security Notes
- Cockpit uses HTTPS by default (self-signed certificate)
- Your browser will show a security warning on first access (expected with self-signed certs)
- Consider using UFW to restrict access: `sudo ufw allow from YOUR_IP to any port 9090`
- Disable Cockpit when not in use: `sudo systemctl stop cockpit.socket`
## Common Use Cases
- Monitor server performance in real-time
- Manage Docker containers without command-line
- View system logs and troubleshoot issues
- Configure network settings
- Apply system updates
- Manage storage and filesystems
## Managing Cockpit
Start/stop Cockpit:
```bash
sudo systemctl start cockpit.socket
sudo systemctl stop cockpit.socket
sudo systemctl status cockpit.socket
```
Disable Cockpit from starting on boot:
```bash
sudo systemctl disable cockpit.socket
```
+13
View File
@@ -0,0 +1,13 @@
from pyinfra import host
from pyinfra.operations import server, apt
apt.packages(
packages=[ "sscg cockpit"],
present=True,
_sudo=True
)
server.service(
'cockpit',
running=True,
)
+8
View File
@@ -0,0 +1,8 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'admin:cockpit',
name: 'Install cockpit and utils',
dependencies: () => [],
});
+34
View File
@@ -0,0 +1,34 @@
from pyinfra import host
from pyinfra.operations import server, files
"""
Deployment script for the admin:hostname cube.
Uses pyinfra's server.hostname operation to set and persist the system hostname.
"""
HOSTNAME = host.data.HOSTNAME
if HOSTNAME:
server.hostname(
name=f"Set system hostname to {HOSTNAME}",
hostname=HOSTNAME,
_sudo=True,
)
# 2. Update /etc/hosts to prevent "unable to resolve host" errors
# This looks for the line starting with 127.0.1.1 and replaces it entirely
files.line(
name="Update /etc/hosts for local resolution",
path="/etc/hosts",
line=r"^127\.0\.1\.1\s+.*",
replace=f"127.0.1.1 {HOSTNAME}",
_sudo=True,
)
# 3. Restart Avahi (Network Broadcast)
# This pushes the name change out to the shared network
server.service(
name="Restart Avahi to broadcast new mDNS name",
service="avahi-daemon",
restarted=True,
_sudo=True,
)
+16
View File
@@ -0,0 +1,16 @@
import { z } from 'zod';
import { cubes, uniqid } from '@bitstack/nopy';
/**
* Manifest for the admin:hostname cube.
* This cube allows for setting and persistently changing the system's hostname.
*/
export default cubes.Manifest({
id: 'admin:hostname',
name: 'Permanently change the hostname',
dependencies: () => [],
schema: z.object({
HOSTNAME: z.string().min(1).max(64).describe('The new hostname for the target host')
.default(`host-${uniqid()}`),
})
});
+23
View File
@@ -0,0 +1,23 @@
# Cube: admin/locale
Configures system keyboard layout permanently by updating `/etc/default/keyboard` and using `localectl`.
## Configuration
- `LAYOUT` (string): Keyboard layout (e.g. "ch", "us", "de"). Default: "ch".
- `MODEL` (string): Keyboard model. Default: "pc105".
- `VARIANT` (string): Keyboard variant. Default: "".
- `OPTIONS` (string): Keyboard options (comma separated). Default: "".
## Usage
```javascript
import { cubes } from '@bitstack/nopy';
export default cubes.Manifest({
name: 'My Host Setup',
dependencies: () => [
['admin:locale', { LAYOUT: 'de' }]
]
});
```
+55
View File
@@ -0,0 +1,55 @@
from pyinfra import host
from pyinfra.operations import server, files
LAYOUT = host.data.LAYOUT
MODEL = host.data.MODEL
VARIANT = host.data.VARIANT
OPTIONS = host.data.OPTIONS
# Update /etc/default/keyboard
files.line(
name="Update XKBMODEL in /etc/default/keyboard",
path="/etc/default/keyboard",
line=r'^XKBMODEL=.*',
replace=f'XKBMODEL="{MODEL}"',
_sudo=True,
)
files.line(
name="Update XKBLAYOUT in /etc/default/keyboard",
path="/etc/default/keyboard",
line=r'^XKBLAYOUT=.*',
replace=f'XKBLAYOUT="{LAYOUT}"',
_sudo=True,
)
files.line(
name="Update XKBVARIANT in /etc/default/keyboard",
path="/etc/default/keyboard",
line=r'^XKBVARIANT=.*',
replace=f'XKBVARIANT="{VARIANT}"',
_sudo=True,
)
files.line(
name="Update XKBOPTIONS in /etc/default/keyboard",
path="/etc/default/keyboard",
line=r'^XKBOPTIONS=.*',
replace=f'XKBOPTIONS="{OPTIONS}"',
_sudo=True,
)
# Apply keyboard configuration
server.shell(
name="Apply keyboard setup",
commands=["setupcon", "service keyboard-setup restart"],
_sudo=True,
)
# Set X11 keyboard layout using localectl if available
server.shell(
name="Set X11 keyboard layout using localectl",
commands=[f"localectl set-x11-keymap {LAYOUT} {MODEL} '{VARIANT}' '{OPTIONS}'"],
_sudo=True,
_ignore_errors=True,
)
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
import { cubes } from '@bitstack/nopy';
export default cubes.Manifest({
id: 'admin:locale',
name: 'Configure system locale and keyboard layout',
dependencies: () => [],
schema: z.object({
LAYOUT: z.string().describe('Keyboard layout (e.g. "ch", "us", "de")').default('ch'),
MODEL: z.string().describe('Keyboard model').default('pc105'),
VARIANT: z.string().describe('Keyboard variant').default(''),
OPTIONS: z.string().describe('Keyboard options (comma separated)').default(''),
}),
});
+39
View File
@@ -0,0 +1,39 @@
# apt:essentials
**Install essential packages**
## Purpose
This cube installs a curated collection of essential development tools and utilities that are commonly needed for server environments and development workflows.
## What This Cube Does
Installs the following packages via apt:
- **fish** - User-friendly command-line shell with autosuggestions and syntax highlighting
- **ranger** - Terminal-based file manager with vi key bindings
- **golang-go** - Go programming language compiler and tools
- **build-essential** - Essential compilation tools (gcc, g++, make, etc.)
- **python3** - Python 3 interpreter and standard library
- **pkg-config** - Helper tool for compiling applications and libraries
- **age** - Modern file encryption tool with small explicit keys
## Configuration
### Parameters
- **UPDATE** (boolean, default: `true`)
- If already installed, should packages be updated?
- Set to `false` to skip package cache updates and only install missing packages
## Dependencies
None - this cube can run standalone.
## Use Cases
This cube is ideal as a base dependency for other cubes that require:
- Basic development tools
- Modern shell environments
- File encryption capabilities
- Python or Go runtimes
+18
View File
@@ -0,0 +1,18 @@
from pyinfra.operations import apt
from pyinfra import host
UPDATE = host.data.UPDATE
apt.packages(
name='Install essentials',
packages=[
'fish',
'build-essential',
'python3',
'pkg-config',
'age'
],
update=UPDATE,
_sudo=True
)
+11
View File
@@ -0,0 +1,11 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'apt:essentials',
name: 'Install essential packages',
dependencies: () => [],
schema: z.object({
UPDATE: z.boolean().describe('If already installed should packages be updated').default(true),
}),
});
+62
View File
@@ -0,0 +1,62 @@
# apt
**Install packages with apt**
## Purpose
This is a generic cube for installing custom packages via the apt package manager. It's useful when you need to install specific packages that aren't covered by other specialized cubes.
## What This Cube Does
1. Optionally updates the apt package cache
2. Installs the specified space-separated list of packages
## Configuration
### Parameters
- **UPDATE** (boolean, default: `true`)
- Update package cache before installing
- Set to `false` to skip updating and only install packages
- **PACKAGES** (string, default: `''`)
- Space-separated list of packages to install
- Example: `"vim git htop curl wget"`
## Dependencies
None - this cube can run standalone.
## Use Cases
Install development tools:
```
PACKAGES="vim git htop tmux"
```
Install database clients:
```
PACKAGES="postgresql-client mysql-client redis-tools"
```
Install system utilities:
```
PACKAGES="curl wget jq unzip zip"
```
## Example
When deploying this cube, you would typically configure it like:
```javascript
exec('apt', {
PACKAGES: 'nginx certbot python3-certbot-nginx',
UPDATE: true
})
```
## Notes
- Package names must match exact apt package names
- Invalid package names will cause the installation to fail
- Use `apt search <package>` to find package names
- Some packages may require additional configuration after installation
+31
View File
@@ -0,0 +1,31 @@
from pyinfra.operations import apt
from pyinfra import host
UPDATE = host.data.UPDATE
PACKAGES = str(host.data.PACKAGES).split(' ')
apt.packages(
name='Install essential packages',
packages=[
'htop',
'age',
'git',
'curl',
'nano',
'wget',
'ca-certificates',
'ufw',
"gnupg",
"lsb-release"
],
update=UPDATE,
_sudo=True
)
apt.packages(
name='Install custom packages',
packages=[p.strip() for p in PACKAGES if p],
update=UPDATE,
_sudo=True
)
+12
View File
@@ -0,0 +1,12 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'apt:install',
name: 'Install packages with apt',
dependencies: () => [],
schema: z.object({
UPDATE: z.boolean().describe('Update package cache before installing').default(false),
PACKAGES: z.string().describe('Space-separated list of packages to install').default('vim htop curl'),
}),
});
+41
View File
@@ -0,0 +1,41 @@
# armor-fail2ban
**Install and enable fail2ban**
## Purpose
This cube installs and configures Fail2ban, an intrusion prevention software that protects your server from brute-force attacks and unauthorized access attempts.
## What is Fail2ban?
Fail2ban monitors log files (e.g., `/var/log/auth.log`) for suspicious activity, such as repeated failed login attempts. When it detects malicious behavior patterns, it automatically:
- Bans the offending IP address by updating firewall rules
- Prevents the attacker from making further connection attempts
- Can send email notifications about bans (if configured)
Common use cases include:
- Protecting SSH from brute-force password attacks
- Blocking repeated failed login attempts on web applications
- Preventing DoS attacks from specific IP addresses
## What This Cube Does
1. Installs the `fail2ban` package via apt
2. Deploys a custom configuration file (`jail.local`) to `/etc/fail2ban/jail.local`
3. Configures fail2ban with sensible defaults for common services
## Configuration
This cube currently has no configurable parameters. The default configuration is applied from the included `jail.local` file.
## Dependencies
None - this cube can run standalone.
## Notes
After deployment, you can:
- Check fail2ban status: `sudo fail2ban-client status`
- View banned IPs: `sudo fail2ban-client status sshd`
- Unban an IP: `sudo fail2ban-client set sshd unbanip <IP_ADDRESS>`
+18
View File
@@ -0,0 +1,18 @@
from pyinfra.operations import apt, files, server
# Install Fail2ban
apt.packages(
name='Install Fail2ban',
packages=['fail2ban'],
update=True,
_sudo=True
)
# Configure Fail2ban
files.put(
name='Configure Fail2ban',
src='jail.local',
dest='/etc/fail2ban/jail.local',
mode='0644',
_sudo=True
)
+8
View File
@@ -0,0 +1,8 @@
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
findtime = 600
bantime = 3600
+7
View File
@@ -0,0 +1,7 @@
import { cubes } from '@bitstack/nopy';
export default cubes.Manifest({
id: 'armor:fail2ban',
name: 'Install and enable fail2ban',
dependencies: () => [],
});
+83
View File
@@ -0,0 +1,83 @@
# armor-ssh
**Secure SSH server by disabling password authentication**
## Purpose
This cube hardens your SSH server configuration by disabling less secure authentication methods, enforcing SSH key-based authentication only.
## Why Disable Password Authentication?
Password-based SSH authentication is vulnerable to:
- **Brute-force attacks**: Automated scripts trying millions of password combinations
- **Dictionary attacks**: Guessing common passwords
- **Credential stuffing**: Using leaked passwords from other breaches
- **Weak passwords**: Users choosing easily guessable passwords
**SSH key authentication is more secure** because:
- Keys are cryptographically strong (2048+ bit keys vs 8-12 character passwords)
- Private keys never travel over the network
- Immune to brute-force attacks
- Can be protected with passphrases for additional security
## What This Cube Does
1. **Disables challenge-response authentication**
- Prevents keyboard-interactive authentication prompts
2. **Optionally disables password authentication** (default: enabled)
- Forces users to authenticate with SSH keys only
- Prevents password-based login attempts
3. **Optionally disables PAM** (Pluggable Authentication Modules)
- Disables PAM-based authentication methods
- Reduces attack surface
4. **Restarts SSH service**
- Applies the new configuration immediately
## Configuration
### Parameters
- **DISABLE_PASSWORD** (boolean, default: `true`)
- Disable password authentication for SSH connections
- ⚠️ **WARNING**: Ensure you have SSH key access configured before enabling this!
- **DISABLE_PAM** (boolean, default: `true`)
- Disable PAM (Pluggable Authentication Modules) for SSH
- Recommended for key-only authentication setups
## Dependencies
None - this cube can run standalone.
## Security Best Practices
**Before deploying this cube**:
1. Ensure you have SSH key authentication set up and tested
2. Keep an alternative access method available (console access, VNC, etc.)
3. Test SSH key login before disabling passwords
4. Consider using the `user-add` or `ssh-keyman` cubes first
**After deployment**:
- Only SSH key authentication will work
- Password login attempts will be rejected
- Make sure to back up your private SSH key securely
## Post-Installation
The SSH service will restart automatically. Your current SSH session will remain active, but new connections must use SSH keys.
To verify the configuration:
```bash
sudo grep -E "PasswordAuthentication|ChallengeResponseAuthentication|UsePAM" /etc/ssh/sshd_config
```
## Recovery
If you get locked out:
1. Access the server via console (physical or cloud provider's web console)
2. Edit `/etc/ssh/sshd_config`
3. Set `PasswordAuthentication yes`
4. Restart SSH: `sudo systemctl restart ssh`
+43
View File
@@ -0,0 +1,43 @@
from pyinfra.operations import files, server
from pyinfra import host
from pyinfra import config
import logging
DISABLE_PASSWORD=host.data.DISABLE_PASSWORD
DISABLE_PAM=host.data.DISABLE_PAM
logger = logging.getLogger(__name__)
config.SUDO = True
files.line(
name='Disable challenge-response authentication in SSH',
path='/etc/ssh/sshd_config',
line='ChallengeResponseAuthentication yes',
replace='ChallengeResponseAuthentication no',
)
if DISABLE_PASSWORD:
files.line(
name='Disable password authentication in SSH',
path='/etc/ssh/sshd_config',
line='PasswordAuthentication yes',
replace='PasswordAuthentication no',
)
else:
logger.info('Password authentication allowed')
if DISABLE_PAM:
files.line(
name='Disable PAM in SSH',
path='/etc/ssh/sshd_config',
line='UsePAM yes',
replace='UsePAM no',
)
# Restart SSH service
server.service(
'ssh',
running=True,
restarted=True,
reloaded=True
)
+18
View File
@@ -0,0 +1,18 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'armor:ssh',
name: 'Secure SSH server by disabling password authentication',
dependencies: () => [],
schema: z.object({
DISABLE_PASSWORD: z
.boolean()
.describe('Disable password authentication for SSH connections')
.default(true),
DISABLE_PAM: z
.boolean()
.describe('Disable PAM (Pluggable Authentication Modules) for SSH')
.default(true),
}),
});
+77
View File
@@ -0,0 +1,77 @@
# armor-ufw
**Activate UFW (Uncomplicated Firewall)**
## Purpose
This cube configures and enables UFW, a user-friendly firewall management tool for Linux systems, providing basic protection against unauthorized network access.
## What is UFW?
UFW (Uncomplicated Firewall) is a frontend for `iptables` designed to make firewall configuration simple and accessible. It provides:
- **Easy-to-understand syntax**: Commands like `ufw allow ssh` instead of complex iptables rules
- **Default deny policy**: Blocks all incoming connections except those explicitly allowed
- **Connection tracking**: Automatically handles related and established connections
- **Application profiles**: Pre-configured rules for common services
Think of UFW as a security gate for your server - it controls which network traffic is allowed in and out.
## What This Cube Does
1. Configures UFW to allow SSH connections (port 22)
- Ensures you don't lock yourself out when enabling the firewall
2. Optionally allows HTTP traffic (port 80) based on the `ALLOW_HTTP` parameter
3. Enables the firewall with the configured rules
## Configuration
### Parameters
- **ALLOW_HTTP** (boolean, default: `true`)
- Allow incoming HTTP traffic on port 80
- Set to `false` if you're only using HTTPS or don't need web traffic
## Dependencies
- **apt:essentials** - Required for basic system tools
## Security Notes
**Important**: This cube automatically allows SSH to prevent lockouts. If you need to allow additional services, you can run:
```bash
sudo ufw allow [port number]/[protocol]
sudo ufw allow [service-name]
```
Examples:
- `sudo ufw allow 443/tcp` - Allow HTTPS
- `sudo ufw allow 3000/tcp` - Allow custom application port
- `sudo ufw allow https` - Allow HTTPS by service name
## Post-Installation
Check firewall status:
```bash
sudo ufw status verbose
sudo ufw status numbered
```
Common UFW commands:
- Delete rule: `sudo ufw delete [rule number]`
- Disable firewall: `sudo ufw disable`
- Reset to defaults: `sudo ufw reset`
## UFW File Locations
UFW rules are stored in the `/etc/ufw` directory:
- `/etc/ufw/user.rules` - Custom rules added via the `ufw` command
- `/etc/ufw/before.rules` - Rules processed before user rules (high priority)
- `/etc/ufw/after.rules` - Rules processed after user rules (exceptions)
- `/etc/ufw/sysctl.conf` - Kernel network parameters (e.g., packet forwarding)
- `/etc/ufw/applications.d/` - Application profiles for common services
- `/etc/default/ufw` - Global UFW settings and default policies
Understanding these locations is helpful for troubleshooting, manual edits, or backing up your firewall configuration.
+13
View File
@@ -0,0 +1,13 @@
from pyinfra.operations import server
from pyinfra import host
ALLOW_HTTP=host.data.ALLOW_HTTP
server.shell(
commands=[
f"ufw allow ssh",
f"ufw allow http" if ALLOW_HTTP else "",
f"ufw enable",
],
_sudo=True
)
+11
View File
@@ -0,0 +1,11 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'armor:ufw',
name: 'Activate ufw (uncomplicated firewall)',
dependencies: () => ['apt:essentials'],
schema: z.object({
ALLOW_HTTP: z.boolean().describe('Allow incoming HTTP traffic on port 80').default(true),
}),
});
+120
View File
@@ -0,0 +1,120 @@
# caddy
**Install Caddy webserver**
## Purpose
This cube installs Caddy, a modern, powerful web server with automatic HTTPS that's designed to be easy to use and configure.
## What is Caddy?
Caddy is a next-generation web server that stands out for its simplicity and built-in security:
- **Automatic HTTPS**: Automatically obtains and renews SSL/TLS certificates from Let's Encrypt
- **Modern HTTP features**: HTTP/2, HTTP/3 (QUIC) support out of the box
- **Simple configuration**: Human-readable Caddyfile format
- **Reverse proxy**: Easy proxying to backend applications
- **Static file serving**: Fast and efficient static site hosting
- **Zero-downtime reloads**: Update config without dropping connections
## What This Cube Does
1. **Adds Caddy's official repository**
- Installs required dependencies (debian-keyring, apt-transport-https, curl)
- Downloads and installs Caddy's GPG signing key
- Configures APT to use Caddy's official stable repository
2. **Installs Caddy**
- Installs the latest stable version of Caddy
- Sets up the Caddy service
3. **Configures TLS**
- Creates a Caddyfile with a reusable TLS snippet
- Configures TLS based on the `TLS` parameter
## Configuration
### Parameters
- **TLS** (string, default: `''`)
- TLS certificate configuration
- **Options**:
- `''` (empty string) - Automatic HTTPS with Let's Encrypt (recommended)
- `'internal'` - Use Caddy's internal CA for self-signed certs (testing only)
- `'/path/to/cert /path/to/key'` - Provide custom certificate paths
## Dependencies
None - this cube can run standalone.
## TLS Configuration Examples
**Automatic HTTPS (Production)**:
```javascript
exec('caddy', { TLS: '' })
```
Caddy will automatically obtain SSL certificates from Let's Encrypt for your domain.
**Self-Signed for Testing**:
```javascript
exec('caddy', { TLS: 'internal' })
```
Uses Caddy's internal CA. Browsers will show security warnings.
**Custom Certificates**:
```javascript
exec('caddy', { TLS: '/etc/ssl/certs/mycert.pem /etc/ssl/private/mykey.pem' })
```
Use your own certificate and private key files.
## Post-Installation
The Caddyfile is created at `/etc/caddy/Caddyfile` with a reusable TLS snippet:
```
(tls_cert) {
tls {TLS}
}
```
Other cubes (like `caddy-spa`) can import this snippet with `import tls_cert`.
## Managing Caddy
Start/stop/restart Caddy:
```bash
sudo systemctl start caddy
sudo systemctl stop caddy
sudo systemctl restart caddy
sudo systemctl status caddy
```
Reload configuration without downtime:
```bash
sudo systemctl reload caddy
```
Test configuration:
```bash
caddy validate --config /etc/caddy/Caddyfile
```
## Common Use Cases
- Reverse proxy for Node.js/Python/Go apps
- Static website hosting
- API gateway
- Load balancer
- SSL/TLS termination
## Notes
- Caddy runs on ports 80 (HTTP) and 443 (HTTPS) by default
- Ensure these ports are open in your firewall (UFW)
- For automatic HTTPS, your domain must point to your server's IP
- Caddy automatically redirects HTTP to HTTPS when using automatic HTTPS
## Additional Resources
- [Caddy Documentation](https://caddyserver.com/docs/)
- [Caddyfile Tutorial](https://caddyserver.com/docs/caddyfile-tutorial)
+34
View File
@@ -0,0 +1,34 @@
from pyinfra.operations import apt, server, files
from pyinfra import host
from io import StringIO
# 🔹 Variables
TLS = host.data.TLS
server.shell(
commands=[
f"sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl",
f"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg",
f"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list"
]
)
apt.packages(
name='Install caddy',
packages=['caddy'],
update=True,
_sudo=True
)
TLS_BLOCK = f"""
(tls_cert) {{
tls {TLS}
}}
"""
files.put(
src = StringIO(TLS_BLOCK), # local filename to upload,
dest = '/etc/caddy/Caddyfile', # the remote filename to upload to
_sudo=True
)
+19
View File
@@ -0,0 +1,19 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'caddy',
name: 'Install Caddy webserver',
dependencies: () => [],
schema: z.object({
// tls /path/to/cert.pem /path/to/key.pem
TLS: z
.string()
.default('')
.describe(`
<empty-string> --> enabled and automatically managed
internal --> use Caddy custom root CA for self-signed certs (for testing purpose)
/path/to/cert /path/to/key --> Provide custom certificates
`),
}),
});
+137
View File
@@ -0,0 +1,137 @@
# caddy-spa
**Configure Caddy to serve a Single Page Application**
## Purpose
This cube adds a reverse proxy configuration to Caddy for serving a Single Page Application (SPA) from a local backend server, with automatic HTTPS support.
## What This Cube Does
1. **Adds domain configuration to Caddyfile**
- Creates a site block for the specified domain
- Configures reverse proxy to forward traffic to local application
- Imports TLS configuration from the `caddy` cube
2. **Configures reverse proxy**
- Proxies all requests to `localhost:{PORT}`
- Preserves headers and client information
- Handles WebSocket connections
3. **Restarts Caddy service**
- Applies the new configuration immediately
## Configuration
### Parameters
- **DOMAIN** (string, default: `''`)
- Domain name for the SPA application
- Example: `'myapp.example.com'`
- Must have DNS pointing to your server's IP
- **PORT** (number, default: `5432`)
- Port number where the SPA will be served
- Your application should be listening on this port locally
## Dependencies
- **caddy** cube (implicitly required) - Must be installed first to provide the `tls_cert` snippet
## What Gets Configured
This cube adds the following to `/etc/caddy/Caddyfile`:
```
# BEGIN DOMAIN myapp.example.com
myapp.example.com {
import tls_cert
reverse_proxy localhost:5432
}
# END myapp.example.com
```
## Use Cases
**Deploy a React/Vue/Angular app**:
```javascript
exec('caddy-spa', {
DOMAIN: 'app.example.com',
PORT: 3000
})
```
**Deploy multiple SPAs**:
```javascript
exec('caddy-spa', { DOMAIN: 'app1.example.com', PORT: 3000 })
exec('caddy-spa', { DOMAIN: 'app2.example.com', PORT: 3001 })
exec('caddy-spa', { DOMAIN: 'app3.example.com', PORT: 3002 })
```
## How It Works
1. User visits `https://myapp.example.com`
2. Caddy receives the request on port 443 (HTTPS)
3. Caddy automatically handles SSL/TLS encryption
4. Request is forwarded to `localhost:5432`
5. Your application receives the request and returns a response
6. Caddy sends the encrypted response back to the user
## Prerequisites
Before deploying this cube:
1. **Install the caddy cube first**
- Provides the base Caddy installation and TLS configuration
2. **Ensure your application is running**
- Your SPA backend should be listening on the specified PORT
- Example: `npm start` or `pm2 start app.js`
3. **Configure DNS**
- Point your domain's A record to your server's IP address
- Wait for DNS propagation (can take a few minutes to hours)
4. **Open firewall ports**
- Ensure ports 80 and 443 are open (for automatic HTTPS)
- `sudo ufw allow 80/tcp`
- `sudo ufw allow 443/tcp`
## Post-Installation
Verify the configuration:
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
```
Check Caddy status:
```bash
sudo systemctl status caddy
```
View Caddy logs:
```bash
sudo journalctl -u caddy -f
```
## Common Issues
**502 Bad Gateway**:
- Your application isn't running on the specified PORT
- Check: `netstat -tlnp | grep {PORT}`
**Certificate errors**:
- DNS not pointing to your server
- Ports 80/443 blocked by firewall
- Check Caddy logs: `sudo journalctl -u caddy -f`
**Domain not resolving**:
- DNS propagation not complete yet
- Verify with: `dig +short myapp.example.com`
## Notes
- Caddy automatically obtains and renews Let's Encrypt certificates
- The reverse proxy preserves the original client IP and headers
- WebSocket connections are automatically supported
- You can add multiple domains by running this cube multiple times with different parameters
+32
View File
@@ -0,0 +1,32 @@
from pyinfra.operations import apt, server, files
from pyinfra import host
from io import StringIO
# 🔹 Variables (Modify as Needed)
DOMAIN = host.data.DOMAIN
PORT = host.data.PORT
site_block = f"""# BEGIN DOMAIN {DOMAIN}
{DOMAIN} {{
import tls_cert
reverse_proxy localhost:{PORT}
}}
# END {DOMAIN}
"""
files.block(
path = '/etc/caddy/Caddyfile',
content = site_block,
present = True,
before = False,
after = False,
_sudo = True
)
server.service(
service='caddy',
running=True,
restarted=True,
_sudo=True
)
+12
View File
@@ -0,0 +1,12 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'caddy:spa',
name: 'Install single page application',
dependencies: () => [],
schema: z.object({
DOMAIN: z.string().describe('Domain name for the SPA application').default(''),
PORT: z.number().describe('Port number where the SPA will be served').default(5432),
}),
});
+18
View File
@@ -0,0 +1,18 @@
from pyinfra.operations import server
from pyinfra import host
USER = host.data.USER
REPO = host.data.REPO
APP = host.data.APP
server.shell(
name="Clone application",
commands=[
f"""
cd $HOME &&
git clone --recurse-submodules {REPO} {APP}
"""],
_sudo=True,
_sudo_user=USER,
_use_sudo_login=True
)
+13
View File
@@ -0,0 +1,13 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'git:clone',
name: 'Clone a repository',
dependencies: () => [],
schema: z.object({
USER: z.string().describe('Username for which to clone the repository').default('vagrant'),
REPO: z.string().default(''),
APP: z.string().describe('The internal name used for this application').default(''),
}),
});
+27
View File
@@ -0,0 +1,27 @@
# Tailscale Cube
Installs and authenticates the Tailscale client on a Linux host.
## Features
- **Automated Installation**: Adds the official Tailscale repository and installs the package.
- **Headless Authentication**: Uses a Tailscale Auth Key for zero-interaction setup.
- **Headscale Support**: Can be configured to connect to a custom login server.
- **Startup persistence**: Ensures the `tailscaled` daemon is enabled and running.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `AUTH_KEY` | `""` | Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). |
| `LOGIN_SERVER` | `https://controlplane.tailscale.com` | The coordination server URL. Set this to your Headscale instance URL if applicable. |
| `EXTRA_ARGS` | `""` | Additional flags to pass to `tailscale up` (e.g., `--advertise-exit-node`). |
| `FORCE_REAUTH` | `false` | If true, forces the client to re-authenticate. |
## Usage
```bash
nopy install tailscale
```
When prompted, provide your `AUTH_KEY`. If you are using Headscale, also provide the `LOGIN_SERVER` URL.
+45
View File
@@ -0,0 +1,45 @@
from pyinfra import host
from pyinfra.operations import server, apt
# Variables from manifest
AUTH_KEY = host.data.AUTH_KEY
LOGIN_SERVER = host.data.LOGIN_SERVER
EXTRA_ARGS = host.data.EXTRA_ARGS
FORCE_REAUTH = host.data.FORCE_REAUTH
# 1. Install Tailscale using the official one-liner script
server.shell(
name="Install Tailscale",
commands=["curl -fsSL https://tailscale.com/install.sh | sh"],
_sudo=True
)
# 2. Ensure Tailscale is enabled and running
# Manually (Linux): sudo systemctl enable --now tailscaled
server.service(
name="Ensure tailscaled is running and enabled on boot",
service="tailscaled",
running=True,
enabled=True,
_sudo=True
)
# 3. Authenticate and bring Tailscale up
# We use --authkey for headless mode
# We use --login-server if it's different from the default
up_command = f"tailscale up --authkey {AUTH_KEY}"
if LOGIN_SERVER and LOGIN_SERVER != "https://controlplane.tailscale.com":
up_command += f" --login-server {LOGIN_SERVER}"
if FORCE_REAUTH:
up_command += " --force-reauth"
if EXTRA_ARGS:
up_command += f" {EXTRA_ARGS}"
server.shell(
name="Authenticate Tailscale (Headless)",
commands=[up_command],
_sudo=True
)
+17
View File
@@ -0,0 +1,17 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'net:tailscale',
name: 'Install and authenticate Tailscale',
dependencies: () => ['apt:essentials'],
schema: z.object({
AUTH_KEY: z.string().describe('Tailscale Auth Key for headless authentication').default(''),
LOGIN_SERVER: z
.string()
.describe('Custom login server (e.g., for Headscale)')
.default('https://controlplane.tailscale.com'),
EXTRA_ARGS: z.string().describe('Additional arguments for tailscale up').default(''),
FORCE_REAUTH: z.boolean().describe('Force re-authentication').default(false),
}),
});
+234
View File
@@ -0,0 +1,234 @@
# WiFi Access Point Cube (NetworkManager)
Configures a Linux device as a WiFi Access Point using NetworkManager's `nmcli` command. **This approach allows the device to simultaneously act as an AP AND remain connected to another WiFi network as a client.**
## Key Features
-**Dual WiFi Mode**: Acts as AP while staying connected to another WiFi
-**Modern Approach**: Uses NetworkManager (nmcli) instead of hostapd
-**Auto-start**: Configured to start on boot
-**IP Sharing**: Built-in internet sharing with `ipv4.method shared`
-**Simple Configuration**: Minimal parameters, maximum functionality
-**Supports 2.4GHz and 5GHz bands**
## Requirements
- Device with WiFi capability (e.g., Raspberry Pi 4/5)
- NetworkManager installed and running
- WiFi hardware that supports AP mode
## Configuration Parameters
### Required
- **SSID**: WiFi network name (1-32 characters)
- **PASSWORD**: WPA2 password (8-63 characters)
### Optional (with defaults)
- **NETWORK_DEVICE**: WiFi interface to use (`wlan0` default)
- **BAND**: Frequency band - `2.4GHz` or `5GHz` (auto-detected from current connection if not specified)
- **CHANNEL**: WiFi channel (auto-detected from current connection if not specified)
- 2.4GHz: 1-14
- 5GHz: 36-165
- **IP_ADDRESS**: AP gateway address (`192.168.50.1` default)
- **CONNECTION_NAME**: NetworkManager connection name (`net:wifi:ap` default)
## Example Usage
### Basic Configuration
```json
{
"SSID": "MyHomeWiFi",
"PASSWORD": "SecurePass123"
}
```
### Advanced Configuration (5GHz with specific channel)
```json
{
"SSID": "FastWiFi5G",
"PASSWORD": "SuperSecure456",
"NETWORK_DEVICE": "wlan1",
"BAND": "5GHz",
"CHANNEL": 36,
"IP_ADDRESS": "10.0.0.1",
"CONNECTION_NAME": "my-hotspot"
}
```
### Auto-Detection Configuration
When `BAND` and `CHANNEL` are not specified, the script will automatically detect the current AP's band and channel using `iw dev <interface> link` and use those values for the new hotspot. This is useful when you want the hotspot to operate on the same band/channel as your existing connection to avoid interference.
```json
{
"SSID": "MyAutoAP",
"PASSWORD": "SecurePass123"
}
```
## What This Cube Does
1. **Installs NetworkManager** (if not present)
2. **Enables NetworkManager service** and ensures it's running
3. **Auto-detects current AP settings** using `iw dev <interface> link` to find the band and channel
4. **Creates WiFi hotspot** using `nmcli` with auto-detected or specified settings
5. **Configures IP sharing** with `ipv4.method shared` (automatic DHCP + NAT)
6. **Enables IP forwarding** for internet routing
7. **Sets autoconnect** so the AP starts on boot
## How It Works (Dual WiFi Mode)
### Simultaneous AP + Client Mode
**Hardware Support Required:**
- Your WiFi hardware must support **simultaneous AP+STA (Station) mode**
- Most modern WiFi chips support this (e.g., Raspberry Pi 4/5, Intel WiFi cards)
- Older hardware may not support it (e.g., Raspberry Pi 3B and earlier have limitations)
**How NetworkManager Handles It:**
1. **Single Interface (e.g., wlan0):**
- If hardware supports AP+STA: ✅ **Both client and AP run on same interface**
- If hardware doesn't support it: ⚠️ **Client connection may be dropped**
2. **Multiple Interfaces (e.g., wlan0 + wlan1):**
- NetworkManager will use one for client, another for AP
- Always works regardless of hardware capabilities
**Example Scenario (Raspberry Pi 4):**
```
[Internet] <--WiFi--> [RPi4 wlan0 (Client+AP)] <--WiFi--> [Devices connect to AP]
```
**How to Check Hardware Support:**
```bash
iw list | grep -A 10 "valid interface combinations"
```
Look for: `* #{ managed } <= 1, #{ AP } <= 1` or `* #{ managed, AP } <= 2`
NetworkManager will:
- Share internet from **any** available source (WiFi client, Ethernet, cellular, etc.)
- Automatically handle routing and NAT
- Try to maintain client connection if hardware supports it
## Post-Installation
After deployment, the device will:
- ✅ Broadcast the WiFi network with your SSID
- ✅ Accept connections with your password
- ✅ Assign IP addresses to connected clients (via built-in DHCP)
- ✅ Share internet connection from any available interface
- ✅ Auto-start the AP on every boot
- ✅ Maintain client WiFi connection (if connected to another network)
## Managing the Hotspot
### View connection status
```bash
nmcli connection show
```
### Stop the hotspot
```bash
sudo nmcli connection down net:wifi:ap
```
### Start the hotspot
```bash
sudo nmcli connection up net:wifi:ap
```
### Disable autostart
```bash
sudo nmcli connection modify net:wifi:ap connection.autoconnect no
```
### Delete the hotspot
```bash
sudo nmcli connection delete net:wifi:ap
```
## Troubleshooting
### AP doesn't start
- Check NetworkManager status: `sudo systemctl status NetworkManager`
- Verify WiFi interface exists: `nmcli device status`
- Check if AP mode is supported: `iw list | grep -A 10 "Supported interface modes"`
### Can't connect to AP
- Verify password is correct (8+ characters)
- Check channel compatibility with your devices
- Try switching between 2.4GHz and 5GHz bands
### No internet on clients
- Verify host device has internet: `ping 8.8.8.8`
- Check IP forwarding: `sysctl net.ipv4.ip_forward`
- NetworkManager should handle NAT automatically with `ipv4.method shared`
### Hotspot conflicts with client WiFi
- This shouldn't happen with NetworkManager
- If it does, check if hardware supports simultaneous AP+STA mode:
```bash
iw list | grep "valid interface combinations"
```
## Advantages Over Traditional Approach
| Feature | Traditional (hostapd) | NetworkManager (nmcli) |
|---------|----------------------|------------------------|
| **Dual WiFi** | ❌ No (conflicts with wpa_supplicant) | ✅ Yes (AP + client simultaneously) |
| **Configuration** | Complex (multiple files) | Simple (one command) |
| **DHCP** | Manual (dnsmasq) | Automatic |
| **NAT** | Manual (iptables) | Automatic |
| **Management** | Multiple services | Single service |
| **Dependencies** | hostapd, dnsmasq, iptables | NetworkManager only |
## Security Notes
- Always use a strong password (minimum 8 characters)
- WPA2 encryption is automatically enabled
- NetworkManager handles firewall rules automatically
## Compatibility
Tested on:
- Raspberry Pi 4/5 with built-in WiFi
- Ubuntu/Debian-based systems with NetworkManager
- Devices with WiFi hardware supporting AP mode
**Note**: Older Raspberry Pi models (3B and earlier) may have limitations with simultaneous AP + client mode due to hardware constraints.
## Troubleshooting
```bash
sudo nmcli con add type wifi con-name MyHotspot ifname wlan0 mode ap ssid YourNewAP ipv4.method shared wifi-sec.key-mgmt wpa-psk wifi-sec.psk "YourNewPassword"
sudo nmcli con up MyHotspot
sudo nmcli con down MyHotspot
sudo nmcli con mod MyHotspot wifi.band <band> wifi.channel <channel_number>
# Find the currently used band and channel
iw dev wlan0 link
```
+70
View File
@@ -0,0 +1,70 @@
from pyinfra.operations import apt, server, systemd
from pyinfra import host
# Extract variables with proper defaults
SSID = host.data.get('SSID')
PASSWORD = host.data.get('PASSWORD')
BAND = host.data.get('BAND', '2.4GHz')
CHANNEL = host.data.get('CHANNEL') # Optional
AP_IP = host.data.get('AP_IP')
CONNECTION_NAME = host.data.get('CONNECTION_NAME')
# Determine band configuration
if BAND == '5GHz':
BAND = 'a'
default_channel = 36 if not CHANNEL else CHANNEL
else: # 2.4GHz
BAND = 'bg'
default_channel = 6 if not CHANNEL else CHANNEL
# Install NetworkManager if not present
apt.packages(
name='Install NetworkManager',
packages=['network-manager'],
update=True,
_sudo=True
)
# Ensure NetworkManager is running
systemd.service(
name='Ensure NetworkManager is running',
service='NetworkManager',
running=True,
enabled=True,
_sudo=True
)
# Check if connection already exists and delete it
server.shell(
name='Delete existing AP connection if present',
commands=[f'nmcli connection delete {CONNECTION_NAME} || true'],
_sudo=True
)
# Add Wifi AP connection
server.shell(
name='Detect WiFi interface',
commands=[
f'sudo nmcli con add type wifi con-name {CONNECTION_NAME} ifname wlan0 mode ap ssid {SSID} ipv4.method shared wifi-sec.key-mgmt wpa-psk wifi-sec.psk "{PASSWORD}"',
# CRITICAL: Force WPA2 (rsn) instead of WPA (wpa)
f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.proto rsn',
f'sudo nmcli connection modify {CONNECTION_NAME} ipv4.addresses {AP_IP}/24',
# Use AES encryption (CCMP) instead of TKIP
f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.pairwise ccmp',
f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.group ccmp',
# Set band
f'sudo nmcli connection modify {CONNECTION_NAME} 802-11-wireless.band {BAND}',
# Enable autostart
f'sudo nmcli connection modify {CONNECTION_NAME} connection.autoconnect yes',
f'sudo nmcli connection modify {CONNECTION_NAME} connection.autoconnect-priority 10',
# Start the AP
f'sudo nmcli connection up {CONNECTION_NAME}'
],
_sudo=True
)
@@ -0,0 +1,20 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'net:wifi:access-point',
name: 'Configure WiFi Access Point (NetworkManager)',
dependencies: () => [],
schema: z.object({
SSID: z.string().min(1).max(32).default('PiPoint').describe('WiFi network name (SSID)'),
PASSWORD: z
.string()
.min(8)
.max(63)
.default('1223334444')
.describe('WPA2 password (8-63 characters)'),
BAND: z.enum(['2.4GHz', '5GHz']).default('2.4GHz').describe('Frequency band'),
AP_IP: z.string().default('192.168.4.1').describe('AP IP address'),
CONNECTION_NAME: z.string().default('pi-point').describe('NetworkManager connection name'),
}),
});
+54
View File
@@ -0,0 +1,54 @@
# network:wifi:connection
**Configure a WiFi client connection using NetworkManager**
## Purpose
This cube allows you to connect your target host (e.g., a Raspberry Pi or a laptop) to an existing WiFi network using `nmcli`.
## What This Cube Does
1. Ensures `network-manager` is installed and running
2. Removes any existing connection with the same name to avoid conflicts
3. Connects to the specified `SSID` using the provided `PASSWORD`
4. Configures the connection to automatically connect on boot (optional)
## Configuration
| Variable | Type | Description | Required | Default |
| :--- | :--- | :--- | :--- | :--- |
| `SSID` | `string` | The WiFi network name | Yes | - |
| `PASSWORD` | `string` | The WiFi password | Yes | - |
| `AUTOCONNECT` | `boolean` | Automatically connect to this network | No | `true` |
| `CONNECTION_NAME` | `string` | Name for the connection in NetworkManager | No | `SSID` |
## Dependencies
- `apt/essentials`: Basic system utilities.
## Usage
### Simple Connection
```bash
nopy install network:wifi:connection --env SSID="MyHomeWiFi" --env PASSWORD="mysecurepassword"
```
### Connection with Custom Name and No Autoconnect
```bash
nopy install network:wifi:connection --env SSID="OfficeWiFi" --env PASSWORD="password123" --env CONNECTION_NAME="Work" --env AUTOCONNECT=false
```
## Security Notes
- WiFi passwords will be stored in `/etc/NetworkManager/system-connections/` on the target host.
- Passing passwords via `--env` may leave them in your local shell history.
## Troubleshooting
You can check the status of your WiFi connections on the target host with:
```bash
nmcli connection show
nmcli device status
```
+43
View File
@@ -0,0 +1,43 @@
from pyinfra import host
from pyinfra.operations import apt, server, systemd
# [agnt://cogen/cogen/network-wifi-connection-2]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
"""
Deployment script for network:wifi:connection.
Uses nmcli to configure a WiFi client connection.
"""
SSID = host.data.SSID
PASSWORD = host.data.PASSWORD
AUTOCONNECT = "yes" if host.data.get('AUTOCONNECT', True) else "no"
CONNECTION_NAME = host.data.get('CONNECTION_NAME', SSID)
# Install NetworkManager if not present
apt.packages(
name='Install NetworkManager',
packages=['network-manager'],
update=True,
_sudo=True
)
# Ensure NetworkManager is running
systemd.service(
name='Ensure NetworkManager is running',
service='NetworkManager',
running=True,
enabled=True,
_sudo=True
)
# Add/Update the WiFi connection
# We delete first to ensure a clean state with the new password/settings
server.shell(
name=f"Configure WiFi connection for {SSID}",
commands=[
f'nmcli connection delete "{CONNECTION_NAME}" || true',
f'nmcli device wifi connect "{SSID}" password "{PASSWORD}" name "{CONNECTION_NAME}"',
f'nmcli connection modify "{CONNECTION_NAME}" connection.autoconnect {AUTOCONNECT}',
],
_sudo=True
)
@@ -0,0 +1,19 @@
import { z } from 'zod';
import { cubes } from '@bitstack/nopy';
// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
/**
* Manifest for the network:wifi:connection cube.
* Configures a WiFi client connection using NetworkManager (nmcli).
*/
export default cubes.Manifest({
id: 'net:wifi:connection',
name: 'network:wifi:connection - Connect to a WiFi network',
schema: z.object({
SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'),
PASSWORD: z.string().min(8).describe('The password for the WiFi network'),
AUTOCONNECT: z.boolean().default(true).describe('Whether to automatically connect to this network'),
CONNECTION_NAME: z.string().optional().describe('Optional name for the connection (defaults to SSID)'),
})
});
+60
View File
@@ -0,0 +1,60 @@
# docker
**Install Docker and tools**
## Purpose
This cube installs Docker Engine, Docker Compose, and popular Docker management tools to enable containerized application deployment and management.
## What is Docker?
Docker is a platform for developing, shipping, and running applications in containers. Containers package an application with all its dependencies, ensuring it runs consistently across different environments.
Key benefits:
- **Isolation**: Each container runs independently with its own filesystem, network, and processes
- **Portability**: Containers run the same way on any system that supports Docker
- **Efficiency**: Containers share the host OS kernel, making them lighter than virtual machines
- **Scalability**: Easily deploy and scale containerized applications
## What This Cube Does
1. **Adds Docker's official repository**
- Downloads and installs Docker's GPG key
- Configures APT to use Docker's official package repository
2. **Installs Docker components**
- `docker-ce` - Docker Community Edition engine
- `docker-ce-cli` - Command-line interface for Docker
- `containerd.io` - Container runtime
- `docker-buildx-plugin` - Extended build capabilities with BuildKit
- `docker-compose-plugin` - Tool for defining multi-container applications
3. **Installs management tools**
- **lazydocker** - Terminal UI for Docker and Docker Compose management
- **docker-ctop** - Container metrics and monitoring (top-like interface for containers)
## Configuration
This cube currently has no configurable parameters.
## Dependencies
None - this cube can run standalone.
## Post-Installation
After deployment:
- Add users to the `docker` group to run Docker without sudo: `sudo usermod -aG docker username`
- Start using Docker: `docker run hello-world`
- Use lazydocker for easy management: `lazydocker`
- Monitor containers: `ctop`
## Notes
The Docker daemon starts automatically on boot. You can manage it with systemd:
- Check status: `sudo systemctl status docker`
- Restart: `sudo systemctl restart docker`
## Additional Resources
- [Installation tutorial Ubuntu 24.04](https://www.cherryservers.com/blog/install-docker-ubuntu)
+75
View File
@@ -0,0 +1,75 @@
from pyinfra import host
from pyinfra.operations import server, apt
DISTRO = host.data.DISTRO
server.shell(
commands=[
"install -m 0755 -d /etc/apt/keyrings",
f"curl -fsSL https://download.docker.com/linux/{DISTRO}/gpg | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg",
"sudo chmod a+r /etc/apt/keyrings/docker.gpg",
f""" echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/{DISTRO} "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null """,
"apt update"
],
_sudo=True
)
apt.packages(
packages=[
"docker-ce",
"docker-ce-cli",
"containerd.io",
"docker-buildx-plugin",
"docker-compose-plugin",
],
present=True,
_sudo=True
)
server.shell(
name="Install Lazydocker system-wide",
commands=[
'export DIR=/usr/local/bin && curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash -s --'
],
_sudo=True
)
# Install required packages
apt.packages(
name="Install required dependencies",
packages=["ca-certificates", "curl", "gnupg", "lsb-release"],
update=True,
_sudo=True
)
# Download and store the GPG key
server.shell(
name="Download and store Azlux repo GPG key",
commands=[
"curl -fsSL https://azlux.fr/repo.gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/azlux-archive-keyring.gpg"
],
_sudo=True
)
# Add the repository to sources.list
server.shell(
name="Add Azlux repository to APT sources",
commands=[
""" echo "deb [arch="$(dpkg --print-architecture)" signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian bookworm main" | sudo tee /etc/apt/sources.list.d/azlux.list > /dev/null """,
],
_sudo=True
)
# Update APT and install docker-ctop
apt.update(
name="Update package lists",
_sudo=True
)
apt.packages(
name="Install docker-ctop",
packages=["docker-ctop"],
_sudo=True
)
+11
View File
@@ -0,0 +1,11 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'runtime:docker',
name: 'Install docker and tools',
dependencies: () => [],
schema: z.object({
DISTRO: z.enum(['ubuntu', 'debian']).default('ubuntu').describe('Linux distribution to target'),
}),
});
+83
View File
@@ -0,0 +1,83 @@
# nodevm
**Install Node.js with essential global packages**
## Purpose
This cube installs the latest LTS (Long Term Support) version of Node.js along with essential global npm packages commonly needed for development and deployment.
## What is Node.js?
Node.js is a JavaScript runtime built on Chrome's V8 engine that allows you to run JavaScript on the server. It's widely used for:
- Building web servers and APIs
- Command-line tools
- Build tools and task runners
- Real-time applications (chat, notifications)
- Microservices
## What This Cube Does
1. **Installs Node.js LTS**
- Downloads and runs the official NodeSource setup script
- Installs the latest LTS version of Node.js
- Includes npm (Node Package Manager)
2. **Installs build dependencies**
- `libssl-dev` - SSL/TLS libraries
- `libtool` - Library building tools
- `cmake` - Cross-platform build system
- `libpng-dev`, `libjpeg-dev`, `libvips-dev` - Image processing libraries
3. **Installs global npm packages**
- **npm@11.1.0** - Latest npm version
- **pm2** - Production process manager for Node.js apps
- **yarn** - Alternative package manager
- **local-web-server** - Local development web server
- **node-gyp** - Node.js native addon build tool
- **inquirer** - Interactive command-line prompts
- **execa** - Better child process execution
- **@dotenvx/dotenvx** - Environment variable management
## Configuration
This cube currently has no configurable parameters.
## Dependencies
None - this cube can run standalone.
## Post-Installation
Verify installation:
```bash
node --version
npm --version
```
Common commands:
- Run a Node.js app: `node app.js`
- Start with PM2: `pm2 start app.js`
- Install packages: `npm install <package>`
- Use yarn: `yarn add <package>`
## PM2 - Process Manager
PM2 is included for production deployments. Common PM2 commands:
```bash
pm2 start app.js # Start application
pm2 list # List running apps
pm2 stop app # Stop application
pm2 restart app # Restart application
pm2 logs # View logs
pm2 startup # Enable PM2 on boot
pm2 save # Save current process list
```
## Notes
- Node.js is installed system-wide
- Global packages are accessible to all users
- npm cache is stored in `~/.npm`
- Use `nvm` if you need multiple Node.js versions
+58
View File
@@ -0,0 +1,58 @@
from pyinfra.operations import server, apt, npm, python
from pyinfra import host
from pyinfra.facts.files import Directory
from pyinfra.facts.server import Which
hasNode = host.get_fact(Which, 'node')
VERSION = host.data.VERSION
ALIAS = host.data.ALIAS
GLOBAL_PACKAGES = host.data.GLOBAL_PACKAGES
USER = host.data.USER
apt.packages(
name=f'Install nodejs tools',
no_recommends=True,
packages=[
'build-essential',
'libssl-dev',
'libtool',
'cmake',
'libcairo2-dev',
'libpango1.0-de',
'libpng-dev',
'libgif-dev',
'libjpeg-dev',
'libvips-dev',
'librsvg2-dev',
'libpixman-1-dev',
],
_sudo = True,
)
server.shell(
commands=[
"curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash",
"omf install nvm",
f"nvm install {VERSION}",
f"nvm alias {ALIAS} {VERSION}",
"set -gx NVM_DIR $HOME/.nvm",
],
_sudo=True,
_su_user=USER,
_use_su_login=True,
_shell_executable='/usr/bin/fish'
)
server.shell(
commands=[
"npm install -g pm2 yarn local-web-server node-gyp inquirer execa @dotenvx/dotenvx"
],
_sudo=True,
_su_user=USER,
_use_su_login=True,
_shell_executable='/usr/bin/fish'
)
+20
View File
@@ -0,0 +1,20 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'runtime:nodevm',
name: 'Install nvm and nodejs with global packages',
dependencies: () => [],
schema: z.object({
VERSION: z
.nullable(z.string())
.describe('Node.js version to install. It is recommended to use semver notation')
.default('v22.20.0'),
USER: z.string().describe('Username for which to install nodejs').default('vagrant'),
ALIAS: z.string().describe('The alias for this node version').default('nodelts'),
GLOBAL_PACKAGES: z
.string()
.describe('Space-separated list of global npm packages to install')
.default('npm-check-updates'),
}),
});
+176
View File
@@ -0,0 +1,176 @@
# TypeStack Install Cube
Deploys a Node.js/TypeScript application from a Git repository as a systemd service with Docker Compose and PM2 support.
## Features
- Clones Git repository
- Installs dependencies with Yarn
- Builds the application
- Starts Docker Compose services
- Creates a systemd service for automatic startup
- Configures PM2 for process management
- Automatic restart on failure
## Requirements
- Git (for cloning repository)
- Yarn (for dependency management)
- Docker and Docker Compose
- PM2 (for process management)
- Node.js/NVM installed
- SSH key access to the repository (if using private repos)
## Configuration Parameters
### Required
- **USER**: System user to run the application (default: `teclabmin`)
- **REPO**: Git repository URL (default: `git@github.com:bennidi/teclab-flintstone.git`)
- **APP**: Application name/directory name (default: `flintstone`)
### Optional
- **ENV**: Application environment (default: `production`)
- **AUTOSTART**: Enable and start service immediately (default: `True`)
- **NODE_PATH**: Path to Node.js binaries (default: `/home/teclabmin/.nvm/versions/node/v21.7.3/bin`)
## Example Usage
### Basic Configuration
```json
{
"USER": "myuser",
"REPO": "git@github.com:myorg/myapp.git",
"APP": "myapp"
}
```
### Advanced Configuration
```json
{
"USER": "appuser",
"REPO": "git@github.com:myorg/myapp.git",
"APP": "myapp",
"ENV": "staging",
"AUTOSTART": false,
"NODE_PATH": "/home/appuser/.nvm/versions/node/v20.0.0/bin"
}
```
## What This Cube Does
1. **Clone Repository**: Clones the specified Git repository to `/home/<USER>/<APP>`
2. **Install Dependencies**: Runs `yarn install` to install all dependencies
3. **Build Application**: Runs `yarn build` to compile the application
4. **Start Docker Services**: Runs `docker compose up -d` to start containerized services
5. **Create Startup Script**: Creates `/home/<USER>/<APP>.service.sh` that:
- Starts Docker Compose services
- Starts PM2 with ecosystem.config.js
6. **Create Systemd Service**: Creates `/etc/systemd/system/<APP>.service` that:
- Runs after Docker service
- Uses the specified user
- Configures proper environment (HOME, PATH)
- Auto-restarts on failure
7. **Enable & Start Service**: Enables and starts the service (if AUTOSTART=True)
## Service Management
### Check service status
```bash
sudo systemctl status <APP>
```
### Start the service
```bash
sudo systemctl start <APP>
```
### Stop the service
```bash
sudo systemctl stop <APP>
```
### Restart the service
```bash
sudo systemctl restart <APP>
```
### View service logs
```bash
sudo journalctl -u <APP> -f
```
### Disable autostart
```bash
sudo systemctl disable <APP>
```
## File Structure
After deployment:
```
/home/<USER>/
├── <APP>/ # Application directory
│ ├── ecosystem.config.js # PM2 configuration
│ ├── docker-compose.yml # Docker services
│ └── ... # Application files
├── <APP>.service.sh # Startup script
/etc/systemd/system/
└── <APP>.service # Systemd service file
```
## Troubleshooting
### Service fails to start
1. Check service logs:
```bash
sudo journalctl -u <APP> -n 50
```
2. Verify Docker is running:
```bash
sudo systemctl status docker
```
3. Check if Node.js path is correct:
```bash
which node
which pm2
```
### Repository clone fails
- Ensure SSH keys are properly configured for the user
- Test SSH access: `ssh -T git@github.com`
- Check repository URL is correct
### Docker Compose fails
- Verify Docker is installed and running
- Check docker-compose.yml exists in the application directory
- Ensure user has Docker permissions: `sudo usermod -aG docker <USER>`
### PM2 not starting
- Verify PM2 is installed: `pm2 --version`
- Check ecosystem.config.js exists
- Verify NODE_PATH includes PM2 binary location
## Notes
- The service type is set to `forking` to support PM2's daemon mode
- Service will auto-restart on failure with a 5-second delay
- Maximum 5 restart attempts in the burst period
- The service waits for Docker to be ready before starting
- Environment variables can be configured in the ecosystem.config.js file
+27
View File
@@ -0,0 +1,27 @@
from pyinfra.operations import systemd
from pyinfra import host
APP = host.data.APP
# Enable and start the service based on AUTOSTART flag
if AUTOSTART:
systemd.service(
name=f'Enable {SERVICE_NAME} service',
service=APP,
enabled=True,
_sudo=True
)
systemd.service(
name=f'Start {SERVICE_NAME} service',
service=APP,
running=True,
_sudo=True
)
else:
server.shell(
name=f'Service {SERVICE_NAME} created but not enabled (AUTOSTART=False)',
commands=[f'echo "Service {SERVICE_NAME} is ready but not started. Enable with: sudo systemctl enable {APP} && sudo systemctl start {APP}"'],
_sudo=False
)
+13
View File
@@ -0,0 +1,13 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'service:autostart',
name: 'Manage systemd service autostart',
dependencies: () => [],
schema: z.object({
APP: z.string().describe('The name of the systemd service (e.g., flintstone)'),
SERVICE_NAME: z.string().optional().describe('Display name for the service').default('Application'),
AUTOSTART: z.boolean().describe('Should the service be enabled and started?').default(true),
}),
});
+32
View File
@@ -0,0 +1,32 @@
# ssh-authorize
**Authorize SSH public key for a user**
## Purpose
This cube adds a specific SSH public key to a user's `authorized_keys` file on the remote server, allowing them to log in via SSH using the corresponding private key.
## Configuration
### Parameters
- **USER** (string, default: `'vagrant'`)
- The username on the remote server to authorize.
- If the user does not exist, `pyinfra` will attempt to create it (though a full user creation with shell/groups is better handled by `user-add`).
- **PUBKEY** (string, required)
- The actual content of the public key (e.g., `ssh-ed25519 AAAA...`).
## Use Cases
Grant access to a developer:
```javascript
exec('ssh-authorize', {
USER: 'developer',
PUBKEY: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...'
})
```
## Dependencies
None.
+13
View File
@@ -0,0 +1,13 @@
from pyinfra import host
from pyinfra.operations import server
USER = host.data.USER
PUBKEY = host.data.PUBKEY
server.user(
name=f"Authorize public key for {USER}",
user=USER,
public_keys=[PUBKEY],
present=True,
_sudo=True
)
+12
View File
@@ -0,0 +1,12 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'ssh:authorize',
name: 'Authorize SSH public key for a user',
dependencies: () => [],
schema: z.object({
USER: z.string().describe('Username to authorize').default('vagrant'),
PUBKEY: z.string().describe('SSH public key string').default(''),
}),
});
+93
View File
@@ -0,0 +1,93 @@
# ssh-keygen
**Generate SSH key for a given user**
## Purpose
This cube generates a new SSH key pair for a specified user, which can be used for secure, passwordless authentication to remote servers and services like GitHub, GitLab, or other SSH-accessible systems.
## What are SSH Keys?
SSH keys provide a more secure and convenient way to authenticate compared to passwords:
- **Public key**: Shared with servers/services you want to access (like GitHub)
- **Private key**: Kept secret on your local machine, never shared
- **Passphrase-free**: This cube generates keys without a passphrase for automation
- **Algorithm support**: RSA (traditional) or Ed25519 (modern, recommended)
## What This Cube Does
1. Creates the `.ssh` directory with proper permissions (700)
2. Generates an SSH key pair using the specified algorithm
3. Saves the keys as `id_{SUFFIX}` and `id_{SUFFIX}.pub`
4. Logs the public key to the console for easy copying
## Configuration
### Parameters
- **SUFFIX** (string, default: `'ed25519'`)
- Suffix for keyname (e.g., `github``id_github.pub`)
- Helps identify the purpose of the key
- **EMAIL** (string, default: `'undefined@bitsquare.dev'`)
- Email address to associate with the SSH key
- Used as a comment in the public key
- **ALGORITHM** (enum: `'rsa'` | `'ed25519'`, default: `'ed25519'`)
- SSH key algorithm type
- **Ed25519**: Modern, faster, more secure (recommended)
- **RSA**: Traditional, widely supported
- **USER** (string, default: `'vagrant'`)
- Username for which to generate the SSH key
- Inherited from `user-add` dependency
## Dependencies
- **user-add** - Creates the user account first
## Post-Installation
After the key is generated:
1. The public key will be logged to the console
2. Copy the public key and add it to the target service:
- **GitHub**: Settings → SSH and GPG keys → New SSH key
- **GitLab**: Preferences → SSH Keys
- **Remote server**: Add to `~/.ssh/authorized_keys`
3. SSH config is automatically set up for the key
## Key Locations
- Private key: `/home/{USER}/.ssh/id_{SUFFIX}`
- Public key: `/home/{USER}/.ssh/id_{SUFFIX}.pub`
## Algorithm Comparison
**Ed25519** (Recommended):
- Smaller keys (256-bit)
- Faster generation and verification
- More secure against certain attacks
- Not supported on very old systems
**RSA**:
- Larger keys (2048-4096 bit)
- Universally supported
- Slower than Ed25519
- Well-tested and trusted
## Example Usage
Generate a key for GitHub access:
```javascript
exec('ssh-keygen', {
SUFFIX: 'github',
EMAIL: 'myemail@example.com',
ALGORITHM: 'ed25519',
USER: 'myuser'
})
```
This creates `id_github` and `id_github.pub` in `/home/myuser/.ssh/`.
+46
View File
@@ -0,0 +1,46 @@
from pyinfra.operations import files, server, python
from pyinfra import host, logger
import logging
NAME=host.data.SUFFIX
EMAIL=host.data.EMAIL
ALGORITHM=host.data.ALGORITHM
USER=host.data.USER
# Ensure the .ssh directory exists
files.directory(
name="Ensure .ssh directory exists",
path=f"/home/{USER}/.ssh",
present=True,
mode=700,
user=USER,
group=USER,
)
# Generate the SSH keypair
server.shell(
name=f"Generate SSH key id_{NAME}",
commands=[
f"ssh-keygen -t {ALGORITHM} -f /home/{USER}/.ssh/id_{NAME} -C '{EMAIL}' -N ''"
]
)
# Print the public key
result = server.shell(
name="Print public key",
commands=[f"cat /home/{USER}/.ssh/id_{NAME}.pub"],
)
def callback():
# 🔹 Extract and log the key output
if result.stdout:
logger.info(f"Public Key for {USER}: {result.stdout.strip()}")
else:
logger.warning(f"No public key found for {USER} at /home/{USER}/.ssh/id_{NAME}.pub")
python.call(
name="Log public key",
function=callback,
)
+20
View File
@@ -0,0 +1,20 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'ssh:keygen',
name: 'Generate SSH key for a given $USER',
dependencies: () => ['user:add'],
schema: z.object({
SUFFIX: z
.string()
.describe('Suffix for keyname, e.g. github => id_github.pub')
.default('ed25519'),
EMAIL: z
.string()
.describe('Email address to associate with the SSH key')
.default('undefined@bitsquare.dev'),
ALGORITHM: z.enum(['rsa', 'ed25519']).describe('SSH key algorithm type').default('ed25519'),
USER: z.string().describe('Username for which to generate the SSH key').default('vagrant'),
}),
});
+110
View File
@@ -0,0 +1,110 @@
# ssh-keyman
**Deploy existing SSH keys to host**
## Purpose
This cube deploys pre-existing SSH key pairs from your local machine to a remote server, configuring them for automatic use with specified hosts (like GitHub, GitLab, etc.).
## What This Cube Does
1. **Copies SSH keys to the server**
- Transfers both private and public keys from local directory to remote `.ssh` folder
- Sets correct file permissions (600 for private, 644 for public)
2. **Configures SSH client**
- Creates/updates `.ssh/config` to use the deployed key for specified hosts
- Disables strict host key checking for easier automation
- Maps each host to use the correct identity file
3. **Ensures security**
- Sets proper directory permissions (700 for `.ssh`)
- Ensures keys are owned by the specified user
## Configuration
### Parameters
- **KEY_NAME** (string, default: `'id_ed25519'`)
- Name of the SSH key file (without extension)
- Must exist in the KEY_DIR directory locally
- **USER** (string, default: `'vagrant'`)
- Username for which to deploy the SSH key
- Inherited from `user-add` dependency
- **HOSTS** (string, default: `'github.com'`)
- Space-separated list of hosts to add to known_hosts
- Example: `'github.com gitlab.com bitbucket.org'`
## Dependencies
- **user-add** - Creates the user account first
## Use Cases
Deploy GitHub SSH key:
```javascript
exec('ssh-keyman', {
KEY_NAME: 'id_github',
HOSTS: 'github.com'
})
```
Deploy key for multiple Git services:
```javascript
exec('ssh-keyman', {
KEY_NAME: 'id_git',
HOSTS: 'github.com gitlab.com bitbucket.org'
})
```
## What Gets Configured
After deployment, the `.ssh/config` file will contain entries like:
```
Host github.com
IdentityFile /home/{USER}/.ssh/id_github
StrictHostKeyChecking no
```
This means when you run `git clone git@github.com:user/repo.git`, it will automatically use the deployed key.
## Key File Requirements
The local KEY_DIR must contain:
- `{KEY_NAME}` - Private key file
- `{KEY_NAME}.pub` - Public key file
For example, if `KEY_NAME=id_github`, you need:
- `./vault/tmp/id_github`
- `./vault/tmp/id_github.pub`
## Security Considerations
- **Private keys are sensitive**: Ensure your local KEY_DIR is secure
- **StrictHostKeyChecking disabled**: Convenient but less secure
- Consider enabling it for production: Edit `/home/{USER}/.ssh/config`
- **Backup your keys**: Keep secure copies of private keys
- **Use different keys**: Consider separate keys for different services
## Post-Installation
Test SSH connection:
```bash
ssh -T git@github.com
# Should show: "Hi username! You've successfully authenticated..."
```
Clone a repository:
```bash
git clone git@github.com:user/repo.git
# Should work without prompting for credentials
```
+96
View File
@@ -0,0 +1,96 @@
from pyinfra.operations import server, files, apt, systemd
from pyinfra import host
import subprocess
import json
def get_keyman_config():
"""Get keyman configuration by calling keyman --print-config"""
try:
result = subprocess.run(
['keyman', '--print-config'],
capture_output=True,
text=True,
check=True
)
return json.loads(result.stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError):
return None
# Load keyman config for defaults
keyman_config = get_keyman_config()
USER = host.data.USER
KEY = host.data.KEY_NAME
# Use KEY_DIR from host data, or fall back to keyman config tmpDir
DIR = host.data.get('KEY_DIR')
if not DIR and keyman_config:
DIR = keyman_config.get('tmpDir')
if not DIR:
DIR = '../../vault/tmp' # Final fallback
# Support multiple hosts separated by space
HOSTS = map(str.lstrip, str(host.data.HOSTS).split(' '))
# 🔹 Define remote paths
SSH_DIR = f"/home/{USER}/.ssh" if USER != "root" else "/root/.ssh"
PRIVATE_KEY_PATH = f"{SSH_DIR}/{KEY}"
PUBLIC_KEY_PATH = f"{SSH_DIR}/{KEY}.pub"
# Ensure the .ssh directory exists
files.directory(
name="Ensure .ssh directory exists",
path=SSH_DIR,
present=True,
mode=700,
user=USER,
group=USER,
_sudo=True
)
# Copy private key to the remote server
files.put(
name="Copy private key",
src=f"{DIR}/{KEY}",
dest=PRIVATE_KEY_PATH,
mode="600",
user=USER,
group=USER,
_sudo=True,
)
# Copy public key to the remote server
files.put(
name="Copy public key",
src=f"{DIR}/{KEY}.pub",
dest=PUBLIC_KEY_PATH,
mode="644",
user=USER,
group=USER,
_sudo=True,
)
# Ensure correct permissions for the private key
server.shell(
name="Set correct permissions for private key",
commands=[f"chmod 600 {PRIVATE_KEY_PATH}"],
_sudo=True,
)
files.file(
name="Ensure .ssh/config directory exists",
path=f"/home/{USER}/.ssh/config",
present=True,
user=USER,
group=USER,
_sudo=True
)
for host in HOSTS:
files.line(
name=f"Configure SSH key for {host}",
path=f"{SSH_DIR}/config",
line=f"Host {host}\n IdentityFile {SSH_DIR}/{KEY}\n StrictHostKeyChecking no",
_sudo=True
)
+19
View File
@@ -0,0 +1,19 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'ssh:keyman',
name: 'Deploy an ssh key managed by keyman',
dependencies: () => [],
schema: z.object({
KEY_NAME: z
.string()
.describe('Name of the SSH key file (without extension)')
.default('id_ed25519'),
USER: z.string().describe('Username for which to deploy the SSH key').default('vagrant'),
HOSTS: z
.string()
.describe('Space-separated list of hosts to add to known_hosts')
.default('github.com'),
}),
});
+130
View File
@@ -0,0 +1,130 @@
# user-add
**Add a user with Fish shell and tools**
## Purpose
This cube creates a new user account with a modern shell environment (Fish), SSH key authentication, and enhanced productivity tools pre-configured.
## What This Cube Does
1. **Creates a new user account**
- Sets up home directory with proper permissions
- Configures password authentication
- Adds user to specified groups (e.g., `docker`, `sudo`)
- Sets Fish as the default login shell
2. **Configures SSH access**
- Deploys the specified SSH public key for passwordless authentication
- Creates `.ssh` directory with proper permissions
- Sets up SSH config file
- Configures SSH agent auto-loading for Fish shell
3. **Installs Fish shell enhancements**
- Installs **Oh My Fish** (OMF) - Fish shell framework with themes and plugins
- Deploys custom Fish configuration (`config.fish`)
- Sets up Fish rc directory for modular configurations
4. **Creates workspace directories**
- Creates `/home/{USER}/tmp` directory for temporary files
## Configuration
### Parameters
- **USER** (string, auto-generated)
- Username for the new user account
- Default: `userXXXXX` (randomly generated 5-character suffix)
- **PASSWORD** (string, auto-generated)
- Password for the new user account
- Default: randomly generated secure password
- **GROUPS** (string, default: `''`)
- Comma-separated list of additional groups (e.g., `"docker,sudo"`)
- Common groups:
- `docker` - Run Docker without sudo
- `sudo` - Administrative privileges
- `www-data` - Web server file access
- **PUBKEY** (string, has default)
- SSH public key to authorize for the user
- Should be your public key for passwordless SSH access
## Dependencies
- **apt:essentials** - Provides Fish shell and basic tools
## What is Fish?
Fish (Friendly Interactive Shell) is a modern command-line shell that focuses on usability:
- **Smart autosuggestions**: Suggests commands as you type based on history
- **Syntax highlighting**: Color-codes commands in real-time
- **Tab completions**: Comprehensive, discoverable command completions
- **No configuration needed**: Works great out of the box
## Post-Installation
After deployment:
- SSH into the server as the new user: `ssh {USER}@server`
- Your SSH key will be pre-authorized (no password needed if using key)
- Fish shell will start automatically with OMF installed
- SSH agent auto-loads to manage your SSH keys
## Notes
- The user's home directory is created at `/home/{USER}`
- Fish configuration is stored in `/home/{USER}/.config/fish/`
- Oh My Fish provides package management: `omf install <package>`
- To switch shells: `chsh -s /bin/bash` (or back to fish: `chsh -s /usr/bin/fish`)
---
# 📌 Most Useful Fish Key Bindings (with Fisher Extensions)
## 🐟 Default Fish Key Bindings
- `Ctrl + C` → Cancel the current command
- `Ctrl + D` → Exit the shell (or logout if in SSH)
- `Ctrl + L` → Clear the terminal
- `Ctrl + R` → Search command history (enhanced by `fzf.fish`)
- `Ctrl + U` → Delete the entire command line
- `Ctrl + W` → Delete the last word
- `Alt + ← / →` → Move backward/forward by a word
## 🔍 Enhanced with `fzf.fish`
- `Ctrl + R`**Fuzzy search command history**
- `Ctrl + T`**Fuzzy search and insert file path**
- `Alt + C`**Fuzzy search directories (`cd` with `z`)**
## 📂 Directory Navigation (with `z`)
- `z <dir>` → Jump to a frequently used directory
- `z -l` → List most-used directories
- `z -c` → Remove a directory from `z`'s database
## 🔄 Process & Job Management
- `Ctrl + Z` → Suspend the current process
- `fg` → Bring a suspended process back to foreground
- `jobs` → List background jobs
## 🎨 Other Handy Shortcuts
- `fish_vi_key_bindings` → Enable Vi mode (press `Esc` for normal mode)
- `Ctrl + G` → Show Git status (if using `fzf.fish`)
- `Ctrl + E` → Edit command line in `$EDITOR`
## ⚙️ Useful Commands for Key Binding
```fish
# Set Fish default key bindings
fish_default_key_bindings
# Enable Vi mode
fish_vi_key_bindings
# Rebind a custom key (Example: Ctrl + G for git status)
bind \cg 'git status'
+10
View File
@@ -0,0 +1,10 @@
if status is-interactive
# Commands to run in interactive sessions can go here
# Execute all scripts in ~/.config/fish/rc/ on shell startup
for script in ~/.config/fish/rc/*.fish
if test -f $script
source $script
end
end
end
+90
View File
@@ -0,0 +1,90 @@
from pyinfra import host
from pyinfra.operations import server, files, apt
from io import StringIO
# Define the username, password, and public key for the new admin user
USER = host.data.USER
HOME_DIR = f"/home/{USER}"
TMP_DIR = f"{HOME_DIR}/tmp"
PASSWORD = host.data.PASSWORD
PUBKEY = host.data.PUBKEY
GROUPS = list(filter(None, map(str.strip, str(host.data.GROUPS).split())))
FISH_PATH = "/usr/bin/fish"
FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish"
FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish"
FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc"
SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish"
apt.packages(
name='Ensure fish shell is installed',
packages=[ 'fish'],
_sudo=True
)
# Ensure the user exists with a login shell
server.user(
name=f"Create user {USER} [{GROUPS}]",
present=True,
user=USER,
password=PASSWORD,
create_home=True,
groups=GROUPS,
shell=FISH_PATH,
public_keys=[PUBKEY],
_sudo=True
)
for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]:
files.directory(
name=f"Ensure {dir} directory exists",
path=dir,
present=True,
mode=700,
user=USER,
group=USER,
_sudo=True,
_sudo_user=USER,
_use_sudo_login=True
)
files.file(
name="Ensure .ssh/config exists",
path=f"{HOME_DIR}/.ssh/config",
present=True,
user=USER,
group=USER,
_sudo=True
)
server.shell(
name=f"Install OMF(Oh My Fish) for {USER}",
commands=[
f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf",
f"fish install-omf --yes --noninteractive",
],
_sudo=True,
_sudo_user=USER,
_use_sudo_login=True
)
files.put(
name="Add SSH agent auto-load script to Fish rc directory",
src="ssh-agent.fish",
dest=SSH_AGENT_SCRIPT,
user=USER,
group=USER,
mode="755", # Make it executable
_sudo=True,
)
files.put(
name="Add custom config.fish",
src="config.fish",
dest=FISH_CONFIG_FILE,
user=USER,
group=USER,
mode="755", # Make it executable
_sudo=True,
)
+25
View File
@@ -0,0 +1,25 @@
import { cubes } from '@bitstack/nopy';
import { z } from 'zod';
export default cubes.Manifest({
id: 'user:add',
name: 'Add a user with fish shell and tools',
dependencies: () => ['apt:essentials'],
schema: z.object({
USER: z
.string()
.describe('Username for the new user account')
.default(() => `user${cubes.uniqid(5)}`),
PASSWORD: z.string().describe('Password for the new user account').default(cubes.uniqid),
GROUPS: z
.string()
.describe('Comma-separated list of additional groups (e.g., "docker,sudo")')
.default(''),
PUBKEY: z
.string()
.describe('SSH public key to authorize for the user')
.default(
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan'
),
}),
});
+14
View File
@@ -0,0 +1,14 @@
# Start SSH agent if not already running
if not set -q SSH_AUTH_SOCK
eval (ssh-agent -c)
end
# Add all private SSH keys in ~/.ssh to the agent
for key in ~/.ssh/id_*;
if test -f $key; and not string match -q "*pub" $key
ssh-add $key 2>/dev/null
end
end
# Export user and group ID for Docker
set -x UID (id -u)
set -x GID (id -g)
+52
View File
@@ -0,0 +1,52 @@
# user:edit
**Modify an existing user's password or group membership**
## Purpose
This cube allows you to update existing user accounts on the target system. It can be used to change passwords, add users to new groups (like `docker` or `sudo`), or revoke group memberships.
## What This Cube Does
1. Identifies the existing user on the target system
2. Updates the user's password if `PASSWORD` is provided
3. Adds the user to the groups specified in `GROUPS`
4. Removes the user from the groups specified in `GROUPS_ABSENT`
## Configuration
| Variable | Type | Description | Required |
| :--- | :--- | :--- | :--- |
| `USER` | `string` | The username of the account to modify | Yes |
| `PASSWORD` | `string` | New password for the user | No |
| `GROUPS` | `string` | Comma-separated list of groups to ADD (e.g., `docker,sudo`) | No |
| `GROUPS_ABSENT` | `string` | Comma-separated list of groups to REMOVE | No |
## Dependencies
- `apt/essentials`: Standard system utilities.
## Usage
### Changing a Password
```bash
nopy install user:edit --env USER=myuser --env PASSWORD=newsecurepassword
```
### Adding a User to the Docker Group
```bash
nopy install user:edit --env USER=myuser --env GROUPS=docker
```
### Revoking Sudo Access
```bash
nopy install user:edit --env USER=myuser --env GROUPS_ABSENT=sudo
```
## Security Notes
- When setting passwords via the CLI, they may be visible in your shell history. Consider using a session file or interactive prompts for sensitive values.
- Changing your own user's groups or password may require a re-login to take full effect.
+24
View File
@@ -0,0 +1,24 @@
from pyinfra import host
from pyinfra.operations import server
# [agnt://cogen/cogen/user-edit-2]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
"""
Deployment script for user:edit.
Updates password and group membership for an existing user.
"""
USER = host.data.USER
PASSWORD = host.data.get('PASSWORD')
GROUPS = [g.strip() for g in str(host.data.get('GROUPS', '')).split(',') if g.strip()]
GROUPS_ABSENT = [g.strip() for g in str(host.data.get('GROUPS_ABSENT', '')).split(',') if g.strip()]
# Update user details
server.user(
name=f"Update user {USER}",
user=USER,
password=PASSWORD,
groups=GROUPS,
groups_absent=GROUPS_ABSENT,
_sudo=True,
)
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
import { cubes } from '@bitstack/nopy';
// [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"}
/**
* Manifest for the user:edit cube.
* Allows modifying existing user accounts (password, groups).
*/
export default cubes.Manifest({
id: 'user:edit',
name: 'user:edit - Modify an existing user account',
dependencies: () => [],
schema: z.object({
USER: z.string().describe('The username of the account to modify'),
PASSWORD: z.string().optional().describe('New password for the user (optional)'),
GROUPS: z.string().optional().describe('Comma-separated list of groups the user SHOULD be in (optional)'),
GROUPS_ABSENT: z.string().optional().describe('Comma-separated list of groups to REMOVE from the user (optional)'),
})
});
+2340
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@bitsquare/ansiblings",
"private": true,
"engines": {
"node": ">=21"
},
"scripts": {
"build": "pnpm -r run build",
"build:clean": "pnpm clean && pnpm build",
"build:tsgo": "tsgo --build",
"clean": "pnpm -r run clean",
"test": "pnpm -r run test",
"typecheck": "tsgo --noEmit",
"typecheck:legacy": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@logtape/logtape": "0.8.0",
"@types/jest": "29.5.5",
"@types/node": ">=21",
"@typescript/native-preview": "7.0.0-dev.20260303.1",
"ts-node": "10.9.2",
"typescript": "5.7.3"
},
"dependencies": {
"commander": "^13.1.0"
},
"pnpm": {
"overrides": {
"@types/jest": "29.5.5",
"@types/node": ">=21",
"@logtape/logtape": "0.8.0",
"ts-node": "10.9.2",
"typescript": "5.7.3",
"commander": "^13.1.0"
}
}
}
+152
View File
@@ -0,0 +1,152 @@
# Keyman - SSH Key Management with Age Encryption
Keyman is a simple command line tool built around the `age` encryption tool. It allows you to manage SSH keys in public GitHub repositories securely by encrypting the private keys.
## Features
- 🔐 Encrypt SSH private keys with age encryption
- 📁 Organized vault structure: `vault/keys/` for encrypted keys, `vault/tmp/` for decrypted keys
- ⚙️ Configurable via `.keymanrc.json` with sensible defaults
- 🔍 Interactive CLI for encrypting, decrypting, and listing keys
- 🔄 Support for key rotation
## Quick Start
### 1. Generate Age Encryption Key
```bash
# Create vault structure
mkdir -p vault/keys vault/tmp
# Generate age encryption key (keep this secret!)
age-keygen -o vault/age.key
# Add to .gitignore
echo "vault/age.key" >> .gitignore
echo "vault/tmp/" >> .gitignore
```
### 2. Generate SSH Keys
```bash
# Generate SSH key pair
ssh-keygen -t ed25519 -f vault/tmp/id_deploy -N "" -C "deploy@myapp.dev"
```
### 3. Run Keyman
```bash
# Run keyman interactively
VAULT_ROOT=./vault keyman
# Or if you have .keymanrc.json configured, just run:
keyman
```
## Configuration
Keyman uses sensible defaults but can be customized via `.keymanrc.json`:
```json
{
"vaultRoot": "./vault",
"keysDir": "keys",
"tmpDir": "tmp",
"ageKeyFile": "age.key"
}
```
### Configuration Priority
1. **VAULT_ROOT** environment variable (highest priority)
2. **.keymanrc.json** file (searched from current directory upward)
3. **Default values** (lowest priority)
### Default Values
- `vaultRoot`: `"vault"`
- `keysDir`: `"keys"`
- `tmpDir`: `"tmp"`
- `ageKeyFile`: `"age.key"`
## Vault Structure
```
project/
├── vault/
│ ├── age.key # Master encryption key (NEVER commit!)
│ ├── keys/ # Encrypted keys (safe to commit)
│ │ └── deploy/ # Each key has its own folder
│ │ ├── id_deploy.pub # Public key
│ │ └── id_deploy.age # Encrypted private key
│ └── tmp/ # Decrypted keys (NEVER commit!)
│ ├── id_deploy # Decrypted private key
│ └── id_deploy.pub # Public key
└── .keymanrc.json # Configuration (optional)
```
## Operations
Keyman provides an interactive menu-driven interface with the following operations:
- **📋 List keys** - Compact view showing all keys with checkbox indicators for their locations
- **🔒 Encrypt keys** - Encrypt SSH keys from `vault/tmp/` and store in `vault/keys/`
- **🔓 Decrypt keys** - Decrypt keys from `vault/keys/` to `vault/tmp/` or `~/.ssh/`
- **❌ Quit** - Exit the program
After completing any operation, keyman automatically returns to the main menu, allowing you to perform multiple operations in a single session without restarting the tool.
### List Keys Output
The list command shows a compact, unified view of all SSH keys with their locations:
```
🔑 SSH Keys:
Key Name [Vault] [Tmp] [.ssh]
──────────────────────────────────────────────────────────
✅ id_deploy (.pub) [✓] [ ] [✓]
🔓 id_github (.pub) [✓] [✓] [ ]
🔒 id_backup (.pub) [✓] [ ] [ ]
⚠️ id_local (.pub) [ ] [ ] [✓]
Legend:
✅ = Managed (encrypted in vault + active in .ssh)
🔓 = Decrypted (in vault + decrypted to tmp)
🔒 = Encrypted only (in vault, not decrypted)
⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)
```
**Features:**
- Public keys are indicated with `(.pub)` suffix instead of separate entries
- Status emoji shows management state at a glance
- Checkboxes `[✓]` show presence in three locations:
- **[Vault]** - Encrypted in vault/keys/
- **[Tmp]** - Decrypted in vault/tmp/
- **[.ssh]** - Active in ~/.ssh/
- Alphabetically sorted for easy scanning
- New **🔓** status for keys decrypted to tmp but not yet in .ssh
## Example Usage
```bash
# Using environment variable
VAULT_ROOT=../../vault keyman
# Using default configuration
keyman
# Keyman will show:
# 📁 Vault Root: /path/to/vault
# 🔑 Keys Directory: /path/to/vault/keys
# 📂 Temp Directory: /path/to/vault/tmp
# 🔐 Age Key: /path/to/vault/age.key
```
## Best Practices
1. **Never commit** `vault/age.key` or `vault/tmp/` to version control
2. **Always backup** your `age.key` securely (password manager, encrypted USB)
3. **Commit** `vault/keys/` - encrypted keys are safe to share
4. **Use environment variables** for CI/CD: `VAULT_ROOT=/path/to/vault keyman`
5. **Keep .keymanrc.json** in your project root for team consistency
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env node
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js';
const args = process.argv.slice(2);
if (args.includes('--print-config')) {
const config = loadConfig();
const paths = resolveConfigPaths(config);
console.log(JSON.stringify(paths));
process.exit(0);
}
keyman();
+76
View File
@@ -0,0 +1,76 @@
import { z } from 'zod';
/**
* Configuration schema for keyman
*/
declare const KeymanConfigSchema: z.ZodObject<{
vaultRoot: z.ZodDefault<z.ZodString>;
keysDir: z.ZodDefault<z.ZodString>;
tmpDir: z.ZodDefault<z.ZodString>;
ageKeyFile: z.ZodDefault<z.ZodString>;
}, "strip", z.ZodTypeAny, {
vaultRoot: string;
keysDir: string;
tmpDir: string;
ageKeyFile: string;
}, {
vaultRoot?: string | undefined;
keysDir?: string | undefined;
tmpDir?: string | undefined;
ageKeyFile?: string | undefined;
}>;
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
/**
* Resolution strategy for merging config properties
* - 'merge': Arrays are concatenated, objects are deep merged (default)
* - 'override': Child value completely replaces parent value
*/
export type ResolutionStrategy = 'merge' | 'override';
/**
* Resolution configuration for customizing merge behavior
*/
export type KeymanResolutionConfig = {
[K in keyof KeymanConfig]?: ResolutionStrategy;
};
/**
* Raw config file structure (includes resolution)
*/
export interface KeymanConfigFile extends Partial<KeymanConfig> {
/** Customize merge behavior for specific properties */
resolution?: KeymanResolutionConfig;
}
/**
* Loads configuration from .keymanrc.json files
*
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
* Multiple config files are merged, with child configs overriding parent configs.
*
* Use the `resolution` property to customize merge behavior:
* ```json
* {
* "vaultRoot": "../vault",
* "resolution": {
* "vaultRoot": "override"
* }
* }
* ```
*
* @returns Validated keyman configuration
*/
export declare function loadConfig(): KeymanConfig;
/**
* Resolves configuration paths relative to VAULT_ROOT or current directory
* @param config The keyman configuration
* @returns Resolved absolute paths
*/
export declare function resolveConfigPaths(config: KeymanConfig): {
vaultRoot: string;
keysDir: string;
tmpDir: string;
keyPath: string;
};
/**
* Gets the paths of all discovered config files (for debugging)
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
*/
export declare function getConfigPaths(): string[];
export {};
+212
View File
@@ -0,0 +1,212 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
/**
* Configuration schema for keyman
*/
const KeymanConfigSchema = z.object({
vaultRoot: z.string().default('vault'),
keysDir: z.string().default('keys'),
tmpDir: z.string().default('tmp'),
ageKeyFile: z.string().default('age.key'),
});
/**
* Default configuration values
*/
const DEFAULT_CONFIG = {
vaultRoot: 'vault',
keysDir: 'keys',
tmpDir: 'tmp',
ageKeyFile: 'age.key',
};
const CONFIG_FILENAME = '.keymanrc.json';
/**
* Path-based properties that should be resolved relative to config file location
*/
const PATH_PROPERTIES = ['vaultRoot'];
/**
* Resolves path properties in a config object relative to the config file's directory
* @param configFile The raw config file contents
* @param configDir Directory containing the config file
* @returns Config with path properties resolved to absolute paths
*/
function resolvePathsRelativeToConfig(configFile, configDir) {
const resolved = { ...configFile };
for (const prop of PATH_PROPERTIES) {
const value = configFile[prop];
if (typeof value === 'string' && !path.isAbsolute(value)) {
resolved[prop] = path.resolve(configDir, value);
}
}
return resolved;
}
/**
* Finds all config files by traversing upwards from cwd to root
* Returns configs in order from root to cwd (parent first, child last)
* @param startDir Directory to start searching from
* @returns Array of paths to .keymanrc.json files
*/
function findConfigFiles(startDir) {
const configPaths = [];
let currentDir = startDir;
// Traverse upwards
while (true) {
const configPath = path.join(currentDir, CONFIG_FILENAME);
if (fs.existsSync(configPath)) {
configPaths.unshift(configPath); // Add to front (root first)
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break; // Reached root
}
currentDir = parentDir;
}
// Also check home directory (lowest priority)
const homeConfig = path.join(os.homedir(), CONFIG_FILENAME);
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
configPaths.unshift(homeConfig);
}
return configPaths;
}
/**
* Deep merges two values based on resolution strategy
*/
function mergeValue(parentValue, childValue, strategy) {
// Override strategy: child replaces parent completely
if (strategy === 'override') {
return childValue;
}
// Merge strategy (default)
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
// Concatenate arrays, remove duplicates for primitives
const combined = [...parentValue, ...childValue];
if (combined.every((v) => typeof v !== 'object')) {
return [...new Set(combined)];
}
return combined;
}
if (typeof parentValue === 'object' &&
parentValue !== null &&
typeof childValue === 'object' &&
childValue !== null &&
!Array.isArray(parentValue) &&
!Array.isArray(childValue)) {
// Deep merge objects
const result = { ...parentValue };
for (const [key, value] of Object.entries(childValue)) {
if (key in result) {
result[key] = mergeValue(result[key], value, 'merge');
}
else {
result[key] = value;
}
}
return result;
}
// Primitives: child overrides parent
return childValue;
}
/**
* Merges a child config into a parent config
*/
function mergeConfigs(parent, childFile) {
const resolution = childFile.resolution || {};
const result = { ...parent };
for (const [key, value] of Object.entries(childFile)) {
if (key === 'resolution')
continue; // Skip resolution property itself
const strategy = resolution[key] || 'merge';
if (key in result) {
result[key] = mergeValue(result[key], value, strategy);
}
else {
result[key] = value;
}
}
return result;
}
/**
* Loads configuration from .keymanrc.json files
*
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
* Multiple config files are merged, with child configs overriding parent configs.
*
* Use the `resolution` property to customize merge behavior:
* ```json
* {
* "vaultRoot": "../vault",
* "resolution": {
* "vaultRoot": "override"
* }
* }
* ```
*
* @returns Validated keyman configuration
*/
export function loadConfig() {
const startDir = process.cwd();
const configPaths = findConfigFiles(startDir);
if (configPaths.length === 0) {
console.error('️ No .keymanrc.json found, using default configuration');
return DEFAULT_CONFIG;
}
// Start with defaults and merge each config file
let config = { ...DEFAULT_CONFIG };
for (const configPath of configPaths) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(content);
// Resolve path properties relative to the config file's directory
const configDir = path.dirname(configPath);
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
config = mergeConfigs(config, resolvedConfig);
console.error(`✅ Loaded configuration from ${configPath}`);
}
catch (error) {
if (error instanceof SyntaxError) {
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
}
else {
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
}
// Continue with other configs instead of failing entirely
}
}
// Validate the final merged result
try {
return KeymanConfigSchema.parse(config);
}
catch (error) {
if (error instanceof z.ZodError) {
console.error('❌ ERROR: Invalid merged configuration:');
error.errors.forEach((err) => {
console.error(` - ${err.path.join('.')}: ${err.message}`);
});
}
console.error('️ Falling back to default configuration');
return DEFAULT_CONFIG;
}
}
/**
* Resolves configuration paths relative to VAULT_ROOT or current directory
* @param config The keyman configuration
* @returns Resolved absolute paths
*/
export function resolveConfigPaths(config) {
// VAULT_ROOT environment variable takes precedence
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
return {
vaultRoot,
keysDir: path.resolve(vaultRoot, config.keysDir),
tmpDir: path.resolve(vaultRoot, config.tmpDir),
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
};
}
/**
* Gets the paths of all discovered config files (for debugging)
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
*/
export function getConfigPaths() {
return findConfigFiles(process.cwd());
}
+1
View File
@@ -0,0 +1 @@
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer';
export async function copyKey(sshDir, tmpDir) {
const getKeys = (dir) => {
if (!fs.existsSync(dir))
return [];
return fs.readdirSync(dir).filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
};
const sshKeys = getKeys(sshDir);
const tmpKeys = getKeys(tmpDir);
const keys = [...new Set([...sshKeys, ...tmpKeys])];
if (keys.length === 0) {
console.log('⚠️ No SSH keys found.');
return;
}
const { selectedKey } = await inquirer.prompt([
{
type: 'list',
name: 'selectedKey',
message: 'Select key to copy public key from:',
choices: keys,
},
]);
// Determine location of the public key
// Prefer tmpDir if it exists there, otherwise sshDir
let pubKeyPath = path.join(tmpDir, `${selectedKey}.pub`);
if (!fs.existsSync(pubKeyPath)) {
pubKeyPath = path.join(sshDir, `${selectedKey}.pub`);
}
if (!fs.existsSync(pubKeyPath)) {
console.error(`❌ Public key not found for ${selectedKey}`);
return;
}
try {
const pubKeyContent = fs.readFileSync(pubKeyPath, 'utf-8').trim();
// Detect OS and use appropriate clipboard command
// Since the environment is Darwin, we prioritize pbcopy, but we can add others for completeness or use a simple check.
// For this specific request on Darwin:
const proc = execa('pbcopy');
proc.stdin?.write(pubKeyContent);
proc.stdin?.end();
await proc;
console.log(`✅ Public key for ${selectedKey} copied to clipboard!`);
}
catch (error) {
console.error(`❌ Failed to copy to clipboard: ${error}`);
}
}
+1
View File
@@ -0,0 +1 @@
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
+45
View File
@@ -0,0 +1,45 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer';
export async function decryptKeys(sshDir, vaultDir, ageKey) {
const keyDir = path.join(vaultDir, 'keys');
const vaultKeys = fs.readdirSync(keyDir).filter((key) => {
const keyfile = path.join(keyDir, key, `id_${key}.age`);
console.log(keyfile);
return fs.existsSync(keyfile);
});
if (vaultKeys.length === 0) {
console.log('⚠️ No encrypted keys found.');
return;
}
const { selectedKeys, decryptMode } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedKeys',
message: 'Select keys to decrypt:',
choices: vaultKeys,
},
{
type: 'list',
name: 'decryptMode',
message: 'Choose decryption location:',
choices: ['Local (vault/tmp)', 'SSH (~/.ssh)'],
},
]);
for (const key of selectedKeys) {
const encryptedKey = path.join(keyDir, key, `id_${key}.age`);
const publicKey = path.join(keyDir, key, `id_${key}.pub`);
const privateKeyOut = decryptMode === 'Local (vault/tmp)'
? path.join(vaultDir, 'tmp', `id_${key}`)
: path.join(sshDir, `id_${key}`);
const publicKeyOut = decryptMode === 'Local (vault/tmp)'
? path.join(vaultDir, 'tmp', `id_${key}.pub`)
: path.join(sshDir, `id_${key}.pub`);
// Decrypt key
await execa('age', ['-d', '-i', ageKey, '-o', privateKeyOut, encryptedKey]);
await execa('cp', [publicKey, publicKeyOut]);
await execa('chmod', ['600', privateKeyOut]);
console.log(`✅ Decrypted: ${privateKeyOut}`);
}
}
+1
View File
@@ -0,0 +1 @@
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
+37
View File
@@ -0,0 +1,37 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer';
export async function encryptKeys(sshDir, vaultDir, tmpDir, pubkey) {
const sshKeys = fs
.readdirSync(sshDir)
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
const tmpKeys = fs
.readdirSync(tmpDir)
.filter((key) => key.startsWith('id_') && !key.endsWith('.pub'));
console.log(tmpKeys);
console.log(sshKeys);
const keys = [...new Set([...sshKeys, ...tmpKeys])];
if (keys.length === 0) {
console.log('⚠️ No private SSH keys found to encrypt.');
return;
}
const { selectedKeys } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedKeys',
message: 'Select SSH keys to encrypt:',
choices: keys,
},
]);
for (const key of selectedKeys) {
const keyPath = path.join(tmpKeys.includes(key) ? tmpDir : sshDir, key);
const vaultPath = path.join(vaultDir, 'keys', key.replace('id_', ''));
fs.mkdirSync(vaultPath, { recursive: true });
// Encrypt key using `age`
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${key}.age`), keyPath]);
// Copy public key and create README
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${key}.pub`));
console.log(`🔒 Encrypted and stored: ${vaultPath}/${key}`);
}
}
+1
View File
@@ -0,0 +1 @@
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
+65
View File
@@ -0,0 +1,65 @@
import fs from 'node:fs';
import path from 'node:path';
import { execa } from 'execa';
import inquirer from 'inquirer';
export async function generateKey(tmpDir, keysDir, pubkey) {
const { algorithm } = await inquirer.prompt([
{
type: 'list',
name: 'algorithm',
message: 'Select algorithm:',
choices: ['ed25519', 'rsa'],
default: 'ed25519',
},
]);
const { keyName } = await inquirer.prompt([
{
type: 'input',
name: 'keyName',
message: 'Enter key name:',
validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'),
},
]);
const { password } = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: 'Enter passphrase (leave empty for no passphrase):',
mask: '*',
},
]);
const { identity } = await inquirer.prompt([
{
type: 'input',
name: 'identity',
message: 'Enter key identity (comment):',
},
]);
const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`;
const keyPath = path.join(tmpDir, fileName);
if (fs.existsSync(keyPath)) {
console.error(`❌ Error: Key file ${fileName} already exists in ${tmpDir}`);
return;
}
try {
console.log(`Generating ${algorithm} key pair...`);
const args = ['-t', algorithm, '-f', keyPath, '-N', password, '-C', identity];
if (algorithm === 'rsa') {
args.push('-b', '4096');
}
await execa('ssh-keygen', args);
console.log(`✅ Key generated: ${keyPath}`);
// Encrypt the key
const folderName = fileName.replace('id_', '');
const vaultPath = path.join(keysDir, folderName);
fs.mkdirSync(vaultPath, { recursive: true });
// Encrypt key using `age`
await execa('age', ['-r', pubkey, '-o', path.join(vaultPath, `${fileName}.age`), keyPath]);
// Copy public key
fs.copyFileSync(`${keyPath}.pub`, path.join(vaultPath, `${fileName}.pub`));
console.log(`🔒 Encrypted and stored: ${vaultPath}`);
}
catch (error) {
console.error(`❌ Error generating/encrypting key: ${error}`);
}
}
+1
View File
@@ -0,0 +1 @@
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
export async function listKeys(sshDir, vaultDir, tmpDir) {
console.log('\n📂 Checking keys in:');
console.log(` SSH: ${sshDir}`);
console.log(` Vault: ${vaultDir}`);
console.log(` Tmp: ${tmpDir}\n`);
const keyMap = new Map();
// Scan SSH directory
if (fs.existsSync(sshDir)) {
const sshFiles = fs.readdirSync(sshDir).filter((file) => file.startsWith('id_'));
for (const file of sshFiles) {
const keyName = file.replace(/\.pub$/, '');
const isPub = file.endsWith('.pub');
if (!keyMap.has(keyName)) {
keyMap.set(keyName, {
name: keyName,
inSsh: !isPub,
hasSshPub: isPub,
inVault: false,
inTmp: false,
hasTmpPub: false,
});
}
else {
const key = keyMap.get(keyName);
if (isPub) {
key.hasSshPub = true;
}
else {
key.inSsh = true;
}
}
}
}
// Scan tmp directory
if (fs.existsSync(tmpDir)) {
const tmpFiles = fs.readdirSync(tmpDir).filter((file) => file.startsWith('id_'));
for (const file of tmpFiles) {
const keyName = file.replace(/\.pub$/, '');
const isPub = file.endsWith('.pub');
if (!keyMap.has(keyName)) {
keyMap.set(keyName, {
name: keyName,
inSsh: false,
hasSshPub: false,
inVault: false,
inTmp: !isPub,
hasTmpPub: isPub,
});
}
else {
const key = keyMap.get(keyName);
if (isPub) {
key.hasTmpPub = true;
}
else {
key.inTmp = true;
}
}
}
}
// Scan vault directory
if (fs.existsSync(vaultDir)) {
const vaultDirs = fs.readdirSync(vaultDir).filter((dir) => {
const stat = fs.statSync(path.join(vaultDir, dir));
return stat.isDirectory();
});
for (const dir of vaultDirs) {
const keyName = `id_${dir}`;
const encryptedPath = path.join(vaultDir, dir, `${keyName}.age`);
if (fs.existsSync(encryptedPath)) {
if (!keyMap.has(keyName)) {
keyMap.set(keyName, {
name: keyName,
inSsh: false,
hasSshPub: false,
inVault: true,
inTmp: false,
hasTmpPub: false,
});
}
else {
keyMap.get(keyName).inVault = true;
}
}
}
}
// Display results
if (keyMap.size === 0) {
console.log('⚠️ No SSH keys found.\n');
return;
}
console.log('🔑 SSH Keys:\n');
console.log(' Key Name [Vault] [Tmp] [.ssh]');
console.log(` ${'─'.repeat(58)}`);
const sortedKeys = Array.from(keyMap.values()).sort((a, b) => a.name.localeCompare(b.name));
for (const key of sortedKeys) {
const vaultMark = key.inVault ? '✓' : ' ';
const tmpMark = key.inTmp ? '✓' : ' ';
const sshMark = key.inSsh ? '✓' : ' ';
// Show (.pub) if present in any location
const hasPub = key.hasSshPub || key.hasTmpPub;
const pubIndicator = hasPub ? ' (.pub)' : '';
// Determine status
const status = key.inVault && key.inSsh ? '✅' : key.inVault && key.inTmp ? '🔓' : key.inVault ? '🔒' : '⚠️ ';
const namePart = `${key.name}${pubIndicator}`.padEnd(32);
console.log(` ${status} ${namePart} [${vaultMark}] [${tmpMark}] [${sshMark}]`);
}
console.log('\n Legend:');
console.log(' ✅ = Managed (encrypted in vault + active in .ssh)');
console.log(' 🔓 = Decrypted (in vault + decrypted to tmp)');
console.log(' 🔒 = Encrypted only (in vault, not decrypted)');
console.log(' ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault)\n');
}
+1
View File
@@ -0,0 +1 @@
export declare function keyman(): Promise<void>;
+79
View File
@@ -0,0 +1,79 @@
import fs from 'node:fs';
import path from 'node:path';
import inquirer from 'inquirer';
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { copyKey } from './keyman.copy.js';
import { decryptKeys } from './keyman.decrypt.js';
import { encryptKeys } from './keyman.encrypt.js';
import { generateKey } from './keyman.generate.js';
import { listKeys } from './keyman.list.js';
import { extractAgePublicKey } from './keyman.utils.js';
// 🔹 Main function to resolve paths and manage flow
export async function keyman() {
// Load configuration from .keymanrc.json or use defaults
const config = loadConfig();
const paths = resolveConfigPaths(config);
console.log(`\n📁 Vault Root: ${paths.vaultRoot}`);
console.log(`🔑 Keys Directory: ${paths.keysDir}`);
console.log(`📂 Temp Directory: ${paths.tmpDir}`);
console.log(`🔐 Age Key: ${paths.keyPath}\n`);
// Get USER input
const { user } = await inquirer.prompt([
{
type: 'input',
name: 'user',
message: 'Specify USER (default: @current):',
default: '@current',
},
]);
const homeDir = user === '@current' ? process.env.HOME || '' : `/home/${user}`;
if (!homeDir) {
console.error('Error: Unable to determine HOME directory.');
process.exit(1);
}
const sshDir = path.join(homeDir, '.ssh');
fs.mkdirSync(paths.vaultRoot, { recursive: true });
fs.mkdirSync(paths.tmpDir, { recursive: true });
// Main loop - keep showing menu until user quits
let running = true;
while (running) {
console.log(`\n${'='.repeat(50)}`);
// 🔹 Show category selection
const { category } = await inquirer.prompt([
{
type: 'list',
name: 'category',
message: 'Select operation:',
choices: [
{ name: '📋 List keys', value: 'list' },
{ name: '📝 Copy public key', value: 'copy' },
{ name: '🆕 Generate key', value: 'generate' },
{ name: '🔒 Encrypt keys', value: 'encrypt' },
{ name: '🔓 Decrypt keys', value: 'decrypt' },
{ name: '❌ Quit', value: 'quit' },
],
},
]);
switch (category) {
case 'list':
await listKeys(sshDir, paths.keysDir, paths.tmpDir);
break;
case 'copy':
await copyKey(sshDir, paths.tmpDir);
break;
case 'generate':
await generateKey(paths.tmpDir, paths.keysDir, extractAgePublicKey(paths.keyPath));
break;
case 'encrypt':
await encryptKeys(sshDir, paths.vaultRoot, paths.tmpDir, extractAgePublicKey(paths.keyPath));
break;
case 'decrypt':
await decryptKeys(sshDir, paths.vaultRoot, paths.keyPath);
break;
case 'quit':
console.log('\n👋 Goodbye!\n');
running = false;
break;
}
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Extracts the public key from an age key file.
* @param keyFilePath Path to the age key file.
* @returns The public key as a string, or null if not found.
*/
export declare function extractAgePublicKey(keyFilePath: string): string | null;
+21
View File
@@ -0,0 +1,21 @@
import fs from 'node:fs';
/**
* Extracts the public key from an age key file.
* @param keyFilePath Path to the age key file.
* @returns The public key as a string, or null if not found.
*/
export function extractAgePublicKey(keyFilePath) {
if (!fs.existsSync(keyFilePath)) {
console.error(`❌ ERROR: Age key file not found at ${keyFilePath}`);
return null;
}
try {
const fileContents = fs.readFileSync(keyFilePath, 'utf-8');
const publicKeyMatch = fileContents.match(/^# public key:\s*(age1[^\s]+)/m);
return publicKeyMatch ? publicKeyMatch[1] : null;
}
catch (error) {
console.error(`❌ ERROR: Failed to read key file - ${error}`);
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@bitstack/keyman",
"description": "A system to simplify ssh key management",
"type": "module",
"version": "1.0.0",
"private": true,
"author": "bitsquare",
"bin": "dist/keyman.cli.js",
"scripts": {
"clean": "rm -rf dist",
"build": "tsgo && chmod +x dist/keyman.cli.js && npm link",
"build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link",
"prepublishOnly": "npm run build",
"keyman": "node --loader ts-node/esm src/keyman.bin.ts"
},
"engines": {
"node": ">=21.0.0"
},
"files": ["dist/"],
"dependencies": {
"execa": "9.5.2",
"inquirer": "8.2.4",
"ts-node": ">=10.9.1",
"typed-dotenv": "10.0.2",
"typescript": ">=5.6.3",
"zod": "^3.24.1",
"zx": "^8.3.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
export * from './keyman.main.js';
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { loadConfig, resolveConfigPaths } from './keyman.config.js';
import { keyman } from './keyman.main.js';
const args = process.argv.slice(2);
if (args.includes('--print-config')) {
const config = loadConfig();
const paths = resolveConfigPaths(config);
console.log(JSON.stringify(paths));
process.exit(0);
}
keyman();
+267
View File
@@ -0,0 +1,267 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
/**
* Configuration schema for keyman
*/
const KeymanConfigSchema = z.object({
vaultRoot: z.string().default('vault'),
keysDir: z.string().default('keys'),
tmpDir: z.string().default('tmp'),
ageKeyFile: z.string().default('age.key'),
});
export type KeymanConfig = z.infer<typeof KeymanConfigSchema>;
/**
* Resolution strategy for merging config properties
* - 'merge': Arrays are concatenated, objects are deep merged (default)
* - 'override': Child value completely replaces parent value
*/
export type ResolutionStrategy = 'merge' | 'override';
/**
* Resolution configuration for customizing merge behavior
*/
export type KeymanResolutionConfig = {
[K in keyof KeymanConfig]?: ResolutionStrategy;
};
/**
* Raw config file structure (includes resolution)
*/
export interface KeymanConfigFile extends Partial<KeymanConfig> {
/** Customize merge behavior for specific properties */
resolution?: KeymanResolutionConfig;
}
/**
* Default configuration values
*/
const DEFAULT_CONFIG: KeymanConfig = {
vaultRoot: 'vault',
keysDir: 'keys',
tmpDir: 'tmp',
ageKeyFile: 'age.key',
};
const CONFIG_FILENAME = '.keymanrc.json';
/**
* Path-based properties that should be resolved relative to config file location
*/
const PATH_PROPERTIES: (keyof KeymanConfig)[] = ['vaultRoot'];
/**
* Resolves path properties in a config object relative to the config file's directory
* @param configFile The raw config file contents
* @param configDir Directory containing the config file
* @returns Config with path properties resolved to absolute paths
*/
function resolvePathsRelativeToConfig(
configFile: KeymanConfigFile,
configDir: string
): KeymanConfigFile {
const resolved = { ...configFile };
for (const prop of PATH_PROPERTIES) {
const value = configFile[prop];
if (typeof value === 'string' && !path.isAbsolute(value)) {
resolved[prop] = path.resolve(configDir, value);
}
}
return resolved;
}
/**
* Finds all config files by traversing upwards from cwd to root
* Returns configs in order from root to cwd (parent first, child last)
* @param startDir Directory to start searching from
* @returns Array of paths to .keymanrc.json files
*/
function findConfigFiles(startDir: string): string[] {
const configPaths: string[] = [];
let currentDir = startDir;
// Traverse upwards
while (true) {
const configPath = path.join(currentDir, CONFIG_FILENAME);
if (fs.existsSync(configPath)) {
configPaths.unshift(configPath); // Add to front (root first)
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break; // Reached root
}
currentDir = parentDir;
}
// Also check home directory (lowest priority)
const homeConfig = path.join(os.homedir(), CONFIG_FILENAME);
if (fs.existsSync(homeConfig) && !configPaths.includes(homeConfig)) {
configPaths.unshift(homeConfig);
}
return configPaths;
}
/**
* Deep merges two values based on resolution strategy
*/
function mergeValue(
parentValue: unknown,
childValue: unknown,
strategy: ResolutionStrategy
): unknown {
// Override strategy: child replaces parent completely
if (strategy === 'override') {
return childValue;
}
// Merge strategy (default)
if (Array.isArray(parentValue) && Array.isArray(childValue)) {
// Concatenate arrays, remove duplicates for primitives
const combined = [...parentValue, ...childValue];
if (combined.every((v) => typeof v !== 'object')) {
return [...new Set(combined)];
}
return combined;
}
if (
typeof parentValue === 'object' &&
parentValue !== null &&
typeof childValue === 'object' &&
childValue !== null &&
!Array.isArray(parentValue) &&
!Array.isArray(childValue)
) {
// Deep merge objects
const result: Record<string, unknown> = { ...parentValue };
for (const [key, value] of Object.entries(childValue)) {
if (key in result) {
result[key] = mergeValue(result[key], value, 'merge');
} else {
result[key] = value;
}
}
return result;
}
// Primitives: child overrides parent
return childValue;
}
/**
* Merges a child config into a parent config
*/
function mergeConfigs(parent: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig {
const resolution = childFile.resolution || {};
const result: Record<string, unknown> = { ...parent };
for (const [key, value] of Object.entries(childFile)) {
if (key === 'resolution') continue; // Skip resolution property itself
const strategy = resolution[key as keyof KeymanConfig] || 'merge';
if (key in result) {
result[key] = mergeValue(result[key], value, strategy);
} else {
result[key] = value;
}
}
return result as unknown as KeymanConfig;
}
/**
* Loads configuration from .keymanrc.json files
*
* Searches for `.keymanrc.json` by traversing upwards from cwd to root.
* Multiple config files are merged, with child configs overriding parent configs.
*
* Use the `resolution` property to customize merge behavior:
* ```json
* {
* "vaultRoot": "../vault",
* "resolution": {
* "vaultRoot": "override"
* }
* }
* ```
*
* @returns Validated keyman configuration
*/
export function loadConfig(): KeymanConfig {
const startDir = process.cwd();
const configPaths = findConfigFiles(startDir);
if (configPaths.length === 0) {
console.error('️ No .keymanrc.json found, using default configuration');
return DEFAULT_CONFIG;
}
// Start with defaults and merge each config file
let config: KeymanConfig = { ...DEFAULT_CONFIG };
for (const configPath of configPaths) {
try {
const content = fs.readFileSync(configPath, 'utf-8');
const rawConfig = JSON.parse(content) as KeymanConfigFile;
// Resolve path properties relative to the config file's directory
const configDir = path.dirname(configPath);
const resolvedConfig = resolvePathsRelativeToConfig(rawConfig, configDir);
config = mergeConfigs(config, resolvedConfig);
console.error(`✅ Loaded configuration from ${configPath}`);
} catch (error) {
if (error instanceof SyntaxError) {
console.warn(`⚠️ Skipping invalid JSON in ${configPath}: ${error.message}`);
} else {
console.warn(`⚠️ Skipping config ${configPath}: ${error}`);
}
// Continue with other configs instead of failing entirely
}
}
// Validate the final merged result
try {
return KeymanConfigSchema.parse(config);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('❌ ERROR: Invalid merged configuration:');
error.errors.forEach((err) => {
console.error(` - ${err.path.join('.')}: ${err.message}`);
});
}
console.error('️ Falling back to default configuration');
return DEFAULT_CONFIG;
}
}
/**
* Resolves configuration paths relative to VAULT_ROOT or current directory
* @param config The keyman configuration
* @returns Resolved absolute paths
*/
export function resolveConfigPaths(config: KeymanConfig) {
// VAULT_ROOT environment variable takes precedence
const vaultRoot = path.resolve(process.env.VAULT_ROOT ?? config.vaultRoot);
return {
vaultRoot,
keysDir: path.resolve(vaultRoot, config.keysDir),
tmpDir: path.resolve(vaultRoot, config.tmpDir),
keyPath: path.resolve(vaultRoot, config.ageKeyFile),
};
}
/**
* Gets the paths of all discovered config files (for debugging)
* @returns Array of paths to .keymanrc.json files, ordered from root to cwd
*/
export function getConfigPaths(): string[] {
return findConfigFiles(process.cwd());
}

Some files were not shown because too many files have changed in this diff Show More