1 # This program is free software; you can redistribute it and/or modify
2 # it under the terms of the GNU General Public License as published by
3 # the Free Software Foundation; either version 2 of the License, or
4 # (at your option) any later version.
6 # This program is distributed in the hope that it will be useful,
7 # but WITHOUT ANY WARRANTY; without even the implied warranty of
8 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9 # GNU Library General Public License for more details.
11 # You should have received a copy of the GNU General Public License
12 # along with this program; if not, write to the Free Software
13 # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
15 # See the COPYING file for license information.
17 # Copyright (c) 2006 Guillaume Chazarain <guichaz@gmail.com>
26 from polysh
.buffered_dispatcher
import buffered_dispatcher
27 from polysh
import callbacks
28 from polysh
.console
import console_output
29 from polysh
import display_names
31 # Either the remote shell is expecting a command or one is already running
32 STATE_NAMES
= ['not_started', 'idle', 'running', 'terminated', 'dead']
38 STATE_DEAD
= range(len(STATE_NAMES
))
40 # Terminal color codes
41 COLORS
= [1] + range(30, 37)
43 # Count the total number of remote_dispatcher.handle_read() invocations
46 def main_loop_iteration(timeout
=None):
47 """Return the number of remote_dispatcher.handle_read() calls made by this
49 prev_nr_read
= nr_handle_read
50 asyncore
.loop(count
=1, timeout
=timeout
, use_poll
=True)
51 return nr_handle_read
- prev_nr_read
55 fd
= options
.log_file
.fileno()
58 written
= os
.write(fd
, msg
)
60 print 'Exception while writing log:', options
.log_file
.name
62 raise asyncore
.ExitNow(1)
65 class remote_dispatcher(buffered_dispatcher
):
66 """A remote_dispatcher is a ssh process we communicate with"""
68 def __init__(self
, hostname
):
69 self
.pid
, fd
= pty
.fork()
72 self
.launch_ssh(hostname
)
76 buffered_dispatcher
.__init
__(self
, fd
)
77 self
.temporary
= False
78 self
.hostname
= hostname
79 self
.debug
= options
.debug
80 self
.enabled
= True # shells can be enabled and disabled
81 self
.state
= STATE_NOT_STARTED
82 self
.term_size
= (-1, -1)
83 self
.display_name
= None
84 self
.change_name(hostname
)
85 self
.init_string
= self
.configure_tty() + self
.set_prompt()
86 self
.init_string_sent
= False
87 self
.read_in_state_not_started
= ''
88 self
.command
= options
.command
89 self
.last_printed_line
= ''
90 if sys
.stdout
.isatty() and not options
.disable_color
:
91 COLORS
.insert(0, COLORS
.pop()) # Rotate the colors
92 self
.color_code
= COLORS
[0]
94 self
.color_code
= None
96 def launch_ssh(self
, name
):
97 """Launch the ssh command in the child process"""
99 name
= '%s@%s' % (options
.user
, name
)
100 evaluated
= options
.ssh
% {'host': name
}
101 if evaluated
== options
.ssh
:
102 evaluated
= '%s %s' % (evaluated
, name
)
103 os
.execlp('/bin/sh', 'sh', '-c', evaluated
)
105 def set_enabled(self
, enabled
):
106 if enabled
!= self
.enabled
and options
.interactive
:
107 # In non-interactive mode, remote processes leave as soon
108 # as they are terminated, but we don't want to break the
109 # indentation if all the remaining processes have short names.
110 display_names
.set_enabled(self
.display_name
, enabled
)
111 self
.enabled
= enabled
113 def change_state(self
, state
):
114 """Change the state of the remote process, logging the change"""
115 if state
is not self
.state
:
117 self
.print_debug('state => %s' % (STATE_NAMES
[state
]))
118 if self
.state
is STATE_NOT_STARTED
:
119 self
.read_in_state_not_started
= ''
122 def disconnect(self
):
123 """We are no more interested in this remote process"""
125 os
.kill(-self
.pid
, signal
.SIGKILL
)
127 # The process was already dead, no problem
129 self
.read_buffer
= ''
130 self
.write_buffer
= ''
131 self
.set_enabled(False)
132 if self
.read_in_state_not_started
:
133 self
.print_lines(self
.read_in_state_not_started
)
134 self
.read_in_state_not_started
= ''
135 if options
.abort_error
and self
.state
is STATE_NOT_STARTED
:
136 raise asyncore
.ExitNow(1)
137 self
.change_state(STATE_DEAD
)
139 def configure_tty(self
):
140 """We don't want \n to be replaced with \r\n, and we disable the echo"""
141 attr
= termios
.tcgetattr(self
.fd
)
142 attr
[1] &= ~termios
.ONLCR
# oflag
143 attr
[3] &= ~termios
.ECHO
# lflag
144 termios
.tcsetattr(self
.fd
, termios
.TCSANOW
, attr
)
145 # unsetopt zle prevents Zsh from resetting the tty
146 return 'unsetopt zle 2> /dev/null;stty -echo -onlcr -ctlecho;'
148 def seen_prompt_cb(self
, unused
):
149 if options
.interactive
:
150 self
.change_state(STATE_IDLE
)
152 p1
, p2
= callbacks
.add('real prompt ends', lambda d
: None, True)
153 self
.dispatch_command('PS1="%s""%s\n"\n' % (p1
, p2
))
154 self
.dispatch_command(self
.command
+ '\n')
155 self
.dispatch_command('exit 2>/dev/null\n')
158 def set_prompt(self
):
159 """The prompt is important because we detect the readyness of a process
160 by waiting for its prompt."""
162 command_line
= 'PS2=;RPS1=;RPROMPT=;'
163 command_line
+= 'PROMPT_COMMAND=;'
164 command_line
+= 'TERM=ansi;'
165 command_line
+= 'unset HISTFILE;'
166 prompt1
, prompt2
= callbacks
.add('prompt', self
.seen_prompt_cb
, True)
167 command_line
+= 'PS1="%s""%s\n"\n' % (prompt1
, prompt2
)
171 """We are always interested in reading from active remote processes if
173 return self
.state
!= STATE_DEAD
and buffered_dispatcher
.readable(self
)
175 def handle_expt(self
):
176 pid
, status
= os
.waitpid(self
.pid
, 0)
177 exit_code
= os
.WEXITSTATUS(status
)
178 options
.exit_code
= max(options
.exit_code
, exit_code
)
179 if exit_code
and options
.interactive
:
180 console_output('Error talking to %s\n' % self
.display_name
)
185 def handle_close(self
):
188 def print_lines(self
, lines
):
189 from polysh
.display_names
import max_display_name_length
190 lines
= lines
.strip('\n')
192 no_empty_lines
= lines
.replace('\n\n', '\n')
193 if len(no_empty_lines
) == len(lines
):
195 lines
= no_empty_lines
198 indent
= max_display_name_length
- len(self
.display_name
)
199 log_prefix
= self
.display_name
+ indent
* ' ' + ' : '
200 if self
.color_code
is None:
201 console_prefix
= log_prefix
203 console_prefix
= '\033[1;%dm%s\033[1;m' % (self
.color_code
,
205 console_data
= (console_prefix
+
206 lines
.replace('\n', '\n' + console_prefix
) + '\n')
207 log_data
= log_prefix
+ lines
.replace('\n', '\n' + log_prefix
) + '\n'
208 console_output(console_data
, logging_msg
=log_data
)
209 self
.last_printed_line
= lines
[lines
.rfind('\n') + 1:]
211 def handle_read_fast_case(self
, data
):
212 """If we are in a fast case we'll avoid the long processing of each
214 if self
.state
is not STATE_RUNNING
or callbacks
.any_in(data
):
218 last_nl
= data
.rfind('\n')
220 # No '\n' in data => slow case
222 self
.read_buffer
= data
[last_nl
+ 1:]
223 self
.print_lines(data
[:last_nl
])
226 def handle_read(self
):
227 """We got some output from a remote shell, this is one of the state
229 if self
.state
== STATE_DEAD
:
231 global nr_handle_read
233 new_data
= buffered_dispatcher
.handle_read(self
)
235 self
.print_debug('==> ' + new_data
)
236 if self
.handle_read_fast_case(self
.read_buffer
):
238 lf_pos
= new_data
.find('\n')
240 # Optimization: we knew there were no '\n' in the previous read
241 # buffer, so we searched only in the new_data and we offset the
242 # found index by the length of the previous buffer
243 lf_pos
+= len(self
.read_buffer
) - len(new_data
)
244 elif self
.state
is STATE_NOT_STARTED
and \
245 options
.password
is not None and \
246 'password:' in self
.read_buffer
.lower():
247 self
.dispatch_write(options
.password
+ '\n')
248 self
.read_buffer
= ''
251 # For each line in the buffer
252 line
= self
.read_buffer
[:lf_pos
+ 1]
253 if callbacks
.process(line
):
255 elif self
.state
in (STATE_IDLE
, STATE_RUNNING
):
256 self
.print_lines(line
)
257 elif self
.state
is STATE_NOT_STARTED
:
258 self
.read_in_state_not_started
+= line
259 if 'The authenticity of host' in line
:
260 msg
= line
.strip('\n') + ' Closing connection.'
262 elif 'REMOTE HOST IDENTIFICATION HAS CHANGED' in line
:
263 msg
= 'Remote host identification has changed.'
268 self
.print_lines(msg
+ ' Consider manually connecting or ' +
269 'using ssh-keyscan.')
271 # Go to the next line in the buffer
272 self
.read_buffer
= self
.read_buffer
[lf_pos
+ 1:]
273 if self
.handle_read_fast_case(self
.read_buffer
):
275 lf_pos
= self
.read_buffer
.find('\n')
276 if self
.state
is STATE_NOT_STARTED
and not self
.init_string_sent
:
277 self
.dispatch_write(self
.init_string
)
278 self
.init_string_sent
= True
280 def print_unfinished_line(self
):
281 """The unfinished line stayed long enough in the buffer to be printed"""
282 if self
.state
is STATE_RUNNING
:
283 if not callbacks
.process(self
.read_buffer
):
284 self
.print_lines(self
.read_buffer
)
285 self
.read_buffer
= ''
288 """Do we want to write something?"""
289 return self
.state
!= STATE_DEAD
and buffered_dispatcher
.writable(self
)
291 def handle_write(self
):
292 """Let's write as much as we can"""
293 num_sent
= self
.send(self
.write_buffer
)
295 if self
.state
is not STATE_NOT_STARTED
or options
.password
is None:
296 self
.print_debug('<== ' + self
.write_buffer
[:num_sent
])
297 self
.write_buffer
= self
.write_buffer
[num_sent
:]
299 def print_debug(self
, msg
):
300 """Log some debugging information to the console"""
301 state
= STATE_NAMES
[self
.state
]
302 msg
= msg
.encode('string_escape')
303 console_output('[dbg] %s[%s]: %s\n' % (self
.display_name
, state
, msg
))
306 """Return a list with all information available about this process"""
307 return [self
.display_name
, self
.enabled
and 'enabled' or 'disabled',
308 STATE_NAMES
[self
.state
] + ':', self
.last_printed_line
.strip()]
310 def dispatch_write(self
, buf
):
311 """There is new stuff to write when possible"""
312 if self
.state
!= STATE_DEAD
and self
.enabled
and self
.allow_write
:
313 buffered_dispatcher
.dispatch_write(self
, buf
)
316 def dispatch_command(self
, command
):
317 if self
.dispatch_write(command
):
318 self
.change_state(STATE_RUNNING
)
320 def change_name(self
, name
):
321 """Change the name of the shell, possibly updating the maximum name
325 self
.display_name
= display_names
.change(self
.display_name
, name
)
327 def rename(self
, string
):
328 """Send to the remote shell, its new name to be shell expanded"""
330 rename1
, rename2
= callbacks
.add('rename', self
.change_name
, False)
331 self
.dispatch_command('/bin/echo "%s""%s"%s\n' %
332 (rename1
, rename2
, string
))
334 self
.change_name(self
.hostname
)
337 display_names
.change(self
.display_name
, None)
338 buffered_dispatcher
.close(self
)