openSUSE:WebYaST TrainingKit
Developing the module
Please note this site is momentarily under construction
This tutorial will show you how to create a module for WebYast. The module will enable user to start or stop ssh-daemon and firewall and additionally decide if the firewall shall be started during the boot process.
While the REST service takes care of the functionality, the web-client gives the user a fine GUI for the browser.
Before we start with the program, we will define the permissions for the new module.
PolkitPolicies
Defining PolkitPolicies
Every Yast Module provides it's own policy file, which defines the actions a user can be granted. They are stored in /usr/share/PolicyKit/policy/ and they usually have the form org.opensuse.yast.system.module_name.policy. For the security module we will need something like write and read access, so create the org.opensuse.yast.system.security.policy file and add these lines to it:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd"> <policyconfig> <vendor>YaST Webservice Project</vendor> <vendor_url>http://en.opensuse.org/YAST</vendor_url> <!-- Rights for security --> <action id="org.opensuse.yast.system.security.read"> <description>Reading security settings</description> <message>Authentication is required to read security settings</message> <defaults> <allow_inactive>no</allow_inactive> <allow_active>no</allow_active> </defaults> </action> <action id="org.opensuse.yast.system.security.write"> <description>Writing security settings</description> <message>Authentication is required to write security settings</message> <defaults> <allow_inactive>no</allow_inactive> <allow_active>no</allow_active> </defaults> </action> </policyconfig>
Granting PolkitPolicies
If you want to watch your module during development, you need to give some permissions to a user, eg. root.
polkit-auth --user root --grant org.opensuse.yast.webservice.write-security polkit-auth --user root --grant org.opensuse.yast.webservice.read-security
D-Bus Service
There are two ways of creating a D-Bus service. You can use existing YaST module and only create a D-Bus interface or you can write a standalone service.
In this tutorial, a standalone, YaST-independent D-Bus service is used. The following example of YaST-based D-Bus service should be taken only as an example, which you can base on in case you want to leverage existing YaST functionality; of course, then you need to update the REST service model below to call proper S-Bus object.
YaST module as D-Bus service (not needed for this tutorial)
YaST (non-web) offers interface called YaPI. This interface can also be used in order to access YaST functionality form WebYaST via D-Bus. The module can be written in various languages (YaST supports - except historical YCP - Perl, Python and Ruby). Put the module you write to, in this example, /usr/share/YaST2/modules/YaPI/MyModule.pm
package YaPI::MyModule; use strict; use YaPI; use YaST::YCP qw(:LOGGING); our %TYPEINFO;
Typeinfo defines intput and output parameters of each functions. The parameters below mean:
- "function" - (no explanation needed)
- "boolean" - the type of the return value
- "string" - the type of parameter
Add more types if needed.
BEGIN{$TYPEINFO{Function1} = ["function","boolean" "string"]; } sub Function1 { my $self = shift; my $param1 = shift; # call other YaST functions here my $out = 1; return $out; } #function which executes specified command and returns exit code BEGIN{$TYPEINGO{Execute} = ["function","integer","string"];} sub Execute { my $self = shift; my $command = shift; return SCR->Execute(".target.bash", $command); # equivalently, you don't need to depend on YaST: # return system($command); } 1;
Next, define policy for such module. Create file /usr/share/PolicyKit/policy/org.opensuse.yast.modules.yapi.mymodule.policy with following contents:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd"> <policyconfig> <vendor>Your_Name</vendor> <vendor_url>http://example.com/appliance</vendor_url> <action id="org.opensuse.yast.modules.yapi.mymodule.function1"> <description>FUNCTION_DESCRIPTION</description> <message>Authentication is required to WHATEVER</message> <defaults> <allow_inactive>no</allow_inactive> <allow_active>no</allow_active> </defaults> </action> </policyconfig>
Be aware that for each function you need to add a separate policy.
Last, grant root and yastws access to the new service:
/usr/sbin/grantwebyastrights --user root --action grant > /dev/null /usr/sbin/grantwebyastrights --user yastws --action grant > /dev/null
Standalone D-Bus service
Standalone service fits better if you do not need any existing (non-web-)YaST functionality, or you do not want to depend on YaST. Following examples assume following (in real life, you need to define them for each service):
D-Bus service: my.new.Service D-Bus interface: my.new.Service.Interface D-Bus object path: /my/new/Service/Path
Create the D-Bus service
The D-Bus service can be written in various languages. As WebYaST itself is written in Ruby, the example below is also in Ruby.
Put the following skeleton into your binary path of your choice, in order to match examples below, into /usr/local/sbin/myNewService.rb
The first part connects to the system bus and registers the service.
#!/usr/bin/env ruby require 'dbus' # Choose the bus (could also be DBus::session_bus, which is not suitable for a system service) bus = DBus::system_bus # Define the service name service = bus.request_service("my.new.Service")
Create the object class and its interface. In the interface, create all methods.
In the example below, the interface provides three metods:
- read, which reads specified file and returns it as a string (note that enclose in array is needed as DBus has multiple return values so it returns array of return values)
- write, which writes specified string to a file
- execute, which executes a command.
Be aware, that these examples are insecure - they allow everyone to run any system command or read/write any system file with the root permissions (as the D-Bus service runs as root).
class MyService < DBus::Object # Create an interface. dbus_interface "my.new.Service.Interface" do # Define D-Bus methods of the service # This method reads whole file which name it gets as a parameter and returns its contents dbus_method :read, "in filename:s, out contents:s" do |filename| [File.open(filename, "r") {|f| f.read }] end # This method dumps a string into a file dbus_method :write, "in name:s, in contents:s" do |filename,contents| File.open(filename, 'w') {|f| f.write(contents) } end # This method executes specified command dbus_method :execute, "in cmd:s, out exit:u" do |cmd| system(cmd) return [$?.exitstatus] end end end
Create the object and export it through its path
# Set the object path obj = MyService.new("/my/new/Service/Interface") # Export it! service.export(obj)
Run the even loop (in this example, the event loop never finishes.
# Now listen to incoming requests main = DBus::Main.new main << bus main.run
Create needed permissions
Edit /etc/dbus-1/system.d/my.new.Service.conf and add following contents in order to grant permission to access and register the service:
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd"> <busconfig> <policy user="root"> <allow own="my.new.Service" /> <allow send_destination="my.new.Service" /> </policy> <policy user="yastws"> <allow send_destination="my.new.Service" /> <!-- introspection is allowed --> <allow send_destination="my.new.Service" send_interface="org.freedesktop.DBus.Introspectable" /> </policy> <policy context="default"> <deny send_destination="my.new.Service"/> <deny send_destination="my.new.Service" send_interface="org.freedesktop.DBus.Introspectable" /> </policy> </busconfig>
Without this file, you cannot even register the service in order to test it.
The configuration above allows root to register the service and yastws to use it. Therefore, the service cannot be used by any other users except yastws, whose permissions the YaST web service uses.
Testing the service
To test the service, just call its methods. Following example shows how to call a D-Bus service.
#!/usr/bin/ruby require 'dbus' bus = DBus.system_bus ruby_service = bus.service("my.new.Service") obj = ruby_service.object("/my/new/Service/Interface") obj.introspect obj.default_iface = "my.new.Service.Interface" puts obj.read("/etc/passwd") obj.write("/tmp/ZZZ", "a\nb\nc") obj.execute("cat /etc/group")
The service must be started - unless it is done automatically (see below), just run /usr/local/sbin/myNewService.rb as root.
From some reason, the 'read' example above only prints the first line of the file - to be investigated. Write works well.
Starting the Service
To start the service automatically, create the service activation config file /usr/share/dbus-1/system-services/my.new.Service.service with following contents:
# DBus service activation config [D-BUS Service] Name=my.new.Service Exec=/usr/local/sbin/myNewService.rb User=root
Keep the [D-Bus Service] section name, only update the name of the service and the binary to execute.
TODO
- Investigate why the 'read file' example returns the first line only
Rest-Service:
Preparing
To start developing you will need the sources, so check out the git repos and install the required packages. You'll need at least gcc (install with "zypper install gcc" on SUSE) and rcov (install with "gem install rcov").
git clone git://gitorious.org/opensuse/yast-rest-service.git cd yast-rest-service make
In following steps, you will also need the web client repository, let's prepare it now:
git clone git://gitorious.org/opensuse/yast-web-client.git cd yast-web-client make
It is very likely that the make command doesn't succeeded, because of some package dependencies. Have a look into the spec file in webclient/package/yast2-webclient.spec and install the required packages. You can find the packages either at the [service] or at [[1]]. Then try again make.
All modules of WebYast are stored in the plug-ins directory, so open a terminal and create a new directory for your modul
mkdir yast-rest-service/plugins/security cd yast-rest-service/plugins/security
At least your new module should have the directories app config lib package tasks and test. Go on and create them.
Now change directory to security/app and create directories for the model, view and controller.
mkdir models views controllers
During development it can be helpful to open the development log in a second terminal.
tail -fn 100 log/development.log
The model
In the model you will define the security class with all it's attributes and methods. We will need 3 variables which were firewall_on_startup, firewall and ssh. They all containing boolean values to show the current status. We will also need some methods to grab these informations and provide them when requested.
Change directory to app/models and create the file security.rb.
The access to the variables can be defined with Ruby's attr_reader. We will only need read access here and define methods for writing and reading to the system later.
class Security attr_reader :firewall, :firewall_on_startup, :ssh
We don't inherit Security from ActiveRecord::Base, as we don't need a database. But we need to define our own to_xml and to_json methods:
def to_xml(options = {}) xml = options[:builder] ||= Builder::XmlMarkup.new(options) xml.instruct! unless options[:skip_instruct] xml.security do xml.tag!(:ssh, @ssh, {:type => "boolean"}) xml.tag!(:firewall, @firewall, {:type => "boolean"}) xml.tag!(:firewall_on_startup, @firewall_on_startup, {:type => "boolean"}) end end def to_json(options = {}) hash = Hash.from_xml(to_xml()) return hash.to_json end
These methods convert the attributes of the security class into xml or json format and provide them as return value.
At that point Security class has some variables. Someone can read them or ask for a nice xml or json format, but there isn't any functionality. Now let's provide some:
def initialize update end
With Security.new() one can get an instance of Security class. There is no other operation needed than refreshing the status. So, all we need is an update method, which will be called by new().
def update @firewall = firewall? @firewall_on_startup = firewall_on_startup? @ssh = ssh? end
Now we just need to define the missing methods which are more or less the get methods for the attributes.
Don't use IO methods at this point. It will bring you into trouble. As WebYast will run as a service it will have it's own user yastws. This user won't have root permissions, but get access to the system via the scr lib and the PolicyKit permissions we have defined before.
def firewall? begin return 0 == execute_cmd("/sbin/rcSuSEfirewall2 status") rescue Exception return nil; end end
Here we hand over the /sbin/rcSuSEfirewall2 command and all parameters in a single string. The command gets executed via the generic D-Bus service which was defined above. The return value will be just compared to zeru to identify whether the firewall is running. The firewall? methods returns true if the firwall is enabled, false if it is disabled and nil if any error occurs.
def ssh? begin return 0 == execute_cmd("/usr/sbin/rcsshd status") rescue Exception return nil; end end
The ssh method does nearly the same, while firewall_on_startup? uses the Yast interface.
def firewall_on_startup? begin 0 == execute_cmd("/sbin/chkconfig -c SuSEfirewall2_setup") rescue Exception return nil; end end
So does the firewall_on_startup? method, it just checks the script via chkconfig.
And finally, the execute_cmd helper, which executes the commands via the D-Bus service defined above:
def execute_cmd(cmd) bus = DBus.system_bus ruby_service = bus.service("my.new.Service") obj = ruby_service.object("/my/new/Service/Interface") obj.introspect obj.default_iface = "my.new.Service.Interface" Integer(obj.execute(cmd)[0]) end end
Now, as we can read the system state of firewall and ssh we can go on with the controller.
The controller
The controller connects the model on the service-side with the representation on the web-client side. The web-client sends a GET-Request the rest-service and expects the current status as response. This can be either in xml or in json format like we defined in our model before.
Defining a new class
Change directory to the controllers and create the file security_controller.rb
cd ../controllers vi security_controller.rb
include ApplicationHelper class SecurityController < ApplicationController
ApplicationController is part of the rest-service and provides some methods which we will need. So define the class SecurityController and derivate it from the ApplicationController.
before_filter :login_required
The before_filter will execute login_required when any action where called. It checks if the user is logged in and redirects to the loggin screen if not.
Now let's define the show method which has to respond to GET requests and collects the data.
def show unless permission_check("org.opensuse.yast.system.security.read") render ErrorResult.error(403, 1, "no permission") and return else @security = Security.new end end end
First permission_check checks the user for his rights. If he isn't allowed to read security informations, the show action rise an error 403 and stops. Else it creates a new Security object, which means the initialize() method of the model grabs the current status of firewall and ssh.
The show action responds the Security object to the web-client, which has to handle the display inside the browser. This will happen here. But first you have to define routes for your new module.
Defining Routes
The routes for every module is defined in config/resources/security.yml inside the plugin/security directory.
interface: org.opensuse.yast.system.security controller: security singular: true
You can start the REST service via calling ./start.sh in the yast-rest-service/webservice directory.
The View
In order to display the data served by the rest service, create (one or more of) the following views. Change directory to views and create the security subdirectory. In this directory, create following files:
show.html.erb
<%= @security.to_xml( :root => "security", :dasherize => false ) -%>
show.json.erb
<%= @security.to_json( :root => "security", :dasherize => false ) -%>
show.xml.erb
<%= @security.to_xml( :root => "security", :dasherize => false ) -%>
You can watch your new module at http://localhost:4984 with your browser. The link name is the value of interface. You can also go directly to http://localhost:4984/security or type http://localhost:4984/security.xml to get a xml tree. Be aware that you need to run yastws with the permissions of the yastws user, or update the DBus permissions.
Web-Client
Like on the service all modules are stored in the plugins directory. Create a security directory in the plugins directory and at least the subdirectories view controller in the app directory. Note that in the check-out you have the security directory already exists. If you follow this tutorial strictly and create the same module, remove it first.
mkdir yast-web-client/plugins/security cd yast-web-client/plugins/security mkdir app package tasks test config cd app mkdir models views controllers
The model
Create a simple model which only maps to the REST service:
cd plugins/security/app/models vi security.rb
and add following contents
class Security < ActiveResource::Base extend YastModel::Base model_interface :"org.opensuse.yast.system.security" end
The controller
Change to the controller directory and create the security_controller.rb file.
cd plugins/security/app/controllers/ vi security_controller.rb
Now define the SecurityController class and derivate it from ApplicationController and include yast/service_resource. Create a before_filter as well.
require 'yast/service_resource' require 'security' class SecurityController < ApplicationController before_filter :login_required, :prepare
This will start the login_required action like on the controller of the rest-service, but also starts the prepare action before executing any other action inside the security controller.
private def prepare @permissions = Security.permissions end
The prepare action reads the permissions and stores them in an instance variable so that they can be accessed by the other functions or by the view.
public # GET /security # GET /security.xml def index security = Security.find (:one) @firewall_on_startup = "checked" if security.firewall_on_startup @firewall = "checked" if security.firewall @ssh = "checked" if security.ssh end end
This defines the 'index' function which reads current settings from the REST service via the model and sets internal variables accordingly.
The view
Create a new directory called security inside the view and write a view for the index action.
mkdir views/security vi views/security/index.erb
On the web-client side the view consists of 3 checkboxes, which are showing the current status of firewall, ssh and the automatic enabled firewall. To change the values one can check or uncheck the boxes and transmit them with the Submit Settings button.
<div class='plugin-icon'><img src='/icons/yast-firewall.png'/> <%=_("Security")%></div> <div class="plugin-content grid_12"> <% form_tag '/security/update' do %> <span> <label> <%= check_box_tag "firewall_on_startup", "true", @firewall_on_startup, :disabled => !@permissions[:write] %> <span><%=_("Enable Firewall Automatic Starting")%></span> </label> <label> <%= check_box_tag "firewall", "true", @firewall, :disabled => !@permissions[:write] %> <span><%=_("Firewall enabled")%></span> </label> <label> <%= check_box_tag "ssh", "true", @ssh, :disabled => !@permissions[:write] %> <span><%=_("SSH login enabled")%></span> </label> <span> <hbox> <%= submit_tag _("Save"), :onclick=>"$('#progress').show()", :disabled => !@permissions[:write], :class => 'button' -%> <a href="/" class="button"><%=_("Back")%></a> </hbox> </span> </span> <% end %> </div>
The views/security/index.erb is nearly plain html. Only the variables for the checkboxes and permissions are embedded within ruby-code. By calling Security.permissions in the controller, we have set @permissions variable. The @permissions[:write_permission] contains either true or false and decides if the user is able to edit the checkbox or not.
The resulting HTML code looks following:
<label> <input checked="checked" id="ssh" name="ssh" type="checkbox" value="true" /> <span>SSH login enabled</span> </label>
Now you can start the web-client and open a browser.
cd yast-web-client/webclient ./start.sh
Go to http://localhost:54984, login and choose the security module. Make sure that the rest-service is still running and set the url and port-number to the web-client settings (that is, after logging-in, you need manually go to URL http://localhost:54984/security). After doing that you can see the 3 options for ssh and firewall and change them. In case there you got an error, you should make sure that you have granted the policies to the right user (root and yastws):
polkit-auth --user root --grant org.opensuse.yast.system.security.read polkit-auth --user root --grant org.opensuse.yast.system.security.write
After clicking the Save button, none of these changes will be sent. Actually we already added an update action to the format tag of the view,
<% form_tag '/security/update' do %>
but haven't defined any update action in the controller. Let's do this now.
Adding new action to the controller
By calling Security.find() we request a new empty Security instance from the rest-service. The params hash contains the POST parameter of the view. Then the update action (in the web-client controller, security-controller.rb) checks the params for the string "true" and stores the return value (true or false) into the security instance.
# POST /security # POST /security.xml def update security = Security.find (:one) security.firewall_on_startup = params[:firewall_on_startup].eql?("true") security.firewall = params[:firewall].eql?("true") security.ssh = params[:ssh].eql?("true")
The save method then sends a POST with the security instance to the security module of the rest-service. If it responds with an error, the web-client flashes an error message.
begin security.save flash[:notice] = _('Security settings have been written.') redirect_success rescue ActiveResource::ServerError => e response = Hash.from_xml(e.response.body) flash[:error] = response["error"]["description"] # these three lines are needed to re-set variables view is using; unneeded @firewall_on_startup = "checked" if security.firewall_on_startup @firewall = "checked" if security.firewall @ssh = "checked" if security.ssh end end
At the end, in case of success, user got redirected to the root url, which is localhost:54984/controlpanel.
Adding an icon
Go to plugins/security and create file called shortcuts.yml' with following contents to add an icon for your module to the control center.
main: icon: '/icons/yast-firewall.png' url: /security groups: [ System ] tags: [ init, script, service ] title: System Security description: Manage system security
Update View
As we use the update action, we need to provide an update view in case redirection to control panel is not desirable (e.g. an error occurred). Create the update.erb in the view directory.
Basically it is the same file like index.erb. You just need to copy it into update.erb.
cp views/security/index.erb views/security/update.erb
We still need to define what to respond from the rest-service side. So change to the rest-service and open the security_controller.rb file.
Rest-service Part 2
On the rest-service side the controller will receive a POST request with some parameters from the web-client. These parameters contains the new values of ssh and firewall and needed to be submitted to the system where the rest-service resides. Therefore we need some new methods inside our Security class of the model.
The Model
The write method gives a small interface to the model, which we can use later in the controller. It expects three boolean values and hands them over to firewall(), firewall_on_startup() and ssh().
#:firewall=>boolean, :ssh=>boolean, :firewall_on_startup=>boolean def save(settings) ssh(settings[:ssh]) firewall(settings[:firewall]) firewall_on_startup(settings[:firewall_on_startup]) end
Again, firewall() and ssh() doing nearly the same. Depending on param, they execute command which will restart or stop the firewall/ssh. It decides whether it succeeded or failed depending on the exit code of the command. If it succeeds, it returns the param. If not, firewall/ssh returns nil.
def ssh(param) if param # start sshd action = "restart" else # stop sshd action = "stop" end begin if 0 == execute_cmd("/usr/sbin/rcsshd #{action}") return param end rescue Exception end return nil; end def firewall(param) if param # start firewall action = "restart" else # stop firewall action = "stop" end begin if 0 == execute_cmd("/sbin/rcSuSEfirewall2 #{action}") return param end rescue Exception end return nil; end
The firewall_on_startup() method evaluates param and then decides how the action and the expected return should look like. It is prety similar to above functions, as it enables or disables the service via calling chkconfig. Due to the architecture of SuSE firewall, there are two services which need to be enabled or disabled. Again, in case of success firewall_on_startup() returns the param and nil in case of a failure.
def firewall_on_startup(param) if param # start firewall on boot action = "-a" else # don't start firewall on boot action = "-d" end begin if 0 == execute_cmd("/sbin/chkconfig #{action} SuSEfirewall2_init && /sbin/chkconfig #{action} SuSEfirewall2_setup") return param end rescue Exception end return nil; end
The Controller
Now we can go on with the rest-service controller and define the create action (which is invoked upon the POST request generated by the security.save instruction in the web-client controller).
def create unless permission_check("org.opensuse.yast.system.security.write") render ErrorResult.error(403, 1, "no permission") and return end
Like in the show action, check the user for the right to write security module first. Then check if the parameter has any values for security class. If it does, we can use the save() method of Security and hand over the parameter of params[:security][..] and render the the object in format specified by the request.
begin @security = Security.new @security.save(params[:security]) respond_to do |format| format.html do render :xml => @security.to_xml( :root => "security", :dasherize => false ), :location => "none" end format.xml do render :xml => @security.to_xml( :root => "security", :dasherize => false ), :location => "none" end format.json do render :json => @security.to_json , :location => "none" end end rescue Exception => e render ErrorResult.error(404, 2, "format or internal error") and return end end
As we already written the update action for the web-client controller, you can now restart client- and service-server and have a look at your new module. Both reading and writing the settings should be working now.