initial transfer
This commit is contained in:
+13
-136
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"hosts": [],
|
||||||
|
"cubeDirs": ["./cubes"],
|
||||||
|
"env": {},
|
||||||
|
"log": {
|
||||||
|
"verbosity": "info",
|
||||||
|
"debug": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
@@ -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 read‑only.
|
||||||
|
# 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
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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: () => [],
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()}`),
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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' }]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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(''),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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>`
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
port = ssh
|
||||||
|
filter = sshd
|
||||||
|
logpath = /var/log/auth.log
|
||||||
|
maxretry = 3
|
||||||
|
findtime = 600
|
||||||
|
bantime = 3600
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { cubes } from '@bitstack/nopy';
|
||||||
|
|
||||||
|
export default cubes.Manifest({
|
||||||
|
id: 'armor:fail2ban',
|
||||||
|
name: 'Install and enable fail2ban',
|
||||||
|
dependencies: () => [],
|
||||||
|
});
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
`),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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(''),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|
||||||
|
```
|
||||||
@@ -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'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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)'),
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -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'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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(''),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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/`.
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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'
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)'),
|
||||||
|
})
|
||||||
|
});
|
||||||
Generated
+2340
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
export * from './keyman.main.js';
|
||||||
|
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
export * from './keyman.main.js';
|
||||||
|
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
export {};
|
||||||
+11
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export declare function copyKey(sshDir: string, tmpDir: string): Promise<void>;
|
||||||
Vendored
+50
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise<void>;
|
||||||
+45
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise<void>;
|
||||||
+37
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise<void>;
|
||||||
+65
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise<void>;
|
||||||
Vendored
+115
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
export declare function keyman(): Promise<void>;
|
||||||
Vendored
+79
@@ -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
@@ -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;
|
||||||
Vendored
+21
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
export * from './keyman.main.js';
|
||||||
|
export { loadConfig, resolveConfigPaths } from './keyman.config.js';
|
||||||
@@ -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();
|
||||||
@@ -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
Reference in New Issue
Block a user