Compare commits

..

No commits in common. "58a8402a91b9b07ad658a6ef280dc2f4fc292726" and "c98742a9ccf46b4cebb920a55d30194db77a5e04" have entirely different histories.

3 changed files with 116 additions and 142 deletions

View File

@ -1,18 +1,14 @@
#!/usr/bin/env python3
#!/usr/bin/env python
# This code taken from:
# http://stackoverflow.com/questions/8290740/simple-versatile-and-re-usable-entry-dialog-sometimes-referred-to-as-input-dia
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk as gtk
import gtk
class EntryDialog(gtk.MessageDialog):
def __init__(self, *args, **kwargs):
'''
Creates a new EntryDialog. Takes all the arguments of the usual
MessageDialog constructor plus one optional named argument
MessageDialog constructor plus one optional named argument
"default_value" to specify the initial contents of the entry.
'''
if 'default_value' in kwargs:
@ -21,22 +17,20 @@ class EntryDialog(gtk.MessageDialog):
else:
default_value = ''
super(EntryDialog, self).__init__(*args, **kwargs)
entry = gtk.Entry()
entry = gtk.Entry()
entry.set_text(str(default_value))
entry.connect("activate",
lambda ent, dlg, resp: dlg.response(resp),
self, gtk.ResponseType.OK)
entry.connect("activate",
lambda ent, dlg, resp: dlg.response(resp),
self, gtk.RESPONSE_OK)
self.vbox.pack_end(entry, True, True, 0)
self.vbox.show_all()
self.entry = entry
def set_value(self, text):
self.entry.set_text(text)
def run(self):
result = super(EntryDialog, self).run()
if result == gtk.ResponseType.OK:
if result == gtk.RESPONSE_OK:
text = self.entry.get_text()
else:
text = None
return text
return text

View File

@ -1,12 +1,22 @@
# crussh: a modern cssh replacement
crussh: a modern cssh replacement
=================================
## What are this?
Backstory
---------
For anyone who needs to administrate clusters of many machines,
[clusterssh](http://sourceforge.net/projects/clusterssh/) has long been a
fallback for when the rest of your automation tools aren't working.
crussh aims to be a simple replacement for cssh with the following improvements:
However, cssh has a number of deficiencies in modern environments:
- Doesn't play nice with window placement of modern window managers.
- Doesn't play nice with modern toolkits' copy and paste behaviour.
- Gets a bit screwy when there's more terminals than can fit on-screen.
- Doesn't support nice antialiased fonts.
crussh aims to be a simple replacement for cssh that corrects these
problems. It does so with the following features:
- Uses a single window to hold multiple terminals.
- Intelligently tiles terminals to fit available window size.
@ -14,56 +24,39 @@ crussh aims to be a simple replacement for cssh with the following improvements:
- Never resizes a terminal smaller than 80x24 characters.
- Uses GTK and the VTE widget to provide modern, anti-aliased terminals.
## Install
Install
-------
The install process is very simple on most distros:
* Install python3 and python3-gi.
* Clone and symlink to your bin dir:
- Install python2, python-gtk2, and python-vte.
- Clone and symlink to your bin dir.
```bash
git clone https://github.com/nergdron/crussh.git
ln -s $(pwd)/crussh/crussh.py ~/bin/crussh
ln -s crussh/crussh.py ~/bin/crussh
```
Run ```crussh HOST [HOST ...]```
- Run "crussh HOST [HOST ...]"
## Examples
Usage Tips
----------
Basic usage is covered via the builtin help, which you can get by running
```crussh -h```. This section covers some common use cases.
To connect to a list of hosts in a file:
```bash
crussh $(cat hostlist.txt)
```
To use a custom login name, public key, or other SSH client options:
```bash
crussh -l someuser -i ~/.ssh/myotherkey -- host [host ...]
```
To do something other than ssh, such as edit a bunch of files in parallel:
```bash
crussh -e nano *.txt
```
## Usage Tips
Doing a clustered paste isn't completely obvious. The following methods will
work, after making sure you're clicked into the text entry box at the bottom of
the window:
Doing a clustered paste isn't completely obvious. The following methods will work:
- middle click or shift-insert to paste the X11 selection buffer.
- control-shift-v to paste the GTK/GNOME clipboard.
## Bugs & To Do
Bugs & TODO
-----------
To see current issues, report problems, and see plans for features,
see the [crussh GitHub issues page](https://github.com/nergdron/crussh/issues).
## Copyright and License
Copyright and License
---------------------
crussh is copyright 2012-2019 by Tessa Nordgren <tessa@sudo.ca>.
crussh is copyright 2012-2016 by Tessa Nordgren <tessa@sudo.ca>.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by

171
crussh.py
View File

@ -1,21 +1,19 @@
#!/usr/bin/env python3
#!/usr/bin/python2
# A cssh replacement written in Python / GTK.
# (c)2012-2019 - Tessa Nordgren <tessa@sudo.ca>.
# (c)2012-2017 - Tessa Nordgren <tessa@sudo.ca>.
# Released under the GPL, version 3: http://www.gnu.org/licenses/
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk as gtk
from gi.repository import Gdk as gdk
from gi.repository import GLib as glib
from gi.repository.Pango import FontDescription
gi.require_version('Vte', '2.91')
from gi.repository import Vte as vte
# Requires: python-gtk2 python-vte
import sys
import math
import json
import os.path
import pygtk
pygtk.require('2.0')
import gtk
import vte
from EntryDialog import EntryDialog
from collections import OrderedDict
@ -47,6 +45,7 @@ class CruSSHConf:
### GUI Objects ###
def initGUI(self, save_func=None):
self.MainWin.set_modal(True)
self.MainWin.props.allow_grow = False
MainBox = gtk.VBox(spacing=5)
MainBox.props.border_width = 5
@ -58,13 +57,13 @@ class CruSSHConf:
GlobalConfTable.props.row_spacing = 5
GlobalConfTable.props.column_spacing = 5
GlobalConfFrame.add(GlobalConfTable)
MainBox.pack_start(GlobalConfFrame, False, False, 0)
MainBox.pack_start(GlobalConfFrame)
GlobalConfTable.attach(gtk.Label("Start Maximized:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND)
GlobalConfTable.attach(gtk.Label("Start Maximized:"), 1, 2, 1, 2, gtk.EXPAND)
MaximizedConf = gtk.CheckButton()
MaximizedConf.set_active(self.Config["start-maximized"])
MaximizedConf.connect("toggled", self.maximized_hook)
GlobalConfTable.attach(MaximizedConf, 2, 3, 1, 2, gtk.AttachOptions.EXPAND)
GlobalConfTable.attach(MaximizedConf, 2, 3, 1, 2, gtk.EXPAND)
TermConfFrame = gtk.Frame(label="Terminal Options")
TermConfTable = gtk.Table(3, 2)
@ -72,34 +71,31 @@ class CruSSHConf:
TermConfTable.props.row_spacing = 5
TermConfTable.props.column_spacing = 5
TermConfFrame.add(TermConfTable)
MainBox.pack_start(TermConfFrame, False, False, 0)
MainBox.pack_start(TermConfFrame)
TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND)
FontConf = gtk.FontButton(font=self.Config["font"])
TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.EXPAND)
FontConf = gtk.FontButton(fontname=self.Config["font"])
FontConf.connect("font-set", self.font_hook)
TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.AttachOptions.EXPAND)
TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.EXPAND)
SizeBox = gtk.HBox()
SizeBox.props.spacing = 5
TermConfTable.attach(SizeBox, 1, 3, 2, 3)
SizeBox.pack_start(gtk.Label("Min Width:"), False, False, 0)
WidthEntry = gtk.SpinButton()
# gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1)
WidthEntry.set_range(1, 9999)
SizeBox.pack_start(gtk.Label("Min Width:"), fill=False, expand=False)
WidthEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1))
WidthEntry.connect("value-changed", self.width_hook)
SizeBox.pack_start(WidthEntry, False, False, 0)
SizeBox.pack_start(gtk.Label("Min Height:"), False, False, 0)
HeightEntry = gtk.SpinButton()
HeightEntry.set_range(1, 9999)
SizeBox.pack_start(WidthEntry, fill=False, expand=False)
SizeBox.pack_start(gtk.Label("Min Height:"), fill=False, expand=False)
HeightEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-height"], lower=1, upper=9999, step_incr=1))
HeightEntry.connect("value-changed", self.height_hook)
SizeBox.pack_start(HeightEntry, False, False, 0)
SizeBox.pack_start(HeightEntry, fill=False, expand=False)
ConfirmBox = gtk.HBox(spacing=5)
CancelButton = gtk.Button(stock=gtk.STOCK_CANCEL)
ConfirmBox.pack_start(CancelButton, False, False, 0)
ConfirmBox.pack_start(CancelButton, fill=False)
SaveButton = gtk.Button(stock=gtk.STOCK_SAVE)
ConfirmBox.pack_start(SaveButton, False, False, 0)
MainBox.pack_start(ConfirmBox, False, False, 0)
ConfirmBox.pack_start(SaveButton, fill=False)
MainBox.pack_start(ConfirmBox, fill=False, expand=False)
# wire up behaviour
CancelButton.connect("clicked", lambda discard: self.MainWin.destroy())
@ -125,6 +121,7 @@ class HostsMask:
def InitGUI(self):
self.MainWin.set_modal(True)
self.MainWin.props.allow_grow = False
MainBox = gtk.VBox(spacing=5)
MainBox.props.border_width = 5
@ -147,20 +144,20 @@ class HostsMask:
for host in hosts:
HostTable = gtk.Table(1, 2)
HostTable.props.column_spacing = 2
HostTable.attach(gtk.Label(host), 0, 1, 0, 1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
HostTable.attach(gtk.Label(host), 0, 1, 0, 1, gtk.EXPAND)
HostCheckbox = gtk.CheckButton()
HostCheckbox.set_active(self.Terminals[host].copy_input)
HostCheckbox.connect("toggled", self.toggle_func, host)
HostTable.attach(HostCheckbox, 1, 2, 0, 1,gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
HostTable.attach(HostCheckbox, 1, 2, 0, 1, gtk.EXPAND)
row = i / cols
col = i % cols
HostsConfTable.attach(HostTable, col, col+1, row, row+1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
HostsConfTable.attach(HostTable, col, col+1, row, row+1, gtk.EXPAND)
i += 1
MainBox.pack_start(HostsConfFrame, False, False, 0)
MainBox.pack_start(HostsConfFrame)
OkButton = gtk.Button(stock=gtk.STOCK_OK)
MainBox.pack_start(OkButton, False, False, 0)
MainBox.pack_start(OkButton, fill=False, expand=False)
# wire up behaviour
OkButton.connect("clicked", lambda discard: self.MainWin.destroy())
@ -180,8 +177,8 @@ class CruSSH:
Config = {
"min-width": 80,
"min-height": 24,
"font": "Ubuntu Mono,monospace Bold 10",
"start-maximized": True
"font": "Ubuntu Mono Bold 10",
"start-maximized": False
}
### State Vars ###
@ -202,7 +199,7 @@ class CruSSH:
hosts = list(self.Terminals.keys())
hosts.reverse()
for host in hosts:
if self.Terminals[host].get_parent() == self.LayoutTable:
if self.Terminals[host].parent == self.LayoutTable:
self.LayoutTable.remove(self.Terminals[host])
self.LayoutTable.resize(rows, cols)
# layout terminals
@ -223,7 +220,7 @@ class CruSSH:
gtk.main_quit()
# main_quit desn't happen immediately
return False
size = self.MainWin.get_size()
size = self.MainWin.allocation
cols = int(math.floor((size.width + self.LayoutTable.props.column_spacing) / float(self.TermMinWidth)))
if cols < 1 or num_terms == 1:
cols = 1
@ -242,30 +239,28 @@ class CruSSH:
def addHost(self, host):
def handle_copy_paste(widget, event):
self.EntryBox.props.buffer.delete_text(0, -1)
# check for paste key shortcut (ctl-shift-v)
if (event.type == gdk.EventType.KEY_PRESS) \
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
and (event.keyval == gdk.keyval_from_name('V')):
if (event.type == gtk.gdk.KEY_PRESS) \
and (event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK) \
and (event.state & gtk.gdk.SHIFT_MASK == gtk.gdk.SHIFT_MASK) \
and (event.keyval == gtk.gdk.keyval_from_name('V')):
widget.paste_clipboard()
return True
elif (event.type == gdk.EventType.KEY_PRESS) \
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
and (event.keyval == gdk.keyval_from_name('C')):
elif (event.type == gtk.gdk.KEY_PRESS) \
and (event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK) \
and (event.state & gtk.gdk.SHIFT_MASK == gtk.gdk.SHIFT_MASK) \
and (event.keyval == gtk.gdk.keyval_from_name('C')):
widget.copy_clipboard()
return True
terminal = vte.Terminal()
terminal.spawn_sync(
vte.PtyFlags.DEFAULT,
None,
[self.ssh_cmd] + self.ssh_args + [host],
[],
glib.SpawnFlags.DO_NOT_REAP_CHILD,
None,
None,
)
# TODO: disable only this terminal widget on child exit
# v.connect("child-exited", lambda term: gtk.main_quit())
cmd_str = self.ssh_cmd
if self.ssh_args is not None:
cmd_str += " " + self.ssh_args
cmd_str += " " + host
cmd = cmd_str.split(' ')
terminal.fork_command(command=cmd[0], argv=cmd)
# track whether we mirror output to this terminal
terminal.copy_input = True
# attach copy/paste handler
@ -280,28 +275,25 @@ class CruSSH:
# -1 == infinite scrollback
terminal.set_scrollback_lines(-1)
terminal.set_size(self.Config["min-width"], self.Config["min-height"])
terminal.set_font(FontDescription(self.Config["font"]))
self.TermMinWidth = terminal.get_char_width() * self.Config["min-width"]
self.TermMinHeight = terminal.get_char_height() * self.Config["min-height"]
terminal.set_font_from_string(self.Config["font"])
self.TermMinWidth = (terminal.get_char_width() * self.Config["min-width"]) + terminal.get_padding()[0]
self.TermMinHeight = (terminal.get_char_height() * self.Config["min-height"]) + terminal.get_padding()[1]
def removeTerminal(self, terminal, status):
to_del = []
def removeTerminal(self, terminal):
for host in self.Terminals.keys():
if terminal == self.Terminals[host]:
self.LayoutTable.remove(self.Terminals[host])
print("Disconnected from " + host)
to_del.append(host)
for host in to_del:
del self.Terminals[host]
del self.Terminals[host]
self.reflow(force=True)
def initGUI(self):
theme = gtk.IconTheme.get_default()
theme = gtk.icon_theme_get_default()
if theme.has_icon("terminal"):
icon = theme.lookup_icon("terminal", 128, flags=gtk.IconLookupFlags.USE_BUILTIN)
icon = theme.lookup_icon("terminal", 128, flags=gtk.ICON_LOOKUP_USE_BUILTIN)
if icon is not None:
self.MainWin.set_icon(icon.load_icon())
gtk.window_set_default_icon(icon.load_icon())
self.MainWin.set_title("crussh: " + ' '.join(self.Terminals.keys()))
self.MainWin.set_role(role="crussh_main_win")
self.MainWin.connect("delete-event", lambda window, event: gtk.main_quit())
@ -309,15 +301,14 @@ class CruSSH:
self.MainWin.add(MainVBox)
MainMenuBar = gtk.MenuBar()
MainVBox.pack_start(MainMenuBar, False, False, 0)
MainVBox.pack_start(MainMenuBar, fill=True, expand=False)
def add_host_handler(self, base):
diag = EntryDialog(self.get_parent().get_parent(),
gtk.DialogFlags.MODAL | gtk.DialogFlags.DESTROY_WITH_PARENT,
gtk.MessageType.QUESTION, gtk.ButtonsType.OK_CANCEL,
diag = EntryDialog(buttons=gtk.BUTTONS_OK, type=gtk.MESSAGE_QUESTION,
message_format="Hostname to add:")
print "test"
host = diag.run()
if host is not None and len(host) > 0:
if len(host) > 0:
base.addHost(host)
diag.destroy()
base.reflow(force=True)
@ -329,7 +320,7 @@ class CruSSH:
AddHostItem.connect("activate", add_host_handler, self)
FileMenu.append(AddHostItem)
FileMenu.append(gtk.SeparatorMenuItem())
QuitItem = gtk.MenuItem(label="Quit")
QuitItem = gtk.ImageMenuItem(gtk.STOCK_QUIT)
QuitItem.connect("activate", lambda discard: gtk.main_quit())
FileMenu.append(QuitItem)
MainMenuBar.append(FileItem)
@ -359,15 +350,15 @@ class CruSSH:
EditMenu.append(PrefsItem)
MainMenuBar.append(EditItem)
self.ScrollWin.props.hscrollbar_policy = gtk.PolicyType.NEVER
self.ScrollWin.props.vscrollbar_policy = gtk.PolicyType.ALWAYS
self.ScrollWin.props.shadow_type = gtk.ShadowType.ETCHED_IN
MainVBox.pack_start(self.ScrollWin, True, True, 0)
self.ScrollWin.props.hscrollbar_policy = gtk.POLICY_NEVER
self.ScrollWin.props.vscrollbar_policy = gtk.POLICY_ALWAYS
self.ScrollWin.props.shadow_type = gtk.SHADOW_ETCHED_IN
MainVBox.pack_start(self.ScrollWin)
self.LayoutTable.set_homogeneous(True)
self.LayoutTable.set_row_spacings(1)
self.LayoutTable.set_col_spacings(1)
self.ScrollWin.add(self.LayoutTable)
self.ScrollWin.add_with_viewport(self.LayoutTable)
self.ScrollWin.set_size_request(self.TermMinWidth, self.TermMinHeight)
# don't display chars while typing.
@ -385,11 +376,10 @@ class CruSSH:
def feed_input(widget, event):
self.EntryBox.props.buffer.delete_text(0, -1)
# check for paste key shortcut (ctl-shift-v)
if (event.type == gdk.EventType.KEY_PRESS) \
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
and (event.get_keyval == gdk.keyval_from_name('V')):
if (event.type == gtk.gdk.KEY_PRESS) \
and (event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK) \
and (event.state & gtk.gdk.SHIFT_MASK == gtk.gdk.SHIFT_MASK) \
and (event.keyval == gtk.gdk.keyval_from_name('V')):
feed_paste(widget)
else:
# propagate to every terminal
@ -409,7 +399,7 @@ class CruSSH:
self.EntryBox.connect("key_release_event", feed_input)
self.EntryBox.connect("paste_clipboard", feed_paste)
self.EntryBox.connect("button_press_event", click_handler)
MainVBox.pack_start(self.EntryBox, False, False, 0)
MainVBox.pack_start(self.EntryBox, False, False)
# reflow layout on size change.
self.MainWin.connect("size-allocate", lambda widget, allocation: self.reflow())
@ -417,7 +407,7 @@ class CruSSH:
# give EntryBox default focus on init
self.EntryBox.props.has_focus = True
def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=[]):
def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=None):
self.ssh_cmd = ssh_cmd
self.ssh_args = ssh_args
# load existing config file, if present
@ -429,11 +419,8 @@ class CruSSH:
pass
# init all terminals
if isinstance(hosts, list):
for host in hosts:
self.addHost(host)
else:
self.addHost(str(hosts))
for host in hosts:
self.addHost(host)
# configure all terminals
self.configTerminals()
# reflow after reconfig for font size changes
@ -450,7 +437,7 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Connect to multiple hosts in parallel.",
usage="%(prog)s [OPTIONS] [--] HOST [HOST ...]",
epilog="* NOTE: options before '--' will be passed directly to the SSH client.")
epilog="* NOTE: You can pass options to ssh if you add '--' before your list of hosts")
parser.add_argument("--ssh", "-s", dest='ssh', default="/usr/bin/ssh",
help="specify the SSH executable to use (default: %(default)s)")
parser.add_argument("--hosts-file", "-f", dest='hosts_file', default="",
@ -470,7 +457,7 @@ if __name__ == "__main__":
try:
offset = hosts.index("--")
except:
ssh_args = []
ssh_args = None
else:
ssh_args = " ".join(hosts[0:offset])
hosts = hosts[offset + 1:]